diff --git a/app/digest.py b/app/digest.py index 229ae7f..7977a17 100644 --- a/app/digest.py +++ b/app/digest.py @@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None: { "protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {}, - "clientInfo": {"name": "kanbante", "version": "0.8.0"}, + "clientInfo": {"name": "kanbante", "version": "0.9.0"}, }, req_id=1, ) diff --git a/app/main.py b/app/main.py index 0fbe265..7d99d10 100644 --- a/app/main.py +++ b/app/main.py @@ -258,6 +258,16 @@ class LiveHub: hub = LiveHub() +async def _publish_board_changed(repo_full_name: str) -> None: + """Tell every connected client this repo's board changed (#15). + + Called after each mutation Kanbante knows about (move, create, + capture, label toggle). Webhooks broadcast separately with a + richer payload; board clients accept both shapes. + """ + await hub.broadcast({"repo": repo_full_name, "type": "board-changed"}) + + # ── Templates ────────────────────────────────────────────── _tpl_env = Environment( @@ -554,6 +564,67 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""): ) +def _board_columns_for( + owner: str, repo: str, all_items: list[dict], milestone: str = "" +) -> tuple[dict[str, list[dict]], list[dict], dict]: + """Reconcile + merge pipeline shared by the board page and its + JSON twin (issue #15). Returns (columns, display_issues, state).""" + full_name = f"{owner}/{repo}" + issues = [i for i in all_items if _is_issue(i)] + open_issues = [i for i in issues if i.get("state") == "open"] + closed_numbers = {i["number"] for i in issues if i.get("state") != "open"} + open_numbers = {i["number"] for i in open_issues} + changes = reconcile_board_state(full_name, open_numbers, closed_numbers) + for number in changes["closed_numbers"]: + log_event(full_name, number, "closed", to_column="done", source="gitea") + _adopt_label_columns(full_name, open_issues, get_board_state(full_name)) + display_issues = open_issues + if milestone: + def _in_ms(issue: dict) -> bool: + ms = issue.get("milestone") or {} + return bool(ms) and ( + str(ms.get("id")) == milestone or ms.get("title") == milestone + ) + + display_issues = [i for i in open_issues if _in_ms(i)] + board_state = get_board_state(full_name) + columns = _merge_issues_with_board(display_issues, board_state) + return columns, display_issues, board_state + + +@app.get("/api/board/{owner}/{repo}") +async def api_board(owner: str, repo: str, milestone: str = ""): + """The board as JSON + server-rendered card fragments (issue #15). + + The board page swaps these fragments in place when the board + changes elsewhere — same merge/reconcile pipeline as the HTML + page, so label adoptions and closures are reflected identically. + """ + full_name = f"{owner}/{repo}" + all_items = await gitea.get_repo_issues(owner, repo, state="all") + columns, display_issues, board_state = _board_columns_for( + owner, repo, all_items, milestone + ) + settings = get_settings() + ages = _card_ages(full_name, display_issues, board_state) + card_template = _tpl_env.get_template("_card.html") + payload: dict[str, dict] = {} + for col in KANBAN_COLUMNS: + cards = columns[col] + if cards: + html = "".join( + card_template.render( + issue=issue, ages=ages, + aging_days=settings.aging_days, full_name=full_name, + ) + for issue in cards + ) + else: + html = '
Glissez une carte ici
' + payload[col] = {"count": len(cards), "html": html} + return {"repo": full_name, "columns": payload} + + def _card_ages( full_name: str, open_issues: list[dict], board: dict[str, list[dict]] ) -> dict[int, dict]: @@ -1052,6 +1123,7 @@ async def _do_capture( repo_full_name, issue["number"], "created", to_column=column, source="capture", ) + await _publish_board_changed(repo_full_name) return { "number": issue["number"], "title": issue.get("title", title), @@ -1500,6 +1572,7 @@ async def api_move( "Label sync failed for %s#%s: %s", repo_full_name, issue_number, exc ) result["label_sync"] = "failed" + await _publish_board_changed(repo_full_name) return result @@ -1534,6 +1607,7 @@ async def api_create_issue( issue = await gitea.create_issue(owner, repo, title, body) move_issue(repo_full_name, issue["number"], column) log_event(repo_full_name, issue["number"], "created", to_column=column) + await _publish_board_changed(repo_full_name) return JSONResponse( { "ok": True, @@ -1736,6 +1810,7 @@ async def api_toggle_issue_label( await gitea.add_issue_label(owner, repo, number, label_id) else: await gitea.remove_issue_label(owner, repo, number, label_id) + await _publish_board_changed(f"{owner}/{repo}") issue = await gitea.get_issue(owner, repo, number) return { "ok": True, diff --git a/app/templates/_card.html b/app/templates/_card.html new file mode 100644 index 0000000..63db4e7 --- /dev/null +++ b/app/templates/_card.html @@ -0,0 +1,33 @@ +Glissez une carte ici
{% endif %} {% for issue in columns[col] %} -