9d1519222c
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>
140 lines
5.4 KiB
JavaScript
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) => (
|
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
));
|
|
}
|
|
}
|
|
|
|
customElements.define('pr-list', PRList);
|