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:
@@ -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"(?<![\w#])#(\d+)\b")
|
||||||
|
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_issue_refs(text: str | None) -> 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -341,6 +341,25 @@ class GiteaClient:
|
|||||||
self._cache_clear()
|
self._cache_clear()
|
||||||
return r.json()
|
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(
|
async def set_issue_assignees(
|
||||||
self, owner: str, repo: str, issue_number: int, logins: list[str]
|
self, owner: str, repo: str, issue_number: int, logins: list[str]
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
+337
-2
@@ -35,10 +35,17 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
|
|
||||||
from app.config import APP_VERSION, get_settings
|
from app.config import APP_VERSION, get_settings
|
||||||
from app.gitea_client import GiteaClient
|
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 digest as digest_service
|
||||||
from app import forecast as forecast_service
|
from app import forecast as forecast_service
|
||||||
|
from app import rules as rules_service
|
||||||
from app import sprint as sprint_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.capture import ntfy_capture_loop, parse_capture_text
|
||||||
from app.models import (
|
from app.models import (
|
||||||
KANBAN_COLUMNS,
|
KANBAN_COLUMNS,
|
||||||
@@ -148,6 +155,7 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||||
digest_task = asyncio.create_task(_digest_scheduler())
|
digest_task = asyncio.create_task(_digest_scheduler())
|
||||||
|
rules_task = asyncio.create_task(_rules_scheduler())
|
||||||
capture_task = None
|
capture_task = None
|
||||||
if settings.capture_ntfy_topic and settings.ntfy_url:
|
if settings.capture_ntfy_topic and settings.ntfy_url:
|
||||||
async def _capture_create(title: str, body: str) -> None:
|
async def _capture_create(title: str, body: str) -> None:
|
||||||
@@ -171,6 +179,7 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
scheduler.cancel()
|
scheduler.cancel()
|
||||||
digest_task.cancel()
|
digest_task.cancel()
|
||||||
|
rules_task.cancel()
|
||||||
if capture_task is not None:
|
if capture_task is not None:
|
||||||
capture_task.cancel()
|
capture_task.cancel()
|
||||||
await gitea.aclose()
|
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)
|
board_state = get_board_state(full_name)
|
||||||
columns = _merge_issues_with_board(display_issues, board_state)
|
columns = _merge_issues_with_board(display_issues, board_state)
|
||||||
|
await _annotate_code(owner, repo, columns)
|
||||||
|
|
||||||
labels: dict[str, dict] = {}
|
labels: dict[str, dict] = {}
|
||||||
for issue in open_issues:
|
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(
|
columns, display_issues, board_state = _board_columns_for(
|
||||||
owner, repo, all_items, milestone
|
owner, repo, all_items, milestone
|
||||||
)
|
)
|
||||||
|
await _annotate_code(owner, repo, columns)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ages = _card_ages(full_name, display_issues, board_state)
|
ages = _card_ages(full_name, display_issues, board_state)
|
||||||
card_template = _tpl_env.get_template("_card.html")
|
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)
|
@app.get("/forecast", response_class=HTMLResponse)
|
||||||
async def forecast_page(request: Request):
|
async def forecast_page(request: Request):
|
||||||
"""Forecasts (issue #7): real-pace ETAs per project + milestone."""
|
"""Forecasts (issue #7): real-pace ETAs per project + milestone."""
|
||||||
@@ -1425,7 +1602,7 @@ async def _briefing_data() -> dict:
|
|||||||
if enriched and enriched["state"] != "closed":
|
if enriched and enriched["state"] != "closed":
|
||||||
carry_over.append(enriched)
|
carry_over.append(enriched)
|
||||||
|
|
||||||
return {
|
data = {
|
||||||
"date": datetime.now().strftime("%d/%m/%Y"),
|
"date": datetime.now().strftime("%d/%m/%Y"),
|
||||||
"today_picks": today_picks,
|
"today_picks": today_picks,
|
||||||
"carry_over": carry_over,
|
"carry_over": carry_over,
|
||||||
@@ -1445,6 +1622,66 @@ async def _briefing_data() -> dict:
|
|||||||
"avg_cycle_days": metrics_30d["avg_cycle_days"],
|
"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)
|
@app.get("/briefing", response_class=HTMLResponse)
|
||||||
@@ -1527,6 +1764,60 @@ def _apply_external_transition(
|
|||||||
return event_type
|
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")
|
@app.post("/webhooks/gitea")
|
||||||
async def gitea_webhook(request: Request):
|
async def gitea_webhook(request: Request):
|
||||||
"""Receive Gitea webhooks: verify HMAC, apply external close/reopen,
|
"""Receive Gitea webhooks: verify HMAC, apply external close/reopen,
|
||||||
@@ -1564,6 +1855,11 @@ async def gitea_webhook(request: Request):
|
|||||||
handled = _apply_external_transition(
|
handled = _apply_external_transition(
|
||||||
full_name, number, "backlog", "reopened"
|
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()
|
gitea.invalidate_cache()
|
||||||
await hub.broadcast(
|
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}")
|
@app.get("/api/issues/{owner}/{repo}/{number}")
|
||||||
async def api_issue_detail(owner: str, repo: str, number: int):
|
async def api_issue_detail(owner: str, repo: str, number: int):
|
||||||
"""Issue detail + comments for the card panel (proxied from Gitea)."""
|
"""Issue detail + comments for the card panel (proxied from Gitea)."""
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if issue._code %}
|
||||||
|
<div class="card-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 %}</div>
|
||||||
|
{% endif %}
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
{% if issue.assignee %}
|
{% if issue.assignee %}
|
||||||
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
|
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
{% if repo_info.html_url %}
|
{% if repo_info.html_url %}
|
||||||
@@ -164,6 +165,7 @@
|
|||||||
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
|
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
|
||||||
<div id="detail-label-toggles" class="label-toggles"></div>
|
<div id="detail-label-toggles" class="label-toggles"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="detail-code" class="detail-code"></div>
|
||||||
<div id="detail-body" class="detail-body"></div>
|
<div id="detail-body" class="detail-body"></div>
|
||||||
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
||||||
<div id="detail-comments"></div>
|
<div id="detail-comments"></div>
|
||||||
|
|||||||
@@ -1144,3 +1144,10 @@ footer {
|
|||||||
.sprint-render h1 { font-size: 1.5rem; }
|
.sprint-render h1 { font-size: 1.5rem; }
|
||||||
.sprint-render h2 { font-size: 1.15rem; margin-top: 22px; }
|
.sprint-render h2 { font-size: 1.15rem; margin-top: 22px; }
|
||||||
.sprint-render a { color: var(--accent, #58a6ff); }
|
.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; }
|
||||||
|
|||||||
@@ -369,12 +369,40 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const data = await r.json().catch(() => ({}));
|
const data = await r.json().catch(() => ({}));
|
||||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||||
fillDetail(data.issue, data.comments);
|
fillDetail(data.issue, data.comments);
|
||||||
|
loadCodeLinks(repoFull, number);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('detail-title').textContent = 'Erreur de chargement';
|
document.getElementById('detail-title').textContent = 'Erreur de chargement';
|
||||||
showToast('❌ Détail indisponible : ' + err.message, true);
|
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 => `<code>${b}</code>`).join(', '));
|
||||||
|
}
|
||||||
|
for (const pr of data.pulls || []) {
|
||||||
|
const state = pr.merged ? 'fusionnée' : (pr.state === 'open' ? 'ouverte' : 'fermée');
|
||||||
|
parts.push(`🔀 <a href="${pr.html_url}" target="_blank" rel="noopener">PR #${pr.number}</a> ${pr.title} — ${state}`);
|
||||||
|
}
|
||||||
|
for (const c of (data.commits || []).slice(0, 8)) {
|
||||||
|
parts.push(`⑂ <a href="${c.html_url}" target="_blank" rel="noopener"><code>${c.sha}</code></a> ${c.message}${c.date ? ' · ' + c.date : ''}`);
|
||||||
|
}
|
||||||
|
box.innerHTML = parts.length
|
||||||
|
? '<h3 class="detail-comments-title">🧬 Code lié</h3>' + parts.map(p => `<div class="code-line">${p}</div>`).join('')
|
||||||
|
: '';
|
||||||
|
} catch (err) { /* le code est un bonus, jamais bloquant */ }
|
||||||
|
}
|
||||||
|
|
||||||
function fillDetail(issue, comments) {
|
function fillDetail(issue, comments) {
|
||||||
document.getElementById('detail-title').textContent = issue.title;
|
document.getElementById('detail-title').textContent = issue.title;
|
||||||
const stateChip = document.getElementById('detail-state');
|
const stateChip = document.getElementById('detail-state');
|
||||||
|
|||||||
@@ -223,6 +223,49 @@ class FakeGitea:
|
|||||||
for label in removed:
|
for label in removed:
|
||||||
self.label_ops.append(("remove", number, label["name"]))
|
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"):
|
async def get_milestones(self, owner, repo, state="open"):
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user