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 re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
_REF_RE = re.compile(r"(?<![\w#])#(\d+)\b")
|
_REF_RE = re.compile(r"(?<![\w#])#(\d+)\b")
|
||||||
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
|
_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
|
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(
|
def find_code_links(
|
||||||
branches: list[dict],
|
branches: list[dict],
|
||||||
pulls: list[dict],
|
pulls: list[dict],
|
||||||
|
|||||||
@@ -341,6 +341,19 @@ class GiteaClient:
|
|||||||
self._cache_clear()
|
self._cache_clear()
|
||||||
return r.json()
|
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]:
|
async def list_branches(self, owner: str, repo: str) -> list[dict]:
|
||||||
"""All branches of a repo (code links, issue #1)."""
|
"""All branches of a repo (code links, issue #1)."""
|
||||||
return await self._get_all_pages(f"/repos/{owner}/{repo}/branches")
|
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.gitea_client import GiteaClient
|
||||||
from app import code_links as code_links_service
|
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 flow as flow_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 rules as rules_service
|
||||||
from app import sprint as sprint_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}
|
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")
|
@app.get("/api/standup")
|
||||||
async def api_standup():
|
async def api_standup():
|
||||||
"""The automatic standup as JSON text (issue #6)."""
|
"""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, PRs and commits linked to a card (issue #1)."""
|
||||||
branches, pulls, commits = await _fetch_code_sources(owner, repo)
|
branches, pulls, commits = await _fetch_code_sources(owner, repo)
|
||||||
links = code_links_service.find_code_links(branches, pulls, commits, number)
|
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}")
|
@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 { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); }
|
||||||
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
|
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
|
||||||
.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; }
|
.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)) {
|
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 : ''}`);
|
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
|
const hasBranch = (data.branches || []).length > 0;
|
||||||
? '<h3 class="detail-comments-title">🧬 Code lié</h3>' + parts.map(p => `<div class="code-line">${p}</div>`).join('')
|
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 */ }
|
} 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"},
|
"owner": {"login": "Projets"},
|
||||||
"archived": archived,
|
"archived": archived,
|
||||||
"html_url": f"http://gitea.test/Projets/{name}",
|
"html_url": f"http://gitea.test/Projets/{name}",
|
||||||
|
"default_branch": "main",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ class FakeGitea:
|
|||||||
_repo("vieux", now - timedelta(days=800)),
|
_repo("vieux", now - timedelta(days=800)),
|
||||||
_repo("archive", now - timedelta(days=1), archived=True),
|
_repo("archive", now - timedelta(days=1), archived=True),
|
||||||
]
|
]
|
||||||
|
self.branches = ["main", "feature/1-tache-un", "issue-5-focus"]
|
||||||
self.issues = {
|
self.issues = {
|
||||||
"Projets/demo": [
|
"Projets/demo": [
|
||||||
_issue(1, "Tâche un"),
|
_issue(1, "Tâche un"),
|
||||||
@@ -224,11 +226,13 @@ class FakeGitea:
|
|||||||
self.label_ops.append(("remove", number, label["name"]))
|
self.label_ops.append(("remove", number, label["name"]))
|
||||||
|
|
||||||
async def list_branches(self, owner, repo):
|
async def list_branches(self, owner, repo):
|
||||||
return [
|
return [{"name": n} for n in self.branches]
|
||||||
{"name": "main"},
|
|
||||||
{"name": "feature/1-tache-un"},
|
async def create_branch(self, owner, repo, new_branch, old_branch):
|
||||||
{"name": "issue-5-focus"},
|
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"):
|
async def list_pulls(self, owner, repo, state="all"):
|
||||||
return [
|
return [
|
||||||
|
|||||||
Reference in New Issue
Block a user