Compare commits
11 Commits
d3abd4416e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 69d38484e8 | |||
| 34ed127653 | |||
| 68b6c3a3f8 | |||
| ad00654487 | |||
| e1999bcf21 | |||
| e59d5bbd29 | |||
| c6d3b5fae8 | |||
| 6ef1c195e3 | |||
| 9d1519222c | |||
| 2b77e15b36 | |||
| 65daedc902 |
+15
-4
@@ -55,14 +55,25 @@ SCAN_FETCH_ENABLED=false
|
|||||||
# "dev" uses a readable console handler; anything else uses structured JSON.
|
# "dev" uses a readable console handler; anything else uses structured JSON.
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
|
|
||||||
|
# Commit identity for git actions the app runs (commit/etc.). Without these,
|
||||||
|
# commits inside the container fail with "empty ident". Set to your name/email.
|
||||||
|
GIT_USER_NAME=
|
||||||
|
GIT_USER_EMAIL=
|
||||||
|
# Note: when GITEA_URL + GITEA_TOKEN are set, the app also configures git to
|
||||||
|
# authenticate to that host over HTTPS (an http.extraheader), so push/fetch/pull
|
||||||
|
# work from the container without a separate SSH key or credential helper.
|
||||||
|
|
||||||
# Optional: also append structured logs to this file. Leave empty to disable.
|
# Optional: also append structured logs to this file. Leave empty to disable.
|
||||||
LOG_FILE=
|
LOG_FILE=
|
||||||
|
|
||||||
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
|
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
|
||||||
# The primary host is a self-hosted Gitea/Forgejo (git.nilles.net). With no
|
# The primary host is a self-hosted Gitea/Forgejo. Set BOTH the base URL and a
|
||||||
# token the forge features are simply absent; the rest of the app is unaffected.
|
# token to enable PRs + "Merge & clean up"; with neither, the forge features are
|
||||||
# Writes (merge PR + delete branch, for "Merge & clean up") are each confirmed
|
# simply absent and the rest of the app is unaffected. A repo is forge-enabled
|
||||||
# per AGENT.md §1.4. The token needs repo read + PR write + branch delete scope.
|
# 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=
|
GITEA_TOKEN=
|
||||||
# Later providers, behind the same interface (unused for now):
|
# Later providers, behind the same interface (unused for now):
|
||||||
GITHUB_TOKEN=
|
GITHUB_TOKEN=
|
||||||
|
|||||||
@@ -365,8 +365,9 @@ obeys the safety rules (§1.4).
|
|||||||
the tool handlers (§1.7). Expected tools (grow as features land):
|
the tool handlers (§1.7). Expected tools (grow as features land):
|
||||||
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
|
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
|
||||||
`get_pending_switch`, `list_prs`.
|
`get_pending_switch`, `list_prs`.
|
||||||
- Act: `git_status/checkout/commit/push/pull/create_branch`,
|
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`,
|
||||||
`create_pr`, `merge_and_cleanup_pr`, `set_active_project`, `ack_switch`.
|
`create_branch`, `git_discard_changes`, `create_pr`, `merge_and_cleanup_pr`,
|
||||||
|
`set_active_project`, `ack_switch`.
|
||||||
- **A tool's result type must be a struct, never a bare slice/map/scalar.** The
|
- **A tool's result type must be a struct, never a bare slice/map/scalar.** The
|
||||||
go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP
|
go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP
|
||||||
structured output must be a JSON **object** (`type: "object"`). A handler that
|
structured output must be a JSON **object** (`type: "object"`). A handler that
|
||||||
@@ -395,18 +396,20 @@ obeys the safety rules (§1.4).
|
|||||||
When the user switches project/task in the app, it is a **request**, not an
|
When the user switches project/task in the app, it is a **request**, not an
|
||||||
instant yank. The cooperative protocol:
|
instant yank. The cooperative protocol:
|
||||||
|
|
||||||
1. **User** picks a new project/task in the app → the app records a
|
1. **User** asks (in the app) for Claude to switch to a project →
|
||||||
**pending‑switch request** (target + optional note) and the UI shows
|
`POST /api/switch` records a **pending‑switch request** (target + optional
|
||||||
"waiting for Claude to reach a good stopping point."
|
note) and `<handoff-bar>` shows "waiting for Claude to reach a good stopping
|
||||||
|
point." (The request is a **user‑only** action — there is no MCP tool to raise
|
||||||
|
it; Claude fulfils requests, it doesn't create them.)
|
||||||
2. **Claude** sees the pending request (it checks at its natural turn‑boundary
|
2. **Claude** sees the pending request (it checks at its natural turn‑boundary
|
||||||
checkpoints via `get_pending_switch`). It **finishes to a safe stopping
|
checkpoints via `get_pending_switch`). It **finishes to a safe stopping
|
||||||
point and preserves work** — never abandons uncommitted changes to switch;
|
point and preserves work** — never abandons uncommitted changes to switch;
|
||||||
it completes the in‑flight step and commits/stashes as appropriate — then
|
it completes the in‑flight step and commits/stashes as appropriate — then
|
||||||
performs the switch (`set_active_project`, moving its working context to the
|
calls **`ack_switch`** with a short summary of where it left the previous
|
||||||
new repo) and calls **`ack_switch`** with a short summary of where it left
|
project. `ack_switch` **atomically** makes the requested target the active
|
||||||
the previous project.
|
project and clears the request.
|
||||||
3. **App** marks the request fulfilled and **notifies the user** over SSE
|
3. **App** records `switch-completed` and **notifies the user** over SSE
|
||||||
("Claude switched to *ProjectB*; *ProjectA* left at: …"). The user proceeds.
|
("Claude switched to *ProjectB* — *left at: …*"). The user proceeds.
|
||||||
|
|
||||||
**Rule:** the switch is Claude‑completed at a checkpoint, not app‑forced. Losing
|
**Rule:** the switch is Claude‑completed at a checkpoint, not app‑forced. Losing
|
||||||
or interrupting uncommitted work to satisfy a switch is a §1.4‑class violation.
|
or interrupting uncommitted work to satisfy a switch is a §1.4‑class violation.
|
||||||
@@ -545,8 +548,12 @@ silently guess.)*
|
|||||||
branch delete) and how it is provisioned; document in `.env.example`.
|
branch delete) and how it is provisioned; document in `.env.example`.
|
||||||
- **Listen address / exposure:** localhost‑only by default (covers `/mcp` too).
|
- **Listen address / exposure:** localhost‑only by default (covers `/mcp` too).
|
||||||
Confirm before binding to a non‑local interface — there is no auth (Section 0).
|
Confirm before binding to a non‑local interface — there is no auth (Section 0).
|
||||||
- **Credential path from the container:** SSH agent socket vs mounted keys vs
|
- ✅ **RESOLVED 2026-09-20:** **Container git auth = the Gitea token over HTTPS.**
|
||||||
credential helper, for pushing/fetching from inside Docker.
|
On startup the app runs `git config --global` to set a commit identity
|
||||||
|
(`GIT_USER_NAME`/`GIT_USER_EMAIL`), `safe.directory=*` (host-owned mounts), and
|
||||||
|
`http.<GITEA_URL>.extraheader: Authorization: token …` so push/fetch/pull work
|
||||||
|
without SSH keys. The token lands in the container's gitconfig (ephemeral,
|
||||||
|
localhost). An SSH-key path stays possible later for non-Gitea remotes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+175
@@ -98,3 +98,178 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
|
|||||||
endpoint directly — no public exposure, no HTTPS needed for this path.
|
endpoint directly — no public exposure, no HTTPS needed for this path.
|
||||||
- **Affects:** `AGENT.md` (§8.1, §11); user's `claude_desktop_config.json` (outside
|
- **Affects:** `AGENT.md` (§8.1, §11); user's `claude_desktop_config.json` (outside
|
||||||
the repo). HTTPS/`:8443` from the prior entry stays available but is now optional.
|
the repo). HTTPS/`:8443` from the prior entry stays available but is now optional.
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 3: activity feed + active project (§8.2)
|
||||||
|
- **What:** Added `internal/activity` (thread-safe active project + bounded event
|
||||||
|
feed with subscriber fan-out, mirrored to logs, no datastore). Service gained
|
||||||
|
`ActiveProject`/`SetActiveProject`/`RecordActivity`/`Activity`/`SubscribeActivity`
|
||||||
|
(and `service.New` now takes the feed). New MCP tools `get_active_project`,
|
||||||
|
`set_active_project`, `get_activity` (object-wrapped outputs). New HTTP:
|
||||||
|
`GET/POST /api/active-project`, `GET /api/activity`, and `GET /events` (SSE).
|
||||||
|
New `<activity-feed>` component (live via EventSource); `<repo-list>` now sets
|
||||||
|
the active project on selection (a user action). Extended the MCP test to cover
|
||||||
|
the new tools; help page documents the feature.
|
||||||
|
- **Why:** The coordination foundation for the graceful project handoff (§8.3):
|
||||||
|
the app and Claude share one active-project + activity view. User actions are
|
||||||
|
recorded as `actor:user`, Claude's as `actor:claude`, so each side can see what
|
||||||
|
the other did.
|
||||||
|
- **Affects:** `internal/activity` (new), `internal/service`, `internal/mcp`
|
||||||
|
(+test), `cmd/server/main.go`, `components/activity-feed` (new),
|
||||||
|
`components/repo-list`, `web/templates/{index,help}.html`.
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 4: graceful project handoff (§8.3)
|
||||||
|
- **What:** `internal/activity` gained a pending-switch model
|
||||||
|
(`RequestSwitch`/`PendingSwitch`/`AckSwitch`/`CancelSwitch`); `AckSwitch`
|
||||||
|
atomically sets the active project to the requested target and records a
|
||||||
|
`switch-completed` event with Claude's summary. Service methods added. New MCP
|
||||||
|
tools `get_pending_switch` and `ack_switch` (request is user-only — no MCP tool
|
||||||
|
raises it). HTTP: `GET/POST/DELETE /api/switch`. New `<handoff-bar>` component:
|
||||||
|
"Ask Claude to switch to <active>", the "waiting for a good stopping point"
|
||||||
|
state with Cancel, and the completion notice; `<activity-feed>` also updates the
|
||||||
|
active project on `switch-completed`. Extended the MCP test to cover the full
|
||||||
|
request→ack→clear flow. Help page + AGENT.md §8.3 updated.
|
||||||
|
- **Why:** The headline feature — the user asks Claude to switch projects; Claude
|
||||||
|
finishes to a safe stopping point, then `ack_switch` completes it and the app
|
||||||
|
notifies the user over SSE. The switch is Claude-completed at a checkpoint,
|
||||||
|
never app-forced (§1.4-class rule).
|
||||||
|
- **Affects:** `internal/activity`, `internal/service`, `internal/mcp` (+test),
|
||||||
|
`cmd/server/main.go`, `components/handoff-bar` (new), `components/activity-feed`,
|
||||||
|
`web/templates/{index,help}.html`, `AGENT.md` (§8.3).
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 2026-09-20 — Forge live-tested (Merge & clean up)
|
||||||
|
- **What:** With `GITEA_URL`+`GITEA_TOKEN` set, verified end-to-end against
|
||||||
|
git.nilles.net: created an isolated throwaway PR via the Gitea API (on a
|
||||||
|
dedicated base branch so `main` was untouched), listed it through `GET
|
||||||
|
/api/repo/prs`, then ran `POST /api/repo/pr/merge` → `{merged:true,
|
||||||
|
branchDeleted:true}`; confirmed the branch was gone (404), the PR list emptied,
|
||||||
|
and the feed logged `pr-merged`. Cleaned up the base branch afterward.
|
||||||
|
- **Why:** Prove the write path with real auth before relying on it.
|
||||||
|
- **Affects:** none (runtime verification only; no code change).
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 6: right-click command menu + git write actions (§6)
|
||||||
|
- **What:** git boundary gained `Pull`/`Push`/`Commit`/`DiscardAll` (the last is
|
||||||
|
§1.4-destructive). Scanner got `RefreshRepo` (single-repo re-scan). Service
|
||||||
|
gained `GitFetch/GitPull/GitPush/GitCommit/GitDiscard` — each records a `git-*`
|
||||||
|
activity event (ok/failed) and refreshes the repo after success; `service.New`
|
||||||
|
takes a refresh hook. New HTTP `POST /api/repo/git {path, op, message?}`. New
|
||||||
|
`<repo-menu>` overlay (plain-language commands: Get latest, Publish, Check for
|
||||||
|
updates, Save my work…, Set as active project, Ask Claude to switch here, Copy
|
||||||
|
path, and the confirmed Discard all changes…); `<repo-list>` emits
|
||||||
|
`repo:contextmenu` on right-click. Added `internal/service` test covering
|
||||||
|
commit/discard on a temp repo.
|
||||||
|
- **Why:** The GUI-first reason the app exists (§0) — run git in plain language
|
||||||
|
without a terminal. Logic lives in the shared service (§1.7) so the same ops can
|
||||||
|
be exposed to Claude via MCP next.
|
||||||
|
- **Affects:** `internal/git`, `internal/repos`, `internal/service` (+test),
|
||||||
|
`cmd/server/main.go`, `components/repo-menu` (new), `components/repo-list`,
|
||||||
|
`web/templates/{index,help}.html`.
|
||||||
|
- **Next:** expose these git ops as MCP tools so Claude can run them too.
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 7: git commands as MCP tools (§1.7 symmetry)
|
||||||
|
- **What:** Added MCP tools `git_fetch`, `git_pull`, `git_push`, `git_commit`,
|
||||||
|
and `git_discard_changes` — thin adapters over the existing service methods
|
||||||
|
(actor=claude), so Claude can run the same commands as the right-click menu.
|
||||||
|
`git_discard_changes`'s description flags it destructive and tells Claude to
|
||||||
|
confirm first (§1.4). Extended the MCP test with a `git_commit` round-trip.
|
||||||
|
Synced AGENT.md §8.1's tool list to the actual names.
|
||||||
|
- **Why:** Complete the §1.7 symmetry — every capability reachable from both the
|
||||||
|
GUI and Claude.
|
||||||
|
- **Affects:** `internal/mcp` (+test), `AGENT.md` (§8.1).
|
||||||
|
- **Note:** the new tools appear in Claude Desktop only after its next restart
|
||||||
|
(tool list cached per connection); network ops still need container git creds.
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 8: git credentials + identity in the container (§11)
|
||||||
|
- **What:** On startup the app configures the container's git (`git config
|
||||||
|
--global`): a commit identity (`GIT_USER_NAME`/`GIT_USER_EMAIL`),
|
||||||
|
`safe.directory=*` for host-owned mounts, and — when `GITEA_URL`+`GITEA_TOKEN`
|
||||||
|
are set — `http.<url>.extraheader: Authorization: token …` so push/fetch/pull
|
||||||
|
authenticate over HTTPS with no SSH key. New `git.CLI.SetGlobalConfig`; new
|
||||||
|
config `GIT_USER_NAME`/`GIT_USER_EMAIL`; `.env.example` documents them.
|
||||||
|
- **Why:** Make the network git commands (menu + MCP) actually work from Docker,
|
||||||
|
and let commits have an author.
|
||||||
|
- **Affects:** `internal/config`, `internal/git`, `cmd/server/main.go`,
|
||||||
|
`.env.example`, `AGENT.md` (§11).
|
||||||
|
- **Verified:** startup logs "git remote auth configured"; container git identity
|
||||||
|
set; `http.extraheader` present; `git_fetch` via the app returned ok.
|
||||||
|
- **Security note:** the token is written to the container's ephemeral gitconfig
|
||||||
|
and passed in a `git config` argv — acceptable for a localhost dev container.
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 9: git_checkout + create_branch
|
||||||
|
- **What:** git boundary `Checkout` (switch existing branch) and `CreateBranch`
|
||||||
|
(git checkout -b). Service `GitCheckout`/`GitCreateBranch` (feed detail names
|
||||||
|
the branch; `gitAction` now takes an ok-detail). HTTP `/api/repo/git` gained
|
||||||
|
ops `checkout` and `create-branch` (+`branch` field). MCP tools `git_checkout`
|
||||||
|
and `create_branch`. `<repo-menu>` gained "Switch branch…" and "New branch…"
|
||||||
|
(prompt for the name). Service test covers create+switch+existing-branch-fails.
|
||||||
|
- **Why:** Round out the everyday git commands in both front doors (§1.7).
|
||||||
|
- **Affects:** `internal/git`, `internal/service` (+test), `internal/mcp`,
|
||||||
|
`cmd/server/main.go`, `components/repo-menu`, `web/templates/help.html`,
|
||||||
|
`AGENT.md` (§8.1). Checkout isn't §1.4-destructive — git refuses if it would
|
||||||
|
overwrite uncommitted changes.
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 10: create_pr
|
||||||
|
- **What:** forge `CreatePullRequest` (Gitea; empty base → repo default branch,
|
||||||
|
via GetRepo). Service `CreatePR` (records `pr-created`). HTTP
|
||||||
|
`POST /api/repo/pr/create`. MCP tool `create_pr`. `<pr-list>` gained a
|
||||||
|
"New pull request…" button (head = selected repo's current branch, base =
|
||||||
|
default). AGENT.md §8.1 lists `create_pr` in Act.
|
||||||
|
- **Why:** Open PRs from the app or Claude — the front half of the PR workflow
|
||||||
|
whose back half is "Merge & clean up".
|
||||||
|
- **Affects:** `internal/forge`, `internal/service`, `internal/mcp`,
|
||||||
|
`cmd/server/main.go`, `components/pr-list`, `web/templates/help.html`,
|
||||||
|
`AGENT.md`.
|
||||||
|
- **Note:** the head branch must already exist on the remote (push first).
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 11: branch-picker submenu
|
||||||
|
- **What:** `<repo-menu>` "Switch branch" is now a flyout submenu populated from
|
||||||
|
`GET /api/repo` (the repo's branches; current one disabled), flipping leftward
|
||||||
|
near the viewport edge; clicking a branch checks it out. "New branch…" still
|
||||||
|
prompts. No backend change.
|
||||||
|
- **Affects:** `components/repo-menu`, `web/templates/help.html`.
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 12: inline command-result toasts
|
||||||
|
- **What:** New `<toast-host>` overlay — components post `toast` CustomEvents
|
||||||
|
(`{message, kind}`; success/error/info) and it shows brief, auto-dismissing,
|
||||||
|
bottom-right toasts. `<repo-menu>` (git ops + coordination actions) and
|
||||||
|
`<pr-list>` (create/merge) now post success/error toasts with friendly labels
|
||||||
|
instead of `alert()`. Activity feed still logs everything.
|
||||||
|
- **Why:** Immediate, legible feedback for the non-expert audience (§6 polish).
|
||||||
|
- **Affects:** `components/toast-host` (new), `components/repo-menu`,
|
||||||
|
`components/pr-list`, `web/templates/index.html`.
|
||||||
|
|
||||||
|
## 2026-09-20 — Slice 13: repo search + filtering
|
||||||
|
- **What:** `<repo-list>` gained a search box (name/path, case-insensitive) and
|
||||||
|
"Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is
|
||||||
|
client-side over the fetched list; state persists per-viewer in
|
||||||
|
`localStorage["gitmanager.repolist.filters"]` (§4). Refresh/selection re-apply
|
||||||
|
the active filters.
|
||||||
|
- **Why:** Keep the dashboard usable as the number of repos grows.
|
||||||
|
- **Affects:** `components/repo-list`, `web/templates/help.html`.
|
||||||
|
- **Dev-server tweak:** `cmd/server` now sends `Cache-Control: no-cache` for
|
||||||
|
`/components` and `/static` so browsers revalidate assets on reload (cached ES
|
||||||
|
modules were defeating hot reload). Server serves the new component (curl-
|
||||||
|
verified); live click-through pending (browser pane was unresponsive).
|
||||||
|
|||||||
+229
-2
@@ -5,16 +5,21 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
|
||||||
|
"gitmanager/internal/activity"
|
||||||
"gitmanager/internal/config"
|
"gitmanager/internal/config"
|
||||||
|
"gitmanager/internal/forge"
|
||||||
"gitmanager/internal/git"
|
"gitmanager/internal/git"
|
||||||
"gitmanager/internal/logging"
|
"gitmanager/internal/logging"
|
||||||
mcpserver "gitmanager/internal/mcp"
|
mcpserver "gitmanager/internal/mcp"
|
||||||
@@ -23,6 +28,30 @@ import (
|
|||||||
"gitmanager/internal/service"
|
"gitmanager/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// configureGit prepares the container's git for operating on the mounted repos:
|
||||||
|
// a commit identity (so commits don't fail with "empty ident"), permission to
|
||||||
|
// work on host-owned mounts, and — when a Gitea token is set — an auth header so
|
||||||
|
// pushes/fetches over HTTPS succeed. The token is written to the container's
|
||||||
|
// gitconfig (ephemeral, localhost); see AGENT.md §11.
|
||||||
|
func configureGit(ctx context.Context, g *git.CLI, cfg config.Config, log *slog.Logger) {
|
||||||
|
set := func(key, value string) {
|
||||||
|
if err := g.SetGlobalConfig(ctx, key, value); err != nil {
|
||||||
|
log.Warn("git config failed", "key", key, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set("safe.directory", "*") // mounted repos are host-owned
|
||||||
|
if cfg.GitUserName != "" {
|
||||||
|
set("user.name", cfg.GitUserName)
|
||||||
|
}
|
||||||
|
if cfg.GitUserEmail != "" {
|
||||||
|
set("user.email", cfg.GitUserEmail)
|
||||||
|
}
|
||||||
|
if cfg.GiteaURL != "" && cfg.GiteaToken != "" {
|
||||||
|
set("http."+cfg.GiteaURL+".extraheader", "Authorization: token "+cfg.GiteaToken)
|
||||||
|
log.Info("git remote auth configured", "host", cfg.GiteaURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,6 +72,7 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
log.Info("git detected", "version", v)
|
log.Info("git detected", "version", v)
|
||||||
}
|
}
|
||||||
|
configureGit(context.Background(), g, cfg, log)
|
||||||
|
|
||||||
// Start the read-only scanner in the background.
|
// Start the read-only scanner in the background.
|
||||||
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
|
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
|
||||||
@@ -51,8 +81,22 @@ func main() {
|
|||||||
go scanner.Run(scanCtx)
|
go scanner.Run(scanCtx)
|
||||||
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
|
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
|
||||||
|
|
||||||
|
// 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).
|
// The one service layer both the HTTP API and the MCP server call (§1.7).
|
||||||
svc := service.New(g, scanner.Index)
|
// scanner.RefreshRepo lets a mutating action re-scan just that repo.
|
||||||
|
svc := service.New(g, scanner.Index, feed, fg, scanner.RefreshRepo)
|
||||||
|
|
||||||
tmpl, err := render.New("web/templates")
|
tmpl, err := render.New("web/templates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,6 +110,18 @@ func main() {
|
|||||||
e.Use(middleware.Recover())
|
e.Use(middleware.Recover())
|
||||||
e.Use(middleware.RequestID())
|
e.Use(middleware.RequestID())
|
||||||
|
|
||||||
|
// Ask browsers to revalidate component/static assets so edits show up on
|
||||||
|
// reload (the dev server hot-reloads; cached ES modules would defeat that).
|
||||||
|
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
p := c.Request().URL.Path
|
||||||
|
if strings.HasPrefix(p, "/components/") || strings.HasPrefix(p, "/static/") {
|
||||||
|
c.Response().Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Static assets and component sources.
|
// Static assets and component sources.
|
||||||
e.Static("/static", "web/static")
|
e.Static("/static", "web/static")
|
||||||
e.Static("/components", "components")
|
e.Static("/components", "components")
|
||||||
@@ -95,7 +151,178 @@ func main() {
|
|||||||
return c.JSON(http.StatusOK, detail)
|
return c.JSON(http.StatusOK, detail)
|
||||||
})
|
})
|
||||||
|
|
||||||
// MCP server — Claude connects here as a custom connector (§8.1). Same
|
// Active project + activity (§8.2).
|
||||||
|
e.GET("/api/active-project", func(c echo.Context) error {
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
|
||||||
|
})
|
||||||
|
e.POST("/api/active-project", func(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
||||||
|
}
|
||||||
|
// A user action in the UI (actor=user) — distinct from Claude's own switches.
|
||||||
|
if _, _, err := svc.SetActiveProject(activity.ActorUser, body.Path); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
|
||||||
|
})
|
||||||
|
e.GET("/api/activity", func(c echo.Context) error {
|
||||||
|
return c.JSON(http.StatusOK, svc.Activity(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Graceful handoff (§8.3): the user requests a switch; Claude completes it.
|
||||||
|
e.GET("/api/switch", func(c echo.Context) error {
|
||||||
|
p, ok := svc.PendingSwitch()
|
||||||
|
if !ok {
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{"pending": false})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{
|
||||||
|
"pending": true, "target": p.Target, "note": p.Note, "requestedAt": p.RequestedAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
e.POST("/api/switch", func(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
||||||
|
}
|
||||||
|
p, err := svc.RequestSwitch(activity.ActorUser, body.Target, body.Note)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{"pending": true, "target": p.Target, "note": p.Note})
|
||||||
|
})
|
||||||
|
e.DELETE("/api/switch", func(c echo.Context) error {
|
||||||
|
svc.CancelSwitch(activity.ActorUser)
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{"pending": false})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Plain-language git commands (the right-click menu, §6). One endpoint,
|
||||||
|
// op-switched. Destructive ops (discard) are confirmed UI-side per §1.4.
|
||||||
|
e.POST("/api/repo/git", func(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
||||||
|
}
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
var out string
|
||||||
|
var err error
|
||||||
|
switch body.Op {
|
||||||
|
case "fetch":
|
||||||
|
out, err = svc.GitFetch(ctx, activity.ActorUser, body.Path)
|
||||||
|
case "pull":
|
||||||
|
out, err = svc.GitPull(ctx, activity.ActorUser, body.Path)
|
||||||
|
case "push":
|
||||||
|
out, err = svc.GitPush(ctx, activity.ActorUser, body.Path)
|
||||||
|
case "commit":
|
||||||
|
out, err = svc.GitCommit(ctx, activity.ActorUser, body.Path, body.Message)
|
||||||
|
case "discard":
|
||||||
|
out, err = svc.GitDiscard(ctx, activity.ActorUser, body.Path)
|
||||||
|
case "checkout":
|
||||||
|
out, err = svc.GitCheckout(ctx, activity.ActorUser, body.Path, body.Branch)
|
||||||
|
case "create-branch":
|
||||||
|
out, err = svc.GitCreateBranch(ctx, activity.ActorUser, body.Path, body.Branch)
|
||||||
|
default:
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown op: " + body.Op})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{"ok": true, "output": out})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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/create", func(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Head string `json:"head"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
||||||
|
}
|
||||||
|
pr, err := svc.CreatePR(c.Request().Context(), activity.ActorUser, body.Path, body.Head, body.Base, body.Title, body.Body)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, pr)
|
||||||
|
})
|
||||||
|
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()
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Flush()
|
||||||
|
|
||||||
|
ch, unsub := svc.SubscribeActivity()
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
keepalive := time.NewTicker(25 * time.Second)
|
||||||
|
defer keepalive.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request().Context().Done():
|
||||||
|
return nil
|
||||||
|
case <-keepalive.C:
|
||||||
|
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
case ev := <-ch:
|
||||||
|
data, err := json.Marshal(ev)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte("event: activity\ndata: " + string(data) + "\n\n")); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// MCP server — Claude connects via the local stdio bridge (§8.1). Same
|
||||||
// service layer as the HTTP API (§1.7); localhost-bound like everything else.
|
// service layer as the HTTP API (§1.7); localhost-bound like everything else.
|
||||||
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
|
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
|
||||||
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
|
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// <activity-feed> — shows the active project and a live feed of what happened
|
||||||
|
// (user AND Claude actions). A self-contained control (AGENT.md §1.1): shadow
|
||||||
|
// DOM, fetches its own initial data, subscribes to the /events SSE stream, and
|
||||||
|
// cleans up on disconnect. It reflects the coordination state that Claude reads
|
||||||
|
// over MCP (§8.2), so the user can see the two staying in sync.
|
||||||
|
|
||||||
|
class ActivityFeed extends HTMLElement {
|
||||||
|
#es = null;
|
||||||
|
#controller = null;
|
||||||
|
#events = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#renderShell();
|
||||||
|
this.#loadInitial();
|
||||||
|
// Live updates. EventSource auto-reconnects if the stream drops.
|
||||||
|
this.#es = new EventSource('/events');
|
||||||
|
this.#es.addEventListener('activity', (e) => {
|
||||||
|
try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore malformed */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.#es?.close();
|
||||||
|
this.#controller?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadInitial() {
|
||||||
|
this.#controller?.abort();
|
||||||
|
this.#controller = new AbortController();
|
||||||
|
try {
|
||||||
|
const [apRes, actRes] = await Promise.all([
|
||||||
|
fetch('/api/active-project', { signal: this.#controller.signal }),
|
||||||
|
fetch('/api/activity', { signal: this.#controller.signal }),
|
||||||
|
]);
|
||||||
|
const ap = await apRes.json();
|
||||||
|
const events = await actRes.json();
|
||||||
|
this.#events = Array.isArray(events) ? events : [];
|
||||||
|
this.#renderActive(ap.path || '');
|
||||||
|
this.#renderFeed();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') this.#renderError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#onEvent(ev) {
|
||||||
|
this.#events.push(ev);
|
||||||
|
if (this.#events.length > 200) this.#events = this.#events.slice(-200);
|
||||||
|
if (ev.kind === 'active-project-changed' || ev.kind === 'switch-completed') {
|
||||||
|
this.#renderActive(ev.repo || '');
|
||||||
|
}
|
||||||
|
this.#renderFeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderActive(path) {
|
||||||
|
const el = this.shadowRoot.getElementById('active');
|
||||||
|
el.textContent = path ? this.#base(path) : 'none';
|
||||||
|
el.title = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderFeed() {
|
||||||
|
const ul = this.shadowRoot.getElementById('feed');
|
||||||
|
// Newest first.
|
||||||
|
ul.replaceChildren(...[...this.#events].reverse().map((ev) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const repo = ev.repo ? this.#base(ev.repo) : '';
|
||||||
|
li.innerHTML = `
|
||||||
|
<span class="actor ${this.#esc(ev.actor)}">${this.#esc(ev.actor)}</span>
|
||||||
|
<span class="kind">${this.#esc(this.#label(ev.kind))}</span>
|
||||||
|
${repo ? `<span class="repo">${this.#esc(repo)}</span>` : ''}
|
||||||
|
${ev.detail ? `<span class="detail">${this.#esc(ev.detail)}</span>` : ''}
|
||||||
|
<span class="time">${this.#time(ev.time)}</span>`;
|
||||||
|
return li;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderError(err) {
|
||||||
|
this.shadowRoot.getElementById('feed').innerHTML =
|
||||||
|
`<li class="error">Could not load activity: ${this.#esc(err.message)}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderShell() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; }
|
||||||
|
.box {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.active { margin-bottom: 8px; color: var(--color-fg-muted); }
|
||||||
|
.active strong { color: var(--fill-accent); }
|
||||||
|
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
|
||||||
|
color: var(--color-fg-muted); margin: 8px 0 6px; }
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px;
|
||||||
|
max-height: 220px; overflow-y: auto; }
|
||||||
|
li { display: flex; align-items: baseline; gap: 8px; font-size: 13px; }
|
||||||
|
.actor { font-size: 11px; padding: 0 6px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong); text-transform: uppercase; }
|
||||||
|
.actor.user { color: var(--git-ahead); border-color: var(--git-ahead); }
|
||||||
|
.actor.claude { color: var(--color-success); border-color: var(--color-success); }
|
||||||
|
.actor.system { color: var(--color-fg-muted); }
|
||||||
|
.repo { font-weight: 600; }
|
||||||
|
.detail { color: var(--color-fg-muted); }
|
||||||
|
.time { margin-left: auto; color: var(--color-fg-muted); font-size: 11px; white-space: nowrap; }
|
||||||
|
.error { color: var(--color-danger); }
|
||||||
|
.empty { color: var(--color-fg-muted); }
|
||||||
|
</style>
|
||||||
|
<div class="box">
|
||||||
|
<div class="active">Active project: <strong id="active">none</strong></div>
|
||||||
|
<h3>Activity</h3>
|
||||||
|
<ul id="feed"><li class="empty">No activity yet.</li></ul>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base(p) {
|
||||||
|
return String(p).split(/[/\\]/).filter(Boolean).pop() || p;
|
||||||
|
}
|
||||||
|
|
||||||
|
#label(kind) {
|
||||||
|
return String(kind || '').replace(/-/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
#time(t) {
|
||||||
|
const d = new Date(t);
|
||||||
|
return isNaN(d) ? '' : d.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('activity-feed', ActivityFeed);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# activity-feed
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
Shows the **active project** and a **live feed** of what happened — both user and
|
||||||
|
Claude actions. It makes the coordination state visible (AGENT.md §8.2): the same
|
||||||
|
active project and events Claude reads over MCP (`get_active_project`,
|
||||||
|
`get_activity`), so the user can watch the two stay in sync. Groundwork for the
|
||||||
|
graceful project handoff (§8.3).
|
||||||
|
|
||||||
|
## Public surface
|
||||||
|
- **Tag:** `<activity-feed>`
|
||||||
|
- **Attributes/properties:** none.
|
||||||
|
- **Fetches (initial):** `GET /api/active-project`, `GET /api/activity`.
|
||||||
|
- **Subscribes:** `EventSource('/events')` — SSE stream; listens for `activity`
|
||||||
|
events (auto-reconnects if the stream drops). Closed on disconnect.
|
||||||
|
- **Renders:** active project (basename, full path on hover) + newest-first list
|
||||||
|
of events, each with an actor badge (user/claude/system), a humanized kind,
|
||||||
|
the repo, optional detail, and a timestamp.
|
||||||
|
|
||||||
|
## History
|
||||||
|
- 2026-09-20: created — slice 3; live active-project + activity view over SSE.
|
||||||
|
- 2026-09-20: also update the active-project display on `switch-completed` events
|
||||||
|
(slice 4 handoff), not only `active-project-changed`.
|
||||||
|
|
||||||
|
## Notes / gotchas
|
||||||
|
- All server-derived text is escaped before insertion (repo paths, details,
|
||||||
|
kinds) — treat as untrusted (§1.1).
|
||||||
|
- Uses shared design tokens for colors/radii — no hardcoded hex.
|
||||||
|
- The feed is capped client-side at 200 events to mirror the server ring buffer;
|
||||||
|
it does not paginate history.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// <handoff-bar> — the graceful project handoff control (AGENT.md §8.3).
|
||||||
|
//
|
||||||
|
// A self-contained control (§1.1): shadow DOM, self-fetching, live over SSE,
|
||||||
|
// cleans up on disconnect. It lets the user ask Claude to switch to the active
|
||||||
|
// project, shows the "waiting for a good stopping point" state while a request
|
||||||
|
// is pending, and announces the completion (with Claude's summary of where it
|
||||||
|
// left off) when Claude calls ack_switch.
|
||||||
|
|
||||||
|
class HandoffBar extends HTMLElement {
|
||||||
|
#es = null;
|
||||||
|
#controller = null;
|
||||||
|
#active = '';
|
||||||
|
#pending = null;
|
||||||
|
#completed = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#renderShell();
|
||||||
|
this.#loadInitial();
|
||||||
|
this.#es = new EventSource('/events');
|
||||||
|
this.#es.addEventListener('activity', (e) => {
|
||||||
|
try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.#es?.close();
|
||||||
|
this.#controller?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadInitial() {
|
||||||
|
this.#controller?.abort();
|
||||||
|
this.#controller = new AbortController();
|
||||||
|
try {
|
||||||
|
const [swRes, apRes] = await Promise.all([
|
||||||
|
fetch('/api/switch', { signal: this.#controller.signal }),
|
||||||
|
fetch('/api/active-project', { signal: this.#controller.signal }),
|
||||||
|
]);
|
||||||
|
const sw = await swRes.json();
|
||||||
|
const ap = await apRes.json();
|
||||||
|
this.#pending = sw.pending ? sw : null;
|
||||||
|
this.#active = ap.path || '';
|
||||||
|
this.#render();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') { /* keep default UI */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#onEvent(ev) {
|
||||||
|
switch (ev.kind) {
|
||||||
|
case 'switch-requested':
|
||||||
|
this.#pending = { target: ev.repo, note: ev.detail };
|
||||||
|
this.#completed = null;
|
||||||
|
break;
|
||||||
|
case 'switch-completed':
|
||||||
|
this.#pending = null;
|
||||||
|
this.#active = ev.repo || this.#active;
|
||||||
|
this.#completed = { target: ev.repo, summary: ev.detail };
|
||||||
|
break;
|
||||||
|
case 'switch-cancelled':
|
||||||
|
this.#pending = null;
|
||||||
|
break;
|
||||||
|
case 'active-project-changed':
|
||||||
|
this.#active = ev.repo || '';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #request() {
|
||||||
|
if (!this.#active) return;
|
||||||
|
await fetch('/api/switch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target: this.#active }),
|
||||||
|
}).catch(() => {});
|
||||||
|
// The SSE switch-requested event updates the UI.
|
||||||
|
}
|
||||||
|
|
||||||
|
async #cancel() {
|
||||||
|
await fetch('/api/switch', { method: 'DELETE' }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const body = this.shadowRoot.getElementById('body');
|
||||||
|
if (this.#pending) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<span class="spinner">⏳</span>
|
||||||
|
<span>Waiting for Claude to reach a good stopping point to switch to
|
||||||
|
<strong>${this.#esc(this.#base(this.#pending.target))}</strong>…</span>
|
||||||
|
<button id="cancel" class="ghost">Cancel</button>`;
|
||||||
|
this.shadowRoot.getElementById('cancel').onclick = () => this.#cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = this.#completed
|
||||||
|
? `<span class="done">✅ Claude switched to
|
||||||
|
<strong>${this.#esc(this.#base(this.#completed.target))}</strong>${
|
||||||
|
this.#completed.summary ? ' — ' + this.#esc(this.#completed.summary) : ''
|
||||||
|
}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!this.#active) {
|
||||||
|
body.innerHTML = `${done}<span class="hint">Select a repository to make it the active project, then hand it off to Claude.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
${done}
|
||||||
|
<button id="ask" class="primary">Ask Claude to switch to ${this.#esc(this.#base(this.#active))}</button>`;
|
||||||
|
this.shadowRoot.getElementById('ask').onclick = () => this.#request();
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderShell() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; }
|
||||||
|
#body {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
button { font: inherit; border-radius: var(--radius-sm); cursor: pointer;
|
||||||
|
padding: 5px 12px; border: 1px solid var(--border-strong);
|
||||||
|
background: var(--surface-2); color: var(--color-fg); }
|
||||||
|
button.primary { border-color: var(--fill-accent); color: var(--fill-accent); }
|
||||||
|
button.ghost { color: var(--color-fg-muted); }
|
||||||
|
button:hover { border-color: var(--fill-accent); }
|
||||||
|
.spinner { font-size: 15px; }
|
||||||
|
.done { color: var(--color-success); }
|
||||||
|
.hint { color: var(--color-fg-muted); }
|
||||||
|
strong { color: var(--fill-accent); }
|
||||||
|
#cancel { margin-left: auto; }
|
||||||
|
</style>
|
||||||
|
<div id="body"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
|
||||||
|
|
||||||
|
#esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('handoff-bar', HandoffBar);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# handoff-bar
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
The user-facing control for the **graceful project handoff** (AGENT.md §8.3). It
|
||||||
|
lets the user ask Claude to switch to the active project, shows the "waiting for a
|
||||||
|
good stopping point" state while the request is pending, and announces completion
|
||||||
|
(with Claude's summary of where it left off) when Claude calls `ack_switch`. The
|
||||||
|
switch is Claude-completed at a checkpoint, never app-forced (§1.4-class rule).
|
||||||
|
|
||||||
|
## Public surface
|
||||||
|
- **Tag:** `<handoff-bar>`
|
||||||
|
- **Attributes/properties:** none.
|
||||||
|
- **Fetches (initial):** `GET /api/switch` (pending request), `GET /api/active-project`.
|
||||||
|
- **Writes:** `POST /api/switch {target}` to request a handoff to the active
|
||||||
|
project (actor=user); `DELETE /api/switch` to cancel.
|
||||||
|
- **Subscribes:** `EventSource('/events')` — reacts to `switch-requested`,
|
||||||
|
`switch-completed` (shows Claude's summary), `switch-cancelled`, and
|
||||||
|
`active-project-changed`. Closed on disconnect.
|
||||||
|
|
||||||
|
## History
|
||||||
|
- 2026-09-20: created — slice 4; request/pending/completed UI over the
|
||||||
|
`/api/switch` endpoints and SSE.
|
||||||
|
|
||||||
|
## Notes / gotchas
|
||||||
|
- All server-derived text is escaped before insertion (targets, summaries).
|
||||||
|
- The request targets the current **active project**; select a repo first (that
|
||||||
|
sets the active project via `<repo-list>`).
|
||||||
|
- The completion message persists until the next request; it is informational.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// <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 = '';
|
||||||
|
#repo = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#renderShell();
|
||||||
|
this.#onSelect = (e) => { this.#repo = e.detail; 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.#toast(`Merged & cleaned up PR #${pr.number}`, 'success');
|
||||||
|
this.#load(this.#path); // refresh the list
|
||||||
|
} catch (err) {
|
||||||
|
this.#toast(`Merge failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast(message, kind) {
|
||||||
|
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #create() {
|
||||||
|
const branch = this.#repo?.branch;
|
||||||
|
if (!branch) return;
|
||||||
|
const title = window.prompt(`Open a pull request from "${branch}" (into the default branch).\nTitle:`, branch);
|
||||||
|
if (title === null) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/repo/pr/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: this.#path, head: branch, base: '', title: title.trim() || branch }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
this.#toast(`Opened PR #${data.number}`, 'success');
|
||||||
|
this.#load(this.#path); // refresh so the new PR appears
|
||||||
|
} catch (err) {
|
||||||
|
this.#toast(`Create PR failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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; }
|
||||||
|
.hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
|
||||||
|
color: var(--color-fg-muted); margin: 0; }
|
||||||
|
#new { margin-left: auto; font: inherit; cursor: pointer; padding: 3px 10px;
|
||||||
|
border-radius: var(--radius-sm); border: 1px solid var(--fill-accent);
|
||||||
|
color: var(--fill-accent); background: transparent; }
|
||||||
|
#new:hover { background: var(--surface-2); }
|
||||||
|
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">
|
||||||
|
<div class="hd">
|
||||||
|
<h3>Pull requests</h3>
|
||||||
|
<button id="new" title="Open a PR from the current branch">New pull request…</button>
|
||||||
|
</div>
|
||||||
|
<div id="body"><p class="muted">Select a repository.</p></div>
|
||||||
|
</div>`;
|
||||||
|
this.shadowRoot.getElementById('new').addEventListener('click', () => this.#create());
|
||||||
|
}
|
||||||
|
|
||||||
|
#esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('pr-list', PRList);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 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()`;
|
||||||
|
`POST /api/repo/pr/create {path, head, base, title}` via the "New pull request…"
|
||||||
|
button (head = the selected repo's current branch, base = the repo default).
|
||||||
|
|
||||||
|
## History
|
||||||
|
- 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up".
|
||||||
|
- 2026-09-20: added "New pull request…" (create_pr) — opens a PR from the
|
||||||
|
selected repo's current branch into the default branch (slice 10).
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
//
|
//
|
||||||
// A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a
|
// A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a
|
||||||
// shadow root, fetches its own data from /api/repos on connect, renders itself,
|
// shadow root, fetches its own data from /api/repos on connect, renders itself,
|
||||||
// and cleans up on disconnect. It talks to the rest of the app only via a
|
// and cleans up on disconnect. It talks to the rest of the app only via
|
||||||
// bubbling/composed `repo:select` CustomEvent — no shared globals.
|
// bubbling/composed CustomEvents (`repo:select`, `repo:contextmenu`) — no shared
|
||||||
|
// globals. Search + filter state is per-viewer view state kept in localStorage
|
||||||
|
// (§4); filtering is client-side over the already-fetched list.
|
||||||
|
|
||||||
|
const FILTER_KEY = 'gitmanager.repolist.filters';
|
||||||
|
|
||||||
class RepoList extends HTMLElement {
|
class RepoList extends HTMLElement {
|
||||||
#refreshMs = 15000;
|
#refreshMs = 15000;
|
||||||
@@ -11,6 +15,7 @@ class RepoList extends HTMLElement {
|
|||||||
#controller = null;
|
#controller = null;
|
||||||
#repos = [];
|
#repos = [];
|
||||||
#selected = null;
|
#selected = null;
|
||||||
|
#filters = { q: '', dirty: false, aheadBehind: false };
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -18,6 +23,7 @@ class RepoList extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
this.#loadFilters();
|
||||||
this.#renderShell();
|
this.#renderShell();
|
||||||
this.#load();
|
this.#load();
|
||||||
this.#timer = setInterval(() => this.#load(), this.#refreshMs);
|
this.#timer = setInterval(() => this.#load(), this.#refreshMs);
|
||||||
@@ -34,7 +40,8 @@ class RepoList extends HTMLElement {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/repos', { signal: this.#controller.signal });
|
const res = await fetch('/api/repos', { signal: this.#controller.signal });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
this.#renderRepos(await res.json());
|
this.#repos = (await res.json()) || [];
|
||||||
|
this.#apply();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name !== 'AbortError') this.#renderError(err);
|
if (err.name !== 'AbortError') this.#renderError(err);
|
||||||
}
|
}
|
||||||
@@ -42,17 +49,111 @@ class RepoList extends HTMLElement {
|
|||||||
|
|
||||||
#select(repo) {
|
#select(repo) {
|
||||||
this.#selected = repo.path;
|
this.#selected = repo.path;
|
||||||
this.#renderRepos(this.#repos); // reflect selection highlight
|
this.#apply(); // reflect selection highlight
|
||||||
// Cross-component communication is via events only (AGENT.md §1.1).
|
// Cross-component communication is via events only (AGENT.md §1.1).
|
||||||
this.dispatchEvent(new CustomEvent('repo:select', {
|
this.dispatchEvent(new CustomEvent('repo:select', {
|
||||||
detail: repo, bubbles: true, composed: true,
|
detail: repo, bubbles: true, composed: true,
|
||||||
}));
|
}));
|
||||||
|
// Selecting a repo makes it the active project (a user action, §8.2). Fire
|
||||||
|
// and forget — the SSE feed reflects the change; failure just skips it.
|
||||||
|
fetch('/api/active-project', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: repo.path }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
#loadFilters() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}');
|
||||||
|
this.#filters = { q: '', dirty: false, aheadBehind: false, ...saved };
|
||||||
|
} catch { /* ignore — use defaults */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
#saveFilters() {
|
||||||
|
try { localStorage.setItem(FILTER_KEY, JSON.stringify(this.#filters)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// #apply computes the filtered set and renders the body + count.
|
||||||
|
#apply() {
|
||||||
|
const body = this.shadowRoot.getElementById('body');
|
||||||
|
const count = this.shadowRoot.getElementById('count');
|
||||||
|
const { q, dirty, aheadBehind } = this.#filters;
|
||||||
|
const ql = q.trim().toLowerCase();
|
||||||
|
const filtered = this.#repos.filter((r) => {
|
||||||
|
if (ql && !(String(r.name).toLowerCase().includes(ql) || String(r.path).toLowerCase().includes(ql))) return false;
|
||||||
|
if (dirty && !r.dirty) return false;
|
||||||
|
if (aheadBehind && !((r.ahead || 0) > 0 || (r.behind || 0) > 0)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
count.textContent = this.#repos.length ? `${filtered.length} of ${this.#repos.length}` : '';
|
||||||
|
|
||||||
|
if (this.#repos.length === 0) {
|
||||||
|
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
body.innerHTML = `<p class="empty">No repositories match your search.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#renderList(filtered, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderList(repos, body) {
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
for (const r of repos) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
if (r.path === this.#selected) li.classList.add('selected');
|
||||||
|
// Right-click opens the command menu (§6) for this repo — via an event,
|
||||||
|
// so <repo-menu> stays decoupled from this component (§1.1).
|
||||||
|
li.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dispatchEvent(new CustomEvent('repo:contextmenu', {
|
||||||
|
detail: { repo: r, x: e.clientX, y: e.clientY },
|
||||||
|
bubbles: true, composed: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
li.innerHTML = `
|
||||||
|
<span class="name">${this.#esc(r.name)}</span>
|
||||||
|
<span class="branch">${this.#esc(r.branch || '—')}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
|
||||||
|
${r.behind ? `<span class="badge behind">↓${r.behind}</span>` : ''}
|
||||||
|
<span class="badge ${r.dirty ? 'dirty' : 'clean'}">${r.dirty ? 'dirty' : 'clean'}</span>
|
||||||
|
`;
|
||||||
|
li.addEventListener('click', () => this.#select(r));
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
body.replaceChildren(ul);
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderError(err) {
|
||||||
|
this.shadowRoot.getElementById('body').innerHTML =
|
||||||
|
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
#renderShell() {
|
#renderShell() {
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display: block; }
|
:host { display: block; }
|
||||||
|
.controls { display: grid; gap: 8px; margin-bottom: 10px; }
|
||||||
|
#search {
|
||||||
|
width: 100%; box-sizing: border-box; font: inherit;
|
||||||
|
background: var(--surface-1); color: var(--color-fg);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
#search:focus { outline: none; border-color: var(--fill-accent); }
|
||||||
|
.chips { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.chip {
|
||||||
|
font: inherit; font-size: 12px; cursor: pointer; padding: 3px 10px;
|
||||||
|
border-radius: 999px; border: 1px solid var(--border-strong);
|
||||||
|
background: transparent; color: var(--color-fg-muted);
|
||||||
|
}
|
||||||
|
.chip.active { border-color: var(--fill-accent); color: var(--fill-accent);
|
||||||
|
background: var(--surface-2); }
|
||||||
|
.count { margin-left: auto; color: var(--color-fg-muted); font-size: 12px; }
|
||||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||||
li {
|
li {
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
@@ -78,38 +179,42 @@ class RepoList extends HTMLElement {
|
|||||||
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
|
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
|
||||||
.error { color: var(--color-danger); }
|
.error { color: var(--color-danger); }
|
||||||
</style>
|
</style>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="search" type="search" placeholder="Search repositories…" autocomplete="off" />
|
||||||
|
<div class="chips">
|
||||||
|
<button id="f-dirty" class="chip" type="button">Dirty</button>
|
||||||
|
<button id="f-ab" class="chip" type="button">Ahead/behind</button>
|
||||||
|
<span id="count" class="count"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="body"><p class="empty">Loading repositories…</p></div>
|
<div id="body"><p class="empty">Loading repositories…</p></div>
|
||||||
`;
|
`;
|
||||||
}
|
|
||||||
|
|
||||||
#renderError(err) {
|
const search = this.shadowRoot.getElementById('search');
|
||||||
this.shadowRoot.getElementById('body').innerHTML =
|
const dirtyBtn = this.shadowRoot.getElementById('f-dirty');
|
||||||
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
|
const abBtn = this.shadowRoot.getElementById('f-ab');
|
||||||
}
|
// Reflect persisted state.
|
||||||
|
search.value = this.#filters.q;
|
||||||
|
dirtyBtn.classList.toggle('active', this.#filters.dirty);
|
||||||
|
abBtn.classList.toggle('active', this.#filters.aheadBehind);
|
||||||
|
|
||||||
#renderRepos(repos) {
|
search.addEventListener('input', () => {
|
||||||
this.#repos = repos || [];
|
this.#filters.q = search.value;
|
||||||
const body = this.shadowRoot.getElementById('body');
|
this.#saveFilters();
|
||||||
if (this.#repos.length === 0) {
|
this.#apply();
|
||||||
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
|
});
|
||||||
return;
|
dirtyBtn.addEventListener('click', () => {
|
||||||
}
|
this.#filters.dirty = !this.#filters.dirty;
|
||||||
const ul = document.createElement('ul');
|
dirtyBtn.classList.toggle('active', this.#filters.dirty);
|
||||||
for (const r of this.#repos) {
|
this.#saveFilters();
|
||||||
const li = document.createElement('li');
|
this.#apply();
|
||||||
if (r.path === this.#selected) li.classList.add('selected');
|
});
|
||||||
li.innerHTML = `
|
abBtn.addEventListener('click', () => {
|
||||||
<span class="name">${this.#esc(r.name)}</span>
|
this.#filters.aheadBehind = !this.#filters.aheadBehind;
|
||||||
<span class="branch">${this.#esc(r.branch || '—')}</span>
|
abBtn.classList.toggle('active', this.#filters.aheadBehind);
|
||||||
<span class="spacer"></span>
|
this.#saveFilters();
|
||||||
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
|
this.#apply();
|
||||||
${r.behind ? `<span class="badge behind">↓${r.behind}</span>` : ''}
|
});
|
||||||
<span class="badge ${r.dirty ? 'dirty' : 'clean'}">${r.dirty ? 'dirty' : 'clean'}</span>
|
|
||||||
`;
|
|
||||||
li.addEventListener('click', () => this.#select(r));
|
|
||||||
ul.appendChild(li);
|
|
||||||
}
|
|
||||||
body.replaceChildren(ul);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#esc(s) {
|
#esc(s) {
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ component pattern the rest of the UI follows.
|
|||||||
|
|
||||||
## Public surface
|
## Public surface
|
||||||
- **Tag:** `<repo-list>`
|
- **Tag:** `<repo-list>`
|
||||||
- **Attributes/properties:** none yet.
|
- **Attributes/properties:** none.
|
||||||
|
- **Search + filter:** a search box (matches name/path, case-insensitive) plus
|
||||||
|
"Dirty" and "Ahead/behind" toggle chips, with a "N of M" count. Filtering is
|
||||||
|
client-side over the fetched list; the state persists per-viewer in
|
||||||
|
`localStorage["gitmanager.repolist.filters"]` (§4).
|
||||||
- **Fetches:** `GET /api/repos` on connect and every 15s (in-flight request is
|
- **Fetches:** `GET /api/repos` on connect and every 15s (in-flight request is
|
||||||
aborted on refresh and on disconnect).
|
aborted on refresh and on disconnect).
|
||||||
- **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail`
|
- **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail`
|
||||||
@@ -21,6 +25,13 @@ component pattern the rest of the UI follows.
|
|||||||
- 2026-09-19: added a selected-item highlight — the clicked repo keeps an
|
- 2026-09-19: added a selected-item highlight — the clicked repo keeps an
|
||||||
accent border/background (the item that `<repo-detail>` is showing). Caches the
|
accent border/background (the item that `<repo-detail>` is showing). Caches the
|
||||||
last `/api/repos` payload so re-selecting re-renders without a refetch.
|
last `/api/repos` payload so re-selecting re-renders without a refetch.
|
||||||
|
- 2026-09-20: selecting a repo now also `POST`s `/api/active-project` to make it
|
||||||
|
the active project (a user action, §8.2) — surfaced in `<activity-feed>` and
|
||||||
|
readable by Claude via `get_active_project`. Fire-and-forget.
|
||||||
|
- 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for
|
||||||
|
`<repo-menu>` (§6). Right-click does not change the selection/active project.
|
||||||
|
- 2026-09-20: added search + "Dirty"/"Ahead-behind" filter chips with a count,
|
||||||
|
persisted in localStorage; filtering is client-side (slice 13).
|
||||||
|
|
||||||
## Notes / gotchas
|
## Notes / gotchas
|
||||||
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// <repo-menu> — the right-click command menu (AGENT.md §6).
|
||||||
|
//
|
||||||
|
// A self-contained control (§1.1): shadow DOM, listens for the bubbling
|
||||||
|
// `repo:contextmenu` event from <repo-list>, and shows a positioned menu of
|
||||||
|
// PLAIN-LANGUAGE commands for people who don't memorize git. Safe commands run
|
||||||
|
// on click; the destructive one ("Discard all changes") confirms first (§1.4).
|
||||||
|
// "Switch branch" is a flyout submenu populated from the repo's branches. It
|
||||||
|
// calls the same endpoints Claude uses via MCP (one service layer, §1.7);
|
||||||
|
// results show up live in <activity-feed>.
|
||||||
|
|
||||||
|
const ITEMS = [
|
||||||
|
{ cmd: 'pull', label: 'Get latest', hint: 'pull' },
|
||||||
|
{ cmd: 'push', label: 'Publish', hint: 'push' },
|
||||||
|
{ cmd: 'fetch', label: 'Check for updates', hint: 'fetch' },
|
||||||
|
{ cmd: 'commit', label: 'Save my work…', hint: 'commit' },
|
||||||
|
{ sub: 'branches', label: 'Switch branch', hint: 'checkout' },
|
||||||
|
{ cmd: 'newbranch', label: 'New branch…', hint: 'branch' },
|
||||||
|
{ sep: true },
|
||||||
|
{ cmd: 'active', label: 'Set as active project' },
|
||||||
|
{ cmd: 'handoff', label: 'Ask Claude to switch here' },
|
||||||
|
{ cmd: 'copy', label: 'Copy path' },
|
||||||
|
{ sep: true },
|
||||||
|
{ cmd: 'discard', label: 'Discard all changes…', hint: 'reset --hard', danger: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
class RepoMenu extends HTMLElement {
|
||||||
|
#repo = null;
|
||||||
|
#onContext = null;
|
||||||
|
#onDocClick = null;
|
||||||
|
#onKey = null;
|
||||||
|
#branchController = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#renderShell();
|
||||||
|
this.#onContext = (e) => this.#open(e.detail);
|
||||||
|
document.addEventListener('repo:contextmenu', this.#onContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
document.removeEventListener('repo:contextmenu', this.#onContext);
|
||||||
|
this.#branchController?.abort();
|
||||||
|
this.#teardownDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
#open({ repo, x, y }) {
|
||||||
|
if (!repo) return;
|
||||||
|
this.#repo = repo;
|
||||||
|
const menu = this.shadowRoot.getElementById('menu');
|
||||||
|
this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path);
|
||||||
|
menu.hidden = false;
|
||||||
|
|
||||||
|
// Clamp to viewport; flip submenus leftward when near the right edge.
|
||||||
|
const rect = menu.getBoundingClientRect();
|
||||||
|
const left = Math.min(x, window.innerWidth - rect.width - 8);
|
||||||
|
const top = Math.min(y, window.innerHeight - rect.height - 8);
|
||||||
|
menu.style.left = Math.max(8, left) + 'px';
|
||||||
|
menu.style.top = Math.max(8, top) + 'px';
|
||||||
|
menu.classList.toggle('flip', left + rect.width + 200 > window.innerWidth);
|
||||||
|
|
||||||
|
this.#loadBranches(repo);
|
||||||
|
|
||||||
|
this.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); };
|
||||||
|
this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); };
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', this.#onDocClick, { once: true });
|
||||||
|
document.addEventListener('keydown', this.#onKey);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#hide() {
|
||||||
|
this.shadowRoot.getElementById('menu').hidden = true;
|
||||||
|
this.#teardownDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
#teardownDismiss() {
|
||||||
|
if (this.#onDocClick) document.removeEventListener('click', this.#onDocClick);
|
||||||
|
if (this.#onKey) document.removeEventListener('keydown', this.#onKey);
|
||||||
|
this.#onDocClick = this.#onKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadBranches(repo) {
|
||||||
|
const sub = this.shadowRoot.getElementById('branches');
|
||||||
|
sub.innerHTML = `<div class="note">Loading…</div>`;
|
||||||
|
this.#branchController?.abort();
|
||||||
|
this.#branchController = new AbortController();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/repo?path=${encodeURIComponent(repo.path)}`, { signal: this.#branchController.signal });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const detail = await res.json();
|
||||||
|
const branches = detail.branches || [];
|
||||||
|
if (branches.length === 0) { sub.innerHTML = `<div class="note">No branches.</div>`; return; }
|
||||||
|
sub.replaceChildren(...branches.map((b) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'branch' + (b.current ? ' cur' : '');
|
||||||
|
btn.textContent = (b.current ? '● ' : '') + b.name;
|
||||||
|
if (b.current) { btn.disabled = true; btn.title = 'Current branch'; }
|
||||||
|
else { btn.dataset.branch = b.name; }
|
||||||
|
return btn;
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') sub.innerHTML = `<div class="note err">Couldn't load branches.</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #dispatch(cmd) {
|
||||||
|
const repo = this.#repo;
|
||||||
|
const name = this.#base(repo.path);
|
||||||
|
this.#hide();
|
||||||
|
switch (cmd) {
|
||||||
|
case 'pull': case 'push': case 'fetch':
|
||||||
|
await this.#git(cmd);
|
||||||
|
break;
|
||||||
|
case 'commit': {
|
||||||
|
const msg = window.prompt(`Commit message for ${name}:`);
|
||||||
|
if (msg && msg.trim()) await this.#git('commit', { message: msg });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'newbranch': {
|
||||||
|
const b = window.prompt(`New branch name in ${name}:`);
|
||||||
|
if (b && b.trim()) await this.#git('create-branch', { branch: b.trim() });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'discard': {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Discard ALL uncommitted changes in ${name}?\n\n` +
|
||||||
|
`This resets tracked files to the last commit and cannot be undone.`
|
||||||
|
);
|
||||||
|
if (ok) await this.#git('discard');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'active':
|
||||||
|
await this.#post('/api/active-project', { path: repo.path });
|
||||||
|
this.#toast(`${name} is now the active project`, 'info');
|
||||||
|
break;
|
||||||
|
case 'handoff':
|
||||||
|
await this.#post('/api/switch', { target: repo.path });
|
||||||
|
this.#toast(`Asked Claude to switch to ${name}`, 'info');
|
||||||
|
break;
|
||||||
|
case 'copy':
|
||||||
|
try { await navigator.clipboard.writeText(repo.path); this.#toast('Path copied', 'info'); }
|
||||||
|
catch { this.#toast('Could not copy path', 'error'); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #checkoutBranch(branch) {
|
||||||
|
this.#hide();
|
||||||
|
await this.#git('checkout', { branch });
|
||||||
|
}
|
||||||
|
|
||||||
|
#okLabel(op, extra) {
|
||||||
|
switch (op) {
|
||||||
|
case 'pull': return 'Got the latest';
|
||||||
|
case 'push': return 'Published';
|
||||||
|
case 'fetch': return 'Checked for updates';
|
||||||
|
case 'commit': return 'Saved your work';
|
||||||
|
case 'checkout': return `Switched to ${extra.branch}`;
|
||||||
|
case 'create-branch': return `Created branch ${extra.branch}`;
|
||||||
|
case 'discard': return 'Discarded changes';
|
||||||
|
default: return 'Done';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #git(op, extra = {}) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/repo/git', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: this.#repo.path, op, ...extra }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
this.#toast(this.#okLabel(op, extra), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
this.#toast(`${this.#okLabel(op, extra)} failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast(message, kind) {
|
||||||
|
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #post(url, body) {
|
||||||
|
try {
|
||||||
|
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderShell() {
|
||||||
|
const rows = ITEMS.map((it) => {
|
||||||
|
if (it.sep) return '<hr>';
|
||||||
|
if (it.sub) {
|
||||||
|
return `<div class="item has-sub" tabindex="0">
|
||||||
|
<span>${it.label}</span><span class="arrow">▸</span>
|
||||||
|
<div class="submenu" id="${it.sub}"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}">
|
||||||
|
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
#menu {
|
||||||
|
position: fixed; z-index: 1000; min-width: 220px;
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius); padding: 4px;
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--border); margin-bottom: 4px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
button, .item { display: flex; align-items: center; gap: 10px; width: 100%;
|
||||||
|
box-sizing: border-box; background: none; border: none; color: var(--color-fg);
|
||||||
|
font: inherit; text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer; }
|
||||||
|
button:hover, .item:hover, .item:focus { background: var(--fill-accent); color: #071019; }
|
||||||
|
button code { margin-left: auto; font-size: 11px; color: var(--color-fg-muted); }
|
||||||
|
button:hover code { color: #071019; }
|
||||||
|
button.danger { color: var(--color-danger); }
|
||||||
|
button.danger:hover { background: var(--color-danger); color: #fff; }
|
||||||
|
hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
|
||||||
|
.has-sub { position: relative; }
|
||||||
|
.has-sub .arrow { margin-left: auto; color: var(--color-fg-muted); }
|
||||||
|
.has-sub:hover .arrow, .has-sub:focus .arrow, .has-sub:focus-within .arrow { color: #071019; }
|
||||||
|
.submenu {
|
||||||
|
position: absolute; left: 100%; top: -5px; display: none;
|
||||||
|
min-width: 180px; max-height: 260px; overflow-y: auto;
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius); padding: 4px; box-shadow: 0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
#menu.flip .submenu { left: auto; right: 100%; }
|
||||||
|
.has-sub:hover .submenu, .has-sub:focus-within .submenu { display: block; }
|
||||||
|
.submenu .branch { color: var(--color-fg); }
|
||||||
|
.submenu .branch.cur { color: var(--color-fg-muted); cursor: default; }
|
||||||
|
.submenu .branch:disabled { background: none; color: var(--color-fg-muted); }
|
||||||
|
.submenu .note { padding: 6px 10px; color: var(--color-fg-muted); font-size: 12px; }
|
||||||
|
.submenu .note.err { color: var(--color-danger); }
|
||||||
|
</style>
|
||||||
|
<div id="menu" hidden>
|
||||||
|
<div class="hdr" id="hdr"></div>
|
||||||
|
${rows}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
this.shadowRoot.getElementById('menu').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
if (btn.dataset.branch !== undefined) { this.#checkoutBranch(btn.dataset.branch); return; }
|
||||||
|
if (btn.dataset.cmd) this.#dispatch(btn.dataset.cmd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('repo-menu', RepoMenu);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# repo-menu
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
The right-click command menu (AGENT.md §6) — the app's reason for being: run git
|
||||||
|
in **plain language** ("Get latest", "Publish", "Save my work…") without a
|
||||||
|
terminal. A self-contained overlay control (§1.1) that any list can summon via an
|
||||||
|
event. Safe commands run on click; the destructive one confirms first (§1.4).
|
||||||
|
|
||||||
|
## Public surface
|
||||||
|
- **Tag:** `<repo-menu>` (place once, near the end of the page).
|
||||||
|
- **Listens:** `repo:contextmenu` on `document` — `detail: { repo, x, y }`
|
||||||
|
(dispatched by `<repo-list>` on right-click). Shows the menu at (x, y).
|
||||||
|
- **Commands → endpoints:**
|
||||||
|
- Get latest / Publish / Check for updates / Save my work… / New branch… /
|
||||||
|
Discard all changes… → `POST /api/repo/git {path, op, message?, branch?}`
|
||||||
|
(op: pull/push/fetch/commit/create-branch/checkout/discard). "Save my work…"
|
||||||
|
prompts for a message; "New branch…" prompts for a name; "Discard all
|
||||||
|
changes…" confirms (destructive).
|
||||||
|
- **Switch branch ▸** — a flyout submenu populated from `GET /api/repo?path=`
|
||||||
|
(the repo's branches; current one disabled). Clicking a branch → checkout.
|
||||||
|
- Set as active project → `POST /api/active-project`.
|
||||||
|
- Ask Claude to switch here → `POST /api/switch` (the handoff request).
|
||||||
|
- Copy path → clipboard.
|
||||||
|
- Dismisses on outside click, Esc, or scroll.
|
||||||
|
|
||||||
|
## History
|
||||||
|
- 2026-09-20: created — slice 6; plain-language git commands + coordination
|
||||||
|
actions, backed by the shared service layer (same ops Claude gets via MCP).
|
||||||
|
- 2026-09-20: added "Switch branch…" (checkout) and "New branch…" (create-branch),
|
||||||
|
both prompting for the branch name (slice 9).
|
||||||
|
- 2026-09-20: "Switch branch" is now a flyout submenu listing the repo's branches
|
||||||
|
(fetched from /api/repo), not a text prompt (slice 11). "New branch…" still prompts.
|
||||||
|
|
||||||
|
## Notes / gotchas
|
||||||
|
- Results aren't shown inline; they appear in `<activity-feed>` (each op records a
|
||||||
|
`git-*` event with ok/failed), and errors raise a browser alert.
|
||||||
|
- Network ops (pull/push/fetch) need git credentials reachable from the server;
|
||||||
|
inside Docker that means a mounted SSH agent / credential helper (§11) — until
|
||||||
|
then they'll report an auth error. commit/discard are local and always work.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// <toast-host> — a singleton overlay that shows brief command-result toasts.
|
||||||
|
//
|
||||||
|
// A self-contained control (AGENT.md §1.1): shadow DOM, no data of its own. Any
|
||||||
|
// component posts a toast by dispatching a `toast` CustomEvent on document:
|
||||||
|
// document.dispatchEvent(new CustomEvent('toast',
|
||||||
|
// { detail: { message: 'Published', kind: 'success' } }));
|
||||||
|
// kind ∈ success | error | info. Toasts stack bottom-right, auto-dismiss (errors
|
||||||
|
// linger longer), and dismiss on click.
|
||||||
|
|
||||||
|
class ToastHost extends HTMLElement {
|
||||||
|
#onToast = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#render();
|
||||||
|
this.#onToast = (e) => this.#show(e.detail || {});
|
||||||
|
document.addEventListener('toast', this.#onToast);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
document.removeEventListener('toast', this.#onToast);
|
||||||
|
}
|
||||||
|
|
||||||
|
#show({ message, kind = 'info', timeout }) {
|
||||||
|
if (!message) return;
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'toast ' + (['success', 'error', 'info'].includes(kind) ? kind : 'info');
|
||||||
|
t.textContent = String(message);
|
||||||
|
t.addEventListener('click', () => t.remove());
|
||||||
|
this.shadowRoot.getElementById('stack').appendChild(t);
|
||||||
|
requestAnimationFrame(() => t.classList.add('in'));
|
||||||
|
const ms = timeout || (kind === 'error' ? 6000 : 3500);
|
||||||
|
setTimeout(() => {
|
||||||
|
t.classList.remove('in');
|
||||||
|
setTimeout(() => t.remove(), 200);
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
#stack {
|
||||||
|
position: fixed; right: 16px; bottom: 16px; z-index: 1100;
|
||||||
|
display: flex; flex-direction: column-reverse; gap: 8px;
|
||||||
|
max-width: min(360px, 90vw);
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
background: var(--surface-2); color: var(--color-fg);
|
||||||
|
border: 1px solid var(--border-strong); border-left-width: 3px;
|
||||||
|
border-radius: var(--radius); padding: 10px 14px; font-size: 13px;
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,.45); cursor: pointer;
|
||||||
|
opacity: 0; transform: translateY(8px); transition: opacity .18s, transform .18s;
|
||||||
|
}
|
||||||
|
.toast.in { opacity: 1; transform: none; }
|
||||||
|
.toast.success { border-left-color: var(--color-success); }
|
||||||
|
.toast.error { border-left-color: var(--color-danger); }
|
||||||
|
.toast.info { border-left-color: var(--fill-accent); }
|
||||||
|
</style>
|
||||||
|
<div id="stack"></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('toast-host', ToastHost);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# toast-host
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
A singleton overlay for brief command-result feedback (AGENT.md §6 polish). It
|
||||||
|
gives immediate, legible confirmation of what a UI action did — "Published",
|
||||||
|
"Merged & cleaned up PR #3", or an error — instead of only the activity feed or a
|
||||||
|
browser `alert()`. Any component can post to it without a reference to it.
|
||||||
|
|
||||||
|
## Public surface
|
||||||
|
- **Tag:** `<toast-host>` (place once, near the end of the page).
|
||||||
|
- **Listens:** `toast` on `document` — `detail: { message, kind?, timeout? }`,
|
||||||
|
where `kind` ∈ `success | error | info` (default `info`).
|
||||||
|
- **Behavior:** toasts stack bottom-right, animate in, auto-dismiss (errors last
|
||||||
|
longer — 6s vs 3.5s), and dismiss on click. Renders nothing until posted to.
|
||||||
|
|
||||||
|
## History
|
||||||
|
- 2026-09-20: created — slice 12; inline toasts for menu/PR command results.
|
||||||
|
|
||||||
|
## Notes / gotchas
|
||||||
|
- Posters build the `CustomEvent` themselves (components are standalone, no shared
|
||||||
|
module); keep the detail shape in sync with this contract.
|
||||||
|
- Messages are set via `textContent` (no HTML injection).
|
||||||
@@ -3,13 +3,18 @@ module gitmanager
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
code.gitea.io/sdk/gitea v0.25.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/labstack/echo/v4 v4.15.4
|
github.com/labstack/echo/v4 v4.15.4
|
||||||
github.com/modelcontextprotocol/go-sdk v1.8.0
|
github.com/modelcontextprotocol/go-sdk v1.8.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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/labstack/gommon v0.5.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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=
|
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/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 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
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 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
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 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
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 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// Package activity holds the app's coordination state: the single active project
|
||||||
|
// (the repo/task currently in focus) and a bounded feed of what happened — user
|
||||||
|
// AND Claude actions. Both are in-memory (mirrored to the logs, no datastore —
|
||||||
|
// AGENT.md §1.3) and queryable so Claude can sync on any turn boundary; new
|
||||||
|
// events also fan out to subscribers for the browser SSE stream (§8.2). This is
|
||||||
|
// the foundation the graceful project handoff (§8.3) builds on.
|
||||||
|
package activity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Actor is who caused an event.
|
||||||
|
type Actor string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActorUser Actor = "user"
|
||||||
|
ActorClaude Actor = "claude"
|
||||||
|
ActorSystem Actor = "system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is one entry in the activity feed.
|
||||||
|
type Event struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Actor Actor `json:"actor"`
|
||||||
|
Kind string `json:"kind"` // e.g. "active-project-changed"
|
||||||
|
Repo string `json:"repo,omitempty"` // repo path, when relevant
|
||||||
|
Detail string `json:"detail,omitempty"` // human-readable extra context
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingSwitch is a user's request for Claude to switch to another project.
|
||||||
|
// It is the request half of the graceful handoff (§8.3) — Claude fulfils it at a
|
||||||
|
// safe stopping point via AckSwitch.
|
||||||
|
type PendingSwitch struct {
|
||||||
|
Target string `json:"target"` // requested repo path
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
RequestedBy Actor `json:"requestedBy"` // normally the user
|
||||||
|
RequestedAt time.Time `json:"requestedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed is the concurrency-safe active-project + activity store with fan-out.
|
||||||
|
type Feed struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
active string // active project path ("" = none)
|
||||||
|
pending *PendingSwitch // an outstanding switch request, if any
|
||||||
|
events []Event
|
||||||
|
maxEvents int
|
||||||
|
nextID int64
|
||||||
|
subs map[chan Event]struct{}
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Feed keeping at most maxEvents recent events.
|
||||||
|
func New(log *slog.Logger, maxEvents int) *Feed {
|
||||||
|
if maxEvents <= 0 {
|
||||||
|
maxEvents = 200
|
||||||
|
}
|
||||||
|
return &Feed{
|
||||||
|
maxEvents: maxEvents,
|
||||||
|
nextID: 1,
|
||||||
|
subs: make(map[chan Event]struct{}),
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveProject returns the current active project path ("" if none).
|
||||||
|
func (f *Feed) ActiveProject() string {
|
||||||
|
f.mu.RLock()
|
||||||
|
defer f.mu.RUnlock()
|
||||||
|
return f.active
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveProject sets the active project and records an event. It is a no-op
|
||||||
|
// (changed=false, zero Event) when path already matches, so repeated sets don't
|
||||||
|
// spam the feed.
|
||||||
|
func (f *Feed) SetActiveProject(actor Actor, path string) (Event, bool) {
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.active == path {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
f.active = path
|
||||||
|
ev, subs := f.appendLocked(actor, "active-project-changed", path, "")
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
publish(subs, ev)
|
||||||
|
return ev, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSwitch records a user's request for Claude to switch to target. The
|
||||||
|
// latest request wins (it overwrites any outstanding one).
|
||||||
|
func (f *Feed) RequestSwitch(actor Actor, target, note string) PendingSwitch {
|
||||||
|
f.mu.Lock()
|
||||||
|
p := PendingSwitch{Target: target, Note: note, RequestedBy: actor, RequestedAt: time.Now()}
|
||||||
|
f.pending = &p
|
||||||
|
ev, subs := f.appendLocked(actor, "switch-requested", target, note)
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
publish(subs, ev)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingSwitch returns the outstanding switch request, if any.
|
||||||
|
func (f *Feed) PendingSwitch() (PendingSwitch, bool) {
|
||||||
|
f.mu.RLock()
|
||||||
|
defer f.mu.RUnlock()
|
||||||
|
if f.pending == nil {
|
||||||
|
return PendingSwitch{}, false
|
||||||
|
}
|
||||||
|
return *f.pending, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AckSwitch completes a pending switch: it makes the requested target the active
|
||||||
|
// project, clears the request, and records a "switch-completed" event carrying
|
||||||
|
// Claude's summary of where it left the previous project. Returns false if there
|
||||||
|
// was nothing pending.
|
||||||
|
func (f *Feed) AckSwitch(actor Actor, summary string) (PendingSwitch, bool) {
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.pending == nil {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return PendingSwitch{}, false
|
||||||
|
}
|
||||||
|
p := *f.pending
|
||||||
|
f.pending = nil
|
||||||
|
f.active = p.Target
|
||||||
|
ev, subs := f.appendLocked(actor, "switch-completed", p.Target, summary)
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
publish(subs, ev)
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelSwitch clears a pending switch (e.g. the user changed their mind).
|
||||||
|
func (f *Feed) CancelSwitch(actor Actor) (PendingSwitch, bool) {
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.pending == nil {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return PendingSwitch{}, false
|
||||||
|
}
|
||||||
|
p := *f.pending
|
||||||
|
f.pending = nil
|
||||||
|
ev, subs := f.appendLocked(actor, "switch-cancelled", p.Target, "")
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
publish(subs, ev)
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record adds an arbitrary event to the feed.
|
||||||
|
func (f *Feed) Record(actor Actor, kind, repo, detail string) Event {
|
||||||
|
f.mu.Lock()
|
||||||
|
ev, subs := f.appendLocked(actor, kind, repo, detail)
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
publish(subs, ev)
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events returns up to limit of the most recent events, oldest first. limit<=0
|
||||||
|
// returns all retained events.
|
||||||
|
func (f *Feed) Events(limit int) []Event {
|
||||||
|
f.mu.RLock()
|
||||||
|
defer f.mu.RUnlock()
|
||||||
|
if limit <= 0 || limit > len(f.events) {
|
||||||
|
limit = len(f.events)
|
||||||
|
}
|
||||||
|
out := make([]Event, limit)
|
||||||
|
copy(out, f.events[len(f.events)-limit:])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe returns a channel of future events and an unsubscribe func the
|
||||||
|
// caller MUST invoke when done (e.g. via defer) to avoid leaking the channel.
|
||||||
|
func (f *Feed) Subscribe() (<-chan Event, func()) {
|
||||||
|
ch := make(chan Event, 16)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.subs[ch] = struct{}{}
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
unsub := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
f.mu.Lock()
|
||||||
|
delete(f.subs, ch)
|
||||||
|
f.mu.Unlock()
|
||||||
|
close(ch)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ch, unsub
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendLocked assigns id/time, appends (trimming to maxEvents), logs, and
|
||||||
|
// returns the event plus a snapshot of subscriber channels to publish to after
|
||||||
|
// the lock is released. Caller must hold f.mu.
|
||||||
|
func (f *Feed) appendLocked(actor Actor, kind, repo, detail string) (Event, []chan Event) {
|
||||||
|
ev := Event{ID: f.nextID, Time: time.Now(), Actor: actor, Kind: kind, Repo: repo, Detail: detail}
|
||||||
|
f.nextID++
|
||||||
|
f.events = append(f.events, ev)
|
||||||
|
if len(f.events) > f.maxEvents {
|
||||||
|
f.events = f.events[len(f.events)-f.maxEvents:]
|
||||||
|
}
|
||||||
|
if f.log != nil {
|
||||||
|
f.log.Info("activity", "actor", actor, "kind", kind, "repo", repo, "detail", detail)
|
||||||
|
}
|
||||||
|
subs := make([]chan Event, 0, len(f.subs))
|
||||||
|
for ch := range f.subs {
|
||||||
|
subs = append(subs, ch)
|
||||||
|
}
|
||||||
|
return ev, subs
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish does a non-blocking send to each subscriber; a full channel (slow
|
||||||
|
// consumer) drops the event rather than stalling the producer.
|
||||||
|
func publish(subs []chan Event, ev Event) {
|
||||||
|
for _, ch := range subs {
|
||||||
|
select {
|
||||||
|
case ch <- ev:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,8 +34,13 @@ type Config struct {
|
|||||||
Dev bool // readable console logging vs structured JSON
|
Dev bool // readable console logging vs structured JSON
|
||||||
LogFile string // optional file to also append logs to
|
LogFile string // optional file to also append logs to
|
||||||
|
|
||||||
GitHubToken string // optional forge token (AGENT.md §8)
|
GitUserName string // commit identity for git actions run by the app
|
||||||
GitLabToken string // optional forge token (AGENT.md §8)
|
GitUserEmail string // commit identity for git actions run by the app
|
||||||
|
|
||||||
|
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.
|
// Load reads .env (if present) then the environment, applying defaults.
|
||||||
@@ -55,6 +60,10 @@ func Load() (Config, error) {
|
|||||||
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
|
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
|
||||||
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
|
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
|
||||||
LogFile: env("LOG_FILE", ""),
|
LogFile: env("LOG_FILE", ""),
|
||||||
|
GitUserName: env("GIT_USER_NAME", ""),
|
||||||
|
GitUserEmail: env("GIT_USER_EMAIL", ""),
|
||||||
|
GiteaURL: env("GITEA_URL", ""),
|
||||||
|
GiteaToken: env("GITEA_TOKEN", ""),
|
||||||
GitHubToken: env("GITHUB_TOKEN", ""),
|
GitHubToken: env("GITHUB_TOKEN", ""),
|
||||||
GitLabToken: env("GITLAB_TOKEN", ""),
|
GitLabToken: env("GITLAB_TOKEN", ""),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPR describes a pull request to open. An empty Base means "the repo's
|
||||||
|
// default branch".
|
||||||
|
type NewPR struct {
|
||||||
|
Head string
|
||||||
|
Base string
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
CreatePullRequest(ctx context.Context, owner, repo string, pr NewPR) (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,151 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePullRequest opens a PR. An empty Base resolves to the repo's default
|
||||||
|
// branch. Requires the head branch to already exist on the remote.
|
||||||
|
func (g *Gitea) CreatePullRequest(_ context.Context, owner, repo string, pr NewPR) (PullRequest, error) {
|
||||||
|
if strings.TrimSpace(pr.Head) == "" {
|
||||||
|
return PullRequest{}, fmt.Errorf("a head branch is required")
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(pr.Base)
|
||||||
|
if base == "" {
|
||||||
|
r, _, err := g.client.GetRepo(owner, repo)
|
||||||
|
if err != nil {
|
||||||
|
return PullRequest{}, err
|
||||||
|
}
|
||||||
|
base = r.DefaultBranch
|
||||||
|
}
|
||||||
|
title := pr.Title
|
||||||
|
if strings.TrimSpace(title) == "" {
|
||||||
|
title = pr.Head
|
||||||
|
}
|
||||||
|
created, _, err := g.client.CreatePullRequest(owner, repo, gitea.CreatePullRequestOption{
|
||||||
|
Head: pr.Head,
|
||||||
|
Base: base,
|
||||||
|
Title: title,
|
||||||
|
Body: pr.Body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return PullRequest{}, err
|
||||||
|
}
|
||||||
|
return toPR(created), 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,13 @@ func (c *CLI) Version(ctx context.Context) (string, error) {
|
|||||||
return c.run(ctx, "", "version")
|
return c.run(ctx, "", "version")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetGlobalConfig sets a global git config value (git config --global key value).
|
||||||
|
// Used at startup to give the container git a commit identity and remote auth.
|
||||||
|
func (c *CLI) SetGlobalConfig(ctx context.Context, key, value string) error {
|
||||||
|
_, err := c.run(ctx, "", "config", "--global", key, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// CurrentBranch returns the checked-out branch, or "HEAD" when detached.
|
// CurrentBranch returns the checked-out branch, or "HEAD" when detached.
|
||||||
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
|
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
|
||||||
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
|
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
@@ -120,6 +127,46 @@ func (c *CLI) Fetch(ctx context.Context, dir string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Mutating operations (user/Claude-initiated only; §1.3, §1.4) -----------
|
||||||
|
|
||||||
|
// Pull integrates the upstream branch (fetch + merge/ff per repo config).
|
||||||
|
func (c *CLI) Pull(ctx context.Context, dir string) (string, error) {
|
||||||
|
return c.run(ctx, dir, "pull")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push publishes the current branch to its upstream. Plain push only — never a
|
||||||
|
// forced push here (that is a §1.4 action to be added deliberately if ever).
|
||||||
|
func (c *CLI) Push(ctx context.Context, dir string) (string, error) {
|
||||||
|
return c.run(ctx, dir, "push")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit stages every change and commits it with message.
|
||||||
|
func (c *CLI) Commit(ctx context.Context, dir, message string) (string, error) {
|
||||||
|
if _, err := c.run(ctx, dir, "add", "-A"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return c.run(ctx, dir, "commit", "-m", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscardAll hard-resets tracked files to HEAD, throwing away uncommitted
|
||||||
|
// changes. DESTRUCTIVE (§1.4): callers MUST confirm with the user first.
|
||||||
|
// Untracked files are left in place.
|
||||||
|
func (c *CLI) DiscardAll(ctx context.Context, dir string) (string, error) {
|
||||||
|
return c.run(ctx, dir, "reset", "--hard", "HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkout switches to an existing branch. Git refuses if uncommitted changes
|
||||||
|
// would be overwritten, so this is not destructive — the error is surfaced.
|
||||||
|
func (c *CLI) Checkout(ctx context.Context, dir, branch string) (string, error) {
|
||||||
|
return c.run(ctx, dir, "checkout", branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBranch creates a new branch from the current HEAD and switches to it
|
||||||
|
// (git checkout -b). Fails if the branch already exists.
|
||||||
|
func (c *CLI) CreateBranch(ctx context.Context, dir, name string) (string, error) {
|
||||||
|
return c.run(ctx, dir, "checkout", "-b", name)
|
||||||
|
}
|
||||||
|
|
||||||
// Branch is a local branch and its upstream, if any.
|
// Branch is a local branch and its upstream, if any.
|
||||||
type Branch struct {
|
type Branch struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"gitmanager/internal/activity"
|
||||||
|
"gitmanager/internal/forge"
|
||||||
"gitmanager/internal/repos"
|
"gitmanager/internal/repos"
|
||||||
"gitmanager/internal/service"
|
"gitmanager/internal/service"
|
||||||
)
|
)
|
||||||
@@ -28,6 +31,88 @@ type listReposOutput struct {
|
|||||||
Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"`
|
Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setActiveProjectInput is the argument schema for set_active_project.
|
||||||
|
type setActiveProjectInput struct {
|
||||||
|
Path string `json:"path" jsonschema:"absolute path of the repository to make active, exactly as returned by list_repos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeProjectOutput reports the active project path (object, per the rule above).
|
||||||
|
type activeProjectOutput struct {
|
||||||
|
Path string `json:"path" jsonschema:"absolute path of the active project, empty when none is set"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// activityOutput wraps the activity feed (object, per the rule above).
|
||||||
|
type activityOutput struct {
|
||||||
|
Events []activity.Event `json:"events" jsonschema:"recent activity events, oldest first"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingSwitchOutput reports whether the user has asked Claude to switch projects.
|
||||||
|
type pendingSwitchOutput struct {
|
||||||
|
Pending bool `json:"pending" jsonschema:"true if the user has requested a switch you should complete"`
|
||||||
|
Target string `json:"target,omitempty" jsonschema:"the repository path to switch to"`
|
||||||
|
Note string `json:"note,omitempty" jsonschema:"an optional note from the user"`
|
||||||
|
RequestedAt time.Time `json:"requestedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ackSwitchInput is the argument schema for ack_switch.
|
||||||
|
type ackSwitchInput struct {
|
||||||
|
Summary string `json:"summary" jsonschema:"a short note on where you left the previous project (shown to the user)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ackSwitchOutput reports the completed switch.
|
||||||
|
type ackSwitchOutput struct {
|
||||||
|
Switched bool `json:"switched"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPRInput describes a pull request to open.
|
||||||
|
type createPRInput struct {
|
||||||
|
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
|
||||||
|
Head string `json:"head" jsonschema:"the branch to merge from (must already exist on the remote)"`
|
||||||
|
Base string `json:"base" jsonschema:"the branch to merge into; leave empty for the repo's default branch"`
|
||||||
|
Title string `json:"title" jsonschema:"the pull request title"`
|
||||||
|
Body string `json:"body" jsonschema:"the pull request description (optional)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitCommitInput is the argument schema for git_commit.
|
||||||
|
type gitCommitInput struct {
|
||||||
|
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
|
||||||
|
Message string `json:"message" jsonschema:"the commit message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitActionOutput carries a git command's output (object, per the schema rule).
|
||||||
|
type gitActionOutput struct {
|
||||||
|
Output string `json:"output" jsonschema:"the git command output (may be empty)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitCheckoutInput selects a branch to switch to.
|
||||||
|
type gitCheckoutInput struct {
|
||||||
|
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
|
||||||
|
Branch string `json:"branch" jsonschema:"the existing branch to switch to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createBranchInput names a new branch to create.
|
||||||
|
type createBranchInput struct {
|
||||||
|
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
|
||||||
|
Name string `json:"name" jsonschema:"the new branch name to create and switch to"`
|
||||||
|
}
|
||||||
|
|
||||||
// NewServer builds the MCP server and registers the (currently read-only) tools.
|
// NewServer builds the MCP server and registers the (currently read-only) tools.
|
||||||
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||||
s := mcpsdk.NewServer(&mcpsdk.Implementation{
|
s := mcpsdk.NewServer(&mcpsdk.Implementation{
|
||||||
@@ -57,6 +142,172 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
|||||||
return nil, detail, nil
|
return nil, detail, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// get_active_project — the repo/task currently in focus (§8.2).
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "get_active_project",
|
||||||
|
Description: "Get the active project — the repository the user is currently focused on. Check this to stay in sync with the user; path is empty when none is set.",
|
||||||
|
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
|
||||||
|
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// set_active_project — Claude switches the focus to another repo.
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "set_active_project",
|
||||||
|
Description: "Set the active project to the given repository path (from list_repos). Use this when switching which repository you are working in so the app and user stay in sync.",
|
||||||
|
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in setActiveProjectInput) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
|
||||||
|
if _, _, err := svc.SetActiveProject(activity.ActorClaude, in.Path); err != nil {
|
||||||
|
return nil, activeProjectOutput{}, fmt.Errorf("%w — call list_repos for valid paths", err)
|
||||||
|
}
|
||||||
|
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// get_activity — recent user + Claude actions, so Claude can catch up.
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "get_activity",
|
||||||
|
Description: "Get the recent activity feed (user and Claude actions, oldest first): repo selections, active-project changes, and more as features land. Use it to see what the user has done since you last looked.",
|
||||||
|
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activityOutput, error) {
|
||||||
|
return nil, activityOutput{Events: svc.Activity(50)}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// get_pending_switch — has the user asked you to switch projects? (§8.3)
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "get_pending_switch",
|
||||||
|
Description: "Check whether the user has asked you to switch to a different project. If pending is true, finish your current work to a SAFE stopping point (commit or stash so nothing is lost), then call ack_switch to complete the handoff.",
|
||||||
|
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, pendingSwitchOutput, error) {
|
||||||
|
p, ok := svc.PendingSwitch()
|
||||||
|
if !ok {
|
||||||
|
return nil, pendingSwitchOutput{Pending: false}, nil
|
||||||
|
}
|
||||||
|
return nil, pendingSwitchOutput{Pending: true, Target: p.Target, Note: p.Note, RequestedAt: p.RequestedAt}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// ack_switch — complete a pending handoff and tell the user where you left off.
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "ack_switch",
|
||||||
|
Description: "Complete a pending project switch: makes the requested target the active project and clears the request. Call this only after reaching a safe stopping point in the current project. Pass a short summary of where you left it — the user is notified.",
|
||||||
|
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in ackSwitchInput) (*mcpsdk.CallToolResult, ackSwitchOutput, error) {
|
||||||
|
p, ok := svc.AckSwitch(in.Summary)
|
||||||
|
if !ok {
|
||||||
|
return nil, ackSwitchOutput{Switched: false}, fmt.Errorf("no pending switch to acknowledge")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
// create_pr — open a pull request from a branch.
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "create_pr",
|
||||||
|
Description: "Open a pull request from head into base (leave base empty for the repo's default branch). The head branch must already exist on the remote — push it first. Path is from list_repos.",
|
||||||
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPRInput) (*mcpsdk.CallToolResult, forge.PullRequest, error) {
|
||||||
|
pr, err := svc.CreatePR(ctx, activity.ActorClaude, in.Path, in.Head, in.Base, in.Title, in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, forge.PullRequest{}, err
|
||||||
|
}
|
||||||
|
return nil, pr, 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
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Git commands (the same ops as the right-click menu, §6/§1.7) --------
|
||||||
|
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "git_fetch",
|
||||||
|
Description: "Fetch updates from the remote for a repository (does not change the working tree). Path is from list_repos.",
|
||||||
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
|
||||||
|
out, err := svc.GitFetch(ctx, activity.ActorClaude, in.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gitActionOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, gitActionOutput{Output: out}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "git_pull",
|
||||||
|
Description: "Pull the latest changes (fetch + merge) into a repository's current branch. Path is from list_repos.",
|
||||||
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
|
||||||
|
out, err := svc.GitPull(ctx, activity.ActorClaude, in.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gitActionOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, gitActionOutput{Output: out}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "git_push",
|
||||||
|
Description: "Push the current branch to its upstream (plain push, never forced). Path is from list_repos.",
|
||||||
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
|
||||||
|
out, err := svc.GitPush(ctx, activity.ActorClaude, in.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gitActionOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, gitActionOutput{Output: out}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "git_commit",
|
||||||
|
Description: "Stage all changes and commit them with a message. Path is from list_repos.",
|
||||||
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitCommitInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
|
||||||
|
out, err := svc.GitCommit(ctx, activity.ActorClaude, in.Path, in.Message)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gitActionOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, gitActionOutput{Output: out}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "git_discard_changes",
|
||||||
|
Description: "DESTRUCTIVE: discard ALL uncommitted changes to tracked files (git reset --hard HEAD). This cannot be undone — confirm the exact repository with the user BEFORE calling. Path is from list_repos.",
|
||||||
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
|
||||||
|
out, err := svc.GitDiscard(ctx, activity.ActorClaude, in.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gitActionOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, gitActionOutput{Output: out}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "git_checkout",
|
||||||
|
Description: "Switch a repository to an existing branch. Git refuses if uncommitted changes would be overwritten (the error is returned). Path is from list_repos.",
|
||||||
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitCheckoutInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
|
||||||
|
out, err := svc.GitCheckout(ctx, activity.ActorClaude, in.Path, in.Branch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gitActionOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, gitActionOutput{Output: out}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||||
|
Name: "create_branch",
|
||||||
|
Description: "Create a new branch from the current HEAD and switch to it (git checkout -b). Fails if the branch already exists. Path is from list_repos.",
|
||||||
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createBranchInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
|
||||||
|
out, err := svc.GitCreateBranch(ctx, activity.ActorClaude, in.Path, in.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gitActionOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, gitActionOutput{Output: out}, nil
|
||||||
|
})
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+117
-1
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"gitmanager/internal/activity"
|
||||||
"gitmanager/internal/git"
|
"gitmanager/internal/git"
|
||||||
"gitmanager/internal/repos"
|
"gitmanager/internal/repos"
|
||||||
"gitmanager/internal/service"
|
"gitmanager/internal/service"
|
||||||
@@ -41,7 +42,7 @@ func TestMCPRoundTrip(t *testing.T) {
|
|||||||
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||||
scanner.Refresh(context.Background())
|
scanner.Refresh(context.Background())
|
||||||
|
|
||||||
svc := service.New(g, scanner.Index)
|
svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo)
|
||||||
srv := NewServer(svc, "test")
|
srv := NewServer(svc, "test")
|
||||||
|
|
||||||
// Wire an in-memory client<->server session.
|
// Wire an in-memory client<->server session.
|
||||||
@@ -100,6 +101,121 @@ func TestMCPRoundTrip(t *testing.T) {
|
|||||||
if !res.IsError {
|
if !res.IsError {
|
||||||
t.Fatalf("expected IsError for unknown repo, got success")
|
t.Fatalf("expected IsError for unknown repo, got success")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// active project starts empty.
|
||||||
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get_active_project: %v", err)
|
||||||
|
}
|
||||||
|
var ap struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
decodeResult(t, res, &ap)
|
||||||
|
if ap.Path != "" {
|
||||||
|
t.Fatalf("expected empty active project, got %q", ap.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set_active_project to our repo, then read it back.
|
||||||
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||||
|
Name: "set_active_project",
|
||||||
|
Arguments: map[string]any{"path": states[0].Path},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set_active_project: %v", err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("set_active_project returned tool error: %+v", res.Content)
|
||||||
|
}
|
||||||
|
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
|
||||||
|
decodeResult(t, res, &ap)
|
||||||
|
if ap.Path != states[0].Path {
|
||||||
|
t.Fatalf("active project = %q, want %q", ap.Path, states[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setting an unknown project is a tool error.
|
||||||
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||||
|
Name: "set_active_project",
|
||||||
|
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set_active_project(bad) protocol error: %v", err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatalf("expected IsError for unknown active project")
|
||||||
|
}
|
||||||
|
|
||||||
|
// the activity feed should now contain the active-project-changed event.
|
||||||
|
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_activity"})
|
||||||
|
var act struct {
|
||||||
|
Events []activity.Event `json:"events"`
|
||||||
|
}
|
||||||
|
decodeResult(t, res, &act)
|
||||||
|
found := false
|
||||||
|
for _, ev := range act.Events {
|
||||||
|
if ev.Kind == "active-project-changed" && ev.Repo == states[0].Path && ev.Actor == activity.ActorClaude {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- graceful handoff (§8.3): user requests, Claude acks -----------------
|
||||||
|
svc.RequestSwitch(activity.ActorUser, states[0].Path, "fix a bug")
|
||||||
|
|
||||||
|
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
|
||||||
|
var ps struct {
|
||||||
|
Pending bool `json:"pending"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
}
|
||||||
|
decodeResult(t, res, &ps)
|
||||||
|
if !ps.Pending || ps.Target != states[0].Path {
|
||||||
|
t.Fatalf("expected pending switch to %q, got %+v", states[0].Path, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||||
|
Name: "ack_switch",
|
||||||
|
Arguments: map[string]any{"summary": "left tests green"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ack_switch: %v", err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("ack_switch tool error: %+v", res.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
|
||||||
|
decodeResult(t, res, &ps)
|
||||||
|
if ps.Pending {
|
||||||
|
t.Fatalf("expected no pending switch after ack, got %+v", ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ack with nothing pending is a tool error.
|
||||||
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||||
|
Name: "ack_switch",
|
||||||
|
Arguments: map[string]any{"summary": "nothing"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ack_switch(empty) protocol error: %v", err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatalf("expected IsError acking with no pending switch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- git_commit via MCP (adapter wiring) --------------------------------
|
||||||
|
if err := os.WriteFile(filepath.Join(repoPath, "extra.txt"), []byte("x\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||||
|
Name: "git_commit",
|
||||||
|
Arguments: map[string]any{"path": repoPath, "message": "add extra via mcp"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git_commit: %v", err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("git_commit tool error: %+v", res.Content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeResult unmarshals the JSON text content of a tool result into v.
|
// decodeResult unmarshals the JSON text content of a tool result into v.
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ func (s *Scanner) Refresh(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshRepo re-scans a single repository and updates the index. Used after a
|
||||||
|
// mutating action so the UI reflects the new state without waiting for the next
|
||||||
|
// full scan.
|
||||||
|
func (s *Scanner) RefreshRepo(ctx context.Context, path string) {
|
||||||
|
s.Index.set(s.refreshOne(ctx, path))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||||
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
+249
-5
@@ -7,21 +7,30 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitmanager/internal/activity"
|
||||||
|
"gitmanager/internal/forge"
|
||||||
"gitmanager/internal/git"
|
"gitmanager/internal/git"
|
||||||
"gitmanager/internal/repos"
|
"gitmanager/internal/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service holds the shared dependencies the capabilities need.
|
// Service holds the shared dependencies the capabilities need.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
git *git.CLI
|
git *git.CLI
|
||||||
index *repos.Index
|
index *repos.Index
|
||||||
|
feed *activity.Feed
|
||||||
|
forge *forge.Gitea // nil when no forge is configured
|
||||||
|
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Service over the git boundary and the scanner's repo index.
|
// New builds a Service over the git boundary, the scanner's repo index, the
|
||||||
func New(g *git.CLI, index *repos.Index) *Service {
|
// activity feed, (optionally) a forge provider, and a single-repo refresh hook
|
||||||
return &Service{git: g, index: index}
|
// (may be nil) used to re-scan a repo after a mutating action.
|
||||||
|
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea, refresh func(context.Context, string)) *Service {
|
||||||
|
return &Service{git: g, index: index, feed: feed, forge: fg, refresh: refresh}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRepos returns a snapshot of every discovered repository.
|
// ListRepos returns a snapshot of every discovered repository.
|
||||||
@@ -45,3 +54,238 @@ func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bo
|
|||||||
}
|
}
|
||||||
return repos.BuildDetail(ctx, s.git, base), true
|
return repos.BuildDetail(ctx, s.git, base), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Activity & active project (§8.2) --------------------------------------
|
||||||
|
|
||||||
|
// ActiveProject returns the current active project path ("" if none).
|
||||||
|
func (s *Service) ActiveProject() string {
|
||||||
|
return s.feed.ActiveProject()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveProject makes path the active project (or clears it when empty). It
|
||||||
|
// rejects a path that is not an indexed repository — the active project must be
|
||||||
|
// a real repo (§1.3). Returns the recorded event and whether it changed.
|
||||||
|
func (s *Service) SetActiveProject(actor activity.Actor, path string) (activity.Event, bool, error) {
|
||||||
|
if path != "" {
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
if _, ok := s.index.Get(path); !ok {
|
||||||
|
return activity.Event{}, false, fmt.Errorf("unknown repository %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ev, changed := s.feed.SetActiveProject(actor, path)
|
||||||
|
return ev, changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordActivity appends an arbitrary event to the feed.
|
||||||
|
func (s *Service) RecordActivity(actor activity.Actor, kind, repo, detail string) activity.Event {
|
||||||
|
return s.feed.Record(actor, kind, repo, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity returns up to limit recent events, oldest first.
|
||||||
|
func (s *Service) Activity(limit int) []activity.Event {
|
||||||
|
return s.feed.Events(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeActivity returns a channel of future events plus an unsubscribe func
|
||||||
|
// the caller must invoke when done.
|
||||||
|
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
|
||||||
|
return s.feed.Subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Graceful project handoff (§8.3) ---------------------------------------
|
||||||
|
|
||||||
|
// RequestSwitch records a user's request for Claude to switch to target. The
|
||||||
|
// target must be an indexed repository.
|
||||||
|
func (s *Service) RequestSwitch(actor activity.Actor, target, note string) (activity.PendingSwitch, error) {
|
||||||
|
target = filepath.Clean(target)
|
||||||
|
if _, ok := s.index.Get(target); !ok {
|
||||||
|
return activity.PendingSwitch{}, fmt.Errorf("unknown repository %q", target)
|
||||||
|
}
|
||||||
|
return s.feed.RequestSwitch(actor, target, note), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingSwitch returns the outstanding switch request, if any.
|
||||||
|
func (s *Service) PendingSwitch() (activity.PendingSwitch, bool) {
|
||||||
|
return s.feed.PendingSwitch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AckSwitch completes the pending handoff on Claude's behalf: sets the active
|
||||||
|
// project to the requested target and records Claude's summary. Returns false if
|
||||||
|
// nothing was pending.
|
||||||
|
func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
|
||||||
|
return s.feed.AckSwitch(activity.ActorClaude, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelSwitch clears a pending switch request.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePR opens a pull request from head into base (empty base = the repo's
|
||||||
|
// default branch) and records the action. The head branch must already exist on
|
||||||
|
// the remote (push it first).
|
||||||
|
func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath, head, base, title, body string) (forge.PullRequest, error) {
|
||||||
|
owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return forge.PullRequest{}, err
|
||||||
|
}
|
||||||
|
pr, err := s.forge.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body})
|
||||||
|
if err != nil {
|
||||||
|
return forge.PullRequest{}, err
|
||||||
|
}
|
||||||
|
s.feed.Record(actor, "pr-created", filepath.Clean(repoPath), fmt.Sprintf("PR #%d %s → %s", pr.Number, pr.Head, pr.Base))
|
||||||
|
return pr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Git actions (the plain-language commands, §6) --------------------------
|
||||||
|
|
||||||
|
// gitAction runs one mutating git op through the boundary, records the outcome
|
||||||
|
// on the activity feed, and refreshes the repo in the index on success. Callers
|
||||||
|
// are responsible for §1.4 confirmation of destructive ops (e.g. discard).
|
||||||
|
func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind, okDetail string, run func(dir string) (string, error)) (string, error) {
|
||||||
|
base, ok := s.index.Get(filepath.Clean(repoPath))
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown repository %q", repoPath)
|
||||||
|
}
|
||||||
|
out, err := run(base.Path)
|
||||||
|
if err != nil {
|
||||||
|
s.feed.Record(actor, kind, base.Path, "failed: "+err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if okDetail == "" {
|
||||||
|
okDetail = "ok"
|
||||||
|
}
|
||||||
|
s.feed.Record(actor, kind, base.Path, okDetail)
|
||||||
|
if s.refresh != nil {
|
||||||
|
s.refresh(ctx, base.Path)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitFetch, GitPull, GitPush, GitCommit, GitDiscard are the mutating commands
|
||||||
|
// the right-click menu (and, later, MCP) invoke.
|
||||||
|
func (s *Service) GitFetch(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
|
||||||
|
return s.gitAction(ctx, actor, repoPath, "git-fetch", "ok", func(d string) (string, error) {
|
||||||
|
return "", s.git.Fetch(ctx, d)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GitPull(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
|
||||||
|
return s.gitAction(ctx, actor, repoPath, "git-pull", "ok", func(d string) (string, error) {
|
||||||
|
return s.git.Pull(ctx, d)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GitPush(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
|
||||||
|
return s.gitAction(ctx, actor, repoPath, "git-push", "ok", func(d string) (string, error) {
|
||||||
|
return s.git.Push(ctx, d)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath, message string) (string, error) {
|
||||||
|
if strings.TrimSpace(message) == "" {
|
||||||
|
return "", fmt.Errorf("a commit message is required")
|
||||||
|
}
|
||||||
|
return s.gitAction(ctx, actor, repoPath, "git-commit", "ok", func(d string) (string, error) {
|
||||||
|
return s.git.Commit(ctx, d, message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitDiscard is DESTRUCTIVE (§1.4) — the caller must confirm with the user first.
|
||||||
|
func (s *Service) GitDiscard(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
|
||||||
|
return s.gitAction(ctx, actor, repoPath, "git-discard", "ok", func(d string) (string, error) {
|
||||||
|
return s.git.DiscardAll(ctx, d)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitCheckout switches to an existing branch.
|
||||||
|
func (s *Service) GitCheckout(ctx context.Context, actor activity.Actor, repoPath, branch string) (string, error) {
|
||||||
|
if strings.TrimSpace(branch) == "" {
|
||||||
|
return "", fmt.Errorf("a branch name is required")
|
||||||
|
}
|
||||||
|
return s.gitAction(ctx, actor, repoPath, "git-checkout", "switched to "+branch, func(d string) (string, error) {
|
||||||
|
return s.git.Checkout(ctx, d, branch)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitCreateBranch creates a new branch from HEAD and switches to it.
|
||||||
|
func (s *Service) GitCreateBranch(ctx context.Context, actor activity.Actor, repoPath, name string) (string, error) {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return "", fmt.Errorf("a branch name is required")
|
||||||
|
}
|
||||||
|
return s.gitAction(ctx, actor, repoPath, "git-create-branch", "created "+name, func(d string) (string, error) {
|
||||||
|
return s.git.CreateBranch(ctx, d, name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitmanager/internal/activity"
|
||||||
|
"gitmanager/internal/git"
|
||||||
|
"gitmanager/internal/repos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGitActions exercises the mutating commands (commit, discard) on a throwaway
|
||||||
|
// temp repo — never a real one.
|
||||||
|
func TestGitActions(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoPath := filepath.Join(root, "r")
|
||||||
|
mustMkdir(t, repoPath)
|
||||||
|
runGit(t, repoPath, "init", "-b", "main")
|
||||||
|
runGit(t, repoPath, "config", "user.email", "t@e.com")
|
||||||
|
runGit(t, repoPath, "config", "user.name", "T")
|
||||||
|
writeFile(t, filepath.Join(repoPath, "a.txt"), "one\n")
|
||||||
|
runGit(t, repoPath, "add", "-A")
|
||||||
|
runGit(t, repoPath, "commit", "-m", "init")
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
g := git.New("git")
|
||||||
|
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||||
|
scanner.Refresh(context.Background())
|
||||||
|
feed := activity.New(log, 200)
|
||||||
|
svc := New(g, scanner.Index, feed, nil, scanner.RefreshRepo)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Commit a new file, then the repo should be clean in the index.
|
||||||
|
writeFile(t, filepath.Join(repoPath, "b.txt"), "two\n")
|
||||||
|
if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, "add b"); err != nil {
|
||||||
|
t.Fatalf("GitCommit: %v", err)
|
||||||
|
}
|
||||||
|
if st, _ := svc.GetRepo(repoPath); st.Dirty {
|
||||||
|
t.Fatalf("expected clean repo after commit, got dirty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit with a blank message is rejected.
|
||||||
|
if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, " "); err == nil {
|
||||||
|
t.Fatalf("expected error committing with blank message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify a tracked file, then discard resets it. (We check the file itself
|
||||||
|
// rather than index dirtiness, since the index only updates on a refresh.)
|
||||||
|
writeFile(t, filepath.Join(repoPath, "a.txt"), "CHANGED\n")
|
||||||
|
if _, err := svc.GitDiscard(ctx, activity.ActorUser, repoPath); err != nil {
|
||||||
|
t.Fatalf("GitDiscard: %v", err)
|
||||||
|
}
|
||||||
|
if st, _ := svc.GetRepo(repoPath); st.Dirty {
|
||||||
|
t.Fatalf("expected clean repo after discard")
|
||||||
|
}
|
||||||
|
// Trim to ignore autocrlf line-ending normalization on Windows.
|
||||||
|
if got := strings.TrimSpace(readFile(t, filepath.Join(repoPath, "a.txt"))); got != "one" {
|
||||||
|
t.Fatalf("a.txt = %q, want restored to \"one\"", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a branch (switches to it), then switch back to main.
|
||||||
|
if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err != nil {
|
||||||
|
t.Fatalf("GitCreateBranch: %v", err)
|
||||||
|
}
|
||||||
|
if st, _ := svc.GetRepo(repoPath); st.Branch != "feature-x" {
|
||||||
|
t.Fatalf("branch = %q, want feature-x", st.Branch)
|
||||||
|
}
|
||||||
|
if _, err := svc.GitCheckout(ctx, activity.ActorUser, repoPath, "main"); err != nil {
|
||||||
|
t.Fatalf("GitCheckout: %v", err)
|
||||||
|
}
|
||||||
|
if st, _ := svc.GetRepo(repoPath); st.Branch != "main" {
|
||||||
|
t.Fatalf("branch = %q, want main", st.Branch)
|
||||||
|
}
|
||||||
|
// Creating an existing branch fails.
|
||||||
|
if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err == nil {
|
||||||
|
t.Fatalf("expected error creating an existing branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The feed recorded the successful actions.
|
||||||
|
kinds := map[string]bool{}
|
||||||
|
for _, e := range feed.Events(0) {
|
||||||
|
if e.Detail == "ok" {
|
||||||
|
kinds[e.Kind] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !kinds["git-commit"] || !kinds["git-discard"] {
|
||||||
|
t.Fatalf("expected git-commit and git-discard ok events, got %v", kinds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMkdir(t *testing.T, p string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.Mkdir(p, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, p, s string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(t *testing.T, p string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGit(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,14 @@
|
|||||||
<li>Run <code>docker compose up</code> and open the dashboard.</li>
|
<li>Run <code>docker compose up</code> and open the dashboard.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
|
<h2>Finding a repository</h2>
|
||||||
|
<p>
|
||||||
|
Use the search box above the list to filter by name or path, and the
|
||||||
|
<strong>Dirty</strong> and <strong>Ahead/behind</strong> chips to show only
|
||||||
|
repos with uncommitted changes or commits to sync. The count shows how many
|
||||||
|
match. Your search and filters are remembered on this device.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Reading the dashboard</h2>
|
<h2>Reading the dashboard</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li>
|
<li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li>
|
||||||
@@ -45,6 +53,29 @@
|
|||||||
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h2>Right-click commands</h2>
|
||||||
|
<p>
|
||||||
|
Right-click any repository for a menu of plain-language commands — no git
|
||||||
|
knowledge needed:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Get latest</strong> — pull the newest changes.</li>
|
||||||
|
<li><strong>Publish</strong> — push your commits.</li>
|
||||||
|
<li><strong>Check for updates</strong> — fetch without changing your files.</li>
|
||||||
|
<li><strong>Save my work…</strong> — commit everything (asks for a message).</li>
|
||||||
|
<li><strong>Switch branch ▸</strong> — hover to pick from the repo's branches.</li>
|
||||||
|
<li><strong>New branch…</strong> — create a branch and switch to it.</li>
|
||||||
|
<li><strong>Set as active project</strong> / <strong>Ask Claude to switch here</strong>.</li>
|
||||||
|
<li><strong>Copy path</strong>.</li>
|
||||||
|
<li><strong>Discard all changes…</strong> — throw away uncommitted edits
|
||||||
|
(asks you to confirm; can't be undone).</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
What each command did shows up in the activity panel. (Get latest / Publish /
|
||||||
|
Check for updates need your server to have git credentials; until then they'll
|
||||||
|
report a sign-in error.)
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Repository details</h2>
|
<h2>Repository details</h2>
|
||||||
<p>
|
<p>
|
||||||
Click any repository in the list to open its details on the right: its
|
Click any repository in the list to open its details on the right: its
|
||||||
@@ -53,6 +84,41 @@
|
|||||||
most recent commits.
|
most recent commits.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h2>Active project & activity</h2>
|
||||||
|
<p>
|
||||||
|
Clicking a repository also makes it your <strong>active project</strong> —
|
||||||
|
the one you're currently focused on — shown in the activity panel at the
|
||||||
|
bottom. That panel also lists recent actions by both you and Claude, updating
|
||||||
|
live. When GitManager is connected to Claude, Claude can see your active
|
||||||
|
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. Use <strong>New pull request…</strong> to
|
||||||
|
open one from the selected repo's current branch (push the branch first).
|
||||||
|
Each open PR 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
|
||||||
|
off. Select the repository (that makes it your active project), then click
|
||||||
|
<strong>"Ask Claude to switch to …"</strong> in the handoff bar. Claude
|
||||||
|
won't drop what it's doing — it finishes to a safe stopping point (saving
|
||||||
|
any in-progress work) and then switches. You'll see a "waiting…" message
|
||||||
|
while it wraps up, and a confirmation with a short note of where it left the
|
||||||
|
previous project once it has switched. You can <strong>Cancel</strong> a
|
||||||
|
pending request any time before Claude completes it.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Background refresh</h2>
|
<h2>Background refresh</h2>
|
||||||
<p>
|
<p>
|
||||||
The dashboard refreshes on its own. By default it does <em>not</em> reach
|
The dashboard refreshes on its own. By default it does <em>not</em> reach
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
(AGENT.md §1.1). Each fetches its own data on connect. -->
|
(AGENT.md §1.1). Each fetches its own data on connect. -->
|
||||||
<script type="module" src="/components/repo-list/repo-list.js"></script>
|
<script type="module" src="/components/repo-list/repo-list.js"></script>
|
||||||
<script type="module" src="/components/repo-detail/repo-detail.js"></script>
|
<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>
|
||||||
|
<script type="module" src="/components/repo-menu/repo-menu.js"></script>
|
||||||
|
<script type="module" src="/components/toast-host/toast-host.js"></script>
|
||||||
<style>
|
<style>
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -22,14 +27,15 @@
|
|||||||
header nav { margin-left: auto; }
|
header nav { margin-left: auto; }
|
||||||
/* Repo list docks LEFT, detail panel docks RIGHT by default (AGENT.md §4).
|
/* Repo list docks LEFT, detail panel docks RIGHT by default (AGENT.md §4).
|
||||||
A real dockable layout is layered on later; this is the static default. */
|
A real dockable layout is layered on later; this is the static default. */
|
||||||
main {
|
main { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.cols {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 20px;
|
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) { main { grid-template-columns: 1fr; } }
|
.right { display: grid; gap: 16px; }
|
||||||
|
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -39,8 +45,17 @@
|
|||||||
<nav><a href="/help">Help</a></nav>
|
<nav><a href="/help">Help</a></nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<repo-list></repo-list>
|
<handoff-bar></handoff-bar>
|
||||||
<repo-detail></repo-detail>
|
<div class="cols">
|
||||||
|
<repo-list></repo-list>
|
||||||
|
<div class="right">
|
||||||
|
<repo-detail></repo-detail>
|
||||||
|
<pr-list hidden></pr-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<activity-feed></activity-feed>
|
||||||
</main>
|
</main>
|
||||||
|
<repo-menu></repo-menu>
|
||||||
|
<toast-host></toast-host>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user