feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
CI / test (push) Successful in 2m17s
CI / test (push) Successful in 2m17s
- Table board_columns par espace (clé immuable, nom/couleur/
ordre libres, étiquette mappée + alias, drapeau Focus) ;
définitions semées paresseusement depuis les valeurs
historiques — sans personnalisation, comportement identique.
- Service app/board_columns.py : résolution, mapping
étiquette→colonne (alias compris, ex. kanban_a_faire de
Flowdeck), colonnes Focus/Wallboard.
- Board, API, adoption, miroirs d'étiquettes, déplacements,
capture, santé, focus, wall, dashboard branchés sur les
colonnes de l'espace ; validations dynamiques dans models
(move_issue, set_column_order) et rules.
- Administration → Colonnes : ajouter (avant Terminé),
renommer, réordonner, mapper, supprimer avec migration des
cartes vers une colonne choisie, réinitialiser ; bouton
🧱 Colonnes sur le board ; journal d'audit.
- Backlog et Terminé : colonnes système non supprimables.
- 254 tests verts (7 nouveaux, palier 20) ; smoke réel contre
le vrai Gitea : création, affichage au board, suppression.
This commit is contained in:
+376
-71
@@ -51,6 +51,7 @@ from app import forecast as forecast_service
|
||||
from app import rules as rules_service
|
||||
from app import sprint as sprint_service
|
||||
from app import connections as connections_service
|
||||
from app import board_columns as bcols
|
||||
from app.briefing import (
|
||||
format_briefing_text,
|
||||
format_standup_text,
|
||||
@@ -59,6 +60,7 @@ from app.briefing import (
|
||||
)
|
||||
from app.capture import ntfy_capture_loop, parse_capture_text
|
||||
from app import agent_journal
|
||||
from app import board_columns as bcols
|
||||
from app import models
|
||||
from app.models import (
|
||||
KANBAN_COLUMNS,
|
||||
@@ -814,7 +816,10 @@ async def archives_export(
|
||||
]
|
||||
for column in KANBAN_COLUMNS:
|
||||
cards = board.get(column) or []
|
||||
lines.append(f"### {COLUMN_LABELS[column]} ({len(cards)})")
|
||||
lines.append(
|
||||
f"### {bcols.names(workspace['id']).get(column, column)} "
|
||||
f"({len(cards)})"
|
||||
)
|
||||
for card in cards:
|
||||
lines.append(f"- #{card['issue_number']}")
|
||||
lines.append("")
|
||||
@@ -853,6 +858,22 @@ async def archives_export(
|
||||
WORKSPACE_COOKIE = "kanbante_workspace"
|
||||
|
||||
|
||||
def _ws_names(request: Request) -> dict[str, str]:
|
||||
"""Display names of the current workspace's columns (0.30.0)."""
|
||||
workspace, _role = _current_workspace(request)
|
||||
return bcols.names(workspace["id"] if workspace else 1)
|
||||
|
||||
|
||||
def _ws_colors(request: Request) -> dict[str, str]:
|
||||
workspace, _role = _current_workspace(request)
|
||||
return bcols.colors(workspace["id"] if workspace else 1)
|
||||
|
||||
|
||||
def _ws_keys(request: Request) -> list[str]:
|
||||
workspace, _role = _current_workspace(request)
|
||||
return bcols.keys(workspace["id"] if workspace else 1)
|
||||
|
||||
|
||||
def _workspace_role_for(workspace_id: int, user: dict | None) -> str | None:
|
||||
if user is None or user.get("id") is None:
|
||||
return "admin" # mode env / auth désactivée : accès complet
|
||||
@@ -1495,6 +1516,259 @@ async def admin_workspace_delete(
|
||||
)
|
||||
|
||||
|
||||
# ── Administration : colonnes du board (0.30.0) ───────────
|
||||
|
||||
def _column_key_from_name(name: str, taken: set[str]) -> str:
|
||||
import unicodedata
|
||||
|
||||
folded = unicodedata.normalize("NFKD", name or "")
|
||||
folded = folded.encode("ascii", "ignore").decode("ascii")
|
||||
base = re.sub(r"[^a-z0-9]+", "_", folded.lower()).strip("_")
|
||||
base = base or "colonne"
|
||||
key, n = base, 2
|
||||
while key in taken:
|
||||
key = f"{base}_{n}"
|
||||
n += 1
|
||||
return key
|
||||
|
||||
|
||||
def _renumber_columns(workspace_id: int) -> None:
|
||||
for position, column in enumerate(
|
||||
models.list_board_columns(workspace_id)
|
||||
):
|
||||
if column["position"] != position:
|
||||
models.upsert_board_column(
|
||||
workspace_id, {**column, "position": position}
|
||||
)
|
||||
bcols.invalidate(workspace_id)
|
||||
|
||||
|
||||
@app.get("/admin/columns")
|
||||
async def admin_columns(request: Request, msg: str = "", err: str = ""):
|
||||
workspace, _role = _current_workspace(request)
|
||||
if workspace is None or (
|
||||
_can_admin_workspace(request, workspace["id"]) is None
|
||||
):
|
||||
return _admin_denied(request)
|
||||
columns = bcols.get_columns(workspace["id"])
|
||||
counts = {
|
||||
c["key"]: models.count_column_cards(workspace["id"], c["key"])
|
||||
for c in columns
|
||||
}
|
||||
return render(
|
||||
request, "admin_columns.html",
|
||||
columns=columns, counts=counts, workspace=workspace,
|
||||
msg=msg, err=err,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/columns")
|
||||
async def admin_column_create(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
color: str = Form("#7c5cfc"),
|
||||
label: str = Form(""),
|
||||
aliases: str = Form(""),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
workspace, _role = _current_workspace(request)
|
||||
if workspace is None:
|
||||
return _admin_denied(request)
|
||||
admin = _can_admin_workspace(request, workspace["id"])
|
||||
if admin is None:
|
||||
return _admin_denied(request)
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return RedirectResponse(
|
||||
"/admin/columns?err=Nom+requis", status_code=303
|
||||
)
|
||||
ws_id = workspace["id"]
|
||||
columns = bcols.get_columns(ws_id)
|
||||
key = _column_key_from_name(name, {c["key"] for c in columns})
|
||||
# Insérée juste avant la colonne « done ».
|
||||
done_pos = next(
|
||||
(c["position"] for c in columns if c["kind"] == "done"),
|
||||
len(columns),
|
||||
)
|
||||
for column in columns:
|
||||
if column["position"] >= done_pos:
|
||||
models.upsert_board_column(
|
||||
ws_id, {**column, "position": column["position"] + 1}
|
||||
)
|
||||
models.upsert_board_column(
|
||||
ws_id,
|
||||
{
|
||||
"key": key, "name": name,
|
||||
"color": color if color.startswith("#") else "#7c5cfc",
|
||||
"position": done_pos,
|
||||
"label": label.strip() or f"kanban:{key}",
|
||||
"aliases": aliases.strip(),
|
||||
"kind": "flow", "in_focus": 1,
|
||||
},
|
||||
)
|
||||
_renumber_columns(ws_id)
|
||||
models.log_admin(
|
||||
admin.get("id"), "column.create", f"{key} (espace {workspace['slug']})"
|
||||
)
|
||||
return RedirectResponse(
|
||||
"/admin/columns?msg=Colonne+ajout%C3%A9e", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/columns/reset")
|
||||
async def admin_columns_reset(
|
||||
request: Request, csrf_token: str = Form("")
|
||||
):
|
||||
workspace, _role = _current_workspace(request)
|
||||
if workspace is None:
|
||||
return _admin_denied(request)
|
||||
admin = _can_admin_workspace(request, workspace["id"])
|
||||
if admin is None:
|
||||
return _admin_denied(request)
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
models.clear_board_columns(workspace["id"])
|
||||
bcols.invalidate(workspace["id"])
|
||||
models.log_admin(
|
||||
admin.get("id"), "column.reset", f"espace {workspace['slug']}"
|
||||
)
|
||||
return RedirectResponse(
|
||||
"/admin/columns?msg=Colonnes+r%C3%A9initialis%C3%A9es+aux+valeurs+par+d%C3%A9faut",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/columns/{key}")
|
||||
async def admin_column_update(
|
||||
request: Request,
|
||||
key: str,
|
||||
name: str = Form(""),
|
||||
color: str = Form(""),
|
||||
label: str = Form(""),
|
||||
aliases: str = Form(""),
|
||||
in_focus: str = Form(""),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
workspace, _role = _current_workspace(request)
|
||||
if workspace is None:
|
||||
return _admin_denied(request)
|
||||
admin = _can_admin_workspace(request, workspace["id"])
|
||||
if admin is None:
|
||||
return _admin_denied(request)
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
ws_id = workspace["id"]
|
||||
current = next(
|
||||
(c for c in bcols.get_columns(ws_id) if c["key"] == key), None
|
||||
)
|
||||
if current is None:
|
||||
return RedirectResponse(
|
||||
"/admin/columns?err=Colonne+inconnue", status_code=303
|
||||
)
|
||||
updated = dict(current)
|
||||
if name.strip():
|
||||
updated["name"] = name.strip()
|
||||
if color.startswith("#"):
|
||||
updated["color"] = color
|
||||
if current["kind"] == "flow":
|
||||
updated["label"] = label.strip()
|
||||
updated["aliases"] = aliases.strip()
|
||||
updated["in_focus"] = 1 if in_focus else 0
|
||||
models.upsert_board_column(ws_id, updated)
|
||||
bcols.invalidate(ws_id)
|
||||
models.log_admin(
|
||||
admin.get("id"), "column.update", f"{key} (espace {workspace['slug']})"
|
||||
)
|
||||
return RedirectResponse(
|
||||
"/admin/columns?msg=Colonne+mise+%C3%A0+jour", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/columns/{key}/move")
|
||||
async def admin_column_move(
|
||||
request: Request,
|
||||
key: str,
|
||||
direction: str = Form("up"),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
workspace, _role = _current_workspace(request)
|
||||
if workspace is None:
|
||||
return _admin_denied(request)
|
||||
admin = _can_admin_workspace(request, workspace["id"])
|
||||
if admin is None:
|
||||
return _admin_denied(request)
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
ws_id = workspace["id"]
|
||||
columns = bcols.get_columns(ws_id)
|
||||
index = next(
|
||||
(i for i, c in enumerate(columns) if c["key"] == key), None
|
||||
)
|
||||
if index is None:
|
||||
return RedirectResponse("/admin/columns", status_code=303)
|
||||
swap = index - 1 if direction == "up" else index + 1
|
||||
if 0 <= swap < len(columns):
|
||||
other = columns[swap]
|
||||
mine = columns[index]
|
||||
models.upsert_board_column(
|
||||
ws_id, {**mine, "position": other["position"]}
|
||||
)
|
||||
models.upsert_board_column(
|
||||
ws_id, {**other, "position": mine["position"]}
|
||||
)
|
||||
_renumber_columns(ws_id)
|
||||
models.log_admin(
|
||||
admin.get("id"), "column.move",
|
||||
f"{key} {direction} (espace {workspace['slug']})",
|
||||
)
|
||||
return RedirectResponse("/admin/columns", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/columns/{key}/delete")
|
||||
async def admin_column_delete(
|
||||
request: Request,
|
||||
key: str,
|
||||
target: str = Form(""),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
workspace, _role = _current_workspace(request)
|
||||
if workspace is None:
|
||||
return _admin_denied(request)
|
||||
admin = _can_admin_workspace(request, workspace["id"])
|
||||
if admin is None:
|
||||
return _admin_denied(request)
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
ws_id = workspace["id"]
|
||||
columns = bcols.get_columns(ws_id)
|
||||
current = next((c for c in columns if c["key"] == key), None)
|
||||
if current is None or current["kind"] != "flow":
|
||||
return RedirectResponse(
|
||||
"/admin/columns?err=Colonne+syst%C3%A8me+non+supprimable",
|
||||
status_code=303,
|
||||
)
|
||||
if target not in {c["key"] for c in columns} or target == key:
|
||||
return RedirectResponse(
|
||||
"/admin/columns?err=Choisis+une+colonne+de+destination",
|
||||
status_code=303,
|
||||
)
|
||||
moved = models.migrate_column_cards(ws_id, key, target)
|
||||
models.delete_board_column(ws_id, key)
|
||||
_renumber_columns(ws_id)
|
||||
models.log_admin(
|
||||
admin.get("id"), "column.delete",
|
||||
f"{key} -> {target} ({moved} cartes, espace {workspace['slug']})",
|
||||
)
|
||||
from urllib.parse import quote as _q
|
||||
|
||||
return RedirectResponse(
|
||||
f"/admin/columns?msg={_q(f'Colonne supprimée, {moved} carte(s) déplacée(s)')}",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin/settings")
|
||||
async def admin_settings(request: Request, msg: str = "", err: str = ""):
|
||||
admin = _require_admin(request)
|
||||
@@ -1849,6 +2123,7 @@ _NAV_BY_TEMPLATE = {
|
||||
"admin.html": "admin",
|
||||
"admin_connections.html": "admin_connections",
|
||||
"admin_workspaces.html": "admin_workspaces",
|
||||
"admin_columns.html": "admin_columns",
|
||||
"admin_settings.html": "admin_settings",
|
||||
"archives.html": "archives",
|
||||
}
|
||||
@@ -2003,7 +2278,9 @@ def _is_issue(item: dict) -> bool:
|
||||
|
||||
|
||||
def _merge_issues_with_board(
|
||||
issues: list[dict], board: dict[str, list[dict]]
|
||||
issues: list[dict],
|
||||
board: dict[str, list[dict]],
|
||||
column_keys: list[str] | None = None,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Merge Gitea issues into kanban columns, honouring saved order."""
|
||||
placement: dict[int, tuple[str, float]] = {}
|
||||
@@ -2011,9 +2288,13 @@ def _merge_issues_with_board(
|
||||
for card in cards:
|
||||
placement[card["issue_number"]] = (col_name, card["position"])
|
||||
|
||||
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
|
||||
columns: dict[str, list[dict]] = {
|
||||
col: [] for col in (column_keys or KANBAN_COLUMNS)
|
||||
}
|
||||
for index, issue in enumerate(issues):
|
||||
col, pos = placement.get(issue["number"], ("backlog", None))
|
||||
if col not in columns:
|
||||
col, pos = "backlog", None
|
||||
issue["_sort"] = (0, pos, index) if pos is not None else (1, 0.0, index)
|
||||
columns[col].append(issue)
|
||||
for col in columns.values():
|
||||
@@ -2075,16 +2356,15 @@ def _adopt_label_columns(
|
||||
for col_name, cards in board_state.items():
|
||||
for card in cards:
|
||||
placement[card["issue_number"]] = col_name
|
||||
lookup = bcols.label_lookup(workspace_id)
|
||||
for issue in open_issues:
|
||||
names = [
|
||||
str(label.get("name", "")) for label in issue.get("labels") or []
|
||||
]
|
||||
kanban = [n for n in names if n.startswith("kanban:")]
|
||||
if len(kanban) != 1:
|
||||
continue
|
||||
target = kanban[0].split(":", 1)[1]
|
||||
if target not in LABELLED_COLUMNS:
|
||||
recognized = [lookup[n.lower()] for n in names if n.lower() in lookup]
|
||||
if len(recognized) != 1:
|
||||
continue
|
||||
target = recognized[0]
|
||||
current = placement.get(issue["number"], "backlog")
|
||||
if current != target:
|
||||
move_issue(
|
||||
@@ -2288,7 +2568,7 @@ def _health_dot(health: dict) -> dict:
|
||||
parts.append(f"{len(zombies)} zombie(s)")
|
||||
if slowest:
|
||||
parts.append(
|
||||
f"goulot : {COLUMN_LABELS[slowest['column']]}"
|
||||
f"goulot : {bcols.names(health.get('_ws_id', 1)).get(slowest['column'], slowest['column'])}"
|
||||
f" (médiane {median} j)"
|
||||
)
|
||||
return {
|
||||
@@ -2382,8 +2662,9 @@ async def dashboard(request: Request):
|
||||
inactive_listed=[r for r in inactive if not r.get("starred")],
|
||||
pinned_repos=pinned,
|
||||
stats=get_stats(),
|
||||
COLUMN_LABELS=COLUMN_LABELS,
|
||||
COLUMN_COLORS=COLUMN_COLORS,
|
||||
COLUMN_LABELS=bcols.names(ws_id),
|
||||
COLUMN_COLORS=bcols.colors(ws_id),
|
||||
column_keys=bcols.keys(ws_id),
|
||||
active_window_days=get_settings().active_window_days,
|
||||
metrics=get_metrics(window_days=30),
|
||||
multi_conn=len(models.workspace_connection_ids(ws_id)) > 1,
|
||||
@@ -2516,7 +2797,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
board_state = get_board_state(
|
||||
full_name, workspace_id=ws_id, connection_id=conn_id
|
||||
)
|
||||
columns = _merge_issues_with_board(display_issues, board_state)
|
||||
columns = _merge_issues_with_board(
|
||||
display_issues, board_state, bcols.keys(ws_id)
|
||||
)
|
||||
await _annotate_code(owner, repo, columns, client)
|
||||
|
||||
labels: dict[str, dict] = {}
|
||||
@@ -2538,8 +2821,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
repo=repo,
|
||||
full_name=full_name,
|
||||
columns=columns,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
column_keys=bcols.keys(ws_id),
|
||||
column_labels=bcols.names(ws_id),
|
||||
column_colors=bcols.colors(ws_id),
|
||||
labels=sorted(labels.values(), key=lambda l: l["name"].lower()),
|
||||
notes=get_notes(
|
||||
full_name, workspace_id=ws_id, connection_id=conn_id
|
||||
@@ -2613,7 +2897,9 @@ def _board_columns_for(
|
||||
)
|
||||
|
||||
display_issues = [i for i in display_issues if _in_ms(i)]
|
||||
columns = _merge_issues_with_board(display_issues, board_state)
|
||||
columns = _merge_issues_with_board(
|
||||
display_issues, board_state, bcols.keys(workspace_id)
|
||||
)
|
||||
return columns, display_issues, board_state
|
||||
|
||||
|
||||
@@ -2642,7 +2928,7 @@ async def api_board(
|
||||
)
|
||||
card_template = _tpl_env.get_template("_card.html")
|
||||
payload: dict[str, dict] = {}
|
||||
for col in KANBAN_COLUMNS:
|
||||
for col in bcols.keys(ws_id):
|
||||
cards = columns[col]
|
||||
if cards:
|
||||
html = "".join(
|
||||
@@ -2738,10 +3024,11 @@ async def _collect_focus_groups(
|
||||
get_board_state(
|
||||
full_name, workspace_id=workspace_id, connection_id=cid
|
||||
),
|
||||
bcols.keys(workspace_id),
|
||||
)
|
||||
cards = [
|
||||
{"issue": issue, "column": col}
|
||||
for col in FOCUS_COLUMNS
|
||||
for col in bcols.focus_keys(workspace_id)
|
||||
for issue in columns[col]
|
||||
]
|
||||
if cards:
|
||||
@@ -2861,8 +3148,8 @@ async def project_page(request: Request, owner: str, repo: str):
|
||||
events=events,
|
||||
notes=get_notes(full_name, workspace_id=ws_id, connection_id=conn_id),
|
||||
EVENT_ICONS=EVENT_ICONS,
|
||||
COLUMN_LABELS=COLUMN_LABELS,
|
||||
COLUMN_COLORS=COLUMN_COLORS,
|
||||
COLUMN_LABELS=bcols.names(ws_id),
|
||||
COLUMN_COLORS=bcols.colors(ws_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -2932,7 +3219,7 @@ async def triage_session(request: Request, owner: str, repo: str):
|
||||
full_name=full_name,
|
||||
queue=queue,
|
||||
queue_json=json.dumps(queue, ensure_ascii=False),
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_labels=bcols.names(ws_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -2969,7 +3256,7 @@ async def _enrich_pick(
|
||||
"title": issue.get("title", ""),
|
||||
"state": issue.get("state", "open"),
|
||||
"column": column,
|
||||
"column_label": COLUMN_LABELS[column],
|
||||
"column_label": bcols.names(workspace_id).get(column, column),
|
||||
"html_url": issue.get("html_url", ""),
|
||||
}
|
||||
|
||||
@@ -3076,7 +3363,9 @@ async def today_page(request: Request):
|
||||
"repo": group["full_name"],
|
||||
"number": card["issue"]["number"],
|
||||
"title": card["issue"].get("title", ""),
|
||||
"column_label": COLUMN_LABELS[card["column"]],
|
||||
"column_label": bcols.names(ws_id).get(
|
||||
card["column"], card["column"]
|
||||
),
|
||||
}
|
||||
)
|
||||
candidates = candidates[:8]
|
||||
@@ -3096,12 +3385,12 @@ async def today_page(request: Request):
|
||||
carry_over=carry_over,
|
||||
candidates=candidates,
|
||||
recap=recap,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_labels=bcols.names(ws_id),
|
||||
)
|
||||
|
||||
|
||||
async def _sprint_markdown(
|
||||
owner: str, repo: str, milestone: str, client=None
|
||||
owner: str, repo: str, milestone: str, client=None, workspace_id: int = 1
|
||||
):
|
||||
"""Assemble the sprint story; (title, markdown) or None."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
@@ -3188,7 +3477,7 @@ async def _sprint_markdown(
|
||||
{
|
||||
"number": issue["number"],
|
||||
"title": issue.get("title", ""),
|
||||
"column_label": COLUMN_LABELS[col],
|
||||
"column_label": bcols.names(workspace_id).get(col, col),
|
||||
}
|
||||
)
|
||||
delivered.sort(key=lambda d: d["number"])
|
||||
@@ -3233,7 +3522,9 @@ async def sprint_page(request: Request, owner: str, repo: str, milestone: str =
|
||||
full_name = f"{owner}/{repo}"
|
||||
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
|
||||
result = (
|
||||
await _sprint_markdown(owner, repo, milestone, client)
|
||||
await _sprint_markdown(
|
||||
owner, repo, milestone, client, workspace_id=ws_id
|
||||
)
|
||||
if milestone
|
||||
else None
|
||||
)
|
||||
@@ -3290,7 +3581,7 @@ async def _apply_rule_actions(
|
||||
text = (
|
||||
f"⚙️ Règle « {rule['name']} »\n"
|
||||
f"{full_name} #{number} « {issue.get('title', '')} » — "
|
||||
f"{COLUMN_LABELS[column]}{age_txt}"
|
||||
f"{bcols.names(1).get(column, column)}{age_txt}"
|
||||
)
|
||||
if await send_ntfy(get_settings(), text):
|
||||
set_kv(guard, "1")
|
||||
@@ -3447,20 +3738,23 @@ async def _project_health(
|
||||
"number": number,
|
||||
"title": issue.get("title", ""),
|
||||
"column": column,
|
||||
"column_label": COLUMN_LABELS[column],
|
||||
"column_label": bcols.names(workspace_id).get(
|
||||
column, column
|
||||
),
|
||||
"days_inactive": days,
|
||||
"html_url": issue.get("html_url", ""),
|
||||
}
|
||||
)
|
||||
zombies.sort(key=lambda z: -z["days_inactive"])
|
||||
return {
|
||||
"_ws_id": workspace_id,
|
||||
"full_name": full_name,
|
||||
"dwell": dwell,
|
||||
"slowest": slowest,
|
||||
"zombies": zombies,
|
||||
"zombie_days": settings.zombie_days,
|
||||
"open_count": len(display_issues),
|
||||
"column_labels": COLUMN_LABELS,
|
||||
"column_labels": bcols.names(workspace_id),
|
||||
}
|
||||
|
||||
|
||||
@@ -3696,8 +3990,8 @@ async def focus(request: Request):
|
||||
request,
|
||||
"focus.html",
|
||||
groups=await _collect_focus_groups(ws_repos, ws_id),
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
column_labels=bcols.names(ws_id),
|
||||
column_colors=bcols.colors(ws_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -3789,14 +4083,16 @@ async def _collect_wall_data(
|
||||
board_state = get_board_state(
|
||||
full_name, workspace_id=workspace_id, connection_id=cid
|
||||
)
|
||||
columns = _merge_issues_with_board(open_issues, board_state)
|
||||
columns = _merge_issues_with_board(
|
||||
open_issues, board_state, bcols.keys(workspace_id)
|
||||
)
|
||||
ages = _card_ages(
|
||||
full_name, open_issues, board_state,
|
||||
workspace_id=workspace_id, connection_id=cid,
|
||||
)
|
||||
aging_days = get_settings().aging_days
|
||||
cards = []
|
||||
for col in WALL_COLUMNS:
|
||||
for col in bcols.wall_keys(workspace_id):
|
||||
for issue in columns[col]:
|
||||
age = ages.get(issue["number"]) or {}
|
||||
days = age.get("days")
|
||||
@@ -3805,14 +4101,18 @@ async def _collect_wall_data(
|
||||
"number": issue["number"],
|
||||
"title": issue.get("title", ""),
|
||||
"column": col,
|
||||
"column_label": COLUMN_LABELS[col],
|
||||
"column_label": bcols.names(workspace_id).get(
|
||||
col, col
|
||||
),
|
||||
"age_days": days,
|
||||
"aging": days is not None and days >= aging_days,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"counts": {
|
||||
col: len(columns[col]) for col in bcols.keys(workspace_id)
|
||||
},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"cards_total": len(cards),
|
||||
@@ -3835,9 +4135,9 @@ async def wall(request: Request, repo: str = ""):
|
||||
"wall.html",
|
||||
slides=slides,
|
||||
single=bool(repo),
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
columns=KANBAN_COLUMNS,
|
||||
column_labels=bcols.names(ws_id),
|
||||
column_colors=bcols.colors(ws_id),
|
||||
columns=bcols.keys(ws_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -3871,8 +4171,8 @@ async def search_page(request: Request, q: str = ""):
|
||||
q=q,
|
||||
groups=groups,
|
||||
total=total,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
column_labels=_ws_names(request),
|
||||
column_colors=_ws_colors(request),
|
||||
)
|
||||
|
||||
|
||||
@@ -3916,8 +4216,8 @@ async def my_issues(request: Request):
|
||||
user=user,
|
||||
groups=groups,
|
||||
total=total,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
column_labels=_ws_names(request),
|
||||
column_colors=_ws_colors(request),
|
||||
)
|
||||
|
||||
|
||||
@@ -3952,9 +4252,9 @@ async def _briefing_data() -> dict:
|
||||
"number": issue["number"],
|
||||
"title": issue["title"],
|
||||
"days": ages[issue["number"]]["days"],
|
||||
"column_label": COLUMN_LABELS[
|
||||
column_of.get(issue["number"], "backlog")
|
||||
],
|
||||
"column_label": bcols.names(1).get(
|
||||
column_of.get(issue["number"], "backlog"), "backlog"
|
||||
),
|
||||
}
|
||||
for issue in open_issues
|
||||
if issue["number"] in ages
|
||||
@@ -4439,14 +4739,11 @@ async def _do_capture(
|
||||
title = (title or "").strip()
|
||||
if not title or len(title) > 255:
|
||||
raise ValueError("Title required (max 255 chars)")
|
||||
if column not in KANBAN_COLUMNS:
|
||||
raise ValueError("Invalid column")
|
||||
parts = _split_full_name(repo_full_name)
|
||||
if parts is None:
|
||||
raise ValueError("Invalid repo (expected owner/name)")
|
||||
owner, repo = parts
|
||||
client, conn_id = await _client_for_repo(repo_full_name)
|
||||
issue = await client.create_issue(owner, repo, title, body or "")
|
||||
# La carte vit dans le premier espace qui détient la connexion.
|
||||
ws_id = next(
|
||||
(
|
||||
@@ -4456,6 +4753,9 @@ async def _do_capture(
|
||||
),
|
||||
1,
|
||||
)
|
||||
if column not in bcols.keys(ws_id):
|
||||
raise ValueError("Invalid column")
|
||||
issue = await client.create_issue(owner, repo, title, body or "")
|
||||
move_issue(
|
||||
repo_full_name, issue["number"], column,
|
||||
workspace_id=ws_id, connection_id=conn_id,
|
||||
@@ -4644,7 +4944,9 @@ async def _weekly_digest() -> dict:
|
||||
"cards": [
|
||||
{
|
||||
"issue": card["issue"],
|
||||
"column": COLUMN_LABELS[card["column"]],
|
||||
"column": bcols.names(1).get(
|
||||
card["column"], card["column"]
|
||||
),
|
||||
}
|
||||
for card in group["cards"]
|
||||
],
|
||||
@@ -4809,7 +5111,7 @@ async def activity(request: Request):
|
||||
"activity.html",
|
||||
events=events,
|
||||
event_icons=EVENT_ICONS,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_labels=_ws_names(request),
|
||||
)
|
||||
|
||||
|
||||
@@ -4835,10 +5137,6 @@ async def api_move(
|
||||
"""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
if to_column not in KANBAN_COLUMNS:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Invalid column"}, status_code=400
|
||||
)
|
||||
parts = _split_full_name(repo_full_name)
|
||||
if parts is None:
|
||||
return JSONResponse(
|
||||
@@ -4846,12 +5144,16 @@ async def api_move(
|
||||
)
|
||||
owner, repo = parts
|
||||
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
|
||||
if to_column not in bcols.keys(ws_id):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Invalid column"}, status_code=400
|
||||
)
|
||||
|
||||
move_issue(
|
||||
repo_full_name, issue_number, to_column,
|
||||
workspace_id=ws_id, connection_id=conn_id,
|
||||
)
|
||||
if from_column in KANBAN_COLUMNS and from_column != to_column:
|
||||
if from_column in bcols.keys(ws_id) and from_column != to_column:
|
||||
remaining = [
|
||||
n for n in _parse_order(from_order) if n != issue_number
|
||||
]
|
||||
@@ -4917,18 +5219,18 @@ async def api_move(
|
||||
)
|
||||
result["gitea_sync"] = "failed"
|
||||
|
||||
# Mirror intermediate columns to Gitea labels (kanban:<column>) so
|
||||
# the workflow state is visible in Gitea itself, not just here.
|
||||
# Mirror the column on the issue's mapped label (0.30.0:
|
||||
# configurable per workspace) so the workflow state is
|
||||
# visible on the forge itself, not just here.
|
||||
result["label_sync"] = "skipped"
|
||||
if settings.sync_labels and from_column != to_column:
|
||||
try:
|
||||
desired = (
|
||||
f"kanban:{to_column}" if to_column in LABELLED_COLUMNS else None
|
||||
)
|
||||
desired = bcols.label_for(ws_id, to_column)
|
||||
recognized = bcols.recognized_labels(ws_id)
|
||||
issue = await client.get_issue(owner, repo, issue_number)
|
||||
current = [
|
||||
label for label in issue.get("labels") or []
|
||||
if str(label.get("name", "")).startswith("kanban:")
|
||||
if str(label.get("name", "")).lower() in recognized
|
||||
]
|
||||
for label in current:
|
||||
if label.get("name") != desired:
|
||||
@@ -4937,7 +5239,8 @@ async def api_move(
|
||||
)
|
||||
if desired and all(l.get("name") != desired for l in current):
|
||||
label = await client.ensure_label(
|
||||
owner, repo, desired, COLUMN_COLORS[to_column]
|
||||
owner, repo, desired,
|
||||
bcols.colors(ws_id).get(to_column, "#6b7280"),
|
||||
)
|
||||
await client.add_issue_label(owner, repo, issue_number, label["id"])
|
||||
result["label_sync"] = "ok"
|
||||
@@ -5097,20 +5400,20 @@ _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, client=None
|
||||
owner: str, repo: str, number: int, column: str, client=None,
|
||||
workspace_id: int = 1,
|
||||
) -> None:
|
||||
"""Mirror a column on the issue's kanban:* label (best effort)."""
|
||||
"""Mirror a column on the issue's mapped label (best effort)."""
|
||||
if not get_settings().sync_labels:
|
||||
return
|
||||
client = client or gitea
|
||||
try:
|
||||
desired = (
|
||||
f"kanban:{column}" if column in LABELLED_COLUMNS else None
|
||||
)
|
||||
desired = bcols.label_for(workspace_id, column)
|
||||
recognized = bcols.recognized_labels(workspace_id)
|
||||
issue = await client.get_issue(owner, repo, number)
|
||||
current = [
|
||||
label for label in issue.get("labels") or []
|
||||
if str(label.get("name", "")).startswith("kanban:")
|
||||
if str(label.get("name", "")).lower() in recognized
|
||||
]
|
||||
for label in current:
|
||||
if label.get("name") != desired:
|
||||
@@ -5119,7 +5422,8 @@ async def _sync_kanban_label(
|
||||
)
|
||||
if desired and all(l.get("name") != desired for l in current):
|
||||
label = await client.ensure_label(
|
||||
owner, repo, desired, COLUMN_COLORS[column]
|
||||
owner, repo, desired,
|
||||
bcols.colors(workspace_id).get(column, "#6b7280"),
|
||||
)
|
||||
await client.add_issue_label(owner, repo, number, label["id"])
|
||||
except Exception as exc:
|
||||
@@ -5226,7 +5530,8 @@ async def api_create_branch(
|
||||
title=issue.get("title", ""),
|
||||
)
|
||||
await _sync_kanban_label(
|
||||
owner, repo, number, "in_progress", client
|
||||
owner, repo, number, "in_progress", client,
|
||||
workspace_id=ws_id,
|
||||
)
|
||||
moved = True
|
||||
await _publish_board_changed(full_name)
|
||||
@@ -5873,7 +6178,7 @@ async def api_palette(q: str = ""):
|
||||
"title": item.get("title", ""),
|
||||
"state": item.get("state", "open"),
|
||||
"column": column,
|
||||
"column_label": COLUMN_LABELS[column],
|
||||
"column_label": bcols.names(1).get(column, column),
|
||||
}
|
||||
)
|
||||
if len(issues) >= 8:
|
||||
|
||||
Reference in New Issue
Block a user