feat(code): cartes liées au code — branches, PRs, commits (issue #1)

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.
This commit is contained in:
Spark
2026-10-11 14:21:11 -04:00
parent 03fa1eb44e
commit 675ce09083
8 changed files with 545 additions and 2 deletions
+337 -2
View File
@@ -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)."""