Add a Phase 3 read-only console that resolves issue↔PR linkage with evidence (closes keyword, branch marker, body mention), surfaces the latest canonical handoff for a focused thread, and deep-links to Gitea only under the admin reveal opt-in. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""HTML views for the Gitea issue↔PR linkage console (#645, Phase 3).
|
|
|
|
Read-only renderer over :mod:`webui.linkage_loader`. The page's job is to make
|
|
three things impossible to misread:
|
|
|
|
* **why** an edge exists — every link carries its evidence badge, so a bare
|
|
``#N`` mention never looks like a closing claim;
|
|
* **what was not loaded** — a partial inventory, an unfocused thread, or an
|
|
unavailable handoff source renders as an explicit qualifier, never as an
|
|
affirmative "none";
|
|
* **that nothing here mutates** — there is no review, merge, or edit control,
|
|
and the deep link out to Gitea appears only under the admin reveal opt-in.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from html import escape
|
|
from typing import Sequence
|
|
|
|
from webui.layout import render_page
|
|
from webui.linkage_loader import (
|
|
EVIDENCE_BRANCH,
|
|
EVIDENCE_CLOSES,
|
|
EVIDENCE_DESCRIPTIONS,
|
|
EVIDENCE_ORDER,
|
|
EVIDENCE_REFERENCE,
|
|
HANDOFF_LOADED,
|
|
HANDOFF_NOT_LOADED,
|
|
HandoffSummary,
|
|
LinkageNode,
|
|
LinkageSnapshot,
|
|
)
|
|
|
|
_EVIDENCE_CSS = {
|
|
EVIDENCE_CLOSES: "badge-health-ok",
|
|
EVIDENCE_BRANCH: "badge-health-skipped",
|
|
EVIDENCE_REFERENCE: "badge-health-unproven",
|
|
}
|
|
|
|
_EVIDENCE_LABEL = {
|
|
EVIDENCE_CLOSES: "closes",
|
|
EVIDENCE_BRANCH: "branch",
|
|
EVIDENCE_REFERENCE: "mention",
|
|
}
|
|
|
|
|
|
def _badge(text: str, css: str) -> str:
|
|
return f'<span class="badge {css}">{escape(text)}</span>'
|
|
|
|
|
|
def _labels(names: Sequence[str]) -> str:
|
|
if not names:
|
|
return '<span class="muted">—</span>'
|
|
return " ".join(_badge(name, "badge-health-skipped") for name in names)
|
|
|
|
|
|
def _ref(node: LinkageNode) -> str:
|
|
"""Render an item reference, hyperlinked only when deep links are revealed."""
|
|
label = f"#{node.number}"
|
|
if node.deep_link:
|
|
return f'<a href="{escape(node.deep_link)}"><code>{escape(label)}</code></a>'
|
|
return f"<code>{escape(label)}</code>"
|
|
|
|
|
|
def _scope_card(snapshot: LinkageSnapshot) -> str:
|
|
focus = (
|
|
"none"
|
|
if snapshot.focus is None
|
|
else f"{snapshot.focus[0]}#{snapshot.focus[1]}"
|
|
)
|
|
completeness = (
|
|
_badge("complete", "badge-health-ok")
|
|
if snapshot.inventory_complete
|
|
else _badge("partial", "badge-health-degraded")
|
|
)
|
|
links_note = (
|
|
"Every linkage edge below is a claim about this loaded window only."
|
|
if snapshot.inventory_complete
|
|
else (
|
|
"Pagination did not complete for this window, so an empty link list "
|
|
"means <em>none found in what was loaded</em> — not that no link exists."
|
|
)
|
|
)
|
|
deep_links = (
|
|
_badge("enabled", "badge-health-ok")
|
|
if snapshot.deep_links_enabled
|
|
else _badge("hidden", "badge-health-skipped")
|
|
)
|
|
return f"""<div class="health-card">
|
|
<h3>Scope</h3>
|
|
<table class="detail">
|
|
<tr><th>Project</th><td><code>{escape(snapshot.project_id or "—")}</code></td></tr>
|
|
<tr><th>Repository</th><td><code>{escape(snapshot.repo_label or "—")}</code></td></tr>
|
|
<tr><th>Item state</th><td><code>{escape(snapshot.state_scope)}</code></td></tr>
|
|
<tr><th>Focused thread</th><td><code>{escape(focus)}</code></td></tr>
|
|
<tr><th>Inventory</th><td>{completeness}</td></tr>
|
|
<tr><th>Gitea deep links</th><td>{deep_links}</td></tr>
|
|
</table>
|
|
<p class="muted">{links_note}</p>
|
|
</div>"""
|
|
|
|
|
|
def _error_card(snapshot: LinkageSnapshot) -> str:
|
|
if snapshot.ok and not snapshot.fetch_error:
|
|
return ""
|
|
return (
|
|
'<div class="health-card health-stale"><strong>Linkage unavailable:</strong> '
|
|
f"{escape(snapshot.fetch_error or 'the linkage read did not complete')}. "
|
|
"No linkage table is rendered: an empty table would read as "
|
|
"<em>no issue is linked to any PR</em>, which this read cannot claim."
|
|
"</div>"
|
|
)
|
|
|
|
|
|
def _contested_card(snapshot: LinkageSnapshot) -> str:
|
|
if not snapshot.contested_issues:
|
|
return ""
|
|
refs = ", ".join(f"<code>#{number}</code>" for number in snapshot.contested_issues)
|
|
return (
|
|
'<div class="health-card health-stale">'
|
|
f"<strong>Contested issues:</strong> {refs}. More than one PR in this "
|
|
"window claims each of these — duplicate work or a superseded PR. "
|
|
"Resolution stays in Gitea and the workflow; this console only reports it."
|
|
"</div>"
|
|
)
|
|
|
|
|
|
def _evidence_badges(evidence: Sequence[str]) -> str:
|
|
return " ".join(
|
|
_badge(
|
|
_EVIDENCE_LABEL.get(name, name),
|
|
_EVIDENCE_CSS.get(name, "badge-health-skipped"),
|
|
)
|
|
for name in EVIDENCE_ORDER
|
|
if name in evidence
|
|
)
|
|
|
|
|
|
def _handoff_inline(handoff: HandoffSummary) -> str:
|
|
type_css = "badge-health-ok" if handoff.cth_type_known else "badge-health-degraded"
|
|
return (
|
|
f'{_badge(handoff.cth_type or "—", type_css)}'
|
|
f'<div class="muted" style="font-size:0.82rem;">'
|
|
f'{escape(handoff.status or "—")} → {escape(handoff.next_owner or "—")}</div>'
|
|
)
|
|
|
|
|
|
def _handoff_cell(node: LinkageNode) -> str:
|
|
"""Render the handoff column, distinguishing 'none found' from 'not loaded'."""
|
|
status = node.handoff_status
|
|
if status.state == HANDOFF_LOADED:
|
|
if node.handoff is None:
|
|
return '<span class="muted">no canonical handoff on this thread</span>'
|
|
return _handoff_inline(node.handoff)
|
|
if status.state == HANDOFF_NOT_LOADED:
|
|
return (
|
|
f'{_badge("not loaded", "badge-health-skipped")}'
|
|
f'<div class="muted" style="font-size:0.82rem;">'
|
|
f'{escape(status.reason or "")}</div>'
|
|
)
|
|
return (
|
|
f'{_badge("unavailable", "badge-health-degraded")}'
|
|
f'<div class="muted" style="font-size:0.82rem;">'
|
|
f'{escape(status.reason or "")}</div>'
|
|
)
|
|
|
|
|
|
def _issue_rows(snapshot: LinkageSnapshot) -> str:
|
|
rows = []
|
|
for node in snapshot.issues:
|
|
if node.linked_prs:
|
|
linked = ", ".join(f"<code>#{number}</code>" for number in node.linked_prs)
|
|
if node.contested:
|
|
linked += " " + _badge("contested", "badge-blocked")
|
|
elif node.links_authoritative:
|
|
linked = '<span class="muted">none</span>'
|
|
else:
|
|
# The distinction an operator needs: nothing found in a window that
|
|
# was not fully loaded is not the same as nothing existing.
|
|
linked = '<span class="muted">none found (partial inventory)</span>'
|
|
rows.append(
|
|
"<tr>"
|
|
f"<td>{_ref(node)}</td>"
|
|
f"<td>{escape(node.title)}</td>"
|
|
f"<td>{escape(node.state or '—')}</td>"
|
|
f"<td>{_labels(node.labels)}</td>"
|
|
f"<td>{linked}</td>"
|
|
f"<td>{_handoff_cell(node)}</td>"
|
|
"</tr>"
|
|
)
|
|
if not rows:
|
|
return '<tr><td colspan="6" class="muted">No issues in the loaded window.</td></tr>'
|
|
return "".join(rows)
|
|
|
|
|
|
def _pr_rows(snapshot: LinkageSnapshot) -> str:
|
|
rows = []
|
|
for node in snapshot.prs:
|
|
if node.links:
|
|
linked = "".join(
|
|
f"<div><code>#{link.issue_number}</code> "
|
|
f"{_evidence_badges(link.evidence)}</div>"
|
|
for link in node.links
|
|
)
|
|
if node.ambiguous:
|
|
linked += _badge("ambiguous", "badge-blocked")
|
|
if node.contested:
|
|
linked += " " + _badge("contested", "badge-blocked")
|
|
elif node.links_authoritative:
|
|
linked = '<span class="muted">no issue reference</span>'
|
|
else:
|
|
linked = '<span class="muted">none found (partial inventory)</span>'
|
|
rows.append(
|
|
"<tr>"
|
|
f"<td>{_ref(node)}</td>"
|
|
f"<td>{escape(node.title)}</td>"
|
|
f"<td>{escape(node.state or '—')}</td>"
|
|
f"<td>{_labels(node.labels)}</td>"
|
|
f"<td>{linked}</td>"
|
|
f"<td>{_handoff_cell(node)}</td>"
|
|
"</tr>"
|
|
)
|
|
if not rows:
|
|
return (
|
|
'<tr><td colspan="6" class="muted">No pull requests in the loaded '
|
|
"window.</td></tr>"
|
|
)
|
|
return "".join(rows)
|
|
|
|
|
|
def _focus_card(snapshot: LinkageSnapshot) -> str:
|
|
"""Render the focused thread's latest canonical handoff, when one was loaded."""
|
|
if snapshot.focus is None:
|
|
return f"""<div class="prompt-card">
|
|
<h3>Canonical handoff</h3>
|
|
<p class="muted">{escape(snapshot.handoff_status.reason or "")}
|
|
Add <code>?issue=N</code> or <code>?pr=N</code> to load the latest
|
|
Canonical Thread Handoff for one thread.</p>
|
|
</div>"""
|
|
|
|
kind, number = snapshot.focus
|
|
target = f"{kind} #{number}"
|
|
if not snapshot.handoff_status.loaded:
|
|
return f"""<div class="prompt-card">
|
|
<h3>Canonical handoff — {escape(target)}</h3>
|
|
<p class="muted">{_badge("unavailable", "badge-health-degraded")}
|
|
{escape(snapshot.handoff_status.reason or "handoff source did not run")}.
|
|
This is not evidence that the thread carries no handoff.</p>
|
|
</div>"""
|
|
|
|
handoff = next(
|
|
(
|
|
node.handoff
|
|
for node in (snapshot.issues + snapshot.prs)
|
|
if node.kind == kind and node.number == number and node.handoff
|
|
),
|
|
None,
|
|
)
|
|
if handoff is None:
|
|
return f"""<div class="prompt-card">
|
|
<h3>Canonical handoff — {escape(target)}</h3>
|
|
<p class="muted">Comments loaded; no Canonical Thread Handoff comment found on
|
|
this thread.</p>
|
|
</div>"""
|
|
|
|
type_css = "badge-health-ok" if handoff.cth_type_known else "badge-health-degraded"
|
|
unknown_note = (
|
|
""
|
|
if handoff.cth_type_known
|
|
else (
|
|
'<p class="muted">The comment\'s heading is not a declared CTH type, '
|
|
"so it is reported as unrecognized rather than republished.</p>"
|
|
)
|
|
)
|
|
return f"""<div class="prompt-card">
|
|
<h3>Canonical handoff — {escape(target)}</h3>
|
|
<p class="meta">{_badge(handoff.cth_type or "—", type_css)}
|
|
by <code>{escape(handoff.author or "unknown")}</code>
|
|
at <code>{escape(handoff.created_at or "unknown")}</code></p>
|
|
{unknown_note}
|
|
<table class="detail">
|
|
<tr><th>Status</th><td>{escape(handoff.status or "—")}</td></tr>
|
|
<tr><th>Next owner</th><td>{escape(handoff.next_owner or "—")}</td></tr>
|
|
<tr><th>Current blocker</th><td>{escape(handoff.current_blocker or "—")}</td></tr>
|
|
<tr><th>Decision</th><td>{escape(handoff.decision or "—")}</td></tr>
|
|
<tr><th>Next action</th><td>{escape(handoff.next_action or "—")}</td></tr>
|
|
</table>
|
|
<p class="muted">Full event history:
|
|
<a href="/api/v1/timeline?{escape(kind)}={number}"><code>/api/v1/timeline</code></a></p>
|
|
</div>"""
|
|
|
|
|
|
def _legend_card(snapshot: LinkageSnapshot) -> str:
|
|
items = "".join(
|
|
f"<li>{_badge(_EVIDENCE_LABEL[name], _EVIDENCE_CSS[name])} — "
|
|
f"{escape(EVIDENCE_DESCRIPTIONS[name])}</li>"
|
|
for name in EVIDENCE_ORDER
|
|
)
|
|
reveal_note = (
|
|
"Gitea deep links are shown because the "
|
|
"<code>GITEA_MCP_REVEAL_ENDPOINTS</code> admin opt-in is set."
|
|
if snapshot.deep_links_enabled
|
|
else (
|
|
"Gitea deep links are withheld. Set "
|
|
"<code>GITEA_MCP_REVEAL_ENDPOINTS=1</code> server-side to reveal "
|
|
"them; item numbers stay usable without them."
|
|
)
|
|
)
|
|
return f"""<div class="prompt-card">
|
|
<h3>How an edge was found</h3>
|
|
<ul class="reasons">{items}</ul>
|
|
<p class="muted">{reveal_note}</p>
|
|
<p class="muted">Read-only surface: no issue or PR editing, no review, and no merge.
|
|
JSON export: <a href="/api/v1/gitea/linkage"><code>/api/v1/gitea/linkage</code></a></p>
|
|
</div>"""
|
|
|
|
|
|
def render_linkage_page(snapshot: LinkageSnapshot) -> str:
|
|
"""Render the full HTML page for the Gitea linkage console."""
|
|
if not snapshot.ok:
|
|
return render_page(
|
|
title="Gitea linkage",
|
|
body_html=f"""<h2>Gitea issue and PR linkage</h2>
|
|
<p class="meta">Phase 3 read-only linkage console (#645).</p>
|
|
{_error_card(snapshot)}
|
|
{_scope_card(snapshot)}""",
|
|
)
|
|
|
|
body = f"""<h2>Gitea issue and PR linkage</h2>
|
|
<p class="meta">Phase 3 read-only linkage console (#645). Gitea remains the source of
|
|
truth; this page reads it and never writes to it.</p>
|
|
{_scope_card(snapshot)}
|
|
{_contested_card(snapshot)}
|
|
|
|
<div class="prompt-card">
|
|
<h3>Issues → pull requests</h3>
|
|
<table class="registry">
|
|
<thead>
|
|
<tr>
|
|
<th>Issue</th><th>Title</th><th>State</th><th>Labels</th>
|
|
<th>Linked PRs</th><th>Latest handoff</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{_issue_rows(snapshot)}</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="prompt-card">
|
|
<h3>Pull requests → issues</h3>
|
|
<table class="registry">
|
|
<thead>
|
|
<tr>
|
|
<th>PR</th><th>Title</th><th>State</th><th>Labels</th>
|
|
<th>Linked issues</th><th>Latest handoff</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{_pr_rows(snapshot)}</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{_focus_card(snapshot)}
|
|
{_legend_card(snapshot)}
|
|
"""
|
|
return render_page(title="Gitea linkage", body_html=body)
|