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:
@@ -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],
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user