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:
+7
-4
@@ -59,10 +59,13 @@ APP_ENV=dev
|
||||
LOG_FILE=
|
||||
|
||||
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
|
||||
# The primary host is a self-hosted Gitea/Forgejo (git.nilles.net). With no
|
||||
# token the forge features are simply absent; the rest of the app is unaffected.
|
||||
# Writes (merge PR + delete branch, for "Merge & clean up") are each confirmed
|
||||
# per AGENT.md §1.4. The token needs repo read + PR write + branch delete scope.
|
||||
# The primary host is a self-hosted Gitea/Forgejo. Set BOTH the base URL and a
|
||||
# token to enable PRs + "Merge & clean up"; with neither, the forge features are
|
||||
# simply absent and the rest of the app is unaffected. A repo is forge-enabled
|
||||
# when its origin remote host matches GITEA_URL's host. Writes (merge PR + delete
|
||||
# branch) are confirmed per AGENT.md §1.4. Token scope: repo read + PR write +
|
||||
# branch delete.
|
||||
GITEA_URL=
|
||||
GITEA_TOKEN=
|
||||
# Later providers, behind the same interface (unused for now):
|
||||
GITHUB_TOKEN=
|
||||
|
||||
@@ -138,3 +138,24 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
|
||||
- **Verified live:** request → "waiting" → cancel, all over SSE with activity
|
||||
logging. The ack/completion path is covered by the test; its live ✅ notice
|
||||
needs the two new MCP tools, which appear after the next Claude Desktop restart.
|
||||
|
||||
## 2026-09-20 — Slice 5: Gitea forge — PRs + "Merge & clean up" (§8.4)
|
||||
- **What:** New `internal/forge` — provider-abstracted forge boundary with a Gitea
|
||||
impl (`code.gitea.io/sdk/gitea`), a remote-URL parser (`ParseRemote`, tested),
|
||||
and read+write ops: `ListPullRequests` and `MergeAndCleanup` (squash-merge +
|
||||
delete the head branch, only when head/base share a repo). Service resolves a
|
||||
repo → owner/repo via its remotes (prefers origin) and records a `pr-merged`
|
||||
activity event. New config `GITEA_URL` + `GITEA_TOKEN` (forge is nil/disabled
|
||||
without both). New MCP tools `list_prs` and `merge_and_cleanup_pr` (the merge
|
||||
tool's description 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 is configured.
|
||||
- **Why:** The feature Thomas asked for — make PRs usable by merging and removing
|
||||
the branch in one tidy step, from the app or via Claude.
|
||||
- **Affects:** `internal/forge` (new, +test), `internal/config`,
|
||||
`internal/service`, `internal/mcp`, `cmd/server/main.go`,
|
||||
`components/pr-list` (new), `web/templates/{index,help}.html`, `.env.example`,
|
||||
`go.mod`.
|
||||
- **Not yet live-tested:** needs `GITEA_URL`+`GITEA_TOKEN` set and a real PR;
|
||||
build/vet/tests pass and the parser is unit-tested. A real merge is irreversible
|
||||
— will only run one against a PR Thomas designates, with confirmation.
|
||||
|
||||
+40
-1
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/config"
|
||||
"gitmanager/internal/forge"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/logging"
|
||||
mcpserver "gitmanager/internal/mcp"
|
||||
@@ -56,8 +57,18 @@ func main() {
|
||||
// Coordination state: active project + activity feed (§8.2).
|
||||
feed := activity.New(log, 200)
|
||||
|
||||
// Forge provider (Gitea) — optional; nil when unconfigured (§8.4).
|
||||
fg, err := forge.NewGitea(cfg.GiteaURL, cfg.GiteaToken)
|
||||
if err != nil {
|
||||
log.Warn("forge disabled — invalid config", "err", err)
|
||||
} else if fg != nil {
|
||||
log.Info("forge enabled", "provider", "gitea", "url", cfg.GiteaURL)
|
||||
} else {
|
||||
log.Info("forge disabled — set GITEA_URL and GITEA_TOKEN to enable")
|
||||
}
|
||||
|
||||
// The one service layer both the HTTP API and the MCP server call (§1.7).
|
||||
svc := service.New(g, scanner.Index, feed)
|
||||
svc := service.New(g, scanner.Index, feed, fg)
|
||||
|
||||
tmpl, err := render.New("web/templates")
|
||||
if err != nil {
|
||||
@@ -150,6 +161,34 @@ func main() {
|
||||
return c.JSON(http.StatusOK, map[string]any{"pending": false})
|
||||
})
|
||||
|
||||
// Forge PRs + "Merge & clean up" (§8.4).
|
||||
e.GET("/api/repo/prs", func(c echo.Context) error {
|
||||
prs, err := svc.ForgePRs(c.Request().Context(), c.QueryParam("path"))
|
||||
if err != nil {
|
||||
// Not configured / not on the forge host is a normal "no PRs here"
|
||||
// state — tell the UI to hide the section rather than error.
|
||||
if err == forge.ErrNotConfigured || err == forge.ErrNotSupported {
|
||||
return c.JSON(http.StatusOK, map[string]any{"supported": false})
|
||||
}
|
||||
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs})
|
||||
})
|
||||
e.POST("/api/repo/pr/merge", func(c echo.Context) error {
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
Number int64 `json:"number"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
||||
}
|
||||
res, err := svc.MergeAndCleanup(c.Request().Context(), activity.ActorUser, body.Path, body.Number)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
})
|
||||
|
||||
// SSE stream of activity events for live UI (§8.2).
|
||||
e.GET("/events", func(c echo.Context) error {
|
||||
w := c.Response()
|
||||
|
||||
@@ -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.
|
||||
@@ -3,13 +3,18 @@ module gitmanager
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.25.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/labstack/echo/v4 v4.15.4
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/42wim/httpsig v1.2.4 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
|
||||
code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
|
||||
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||
@@ -32,20 +42,34 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -34,8 +34,10 @@ type Config struct {
|
||||
Dev bool // readable console logging vs structured JSON
|
||||
LogFile string // optional file to also append logs to
|
||||
|
||||
GitHubToken string // optional forge token (AGENT.md §8)
|
||||
GitLabToken string // optional forge token (AGENT.md §8)
|
||||
GiteaURL string // Gitea/Forgejo base URL (e.g. https://git.nilles.net)
|
||||
GiteaToken string // Gitea token (read + PR write + branch delete) — §8.4
|
||||
GitHubToken string // optional forge token (later provider)
|
||||
GitLabToken string // optional forge token (later provider)
|
||||
}
|
||||
|
||||
// Load reads .env (if present) then the environment, applying defaults.
|
||||
@@ -55,6 +57,8 @@ func Load() (Config, error) {
|
||||
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
|
||||
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
|
||||
LogFile: env("LOG_FILE", ""),
|
||||
GiteaURL: env("GITEA_URL", ""),
|
||||
GiteaToken: env("GITEA_TOKEN", ""),
|
||||
GitHubToken: env("GITHUB_TOKEN", ""),
|
||||
GitLabToken: env("GITLAB_TOKEN", ""),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package forge is the provider-abstracted boundary to a git hosting service
|
||||
// (AGENT.md §8.4). Gitea/Forgejo is the first provider; GitHub/GitLab can drop in
|
||||
// behind the same interface. It is read + write: the write path powers
|
||||
// "Merge & clean up" (merge a PR + delete its branch), which callers must confirm
|
||||
// per §1.4. With no provider configured the features degrade gracefully.
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sentinel errors so callers (and the UI) can degrade gracefully.
|
||||
var (
|
||||
ErrNotConfigured = errors.New("forge integration not configured")
|
||||
ErrNotSupported = errors.New("repository is not on a supported forge host")
|
||||
)
|
||||
|
||||
// PullRequest is the provider-neutral view of an open PR/MR.
|
||||
type PullRequest struct {
|
||||
Number int64 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Head string `json:"head"` // head branch
|
||||
Base string `json:"base"` // base branch
|
||||
Draft bool `json:"draft"`
|
||||
Mergeable bool `json:"mergeable"`
|
||||
URL string `json:"url"`
|
||||
SameRepo bool `json:"sameRepo"` // head & base in the same repo (branch is deletable)
|
||||
}
|
||||
|
||||
// MergeMethod selects how a PR is merged.
|
||||
type MergeMethod string
|
||||
|
||||
const (
|
||||
MergeSquash MergeMethod = "squash"
|
||||
MergeMerge MergeMethod = "merge"
|
||||
MergeRebase MergeMethod = "rebase"
|
||||
)
|
||||
|
||||
// MergeResult reports the outcome of a merge-and-cleanup.
|
||||
type MergeResult struct {
|
||||
Number int64 `json:"number"`
|
||||
Merged bool `json:"merged"`
|
||||
Branch string `json:"branch"`
|
||||
BranchDeleted bool `json:"branchDeleted"`
|
||||
}
|
||||
|
||||
// Provider talks to one hosting provider.
|
||||
type Provider interface {
|
||||
// Handles reports whether this provider serves the given remote host.
|
||||
Handles(host string) bool
|
||||
ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error)
|
||||
MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error)
|
||||
}
|
||||
|
||||
// ParseRemote extracts (host, owner, repo) from a git remote URL, handling both
|
||||
// https ("https://host/owner/repo.git") and scp-like ssh ("git@host:owner/repo.git").
|
||||
func ParseRemote(remote string) (host, owner, repo string, ok bool) {
|
||||
remote = strings.TrimSpace(remote)
|
||||
if remote == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
// scp-like ssh form has no scheme: user@host:path
|
||||
if !strings.Contains(remote, "://") && strings.Contains(remote, "@") && strings.Contains(remote, ":") {
|
||||
rest := remote[strings.Index(remote, "@")+1:]
|
||||
colon := strings.Index(rest, ":")
|
||||
host = rest[:colon]
|
||||
owner, repo, ok = splitOwnerRepo(rest[colon+1:])
|
||||
return host, owner, repo, ok
|
||||
}
|
||||
|
||||
u, err := url.Parse(remote)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
owner, repo, ok = splitOwnerRepo(u.Path)
|
||||
return u.Hostname(), owner, repo, ok
|
||||
}
|
||||
|
||||
// splitOwnerRepo turns "/owner/repo.git" (or subgroups) into (owner, repo). It
|
||||
// takes the last two path segments, which covers the common single-owner case.
|
||||
func splitOwnerRepo(path string) (owner, repo string, ok bool) {
|
||||
path = strings.Trim(path, "/")
|
||||
path = strings.TrimSuffix(path, ".git")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
owner = parts[len(parts)-2]
|
||||
repo = parts[len(parts)-1]
|
||||
if owner == "" || repo == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return owner, repo, true
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package forge
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseRemote(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
host, owner, repo string
|
||||
ok bool
|
||||
}{
|
||||
{"https://git.nilles.net/TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
|
||||
{"https://git.nilles.net/TBNilles/GitManager", "git.nilles.net", "TBNilles", "GitManager", true},
|
||||
{"git@git.nilles.net:TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
|
||||
{"https://git.nilles.net:3000/org/sub/Repo.git", "git.nilles.net", "sub", "Repo", true},
|
||||
{"ssh://git@git.nilles.net:2222/TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
|
||||
{"not a url", "", "", "", false},
|
||||
{"https://git.nilles.net/", "", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
host, owner, repo, ok := ParseRemote(c.in)
|
||||
if ok != c.ok {
|
||||
t.Errorf("ParseRemote(%q) ok = %v, want %v", c.in, ok, c.ok)
|
||||
continue
|
||||
}
|
||||
// Field values only matter on success.
|
||||
if ok && (host != c.host || owner != c.owner || repo != c.repo) {
|
||||
t.Errorf("ParseRemote(%q) = (%q,%q,%q), want (%q,%q,%q)",
|
||||
c.in, host, owner, repo, c.host, c.owner, c.repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
// Gitea is a Provider backed by a Gitea/Forgejo instance. It also decides which
|
||||
// repos it serves: only those whose remote host matches its base URL.
|
||||
type Gitea struct {
|
||||
host string
|
||||
client *gitea.Client
|
||||
}
|
||||
|
||||
// NewGitea builds a Gitea provider from a base URL and token. It returns
|
||||
// (nil, nil) when not configured (either value empty) so forge features simply
|
||||
// stay absent (§8.4 graceful degradation).
|
||||
func NewGitea(baseURL, token string) (*Gitea, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
token = strings.TrimSpace(token)
|
||||
if baseURL == "" || token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return nil, fmt.Errorf("invalid GITEA_URL %q", baseURL)
|
||||
}
|
||||
c, err := gitea.NewClient(baseURL, gitea.SetToken(token))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Gitea{host: u.Hostname(), client: c}, nil
|
||||
}
|
||||
|
||||
// Handles reports whether a remote host is this Gitea instance.
|
||||
func (g *Gitea) Handles(host string) bool {
|
||||
return strings.EqualFold(host, g.host)
|
||||
}
|
||||
|
||||
// ListPullRequests lists the open PRs of owner/repo.
|
||||
func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullRequest, error) {
|
||||
prs, _, err := g.client.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{State: gitea.StateOpen})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]PullRequest, 0, len(prs))
|
||||
for _, p := range prs {
|
||||
out = append(out, toPR(p))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MergeAndCleanup merges the PR and deletes its head branch (when the head is in
|
||||
// the same repo — never a fork's branch). Callers MUST have confirmed with the
|
||||
// user first (§1.4).
|
||||
func (g *Gitea) MergeAndCleanup(_ context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error) {
|
||||
pr, _, err := g.client.GetPullRequest(owner, repo, number)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
branch := ""
|
||||
if pr.Head != nil {
|
||||
branch = pr.Head.Ref
|
||||
}
|
||||
sameRepo := pr.Head != nil && pr.Base != nil && pr.Head.RepoID == pr.Base.RepoID
|
||||
del := sameRepo && branch != ""
|
||||
|
||||
merged, _, err := g.client.MergePullRequest(owner, repo, number, gitea.MergePullRequestOption{
|
||||
Style: toStyle(method),
|
||||
DeleteBranchAfterMerge: &del,
|
||||
})
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
|
||||
res := MergeResult{Number: number, Merged: merged, Branch: branch, BranchDeleted: del && merged}
|
||||
// Best-effort fallback in case the merge option didn't delete the branch
|
||||
// (older Gitea). Ignore the error — the branch may already be gone.
|
||||
if merged && del {
|
||||
_, _, _ = g.client.DeleteRepoBranch(owner, repo, branch)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func toPR(p *gitea.PullRequest) PullRequest {
|
||||
pr := PullRequest{
|
||||
Number: p.Index,
|
||||
Title: p.Title,
|
||||
Draft: p.Draft,
|
||||
Mergeable: p.Mergeable,
|
||||
URL: p.HTMLURL,
|
||||
}
|
||||
if p.Poster != nil {
|
||||
pr.Author = p.Poster.UserName
|
||||
}
|
||||
if p.Head != nil {
|
||||
pr.Head = p.Head.Ref
|
||||
}
|
||||
if p.Base != nil {
|
||||
pr.Base = p.Base.Ref
|
||||
}
|
||||
if p.Head != nil && p.Base != nil {
|
||||
pr.SameRepo = p.Head.RepoID == p.Base.RepoID
|
||||
}
|
||||
return pr
|
||||
}
|
||||
|
||||
func toStyle(m MergeMethod) gitea.MergeStyle {
|
||||
switch m {
|
||||
case MergeMerge:
|
||||
return gitea.MergeStyleMerge
|
||||
case MergeRebase:
|
||||
return gitea.MergeStyleRebase
|
||||
default:
|
||||
return gitea.MergeStyleSquash
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/forge"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
)
|
||||
@@ -64,6 +65,22 @@ type ackSwitchOutput struct {
|
||||
Target string `json:"target,omitempty" jsonschema:"the repository now active"`
|
||||
}
|
||||
|
||||
// repoPathInput selects a repository by path (from list_repos).
|
||||
type repoPathInput struct {
|
||||
Path string `json:"path" jsonschema:"absolute path of the repository, exactly as returned by list_repos"`
|
||||
}
|
||||
|
||||
// prListOutput wraps the pull requests (object, per the schema rule).
|
||||
type prListOutput struct {
|
||||
PRs []forge.PullRequest `json:"prs" jsonschema:"open pull requests for the repository"`
|
||||
}
|
||||
|
||||
// mergePRInput selects the PR to merge and clean up.
|
||||
type mergePRInput struct {
|
||||
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
|
||||
Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"`
|
||||
}
|
||||
|
||||
// NewServer builds the MCP server and registers the (currently read-only) tools.
|
||||
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||
s := mcpsdk.NewServer(&mcpsdk.Implementation{
|
||||
@@ -144,6 +161,30 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||
return nil, ackSwitchOutput{Switched: true, Target: p.Target}, nil
|
||||
})
|
||||
|
||||
// list_prs — open pull requests for a repo (§8.4).
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "list_prs",
|
||||
Description: "List the open pull requests for a repository (needs a configured forge such as Gitea). The path must be one returned by list_repos.",
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, prListOutput, error) {
|
||||
prs, err := svc.ForgePRs(ctx, in.Path)
|
||||
if err != nil {
|
||||
return nil, prListOutput{}, err
|
||||
}
|
||||
return nil, prListOutput{PRs: prs}, nil
|
||||
})
|
||||
|
||||
// merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch.
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "merge_and_cleanup_pr",
|
||||
Description: "Merge a pull request (squash) AND delete its source branch — the \"Merge & clean up\" action. This is irreversible: confirm the exact PR number and repository with the user BEFORE calling. Merged history remains on the host; only the branch is removed.",
|
||||
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in mergePRInput) (*mcpsdk.CallToolResult, forge.MergeResult, error) {
|
||||
res, err := svc.MergeAndCleanup(ctx, activity.ActorClaude, in.Path, in.Number)
|
||||
if err != nil {
|
||||
return nil, forge.MergeResult{}, err
|
||||
}
|
||||
return nil, res, nil
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestMCPRoundTrip(t *testing.T) {
|
||||
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||
scanner.Refresh(context.Background())
|
||||
|
||||
svc := service.New(g, scanner.Index, activity.New(log, 200))
|
||||
svc := service.New(g, scanner.Index, activity.New(log, 200), nil)
|
||||
srv := NewServer(svc, "test")
|
||||
|
||||
// Wire an in-memory client<->server session.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/forge"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
)
|
||||
@@ -20,12 +21,13 @@ type Service struct {
|
||||
git *git.CLI
|
||||
index *repos.Index
|
||||
feed *activity.Feed
|
||||
forge *forge.Gitea // nil when no forge is configured
|
||||
}
|
||||
|
||||
// New builds a Service over the git boundary, the scanner's repo index, and the
|
||||
// activity feed.
|
||||
func New(g *git.CLI, index *repos.Index, feed *activity.Feed) *Service {
|
||||
return &Service{git: g, index: index, feed: feed}
|
||||
// New builds a Service over the git boundary, the scanner's repo index, the
|
||||
// activity feed, and (optionally) a forge provider.
|
||||
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea) *Service {
|
||||
return &Service{git: g, index: index, feed: feed, forge: fg}
|
||||
}
|
||||
|
||||
// ListRepos returns a snapshot of every discovered repository.
|
||||
@@ -115,3 +117,75 @@ func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
|
||||
func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
|
||||
return s.feed.CancelSwitch(actor)
|
||||
}
|
||||
|
||||
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
|
||||
|
||||
// ForgeConfigured reports whether any forge provider is set up.
|
||||
func (s *Service) ForgeConfigured() bool { return s.forge != nil }
|
||||
|
||||
// ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured
|
||||
// when no provider is set, or forge.ErrNotSupported when the repo's remote is not
|
||||
// on the configured host.
|
||||
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
|
||||
owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.forge.ListPullRequests(ctx, owner, repo)
|
||||
}
|
||||
|
||||
// MergeAndCleanup merges a PR and deletes its branch, then records the action.
|
||||
// The caller is responsible for confirming with the user first (§1.4); actor
|
||||
// distinguishes a UI action (user) from an MCP one (claude).
|
||||
func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, repoPath string, number int64) (forge.MergeResult, error) {
|
||||
owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
if err != nil {
|
||||
return forge.MergeResult{}, err
|
||||
}
|
||||
res, err := s.forge.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
|
||||
if err != nil {
|
||||
return forge.MergeResult{}, err
|
||||
}
|
||||
detail := fmt.Sprintf("merged PR #%d", res.Number)
|
||||
if res.BranchDeleted && res.Branch != "" {
|
||||
detail += fmt.Sprintf(", deleted branch %s", res.Branch)
|
||||
}
|
||||
s.feed.Record(actor, "pr-merged", filepath.Clean(repoPath), detail)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// resolveForge maps a repo path to (owner, repo) on the configured forge host via
|
||||
// its git remotes, preferring "origin".
|
||||
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
|
||||
if s.forge == nil {
|
||||
return "", "", forge.ErrNotConfigured
|
||||
}
|
||||
base, ok := s.index.Get(filepath.Clean(repoPath))
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown repository %q", repoPath)
|
||||
}
|
||||
remotes, err := s.git.RemoteDetails(ctx, base.Path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Prefer origin, then any matching remote.
|
||||
var fallback [2]string
|
||||
haveFallback := false
|
||||
for _, rm := range remotes {
|
||||
host, o, r, ok := forge.ParseRemote(rm.URL)
|
||||
if !ok || !s.forge.Handles(host) {
|
||||
continue
|
||||
}
|
||||
if rm.Name == "origin" {
|
||||
return o, r, nil
|
||||
}
|
||||
if !haveFallback {
|
||||
fallback = [2]string{o, r}
|
||||
haveFallback = true
|
||||
}
|
||||
}
|
||||
if haveFallback {
|
||||
return fallback[0], fallback[1], nil
|
||||
}
|
||||
return "", "", forge.ErrNotSupported
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@
|
||||
project and this activity, so you stay on the same page.
|
||||
</p>
|
||||
|
||||
<h2>Pull requests: Merge & clean up</h2>
|
||||
<p>
|
||||
When a repository is hosted on your Gitea server, its open pull requests
|
||||
appear under the details panel. Each has a <strong>Merge & clean up</strong>
|
||||
button: it squash-merges the pull request and <strong>deletes its
|
||||
branch</strong> in one step, so finished work doesn't leave branches lying
|
||||
around. You'll be asked to confirm — it names the pull request and the branch
|
||||
that will be deleted — because it can't be undone (the merged history stays
|
||||
on the server; only the branch is removed). If you don't use a Gitea server,
|
||||
this section stays hidden.
|
||||
</p>
|
||||
|
||||
<h2>Handing a project off to Claude</h2>
|
||||
<p>
|
||||
When GitManager is connected to Claude, you can hand the current project
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<script type="module" src="/components/repo-detail/repo-detail.js"></script>
|
||||
<script type="module" src="/components/activity-feed/activity-feed.js"></script>
|
||||
<script type="module" src="/components/handoff-bar/handoff-bar.js"></script>
|
||||
<script type="module" src="/components/pr-list/pr-list.js"></script>
|
||||
<style>
|
||||
header {
|
||||
display: flex;
|
||||
@@ -31,6 +32,7 @@
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.right { display: grid; gap: 16px; }
|
||||
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
@@ -44,7 +46,10 @@
|
||||
<handoff-bar></handoff-bar>
|
||||
<div class="cols">
|
||||
<repo-list></repo-list>
|
||||
<repo-detail></repo-detail>
|
||||
<div class="right">
|
||||
<repo-detail></repo-detail>
|
||||
<pr-list hidden></pr-list>
|
||||
</div>
|
||||
</div>
|
||||
<activity-feed></activity-feed>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user