feat(branch): créer la branche depuis la carte (issue #3)

slugify + branch_name_for (issue-<n>-<slug>, reconnu par #1/#2);
client create_branch; POST /api/issues/{o}/{r}/{n}/branch (nom
libre validé, idempotent si existante); la carte passe en En
cours, journalisée source branch, étiquette synchronisée;
formulaire dans la section Code lié du panneau détail.
This commit is contained in:
Spark
2026-10-11 14:49:38 -04:00
parent 5b41c5ea74
commit 1932d11457
6 changed files with 316 additions and 9 deletions
+21
View File
@@ -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"(?<![\w#])#(\d+)\b")
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
@@ -27,6 +28,26 @@ def branch_issue_number(branch_name: str | None) -> 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-<n>-<slug>.
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],
+13
View File
@@ -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")
+229 -1
View File
@@ -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-<n>-<slug of the title> — 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}")
+13
View File
@@ -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; }
+31 -3
View File
@@ -397,9 +397,37 @@ document.addEventListener('DOMContentLoaded', () => {
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('')
: '';
const hasBranch = (data.branches || []).length > 0;
if (!hasBranch && data.suggested_branch) {
parts.push(`🌿 <input id="branch-name" class="branch-input" value="${data.suggested_branch}" size="36" spellcheck="false"> <button type="button" class="btn-sm" id="branch-create">Créer la branche</button>`);
}
box.innerHTML = '<h3 class="detail-comments-title">🧬 Code lié</h3>' + (
parts.length
? parts.map(p => `<div class="code-line">${p}</div>`).join('')
: '<div class="code-line">Aucun élément lié pour l\'instant.</div>'
);
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 */ }
}
+9 -5
View File
@@ -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 [