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>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// <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);
|
||||
@@ -0,0 +1,26 @@
|
||||
# pr-list
|
||||
|
||||
## Intent
|
||||
Shows the open pull requests for the selected repository and provides the
|
||||
**"Merge & clean up"** action (AGENT.md §8.4) — the feature that makes PRs usable
|
||||
for someone who otherwise finds them clutter: squash-merge and delete the branch
|
||||
in one click. The merge is DESTRUCTIVE (§1.4), so the button confirms first,
|
||||
naming the PR, base, and branch to be deleted.
|
||||
|
||||
## Public surface
|
||||
- **Tag:** `<pr-list>`
|
||||
- **Attributes/properties:** none. Hides itself (`hidden`) when no forge is
|
||||
configured or the repo isn't on the forge host.
|
||||
- **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`.
|
||||
- **Fetches:** `GET /api/repo/prs?path=…` (`{supported:false}` → hidden).
|
||||
- **Writes:** `POST /api/repo/pr/merge {path, number}` after a `confirm()`.
|
||||
|
||||
## History
|
||||
- 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up".
|
||||
|
||||
## Notes / gotchas
|
||||
- Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component
|
||||
stays hidden (graceful degradation).
|
||||
- Fork PRs are labelled; their branch lives in the fork, so cleanup only deletes
|
||||
branches in the same repo (the server enforces this too).
|
||||
- All server-derived text is escaped; the PR link opens in a new tab.
|
||||
Reference in New Issue
Block a user