Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25040f974c | ||
|
|
99764e71b2 | ||
|
|
1088dd0b48 |
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Board dynamique sans refresh** (issue #15) : le board reflète
|
||||
l'état réel en continu. Côté serveur, chaque mutation connue
|
||||
(déplacement, création, capture, bascule d'étiquette) est
|
||||
désormais diffusée sur le hub SSE — jusqu'ici seuls les webhooks
|
||||
émettaient — et les webhooks bénéficient du même canal pour
|
||||
toutes leurs actions (étiquettes, créations, éditions comprises).
|
||||
Nouvel endpoint `GET /api/board/{owner}/{repo}` : état du board
|
||||
en JSON avec les cartes **rendues côté serveur** (partiel
|
||||
`_card.html` partagé avec la page), même pipeline de
|
||||
réconciliation/adoption que le board HTML. Côté client,
|
||||
`board-live.js` applique le **diff en place** (seules les
|
||||
colonnes changées sont remplacées, signature par colonne),
|
||||
anime les cartes survivantes vers leur nouvelle place (FLIP,
|
||||
désactivé si `prefers-reduced-motion`), diffère l'application
|
||||
pendant un drag local, réapplique filtres/compteurs/indices, et
|
||||
**sonde toutes les 45 s** en repli (installations sans webhooks,
|
||||
onglet visible). Ni le défilement, ni le panneau détail, ni
|
||||
Sortable ne sont affectés. Focus et Wallboard conservent leur
|
||||
recharge douce, désormais déclenchée par toutes les mutations.
|
||||
|
||||
6 tests ajoutés (127 au total).
|
||||
|
||||
## 0.8.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
|
||||
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
|
||||
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
|
||||
- 🔄 **Board dynamique** — aucun refresh manuel : toute modification (autre session, Gitea, capture) est diffusée en SSE et appliquée **en place** avec animation des cartes (`/api/board/{owner}/{repo}` en JSON + fragments rendus serveur, diff DOM/FLIP) ; repli par sondage de 45 s sans webhooks ; drag local, filtres et panneau détail préservés
|
||||
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
|
||||
- 🧠 **Digest hebdo → ObsiGate** — résumé markdown de la semaine (journal + focus) composé automatiquement, **livré seulement sur approbation** (page `/digest`) vers le serveur MCP d'ObsiGate (client MCP intégré, outil et gabarit d'arguments configurables) ou un webhook générique
|
||||
- ⚡ **Capture éclair** — page `/capture` (formulaire), endpoint `POST /api/capture` protégé par un jeton dédié, et écoute ntfy : un message publié sur le topic de capture devient une issue dans le dépôt par défaut
|
||||
@@ -166,6 +167,7 @@ kanbante/
|
||||
| GET | `/wall` | Wallboard plein écran (rotation des projets actifs ; `?repo=` pour un seul projet) |
|
||||
| GET | `/capture` | Page de capture éclair (formulaire + statut + instructions) |
|
||||
| GET | `/api/palette` | Données de la palette Ctrl+K (projets + recherche d'issues `?q=`) |
|
||||
| GET | `/api/board/{owner}/{repo}` | Board en JSON + fragments HTML des cartes (base du board dynamique, `?milestone=` accepté) |
|
||||
| GET | `/digest` | Digest hebdo : aperçu, statut des cibles, approbation d'envoi |
|
||||
| POST | `/digest/send` | Livrer le digest (cible `obsigate` ou `webhook`) — approbation explicite |
|
||||
| POST | `/api/capture` | Capture machine-à-machine (jeton `X-Capture-Token`, JSON ou texte : 1re ligne = titre) |
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.8.0"
|
||||
APP_VERSION = "0.9.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.8.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.9.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+75
@@ -258,6 +258,16 @@ class LiveHub:
|
||||
hub = LiveHub()
|
||||
|
||||
|
||||
async def _publish_board_changed(repo_full_name: str) -> None:
|
||||
"""Tell every connected client this repo's board changed (#15).
|
||||
|
||||
Called after each mutation Kanbante knows about (move, create,
|
||||
capture, label toggle). Webhooks broadcast separately with a
|
||||
richer payload; board clients accept both shapes.
|
||||
"""
|
||||
await hub.broadcast({"repo": repo_full_name, "type": "board-changed"})
|
||||
|
||||
|
||||
# ── Templates ──────────────────────────────────────────────
|
||||
|
||||
_tpl_env = Environment(
|
||||
@@ -554,6 +564,67 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
)
|
||||
|
||||
|
||||
def _board_columns_for(
|
||||
owner: str, repo: str, all_items: list[dict], milestone: str = ""
|
||||
) -> tuple[dict[str, list[dict]], list[dict], dict]:
|
||||
"""Reconcile + merge pipeline shared by the board page and its
|
||||
JSON twin (issue #15). Returns (columns, display_issues, state)."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
issues = [i for i in all_items if _is_issue(i)]
|
||||
open_issues = [i for i in issues if i.get("state") == "open"]
|
||||
closed_numbers = {i["number"] for i in issues if i.get("state") != "open"}
|
||||
open_numbers = {i["number"] for i in open_issues}
|
||||
changes = reconcile_board_state(full_name, open_numbers, closed_numbers)
|
||||
for number in changes["closed_numbers"]:
|
||||
log_event(full_name, number, "closed", to_column="done", source="gitea")
|
||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||
display_issues = open_issues
|
||||
if milestone:
|
||||
def _in_ms(issue: dict) -> bool:
|
||||
ms = issue.get("milestone") or {}
|
||||
return bool(ms) and (
|
||||
str(ms.get("id")) == milestone or ms.get("title") == milestone
|
||||
)
|
||||
|
||||
display_issues = [i for i in open_issues if _in_ms(i)]
|
||||
board_state = get_board_state(full_name)
|
||||
columns = _merge_issues_with_board(display_issues, board_state)
|
||||
return columns, display_issues, board_state
|
||||
|
||||
|
||||
@app.get("/api/board/{owner}/{repo}")
|
||||
async def api_board(owner: str, repo: str, milestone: str = ""):
|
||||
"""The board as JSON + server-rendered card fragments (issue #15).
|
||||
|
||||
The board page swaps these fragments in place when the board
|
||||
changes elsewhere — same merge/reconcile pipeline as the HTML
|
||||
page, so label adoptions and closures are reflected identically.
|
||||
"""
|
||||
full_name = f"{owner}/{repo}"
|
||||
all_items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||
columns, display_issues, board_state = _board_columns_for(
|
||||
owner, repo, all_items, milestone
|
||||
)
|
||||
settings = get_settings()
|
||||
ages = _card_ages(full_name, display_issues, board_state)
|
||||
card_template = _tpl_env.get_template("_card.html")
|
||||
payload: dict[str, dict] = {}
|
||||
for col in KANBAN_COLUMNS:
|
||||
cards = columns[col]
|
||||
if cards:
|
||||
html = "".join(
|
||||
card_template.render(
|
||||
issue=issue, ages=ages,
|
||||
aging_days=settings.aging_days, full_name=full_name,
|
||||
)
|
||||
for issue in cards
|
||||
)
|
||||
else:
|
||||
html = '<p class="column-empty">Glissez une carte ici</p>'
|
||||
payload[col] = {"count": len(cards), "html": html}
|
||||
return {"repo": full_name, "columns": payload}
|
||||
|
||||
|
||||
def _card_ages(
|
||||
full_name: str, open_issues: list[dict], board: dict[str, list[dict]]
|
||||
) -> dict[int, dict]:
|
||||
@@ -1052,6 +1123,7 @@ async def _do_capture(
|
||||
repo_full_name, issue["number"], "created",
|
||||
to_column=column, source="capture",
|
||||
)
|
||||
await _publish_board_changed(repo_full_name)
|
||||
return {
|
||||
"number": issue["number"],
|
||||
"title": issue.get("title", title),
|
||||
@@ -1500,6 +1572,7 @@ async def api_move(
|
||||
"Label sync failed for %s#%s: %s", repo_full_name, issue_number, exc
|
||||
)
|
||||
result["label_sync"] = "failed"
|
||||
await _publish_board_changed(repo_full_name)
|
||||
return result
|
||||
|
||||
|
||||
@@ -1534,6 +1607,7 @@ async def api_create_issue(
|
||||
issue = await gitea.create_issue(owner, repo, title, body)
|
||||
move_issue(repo_full_name, issue["number"], column)
|
||||
log_event(repo_full_name, issue["number"], "created", to_column=column)
|
||||
await _publish_board_changed(repo_full_name)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -1736,6 +1810,7 @@ async def api_toggle_issue_label(
|
||||
await gitea.add_issue_label(owner, repo, number, label_id)
|
||||
else:
|
||||
await gitea.remove_issue_label(owner, repo, number, label_id)
|
||||
await _publish_board_changed(f"{owner}/{repo}")
|
||||
issue = await gitea.get_issue(owner, repo, number)
|
||||
return {
|
||||
"ok": True,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<div class="kanban-card"
|
||||
data-issue="{{ issue.number }}"
|
||||
data-repo="{{ full_name }}"
|
||||
data-title="{{ issue.title|lower }}"
|
||||
data-labels="{{ issue.labels|map(attribute='name')|join(',')|lower }}">
|
||||
<div class="card-id">
|
||||
#{{ issue.number }}
|
||||
{% if issue.html_url %}
|
||||
<a href="{{ issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-title">{{ issue.title }}</div>
|
||||
{% if issue.labels %}
|
||||
<div class="card-labels">
|
||||
{% for label in issue.labels %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
|
||||
{{ label.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-meta">
|
||||
{% if issue.assignee %}
|
||||
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
|
||||
{% endif %}
|
||||
<span class="card-date">{{ issue.created_at[:10] }}</span>
|
||||
{% set age = ages.get(issue.number) %}
|
||||
{% if age and age.days >= aging_days %}
|
||||
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
|
||||
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,39 +90,7 @@
|
||||
<p class="column-empty">Glissez une carte ici</p>
|
||||
{% endif %}
|
||||
{% for issue in columns[col] %}
|
||||
<div class="kanban-card"
|
||||
data-issue="{{ issue.number }}"
|
||||
data-repo="{{ full_name }}"
|
||||
data-title="{{ issue.title|lower }}"
|
||||
data-labels="{{ issue.labels|map(attribute='name')|join(',')|lower }}">
|
||||
<div class="card-id">
|
||||
#{{ issue.number }}
|
||||
{% if issue.html_url %}
|
||||
<a href="{{ issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-title">{{ issue.title }}</div>
|
||||
{% if issue.labels %}
|
||||
<div class="card-labels">
|
||||
{% for label in issue.labels %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
|
||||
{{ label.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-meta">
|
||||
{% if issue.assignee %}
|
||||
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
|
||||
{% endif %}
|
||||
<span class="card-date">{{ issue.created_at[:10] }}</span>
|
||||
{% set age = ages.get(issue.number) %}
|
||||
{% if age and age.days >= aging_days %}
|
||||
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
|
||||
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% include "_card.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +174,7 @@
|
||||
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
|
||||
{% endif %}
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/board-live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Kanbante — dynamic board (issue #15).
|
||||
*
|
||||
* The board reflects reality without a manual refresh: any change
|
||||
* (another session, Gitea via webhook/labels, capture) is pushed
|
||||
* over SSE; the client then fetches server-rendered card fragments
|
||||
* from /api/board/<repo> and swaps only the columns that changed,
|
||||
* animating surviving cards to their new place (FLIP). A discreet
|
||||
* 45 s poll covers installs without Gitea webhooks. Local drags are
|
||||
* never interrupted: a refresh arriving mid-drag waits for its end.
|
||||
* Filters, scroll, the detail drawer and Sortable all survive,
|
||||
* because only column contents are replaced — never the page.
|
||||
*/
|
||||
(function () {
|
||||
var match = window.location.pathname.match(/^\/board\/(.+)$/);
|
||||
if (!match) return;
|
||||
var repoPath = match[1].replace(/\/$/, '');
|
||||
var milestone = new URLSearchParams(window.location.search).get('milestone') || '';
|
||||
var apiUrl = '/api/board/' + repoPath + (milestone ? '?milestone=' + encodeURIComponent(milestone) : '');
|
||||
var repoFullName = repoPath.split('/').map(decodeURIComponent).join('/');
|
||||
|
||||
var lastEventAt = Date.now();
|
||||
var inFlight = false;
|
||||
var queued = false;
|
||||
var debounce = null;
|
||||
var failedOnce = false;
|
||||
|
||||
function signature(html) {
|
||||
var h = 0;
|
||||
for (var i = 0; i < html.length; i++) {
|
||||
h = ((h << 5) - h + html.charCodeAt(i)) | 0;
|
||||
}
|
||||
return html.length + ':' + h;
|
||||
}
|
||||
|
||||
function captureRects() {
|
||||
var rects = {};
|
||||
document.querySelectorAll('.kanban-card').forEach(function (card) {
|
||||
rects[card.getAttribute('data-issue')] = card.getBoundingClientRect();
|
||||
});
|
||||
return rects;
|
||||
}
|
||||
|
||||
function playFlip(before) {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
document.querySelectorAll('.kanban-card').forEach(function (card) {
|
||||
var key = card.getAttribute('data-issue');
|
||||
var old = before[key];
|
||||
if (!old) {
|
||||
card.animate(
|
||||
[{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'none' }],
|
||||
{ duration: 220, easing: 'ease-out' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
var now = card.getBoundingClientRect();
|
||||
var dx = old.left - now.left;
|
||||
var dy = old.top - now.top;
|
||||
if (dx || dy) {
|
||||
card.animate(
|
||||
[{ transform: 'translate(' + dx + 'px,' + dy + 'px)' }, { transform: 'none' }],
|
||||
{ duration: 280, easing: 'ease-in-out' }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showFallbackPill() {
|
||||
if (document.getElementById('live-pill')) return;
|
||||
var pill = document.createElement('button');
|
||||
pill.id = 'live-pill';
|
||||
pill.type = 'button';
|
||||
pill.textContent = '🔄 Actualisation automatique en échec — cliquer pour recharger';
|
||||
pill.addEventListener('click', function () { window.location.reload(); });
|
||||
document.body.appendChild(pill);
|
||||
}
|
||||
|
||||
function applyData(data) {
|
||||
var before = captureRects();
|
||||
var changed = false;
|
||||
Object.keys(data.columns).forEach(function (col) {
|
||||
var container = document.querySelector('.column-cards[data-column="' + col + '"]');
|
||||
if (!container) return;
|
||||
var payload = data.columns[col];
|
||||
var sig = signature(payload.html);
|
||||
if (container.getAttribute('data-sig') === sig) return;
|
||||
container.innerHTML = payload.html;
|
||||
container.setAttribute('data-sig', sig);
|
||||
changed = true;
|
||||
});
|
||||
if (!changed) return;
|
||||
playFlip(before);
|
||||
if (window.kanbanteUpdateCounts) window.kanbanteUpdateCounts();
|
||||
if (window.kanbanteRefreshHints) window.kanbanteRefreshHints();
|
||||
if (window.kanbanteApplyFilters) window.kanbanteApplyFilters();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (window.__kanbanteDragging) {
|
||||
setTimeout(refresh, 700); // retry once the local drag ends
|
||||
return;
|
||||
}
|
||||
if (inFlight) { queued = true; return; }
|
||||
inFlight = true;
|
||||
fetch(apiUrl, { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (data) { failedOnce = false; applyData(data); })
|
||||
.catch(function () { if (!failedOnce) { failedOnce = true; showFallbackPill(); } })
|
||||
.finally(function () {
|
||||
inFlight = false;
|
||||
if (queued) { queued = false; refresh(); }
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(refresh, 400);
|
||||
}
|
||||
|
||||
// 1. Push: SSE events about this repo (internal moves broadcast
|
||||
// {"type": "board-changed"}; webhooks broadcast their payload).
|
||||
if (window.EventSource) {
|
||||
var source = new EventSource('/api/stream');
|
||||
source.addEventListener('gitea', function (e) {
|
||||
try {
|
||||
var payload = JSON.parse(e.data);
|
||||
if (payload.repo && payload.repo !== repoFullName) return;
|
||||
} catch (err) { /* unparseable -> refresh anyway */ }
|
||||
lastEventAt = Date.now();
|
||||
scheduleRefresh();
|
||||
});
|
||||
source.addEventListener('error', function () { /* EventSource retries itself */ });
|
||||
}
|
||||
|
||||
// 2. Fallback poll (no webhooks configured, or SSE missed
|
||||
// something): only when visible and quiet for a full cycle.
|
||||
setInterval(function () {
|
||||
if (document.visibilityState === 'visible' && Date.now() - lastEventAt >= 45000) {
|
||||
lastEventAt = Date.now();
|
||||
refresh();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 3. Coming back to the tab: catch up immediately.
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') refresh();
|
||||
});
|
||||
})();
|
||||
@@ -152,6 +152,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Exposed for board-live.js (issue #15): after a remote refresh
|
||||
// swaps column fragments, filters/counts/hints must be recomputed.
|
||||
window.kanbanteApplyFilters = applyFilters;
|
||||
window.kanbanteRefreshHints = refreshEmptyHints;
|
||||
window.kanbanteUpdateCounts = updateCounts;
|
||||
|
||||
// Keyboard: "/" jumps to the filter box.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || '')) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Palier 6 (0.9.0): board dynamique sans refresh (issue #15)."""
|
||||
|
||||
import re
|
||||
|
||||
import app.main as main
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def _page_counts(html: str) -> dict[str, int]:
|
||||
counts = {}
|
||||
for col, count in re.findall(
|
||||
r'data-column="(\w+)">\s*<div class="column-header".*?column-count[^>]*>(\d+)',
|
||||
html, re.S,
|
||||
):
|
||||
counts[col] = int(count)
|
||||
return counts
|
||||
|
||||
|
||||
def test_api_board_matches_page(client, fake_gitea):
|
||||
page = client.get("/board/Projets/demo")
|
||||
assert page.status_code == 200
|
||||
api = client.get("/api/board/Projets/demo")
|
||||
assert api.status_code == 200
|
||||
data = api.json()
|
||||
assert data["repo"] == "Projets/demo"
|
||||
page_counts = _page_counts(page.text)
|
||||
for col in ("backlog", "todo", "in_progress", "qa", "review", "done"):
|
||||
assert data["columns"][col]["count"] == page_counts[col], col
|
||||
assert "Tâche un" in data["columns"]["backlog"]["html"]
|
||||
assert 'data-issue="1"' in data["columns"]["backlog"]["html"]
|
||||
# Empty column carries the same hint as the server-rendered page.
|
||||
assert "Glissez une carte ici" in data["columns"]["qa"]["html"]
|
||||
|
||||
|
||||
def test_api_board_reflects_move(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/move", data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"from_column": "backlog", "to_column": "in_progress",
|
||||
"to_order": "1", "csrf_token": token,
|
||||
})
|
||||
assert r.status_code == 200 and r.json()["ok"]
|
||||
data = client.get("/api/board/Projets/demo").json()
|
||||
assert 'data-issue="1"' in data["columns"]["in_progress"]["html"]
|
||||
# #5 ("Tâche focus", also unplaced) stays in the backlog.
|
||||
assert data["columns"]["backlog"]["count"] == 1
|
||||
assert 'data-issue="5"' in data["columns"]["backlog"]["html"]
|
||||
|
||||
|
||||
def test_board_page_uses_board_live_not_live(client, fake_gitea):
|
||||
page = client.get("/board/Projets/demo")
|
||||
assert "/static/js/board-live.js" in page.text
|
||||
assert "/static/js/live.js" not in page.text
|
||||
focus = client.get("/focus")
|
||||
assert "/static/js/live.js" in focus.text # unchanged elsewhere
|
||||
|
||||
|
||||
def test_move_broadcasts_board_changed(client, fake_gitea, monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def record(payload):
|
||||
sent.append(payload)
|
||||
|
||||
monkeypatch.setattr(main.hub, "broadcast", record)
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/move", data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"from_column": "backlog", "to_column": "todo",
|
||||
"to_order": "1", "csrf_token": token,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert any(
|
||||
p.get("type") == "board-changed" and p.get("repo") == "Projets/demo"
|
||||
for p in sent
|
||||
), sent
|
||||
|
||||
|
||||
def test_create_issue_broadcasts(client, fake_gitea, monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def record(payload):
|
||||
sent.append(payload)
|
||||
|
||||
monkeypatch.setattr(main.hub, "broadcast", record)
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/issues", data={
|
||||
"repo_full_name": "Projets/demo", "title": "Nouvelle carte live",
|
||||
"column": "backlog", "csrf_token": token,
|
||||
})
|
||||
assert r.status_code == 201
|
||||
assert any(p.get("type") == "board-changed" for p in sent), sent
|
||||
|
||||
|
||||
def test_webhook_label_event_broadcasts(client, fake_gitea, monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def record(payload):
|
||||
sent.append(payload)
|
||||
|
||||
monkeypatch.setattr(main.hub, "broadcast", record)
|
||||
r = client.post(
|
||||
"/webhooks/gitea",
|
||||
json={
|
||||
"action": "labeled",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"issue": {"number": 1},
|
||||
},
|
||||
headers={"x-gitea-event": "issues"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
assert sent and sent[0]["action"] == "labeled"
|
||||
Reference in New Issue
Block a user