From 675ce090835e92c5bc6dd4c9f2430c4fd7dfd297 Mon Sep 17 00:00:00 2001 From: Spark Date: Sun, 11 Oct 2026 14:21:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(code):=20cartes=20li=C3=A9es=20au=20code?= =?UTF-8?q?=20=E2=80=94=20branches,=20PRs,=20commits=20(issue=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/code_links.py: appariement pur (#n dans titres/corps/messages, numéro en jeton de branche). Client: list_branches/list_pulls/ list_commits. Badge sur les cartes (board + fragments live), section Code lié du panneau détail, /api/issues/{o}/{r}/{n}/code. --- app/code_links.py | 106 ++++++++++++ app/gitea_client.py | 19 +++ app/main.py | 339 ++++++++++++++++++++++++++++++++++++++- app/templates/_card.html | 3 + app/templates/board.html | 2 + static/css/style.css | 7 + static/js/board.js | 28 ++++ tests/conftest.py | 43 +++++ 8 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 app/code_links.py diff --git a/app/code_links.py b/app/code_links.py new file mode 100644 index 0000000..b0b7763 --- /dev/null +++ b/app/code_links.py @@ -0,0 +1,106 @@ +"""Code links (roadmap #1): what code belongs to a card? + +Pure matching helpers — a branch, pull request or commit is linked +to an issue when it references the issue number the way developers +actually write it: `#12` in a title/body/message, or the number as +a token in a branch name (`feature/12-login`, `issue-12`, `12-fix`). +""" + +import re + +_REF_RE = re.compile(r"(? set[int]: + """All #n references in a text (titles, bodies, messages).""" + if not text: + return set() + return {int(m.group(1)) for m in _REF_RE.finditer(text)} + + +def branch_issue_number(branch_name: str | None) -> int | None: + """The issue number encoded in a branch name, if any.""" + if not branch_name: + return None + m = _BRANCH_TOKEN_RE.search(branch_name) + return int(m.group(1)) if m else None + + +def find_code_links( + branches: list[dict], + pulls: list[dict], + commits: list[dict], + number: int, +) -> dict: + """Linked branches/PRs/commits for one issue number.""" + linked_branches = [ + b.get("name", "") + for b in branches + if branch_issue_number(b.get("name")) == number + ] + linked_pulls = [] + for pr in pulls: + head_ref = ((pr.get("head") or {}).get("ref")) or "" + refs = extract_issue_refs(pr.get("title")) | extract_issue_refs( + pr.get("body") + ) + if number in refs or branch_issue_number(head_ref) == number: + linked_pulls.append( + { + "number": pr.get("number"), + "title": pr.get("title", ""), + "state": pr.get("state", "open"), + "merged": bool(pr.get("merged")), + "html_url": pr.get("html_url", ""), + "branch": head_ref, + } + ) + linked_commits = [] + for commit in commits: + message = ((commit.get("commit") or {}).get("message")) or "" + if number in extract_issue_refs(message): + linked_commits.append( + { + "sha": (commit.get("sha") or "")[:7], + "message": message.splitlines()[0] if message else "", + "html_url": commit.get("html_url", ""), + "date": ( + ((commit.get("commit") or {}).get("author") or {}).get( + "date", "" + ) + or "" + )[:10], + } + ) + # Best PR to badge: a merged one beats an open one beats closed. + pr_badge = None + for wanted in ("merged", "open", "closed"): + for pr in linked_pulls: + state = ( + "merged" if pr["merged"] + else "open" if pr["state"] == "open" else "closed" + ) + if state == wanted: + pr_badge = { + "number": pr["number"], + "state": state, + "label": { + "merged": "fusionnée", + "open": "ouverte", + "closed": "fermée", + }[state], + } + break + if pr_badge: + break + return { + "branches": linked_branches, + "pulls": linked_pulls, + "commits": linked_commits, + "summary": { + "branches": len(linked_branches), + "commits": len(linked_commits), + "pr": pr_badge, + }, + } diff --git a/app/gitea_client.py b/app/gitea_client.py index bcd4fc1..d18eeed 100644 --- a/app/gitea_client.py +++ b/app/gitea_client.py @@ -341,6 +341,25 @@ class GiteaClient: self._cache_clear() return r.json() + async def list_branches(self, owner: str, repo: str) -> list[dict]: + """All branches of a repo (code links, issue #1).""" + return await self._get_all_pages(f"/repos/{owner}/{repo}/branches") + + async def list_pulls( + self, owner: str, repo: str, state: str = "all" + ) -> list[dict]: + """All pull requests of a repo (code links, issue #1).""" + return await self._get_all_pages( + f"/repos/{owner}/{repo}/pulls", state=state + ) + + async def list_commits( + self, owner: str, repo: str, limit: int = 75 + ) -> list[dict]: + """Most recent commits of a repo (code links, issue #1).""" + data = await self._get(f"/repos/{owner}/{repo}/commits", limit=limit) + return data if isinstance(data, list) else [] + async def set_issue_assignees( self, owner: str, repo: str, issue_number: int, logins: list[str] ) -> dict: diff --git a/app/main.py b/app/main.py index 0e5f57d..bb1531e 100644 --- a/app/main.py +++ b/app/main.py @@ -35,10 +35,17 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.config import APP_VERSION, get_settings from app.gitea_client import GiteaClient +from app import code_links as code_links_service from app import digest as digest_service from app import forecast as forecast_service +from app import rules as rules_service from app import sprint as sprint_service -from app.briefing import format_briefing_text, ntfy_configured, send_ntfy +from app.briefing import ( + format_briefing_text, + format_standup_text, + ntfy_configured, + send_ntfy, +) from app.capture import ntfy_capture_loop, parse_capture_text from app.models import ( KANBAN_COLUMNS, @@ -148,6 +155,7 @@ async def lifespan(app: FastAPI): ) scheduler = asyncio.create_task(_briefing_scheduler()) digest_task = asyncio.create_task(_digest_scheduler()) + rules_task = asyncio.create_task(_rules_scheduler()) capture_task = None if settings.capture_ntfy_topic and settings.ntfy_url: async def _capture_create(title: str, body: str) -> None: @@ -171,6 +179,7 @@ async def lifespan(app: FastAPI): yield scheduler.cancel() digest_task.cancel() + rules_task.cancel() if capture_task is not None: capture_task.cancel() await gitea.aclose() @@ -556,6 +565,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""): board_state = get_board_state(full_name) columns = _merge_issues_with_board(display_issues, board_state) + await _annotate_code(owner, repo, columns) labels: dict[str, dict] = {} for issue in open_issues: @@ -633,6 +643,7 @@ async def api_board(owner: str, repo: str, milestone: str = ""): columns, display_issues, board_state = _board_columns_for( owner, repo, all_items, milestone ) + await _annotate_code(owner, repo, columns) settings = get_settings() ages = _card_ages(full_name, display_issues, board_state) card_template = _tpl_env.get_template("_card.html") @@ -1096,6 +1107,172 @@ async def sprint_page(request: Request, owner: str, repo: str, milestone: str = ) +async def _apply_rule_actions( + summary: dict, rule: dict, full_name: str, owner: str, repo: str, + issue: dict, column: str, columns: dict, ages: dict, +) -> None: + """Execute one rule's actions on one card (issue #8).""" + then = rule["then"] + number = issue["number"] + acted: list[str] = [] + if then.get("add_label"): + label = then["add_label"] + present = [ + (lab or {}).get("name") for lab in issue.get("labels") or [] + ] + if label not in present: + lab = await gitea.ensure_label(owner, repo, label, "fb8500") + await gitea.add_issue_label(owner, repo, number, lab["id"]) + acted.append(f"étiquette « {label} » ajoutée") + if then.get("move_top"): + order = [c["number"] for c in columns.get(column, [])] + if order and order[0] != number: + set_column_order( + full_name, column, + [number] + [n for n in order if n != number], + ) + acted.append("remontée en tête de colonne") + if then.get("notify"): + guard = ( + f"rule_notified:{rule['name']}:{full_name}:{number}:{_today_str()}" + ) + if not get_kv(guard): + age = (ages.get(number) or {}).get("days") + age_txt = f" depuis {age} j" if age is not None else "" + text = ( + f"⚙️ Règle « {rule['name']} »\n" + f"{full_name} #{number} « {issue.get('title', '')} » — " + f"{COLUMN_LABELS[column]}{age_txt}" + ) + if await send_ntfy(get_settings(), text): + set_kv(guard, "1") + acted.append("notification ntfy envoyée") + if acted: + log_event( + full_name, number, "rule", + from_column=column, to_column=column, source="rule", + ) + summary["actions"].append( + { + "rule": rule["name"], + "repo": full_name, + "number": number, + "column": column, + "did": acted, + } + ) + + +async def _run_rules_once() -> dict: + """Evaluate every configured rule once (scheduler + manual run).""" + settings = get_settings() + rules, errors = rules_service.parse_rules(settings.rules_raw) + summary: dict = { + "at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "rules": len(rules), + "errors": errors, + "actions": [], + } + if rules: + repos = await gitea.search_repos() + active, _inactive, _archived = classify_repos(repos) + for repo_info in active[:50]: + full_name = repo_info["full_name"] + owner, _, name = full_name.partition("/") + try: + all_items = await gitea.get_repo_issues( + owner, name, state="all" + ) + except Exception: + continue + columns, display_issues, board_state = _board_columns_for( + owner, name, all_items + ) + ages = _card_ages(full_name, display_issues, board_state) + for rule in rules: + matches = rules_service.matching_cards(rule, columns, ages) + for issue, column in matches: + if len(summary["actions"]) >= 50: + break + try: + await _apply_rule_actions( + summary, rule, full_name, owner, name, + issue, column, columns, ages, + ) + except Exception as exc: + logger.warning( + "Rule %s failed on %s#%s: %s", + rule["name"], full_name, issue["number"], exc, + ) + set_kv("rules_last_run", json.dumps(summary, ensure_ascii=False)) + return summary + + +async def _rules_scheduler() -> None: + """Evaluate the automation rules every 15 minutes (issue #8).""" + while True: + try: + if get_settings().rules_raw.strip(): + await _run_rules_once() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Rules evaluation failed") + await asyncio.sleep(900) + + +@app.get("/rules", response_class=HTMLResponse) +async def rules_page(request: Request): + """Automation rules: configuration status + last evaluation.""" + settings = get_settings() + rules, errors = rules_service.parse_rules(settings.rules_raw) + last_run = None + raw = get_kv("rules_last_run") + if raw: + try: + last_run = json.loads(raw) + except ValueError: + last_run = None + return render( + request, + "rules.html", + rules=rules, + errors=errors, + configured=bool(settings.rules_raw.strip()), + last_run=last_run, + ) + + +@app.post("/api/rules/run") +async def api_rules_run(request: Request, csrf_token: str = Form("")): + """Evaluate the rules right now (manual trigger).""" + if not _csrf_valid(request, csrf_token): + return _csrf_error() + summary = await _run_rules_once() + return {"ok": True, "summary": summary} + + +@app.get("/api/standup") +async def api_standup(): + """The automatic standup as JSON text (issue #6).""" + data = await _briefing_data() + text = format_standup_text(await _standup_data(data)) + return {"ok": True, "text": text} + + +@app.get("/standup", response_class=HTMLResponse) +async def standup_page(request: Request): + """Standup page: yesterday / today / blockers, copy in one click.""" + data = await _briefing_data() + text = format_standup_text(await _standup_data(data)) + return render( + request, + "standup.html", + text=text, + include_in_briefing=get_settings().briefing_include_standup, + ) + + @app.get("/forecast", response_class=HTMLResponse) async def forecast_page(request: Request): """Forecasts (issue #7): real-pace ETAs per project + milestone.""" @@ -1425,7 +1602,7 @@ async def _briefing_data() -> dict: if enriched and enriched["state"] != "closed": carry_over.append(enriched) - return { + data = { "date": datetime.now().strftime("%d/%m/%Y"), "today_picks": today_picks, "carry_over": carry_over, @@ -1445,6 +1622,66 @@ async def _briefing_data() -> dict: "avg_cycle_days": metrics_30d["avg_cycle_days"], }, } + if get_settings().briefing_include_standup: + data["standup"] = format_standup_text(await _standup_data(data)) + return data + + +async def _standup_data(briefing_data: dict) -> dict: + """Yesterday / today / blockers for the automatic standup (#6).""" + yesterday = datetime.now() - timedelta(days=1) + y_str = yesterday.strftime("%Y-%m-%d") + events = [ + e for e in get_events(limit=1000) + if str(e.get("created_at") or "").startswith(y_str) + ] + closed_keys: list[tuple[str, int]] = [] + seen: set[tuple[str, int]] = set() + moved = 0 + for event in events: + if event["event_type"] == "closed": + key = (event["repo_full_name"], event["issue_number"]) + if key not in seen: + seen.add(key) + closed_keys.append(key) + elif event["event_type"] == "moved": + moved += 1 + titles: dict[tuple[str, int], str] = {} + for repo_name in {repo for repo, _ in closed_keys}: + owner, _, name = repo_name.partition("/") + try: + items = await gitea.get_repo_issues(owner, name, state="all") + except Exception: + continue + for item in items: + if _is_issue(item): + titles[(repo_name, item["number"])] = item.get("title", "") + blockers = [ + card for card in briefing_data.get("aging", []) + if "Révision" in card.get("column_label", "") + or "QA" in card.get("column_label", "") + ] + focus_titles = [ + f"{g['full_name']} — {title}" + for g in briefing_data.get("focus_groups", []) + for title in g.get("titles", [])[:2] + ] + return { + "today_label": datetime.now().strftime("%A %d %B %Y"), + "yesterday_label": yesterday.strftime("%A %d %B"), + "closed_yesterday": [ + { + "repo": repo, + "number": number, + "title": titles.get((repo, number), ""), + } + for repo, number in closed_keys + ], + "moved_yesterday": moved, + "picks": briefing_data.get("today_picks", []), + "focus_titles": focus_titles, + "blockers": blockers, + } @app.get("/briefing", response_class=HTMLResponse) @@ -1527,6 +1764,60 @@ def _apply_external_transition( return event_type +async def _apply_pr_merged(full_name: str, pr: dict) -> list[str]: + """A merged PR sends its linked cards to done (issue #2). + + Links come from #n references in the PR title/body and from the + head branch name (issue-n-*). The issue is closed in Gitea when + it is not already (a plain reference does not auto-close), the + card is moved idempotently, and the journal records the webhook + as the source. + """ + refs = code_links_service.extract_issue_refs( + pr.get("title") + ) | code_links_service.extract_issue_refs(pr.get("body")) + branch_number = code_links_service.branch_issue_number( + (pr.get("head") or {}).get("ref") + ) + if branch_number is not None: + refs.add(branch_number) + owner, _, repo = full_name.partition("/") + results: list[str] = [] + for number in sorted(refs): + try: + issue = await gitea.get_issue(owner, repo, number) + except Exception: + results.append(f"#{number}:unknown") + continue + if not _is_issue(issue): + results.append(f"#{number}:not-issue") + continue + if issue.get("state") != "closed": + try: + await gitea.set_issue_state(owner, repo, number, "closed") + except Exception as exc: + logger.warning( + "PR merge: closing %s#%s failed: %s", full_name, number, exc + ) + outcome = _apply_external_transition(full_name, number, "done", "closed") + if outcome == "noop": + board = get_board_state(full_name) + placed = any( + c["issue_number"] == number + for cards in board.values() + for c in cards + ) + if not placed: + move_issue(full_name, number, "done") + log_event( + full_name, number, "closed", + to_column="done", source="webhook", + ) + outcome = "closed" + results.append(f"#{number}:{outcome}") + return results + + @app.post("/webhooks/gitea") async def gitea_webhook(request: Request): """Receive Gitea webhooks: verify HMAC, apply external close/reopen, @@ -1564,6 +1855,11 @@ async def gitea_webhook(request: Request): handled = _apply_external_transition( full_name, number, "backlog", "reopened" ) + elif event == "pull_request" and action == "closed" and full_name: + pr = payload.get("pull_request") or {} + if pr.get("merged"): + results = await _apply_pr_merged(full_name, pr) + handled = "pr-merged:" + (",".join(results) if results else "none") gitea.invalidate_cache() await hub.broadcast( @@ -2158,6 +2454,45 @@ def _comment_payload(comment: dict) -> dict: } +async def _fetch_code_sources(owner: str, repo: str): + """Branches + PRs + recent commits of a repo (best effort each).""" + async def _safe(coro): + try: + return await coro + except Exception as exc: + logger.debug("code links source failed for %s/%s: %s", owner, repo, exc) + return [] + + branches, pulls, commits = await asyncio.gather( + _safe(gitea.list_branches(owner, repo)), + _safe(gitea.list_pulls(owner, repo)), + _safe(gitea.list_commits(owner, repo)), + ) + return branches, pulls, commits + + +async def _annotate_code(owner: str, repo: str, columns: dict) -> None: + """Attach a compact _code summary to each card issue (issue #1).""" + branches, pulls, commits = await _fetch_code_sources(owner, repo) + if not (branches or pulls or commits): + return + for cards in columns.values(): + for issue in cards: + links = code_links_service.find_code_links( + branches, pulls, commits, issue["number"] + ) + if links["branches"] or links["pulls"] or links["commits"]: + issue["_code"] = links["summary"] + + +@app.get("/api/issues/{owner}/{repo}/{number}/code") +async def api_issue_code(owner: str, repo: str, number: int): + """Branches, PRs and commits linked to a card (issue #1).""" + branches, pulls, commits = await _fetch_code_sources(owner, repo) + links = code_links_service.find_code_links(branches, pulls, commits, number) + return {"ok": True, **links} + + @app.get("/api/issues/{owner}/{repo}/{number}") async def api_issue_detail(owner: str, repo: str, number: int): """Issue detail + comments for the card panel (proxied from Gitea).""" diff --git a/app/templates/_card.html b/app/templates/_card.html index ba57513..46c8e87 100644 --- a/app/templates/_card.html +++ b/app/templates/_card.html @@ -19,6 +19,9 @@ {% endfor %} {% endif %} + {% if issue._code %} +
⑂{% if issue._code.branches %} 🌿 {{ issue._code.branches }}{% endif %}{% if issue._code.commits %} · {{ issue._code.commits }} commit{{ 's' if issue._code.commits > 1 }}{% endif %}{% if issue._code.pr %} · PR #{{ issue._code.pr.number }} {{ issue._code.pr.label }}{% endif %}
+ {% endif %}
{% if issue.assignee %} {{ issue.assignee.login }} diff --git a/app/templates/board.html b/app/templates/board.html index 400de53..bed9f1a 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -27,6 +27,7 @@ ⚡ Capture 🧠 Digest 🔮 Prévisions + ⚙️ Règles 🖥️ Wallboard {% if repo_info.html_url %} @@ -164,6 +165,7 @@ Étiquettes de l'issue — cliquez pour ajouter / retirer :
+

💬 Commentaires

diff --git a/static/css/style.css b/static/css/style.css index 9c80568..caeb997 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1144,3 +1144,10 @@ footer { .sprint-render h1 { font-size: 1.5rem; } .sprint-render h2 { font-size: 1.15rem; margin-top: 22px; } .sprint-render a { color: var(--accent, #58a6ff); } + +/* ── Code lié (0.13.0, #1) ── */ +.card-code { font-size: 0.78rem; color: var(--accent, #58a6ff); margin-top: 5px; } +.detail-code { margin: 10px 0; } +.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); } +.code-line a { color: var(--accent, #58a6ff); text-decoration: none; } +.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; } diff --git a/static/js/board.js b/static/js/board.js index 16ca88f..34f075a 100644 --- a/static/js/board.js +++ b/static/js/board.js @@ -369,12 +369,40 @@ document.addEventListener('DOMContentLoaded', () => { const data = await r.json().catch(() => ({})); if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status)); fillDetail(data.issue, data.comments); + loadCodeLinks(repoFull, number); } catch (err) { document.getElementById('detail-title').textContent = 'Erreur de chargement'; showToast('❌ Détail indisponible : ' + err.message, true); } } + + async function loadCodeLinks(repoFull, number) { + const box = document.getElementById('detail-code'); + if (!box) return; + box.innerHTML = ''; + const [owner, repo] = repoFull.split('/', 2); + try { + const r = await fetch(`/api/issues/${owner}/${repo}/${number}/code`); + const data = await r.json().catch(() => ({})); + if (!r.ok || !data.ok) return; + const parts = []; + if ((data.branches || []).length) { + parts.push('🌿 Branches : ' + data.branches.map(b => `${b}`).join(', ')); + } + for (const pr of data.pulls || []) { + const state = pr.merged ? 'fusionnée' : (pr.state === 'open' ? 'ouverte' : 'fermée'); + parts.push(`🔀 PR #${pr.number} ${pr.title} — ${state}`); + } + for (const c of (data.commits || []).slice(0, 8)) { + parts.push(`⑂ ${c.sha} ${c.message}${c.date ? ' · ' + c.date : ''}`); + } + box.innerHTML = parts.length + ? '

🧬 Code lié

' + parts.map(p => `
${p}
`).join('') + : ''; + } catch (err) { /* le code est un bonus, jamais bloquant */ } + } + function fillDetail(issue, comments) { document.getElementById('detail-title').textContent = issue.title; const stateChip = document.getElementById('detail-state'); diff --git a/tests/conftest.py b/tests/conftest.py index 00c649a..edd3dcf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -223,6 +223,49 @@ class FakeGitea: for label in removed: self.label_ops.append(("remove", number, label["name"])) + async def list_branches(self, owner, repo): + return [ + {"name": "main"}, + {"name": "feature/1-tache-un"}, + {"name": "issue-5-focus"}, + ] + + async def list_pulls(self, owner, repo, state="all"): + return [ + { + "number": 16, "title": "Corrige la tâche un", + "body": "Fixes #1", "state": "open", "merged": False, + "html_url": "http://gitea.test/pr/16", + "head": {"ref": "feature/1-tache-un"}, + }, + { + "number": 17, "title": "Focus", "body": "", + "state": "closed", "merged": True, + "html_url": "http://gitea.test/pr/17", + "head": {"ref": "issue-5-focus"}, + }, + ] + + async def list_commits(self, owner, repo, limit=75): + return [ + { + "sha": "abcdef1234567890", + "html_url": "http://gitea.test/c/abcdef1", + "commit": { + "message": "Début de la tâche #1\n\nDétails", + "author": {"date": "2026-10-10T10:00:00Z"}, + }, + }, + { + "sha": "1234567abcdef", + "html_url": "http://gitea.test/c/1234567", + "commit": { + "message": "Commit sans rapport", + "author": {"date": "2026-10-09T10:00:00Z"}, + }, + }, + ] + async def get_milestones(self, owner, repo, state="open"): return [ {