diff --git a/.env.example b/.env.example index 0681b3b..d11b563 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/CHANGELOG.md b/CHANGELOG.md index 6010329..ce92f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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. diff --git a/cmd/server/main.go b/cmd/server/main.go index 6091d6d..9b0f61a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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() diff --git a/components/pr-list/pr-list.js b/components/pr-list/pr-list.js new file mode 100644 index 0000000..649e3e7 --- /dev/null +++ b/components/pr-list/pr-list.js @@ -0,0 +1,139 @@ +// — 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 = `

No open pull requests.

`; + return; + } + body.replaceChildren(...prs.map((pr) => { + const li = document.createElement('li'); + li.innerHTML = ` +
+ #${pr.number} + ${this.#esc(pr.title)} + ${pr.draft ? `draft` : ''} +
+
+ ${this.#esc(pr.author)} · ${this.#esc(pr.head)}${this.#esc(pr.base)} + ${pr.sameRepo ? '' : `fork`} +
`; + 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 = `

${this.#esc(msg)}

`; + } + + #renderShell() { + this.shadowRoot.innerHTML = ` + +
+

Pull requests

+

Select a repository.

+
`; + } + + #esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); + } +} + +customElements.define('pr-list', PRList); diff --git a/components/pr-list/pr-list.md b/components/pr-list/pr-list.md new file mode 100644 index 0000000..bfee90e --- /dev/null +++ b/components/pr-list/pr-list.md @@ -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:** `` +- **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. diff --git a/go.mod b/go.mod index c25cedd..c7eb806 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index e18dcf3..7252149 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index 5e9e445..3737160 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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", ""), } diff --git a/internal/forge/forge.go b/internal/forge/forge.go new file mode 100644 index 0000000..18d126b --- /dev/null +++ b/internal/forge/forge.go @@ -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 +} diff --git a/internal/forge/forge_test.go b/internal/forge/forge_test.go new file mode 100644 index 0000000..a6d0db6 --- /dev/null +++ b/internal/forge/forge_test.go @@ -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) + } + } +} diff --git a/internal/forge/gitea.go b/internal/forge/gitea.go new file mode 100644 index 0000000..bf65cce --- /dev/null +++ b/internal/forge/gitea.go @@ -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 + } +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 62f290b..99f6c5d 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -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 } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 45f40cf..f8aa8a4 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -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. diff --git a/internal/service/service.go b/internal/service/service.go index ffca5f4..fec4a23 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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 +} diff --git a/web/templates/help.html b/web/templates/help.html index f0c2026..ecc1ecb 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -62,6 +62,18 @@ project and this activity, so you stay on the same page.

+

Pull requests: Merge & clean up

+

+ When a repository is hosted on your Gitea server, its open pull requests + appear under the details panel. Each has a Merge & clean up + button: it squash-merges the pull request and deletes its + branch 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. +

+

Handing a project off to Claude

When GitManager is connected to Claude, you can hand the current project diff --git a/web/templates/index.html b/web/templates/index.html index c2e5699..4351692 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -11,6 +11,7 @@ + @@ -44,7 +46,10 @@

- +
+ + +