Files
GitManager/components/pr-list/pr-list.js
T
TBNilles 9d1519222c Slice 5: Gitea forge - PRs and Merge & clean up
New internal/forge (provider-abstracted, Gitea impl via code.gitea.io/sdk/gitea) with tested remote-URL parsing and read+write ops: list open PRs, and merge-and-cleanup (squash-merge + delete head branch when head/base share a repo). Service resolves repo->owner/repo from remotes (prefers origin) and records a pr-merged event; config gains GITEA_URL/GITEA_TOKEN (forge disabled without both). MCP tools list_prs and merge_and_cleanup_pr (merge tool tells Claude to confirm first, 1.4). HTTP GET /api/repo/prs, POST /api/repo/pr/merge. New <pr-list> component with a confirming Merge & clean up button, hidden when no forge. Verified graceful-disabled path; real merge pending token + a designated PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:37:51 -04:00

140 lines
5.4 KiB
JavaScript

// <pr-list> — open pull requests for the selected repo, with "Merge & clean up".
//
// A self-contained control (AGENT.md §1.1): shadow DOM, self-fetching, cleans up
// on disconnect. It listens for `repo:select` and shows the repo's open PRs when
// a forge (Gitea) is configured; otherwise it stays hidden (graceful, §8.4).
// "Merge & clean up" is a DESTRUCTIVE action (§1.4): it confirms — naming the PR,
// base, and branch to be deleted — before calling the server.
class PRList extends HTMLElement {
#controller = null;
#onSelect = null;
#path = '';
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#onSelect = (e) => this.#load(e.detail?.path);
document.addEventListener('repo:select', this.#onSelect);
}
disconnectedCallback() {
document.removeEventListener('repo:select', this.#onSelect);
this.#controller?.abort();
}
async #load(path) {
if (!path) return;
this.#path = path;
this.#controller?.abort();
this.#controller = new AbortController();
try {
const res = await fetch(`/api/repo/prs?path=${encodeURIComponent(path)}`, { signal: this.#controller.signal });
const data = await res.json();
if (!data.supported) { this.hidden = true; return; }
this.hidden = false;
this.#render(data.prs || []);
} catch (err) {
if (err.name !== 'AbortError') { this.hidden = false; this.#error(err.message); }
}
}
async #merge(pr) {
const ok = window.confirm(
`Merge & clean up PR #${pr.number}: "${pr.title}"?\n\n` +
`This squash-merges it into ${pr.base} and DELETES the branch "${pr.head}".\n` +
`This cannot be undone.`
);
if (!ok) return;
try {
const res = await fetch('/api/repo/pr/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.#path, number: pr.number }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this.#load(this.#path); // refresh the list
} catch (err) {
this.#error(`Merge failed: ${err.message}`);
}
}
#render(prs) {
const body = this.shadowRoot.getElementById('body');
if (prs.length === 0) {
body.innerHTML = `<p class="muted">No open pull requests.</p>`;
return;
}
body.replaceChildren(...prs.map((pr) => {
const li = document.createElement('li');
li.innerHTML = `
<div class="row">
<a class="num" href="${this.#esc(pr.url)}" target="_blank" rel="noopener">#${pr.number}</a>
<span class="title">${this.#esc(pr.title)}</span>
${pr.draft ? `<span class="badge draft">draft</span>` : ''}
</div>
<div class="meta">
${this.#esc(pr.author)} · <code>${this.#esc(pr.head)}</code> → <code>${this.#esc(pr.base)}</code>
${pr.sameRepo ? '' : `<span class="badge fork">fork</span>`}
</div>`;
const btn = document.createElement('button');
btn.textContent = 'Merge & clean up';
btn.className = 'merge';
btn.title = pr.draft ? 'This PR is a draft' : 'Squash-merge and delete the branch';
btn.onclick = () => this.#merge(pr);
li.querySelector('.row').appendChild(btn);
return li;
}));
}
#error(msg) {
this.shadowRoot.getElementById('body').innerHTML = `<p class="error">${this.#esc(msg)}</p>`;
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.box { background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 0 0 8px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
li { border-top: 1px solid var(--border); padding-top: 8px; }
li:first-child { border-top: none; padding-top: 0; }
.row { display: flex; align-items: center; gap: 8px; }
.num { color: var(--fill-accent); text-decoration: none; font-weight: 600; }
.title { flex: 1; }
.meta { color: var(--color-fg-muted); font-size: 12px; margin-top: 2px; }
code { background: var(--surface-2); padding: 0 5px; border-radius: var(--radius-sm); }
.badge { font-size: 11px; padding: 0 6px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); }
.draft { color: var(--color-warning); border-color: var(--color-warning); }
.fork { color: var(--color-fg-muted); margin-left: 6px; }
button.merge { font: inherit; cursor: pointer; padding: 4px 10px;
border-radius: var(--radius-sm); border: 1px solid var(--color-danger);
color: var(--color-danger); background: transparent; }
button.merge:hover { background: var(--color-danger-bg); }
.muted { color: var(--color-fg-muted); }
.error { color: var(--color-danger); }
</style>
<div class="box">
<h3>Pull requests</h3>
<div id="body"><p class="muted">Select a repository.</p></div>
</div>`;
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('pr-list', PRList);