diff --git a/app/code_links.py b/app/code_links.py index b0b7763..fec725a 100644 --- a/app/code_links.py +++ b/app/code_links.py @@ -7,6 +7,7 @@ a token in a branch name (`feature/12-login`, `issue-12`, `12-fix`). """ import re +import unicodedata _REF_RE = re.compile(r"(? int | None: return int(m.group(1)) if m else None +def slugify_title(title: str, max_len: int = 40) -> str: + """ASCII slug for a branch name: 'Créer la branche !' -> 'creer-la-branche'.""" + normalized = unicodedata.normalize("NFKD", title or "") + ascii_text = normalized.encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-") + slug = re.sub(r"-{2,}", "-", slug) + if len(slug) > max_len: + slug = slug[:max_len].rstrip("-") + return slug or "issue" + + +def branch_name_for(number: int, title: str) -> str: + """The branch a card would create (issue #3): issue--. + + The name is recognized back by branch_issue_number, which is + what links the future PR to this card (issue #2). + """ + return f"issue-{number}-{slugify_title(title)}" + + def find_code_links( branches: list[dict], pulls: list[dict], diff --git a/app/gitea_client.py b/app/gitea_client.py index d18eeed..3bf5c6c 100644 --- a/app/gitea_client.py +++ b/app/gitea_client.py @@ -341,6 +341,19 @@ class GiteaClient: self._cache_clear() return r.json() + async def create_branch( + self, owner: str, repo: str, new_branch: str, old_branch: str + ) -> dict: + """Create a branch from another one (issue #3).""" + return await self._request( + "POST", + f"/repos/{owner}/{repo}/branches", + json={ + "new_branch_name": new_branch, + "old_branch_name": old_branch, + }, + ) + 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") diff --git a/app/main.py b/app/main.py index bb1531e..51904e9 100644 --- a/app/main.py +++ b/app/main.py @@ -37,6 +37,7 @@ 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 flow as flow_service from app import forecast as forecast_service from app import rules as rules_service from app import sprint as sprint_service @@ -1252,6 +1253,94 @@ async def api_rules_run(request: Request, csrf_token: str = Form("")): return {"ok": True, "summary": summary} +async def _project_health(owner: str, repo: str) -> dict: + """Bottlenecks + zombies for one project (issue #9).""" + full_name = f"{owner}/{repo}" + settings = get_settings() + all_items = await gitea.get_repo_issues(owner, repo, state="all") + columns, display_issues, _bs = _board_columns_for( + owner, repo, all_items + ) + events = get_events(repo_full_name=full_name, limit=2000) + dwell = flow_service.summarize_dwell(flow_service.column_dwell(events)) + slowest = flow_service.slowest_column(dwell) + activity = flow_service.last_activity(events) + placement = { + issue["number"]: col + for col, cards in columns.items() + for issue in cards + } + now = datetime.now() + zombies = [] + for issue in display_issues: + number = issue["number"] + candidates = [ + ts for ts in ( + activity.get((full_name, number)), + flow_service.parse_ts(issue.get("updated_at")), + flow_service.parse_ts(issue.get("created_at")), + ) + if ts is not None + ] + if not candidates: + continue + days = (now - max(candidates)).days + if days >= settings.zombie_days: + column = placement.get(number, "backlog") + zombies.append( + { + "number": number, + "title": issue.get("title", ""), + "column": column, + "column_label": COLUMN_LABELS[column], + "days_inactive": days, + "html_url": issue.get("html_url", ""), + } + ) + zombies.sort(key=lambda z: -z["days_inactive"]) + return { + "full_name": full_name, + "dwell": dwell, + "slowest": slowest, + "zombies": zombies, + "zombie_days": settings.zombie_days, + "open_count": len(display_issues), + "column_labels": COLUMN_LABELS, + } + + +@app.get("/health", response_class=HTMLResponse) +async def health_index(request: Request): + """Flow health, global summary (issue #9).""" + repos = await gitea.search_repos() + active, _inactive, _archived = classify_repos(repos) + projects = [] + for repo_info in active: + owner, _, name = repo_info["full_name"].partition("/") + try: + projects.append(await _project_health(owner, name)) + except Exception as exc: + logger.warning("Health failed for %s: %s", repo_info["full_name"], exc) + return render(request, "health.html", projects=projects) + + +@app.get("/health/{owner}/{repo}", response_class=HTMLResponse) +async def health_project(request: Request, owner: str, repo: str): + """Flow health of one project: dwell per column + zombies.""" + repos = await gitea.search_repos() + known = {r["full_name"] for r in repos} + if f"{owner}/{repo}" not in known: + return render( + request, "health_project.html", + health=None, full_name=f"{owner}/{repo}", + ) + health = await _project_health(owner, repo) + return render( + request, "health_project.html", + health=health, full_name=f"{owner}/{repo}", + ) + + @app.get("/api/standup") async def api_standup(): """The automatic standup as JSON text (issue #6).""" @@ -2490,7 +2579,146 @@ 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} + suggested = f"issue-{number}" + try: + issue = await gitea.get_issue(owner, repo, number) + suggested = code_links_service.branch_name_for( + number, issue.get("title", "") + ) + except Exception: + pass + return {"ok": True, "suggested_branch": suggested, **links} + + +_BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") + + +async def _sync_kanban_label( + owner: str, repo: str, number: int, column: str +) -> None: + """Mirror a column on the issue's kanban:* label (best effort).""" + if not get_settings().sync_labels: + return + try: + desired = ( + f"kanban:{column}" if column in LABELLED_COLUMNS else None + ) + issue = await gitea.get_issue(owner, repo, number) + current = [ + label for label in issue.get("labels") or [] + if str(label.get("name", "")).startswith("kanban:") + ] + for label in current: + if label.get("name") != desired: + await gitea.remove_issue_label( + owner, repo, number, label["id"] + ) + if desired and all(l.get("name") != desired for l in current): + label = await gitea.ensure_label( + owner, repo, desired, COLUMN_COLORS[column] + ) + await gitea.add_issue_label(owner, repo, number, label["id"]) + except Exception as exc: + logger.warning( + "Label sync failed for %s/%s#%s: %s", owner, repo, number, exc + ) + + +@app.post("/api/issues/{owner}/{repo}/{number}/branch") +async def api_create_branch( + owner: str, + repo: str, + number: int, + request: Request, + name: str = Form(""), + csrf_token: str = Form(""), +): + """Create the card's branch in Gitea (issue #3), then start the card. + + Default name: issue-- — the convention the + merged-PR automation (issue #2) recognizes. Creating the branch + moves the card to In Progress and journals it. + """ + if not _csrf_valid(request, csrf_token): + return _csrf_error() + try: + issue = await gitea.get_issue(owner, repo, number) + except Exception: + return JSONResponse( + {"ok": False, "error": "Issue introuvable"}, status_code=404 + ) + if not _is_issue(issue): + return JSONResponse( + {"ok": False, "error": "Pas une issue"}, status_code=400 + ) + branch = (name or "").strip() or code_links_service.branch_name_for( + number, issue.get("title", "") + ) + if ( + not _BRANCH_NAME_RE.match(branch) + or ".." in branch + or branch.endswith("/") + or branch.endswith(".lock") + ): + return JSONResponse( + {"ok": False, "error": f"Nom de branche invalide : {branch}"}, + status_code=400, + ) + repo_info = {} + try: + repo_info = await gitea.get_repo(owner, repo) + except Exception: + pass + base_url = repo_info.get("html_url", "") + branch_url = f"{base_url}/src/branch/{branch}" if base_url else "" + existing = await _safe_list_branches(owner, repo) + if branch in existing: + return { + "ok": True, "branch": branch, "exists": True, + "url": branch_url, "moved": False, + } + default_branch = repo_info.get("default_branch") or "main" + try: + await gitea.create_branch(owner, repo, branch, default_branch) + except Exception as exc: + return JSONResponse( + {"ok": False, "error": f"Création impossible : {exc}"}, + status_code=502, + ) + gitea.invalidate_cache() + + full_name = f"{owner}/{repo}" + moved = False + if issue.get("state") == "open": + board = get_board_state(full_name) + current = next( + ( + col for col, cards in board.items() + if any(c["issue_number"] == number for c in cards) + ), + "backlog", + ) + if current != "in_progress": + move_issue(full_name, number, "in_progress") + log_event( + full_name, number, "moved", + from_column=current, to_column="in_progress", + source="branch", + ) + await _sync_kanban_label(owner, repo, number, "in_progress") + moved = True + await _publish_board_changed(full_name) + return { + "ok": True, "branch": branch, "from": default_branch, + "url": branch_url, "moved": moved, + } + + +async def _safe_list_branches(owner: str, repo: str) -> list[str]: + try: + return [b.get("name", "") for b in await gitea.list_branches(owner, repo)] + except Exception: + return [] @app.get("/api/issues/{owner}/{repo}/{number}") diff --git a/static/css/style.css b/static/css/style.css index caeb997..4f98e27 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1151,3 +1151,16 @@ footer { .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; } + +/* ── Branche depuis la carte (0.14.0, #3) ── */ +.branch-input { + background: var(--surface-3, #1d2942); color: var(--text-1); + border: 1px solid var(--border, #22304a); border-radius: 6px; + padding: 3px 8px; font-family: monospace; font-size: 0.85rem; +} + +/* ── Santé du flux (0.14.0, #9) ── */ +.health-table { width: 100%; border-collapse: collapse; margin: 10px 0; } +.health-table th, .health-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border, #22304a); } +.health-slowest td { color: var(--warn, #e8a33d); font-weight: 600; } +.zombie-actions { display: flex; gap: 6px; flex-shrink: 0; } diff --git a/static/js/board.js b/static/js/board.js index 34f075a..3c48e92 100644 --- a/static/js/board.js +++ b/static/js/board.js @@ -397,9 +397,37 @@ document.addEventListener('DOMContentLoaded', () => { 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('') - : ''; + const hasBranch = (data.branches || []).length > 0; + if (!hasBranch && data.suggested_branch) { + parts.push(`🌿 `); + } + box.innerHTML = '

🧬 Code lié

' + ( + parts.length + ? parts.map(p => `
${p}
`).join('') + : '
Aucun élément lié pour l\'instant.
' + ); + const createBtn = document.getElementById('branch-create'); + if (createBtn) { + createBtn.addEventListener('click', async () => { + const name = (document.getElementById('branch-name') || {}).value || ''; + const form = new FormData(); + form.append('name', name); + form.append('csrf_token', csrfToken); + createBtn.disabled = true; + try { + const r2 = await fetch(`/api/issues/${owner}/${repo}/${number}/branch`, { method: 'POST', body: form }); + const body = await r2.json().catch(() => ({})); + if (!r2.ok || !body.ok) throw new Error(body.error || ('HTTP ' + r2.status)); + showToast(body.exists + ? `🌿 La branche ${body.branch} existait déjà` + : `🌿 Branche ${body.branch} créée depuis ${body.from} — carte en « En cours »`); + loadCodeLinks(repoFull, number); + } catch (err) { + createBtn.disabled = false; + showToast('❌ Branche non créée : ' + err.message, true); + } + }); + } } catch (err) { /* le code est un bonus, jamais bloquant */ } } diff --git a/tests/conftest.py b/tests/conftest.py index edd3dcf..348b4ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,7 @@ def _repo(name: str, updated: datetime, archived: bool = False) -> dict: "owner": {"login": "Projets"}, "archived": archived, "html_url": f"http://gitea.test/Projets/{name}", + "default_branch": "main", } @@ -72,6 +73,7 @@ class FakeGitea: _repo("vieux", now - timedelta(days=800)), _repo("archive", now - timedelta(days=1), archived=True), ] + self.branches = ["main", "feature/1-tache-un", "issue-5-focus"] self.issues = { "Projets/demo": [ _issue(1, "Tâche un"), @@ -224,11 +226,13 @@ class FakeGitea: 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"}, - ] + return [{"name": n} for n in self.branches] + + async def create_branch(self, owner, repo, new_branch, old_branch): + if new_branch in self.branches: + raise RuntimeError("branch exists") + self.branches.append(new_branch) + return {"name": new_branch} async def list_pulls(self, owner, repo, state="all"): return [