Compare commits
8 Commits
c6d3b5fae8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 32ae17cc9f | |||
| e30c3b632a | |||
| 69d38484e8 | |||
| 34ed127653 | |||
| 68b6c3a3f8 | |||
| ad00654487 | |||
| e1999bcf21 | |||
| e59d5bbd29 |
+11
-68
@@ -1,72 +1,15 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitManager configuration. Copy this file to `.env` and fill in values.
|
||||
# NEVER commit a real `.env` (it is git-ignored). See AGENT.md §1.5.
|
||||
# GitManager configuration. Copy this file to `.env`.
|
||||
# NEVER commit a real `.env` (it is git-ignored).
|
||||
#
|
||||
# The ONLY thing configured in .env is the root directory that holds your
|
||||
# projects. Each project is a subdirectory of that root, and its forge is
|
||||
# detected from its git remote. Everything else — forge hosts + access tokens
|
||||
# and the git commit identity — is managed in the app's Settings (/settings) and
|
||||
# stored in a private database (AGENT.md §1.5). Runtime bootstrap (listen address,
|
||||
# TLS, DB path, the container projects root) is set by docker-compose.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Address the HTTP server binds to. Localhost-only by default: there is NO
|
||||
# authentication (AGENT.md §0). Only bind to a non-local interface deliberately.
|
||||
LISTEN_ADDR=127.0.0.1:8080
|
||||
|
||||
# HTTPS (optional, but REQUIRED for the MCP connector — Claude Desktop only
|
||||
# accepts https:// URLs). When HTTPS_ADDR and both cert/key are set, an HTTPS
|
||||
# listener starts alongside HTTP. docker-compose sets these to the mounted certs.
|
||||
# Generate a locally-trusted cert on the HOST with mkcert (installs a local CA
|
||||
# your OS — and Claude Desktop — will trust), from the project root:
|
||||
# winget install FiloSottile.mkcert
|
||||
# mkcert -install # trust step (adds the local CA)
|
||||
# mkdir certs
|
||||
# mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
|
||||
# Then connect Claude Desktop to https://localhost:8443/mcp
|
||||
HTTPS_ADDR=
|
||||
TLS_CERT_FILE=
|
||||
TLS_KEY_FILE=
|
||||
|
||||
# Roots to scan for Git repositories, comma-separated (absolute paths).
|
||||
# Inside Docker these must be the *container* paths that the host roots are
|
||||
# mounted to (see docker-compose.yml). Example: /repos,/work/other
|
||||
GIT_REPO_ROOTS=/repos
|
||||
|
||||
# DOCKER ONLY: the HOST folder that holds your repositories. docker-compose
|
||||
# mounts it to /repos inside the container (which GIT_REPO_ROOTS points at).
|
||||
# Ignored when running the binary directly. Example: C:/Users/you/Projects
|
||||
# Host folder that holds your projects. docker-compose mounts it to /repos inside
|
||||
# the container (scanned as PROJECTS_ROOT). Example: C:/Users/you/Projects
|
||||
REPOS_HOST_PATH=./repos
|
||||
|
||||
# Path to the git binary. "git" resolves it from PATH (git is installed in the
|
||||
# container image).
|
||||
GIT_BIN=git
|
||||
|
||||
# --- Repo scanner (read-only; AGENT.md §5) ---------------------------------
|
||||
|
||||
# How often the background scanner refreshes repo state.
|
||||
SCAN_INTERVAL=30s
|
||||
|
||||
# Max directory depth to descend under each root when discovering repos.
|
||||
SCAN_MAX_DEPTH=4
|
||||
|
||||
# Directory names to skip during discovery, comma-separated.
|
||||
SCAN_IGNORE=node_modules,vendor,.cache
|
||||
|
||||
# Allow the scanner to run `git fetch` (network) to keep ahead/behind counts
|
||||
# current. OFF by default — no unsolicited network. (AGENT.md §5)
|
||||
SCAN_FETCH_ENABLED=false
|
||||
|
||||
# --- Logging (AGENT.md §7) --------------------------------------------------
|
||||
|
||||
# "dev" uses a readable console handler; anything else uses structured JSON.
|
||||
APP_ENV=dev
|
||||
|
||||
# Optional: also append structured logs to this file. Leave empty to disable.
|
||||
LOG_FILE=
|
||||
|
||||
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
|
||||
# The primary host is a self-hosted Gitea/Forgejo. Set BOTH the base URL and a
|
||||
# token to enable PRs + "Merge & clean up"; with neither, the forge features are
|
||||
# simply absent and the rest of the app is unaffected. A repo is forge-enabled
|
||||
# when its origin remote host matches GITEA_URL's host. Writes (merge PR + delete
|
||||
# branch) are confirmed per AGENT.md §1.4. Token scope: repo read + PR write +
|
||||
# branch delete.
|
||||
GITEA_URL=
|
||||
GITEA_TOKEN=
|
||||
# Later providers, behind the same interface (unused for now):
|
||||
GITHUB_TOKEN=
|
||||
GITLAB_TOKEN=
|
||||
|
||||
@@ -121,10 +121,12 @@ values** rather than reintroducing hardcoded hex.
|
||||
- **The background scanner is read‑only.** It may run `status`, `rev-list`,
|
||||
`for-each-ref`, `log`, and (only when explicitly enabled) `fetch`. It must never
|
||||
run a command that changes local state.
|
||||
- **Never invent local persistence for domain state.** If something must survive a
|
||||
restart and it isn't already in Git, it is either app config (`.env`, Section
|
||||
1.4) or per‑user UI state (browser `localStorage`, Section 4). Adding any other
|
||||
datastore (SQLite, a server‑side DB) requires asking first — the default is no.
|
||||
- **Git stays the system of record for repository data** — never mirror repo
|
||||
contents into another store. The one sanctioned datastore is the **config store**
|
||||
(§1.5): a private SQLite DB holding *app configuration* (forge hosts + tokens,
|
||||
git identity), not git data. Anything beyond
|
||||
that (mirroring repo/PR data, a server‑side app DB) still requires asking first.
|
||||
Per‑user UI state remains in browser `localStorage` (§4).
|
||||
|
||||
### 1.4 Destructive Git operations are explicit, confirmed, and never automatic
|
||||
|
||||
@@ -142,12 +144,26 @@ must be treated as such:
|
||||
destructive default. Prefer the safe variant (`--force-with-lease` over
|
||||
`--force`) and surface it as such.
|
||||
|
||||
### 1.5 Configuration via `.env`
|
||||
### 1.5 Configuration: bootstrap `.env` + a private config store
|
||||
|
||||
Repo scan roots, the `git` binary path, scan/fetch behavior, the listen address,
|
||||
and any optional forge tokens are all configurable through a single `.env` file. A
|
||||
committed `.env.example` documents every variable. **Never commit a real `.env`**
|
||||
and never hardcode paths, tokens, hosts, or the set of watched repositories.
|
||||
Configuration is split three ways:
|
||||
|
||||
- **`.env` — the projects root, and nothing else.** The only value a user sets in
|
||||
`.env` is `REPOS_HOST_PATH`: the host directory that holds their projects, mounted
|
||||
into the container as the single **projects root** (`PROJECTS_ROOT`, default
|
||||
`/repos`). **Each project is a subdirectory of that root.** `.env` is git‑ignored;
|
||||
a committed `.env.example` documents it.
|
||||
- **Runtime bootstrap — compose/env defaults, not the `.env` file.** Listen
|
||||
address, TLS, `APP_ENV`, `LOG_FILE`, the `git` binary, scan tuning, the config
|
||||
DB path, and `PROJECTS_ROOT` come from `docker-compose.yml`'s `environment:` and
|
||||
in‑code defaults — not from the user's `.env`.
|
||||
- **Config store (`internal/store`, SQLite)** — **forge hosts + access tokens** and
|
||||
the **git commit identity**, managed at runtime in the app's **Settings**
|
||||
(`/settings`), not by editing files. A project's **forge is derived from its git
|
||||
remote** (matched to a configured forge) — not stored per project (§8.4). The DB
|
||||
must not be reachable outside the container: it lives on a **private named Docker
|
||||
volume** (`/data`) — never bind‑mounted, never on a published port; tokens rely on
|
||||
that isolation. Never hardcode tokens/hosts/paths in code.
|
||||
|
||||
### 1.6 Everything runs in Docker / docker‑compose
|
||||
|
||||
@@ -187,14 +203,15 @@ MCP handler.
|
||||
| MCP server | **`github.com/modelcontextprotocol/go-sdk`**, served over **Streamable HTTP** at `/mcp` | Claude Desktop connects as a custom connector. Tools are thin adapters over the shared service layer (§1.7). See Section 8. |
|
||||
| Real‑time (app → browser) | **Server‑Sent Events** (`net/http`, stdlib) | Push activity + handoff notifications and live repo updates to the components; replaces list polling over time. |
|
||||
| Diff rendering | Server produces unified diff from `git`; client renders it in a component | No heavy client diff lib without asking. |
|
||||
| Config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5. |
|
||||
| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. No database sink (there is no database). |
|
||||
| Bootstrap config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5 (bootstrap only). |
|
||||
| Config store | **SQLite via `modernc.org/sqlite`** (pure Go, no CGO) in `internal/store`, on a private `/data` volume | Forges + tokens, project dirs, git identity. Not the git data store (§1.3). |
|
||||
| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. |
|
||||
| Hot reload (dev) | **air** (`github.com/air-verse/air`) | Inside the app container. |
|
||||
|
||||
Anything not in this table that you want to add as a dependency: **propose it and
|
||||
wait for approval.** Keep the dependency surface small. Note in particular there is
|
||||
**no database and no auth library** — do not add one without asking (Section 1.3,
|
||||
Section 0).
|
||||
wait for approval.** Keep the dependency surface small. There is **no auth library**
|
||||
(§0) and **no datastore beyond the config store** (§1.3, §1.5) — do not add one
|
||||
without asking.
|
||||
|
||||
---
|
||||
|
||||
@@ -211,7 +228,8 @@ Section 0).
|
||||
├── cmd/
|
||||
│ └── server/main.go # entrypoint: wire config, git, scanner, router
|
||||
├── internal/
|
||||
│ ├── config/ # .env loading, typed config struct
|
||||
│ ├── config/ # bootstrap .env loading, typed config struct (§1.5)
|
||||
│ ├── store/ # SQLite config store: forges+tokens, git identity (§1.5)
|
||||
│ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops)
|
||||
│ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker
|
||||
│ ├── service/ # the ONE service layer both the HTTP API and MCP call (§1.7)
|
||||
@@ -231,8 +249,10 @@ Section 0).
|
||||
```
|
||||
|
||||
> If a new concern doesn't fit cleanly, **ask** before inventing a new top‑level
|
||||
> directory. Keep `components/` strictly for web components. There is deliberately
|
||||
> **no `migrations/` and no `db/`** — see Section 1.3.
|
||||
> directory. Keep `components/` strictly for web components. The only datastore is
|
||||
> the SQLite **config store** (`internal/store`) on the private `/data` volume
|
||||
> (§1.5) — schema is created in‑code (`CREATE TABLE IF NOT EXISTS`); there is no
|
||||
> `migrations/` framework yet (add one if the schema grows non‑trivially).
|
||||
|
||||
---
|
||||
|
||||
@@ -365,9 +385,9 @@ obeys the safety rules (§1.4).
|
||||
the tool handlers (§1.7). Expected tools (grow as features land):
|
||||
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
|
||||
`get_pending_switch`, `list_prs`.
|
||||
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`,
|
||||
`git_discard_changes`, `merge_and_cleanup_pr`, `set_active_project`,
|
||||
`ack_switch`. (More — `git_checkout`, `create_branch`, `create_pr` — as they land.)
|
||||
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`,
|
||||
`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
|
||||
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
|
||||
@@ -432,9 +452,16 @@ later behind the same interface).
|
||||
per §1.4**, names the PR/branch, and prefers the tidy default (squash‑merge +
|
||||
delete branch). Note a merged PR remains in the host's history; "clean up"
|
||||
means removing the **branch**, not falsifying history.
|
||||
- **Enablement is per‑host and token‑gated.** Tokens come from `.env`
|
||||
(e.g. `GITEA_TOKEN`); with no token the forge features are simply absent and
|
||||
the rest of the app works unchanged (**graceful degradation**).
|
||||
- **Multiple hosts, configured in the store.** Forges (base URL + token) live in
|
||||
the **config store** (§1.5) and are managed in Settings — not `.env`. A repo is
|
||||
forge‑enabled when a remote's host matches a configured forge; with none
|
||||
matching the forge features are simply absent (**graceful degradation**). The
|
||||
service resolves a repo → provider by matching remotes (preferring `origin`)
|
||||
against the stored forges, caching a client per host.
|
||||
- **A project's forge is derived from git, not stored per project.** The scanner
|
||||
reads each repo's `origin` remote and labels it with the matching configured
|
||||
forge's name (or the bare host when unmatched); the dashboard shows that label
|
||||
next to the project name (`repos.State.Forge`, via `svc.ForgeDisplay`).
|
||||
- The provider is inferred from a repo's remote URL. Never send repo data to a
|
||||
host the user didn't configure.
|
||||
|
||||
@@ -502,9 +529,10 @@ component carries its own context.
|
||||
4. If it touches repositories or forges: put the logic in the **service layer**
|
||||
(§1.7) over the `internal/git` / `internal/forge` boundaries — never in an HTTP
|
||||
or MCP handler — so both the GUI and Claude get it. Respect **Git = system of
|
||||
record** (§1.3), treat **destructive operations** per §1.4, and never add a
|
||||
datastore. A new capability generally means: service method → HTTP handler →
|
||||
MCP tool → UI control.
|
||||
record** (§1.3), treat **destructive operations** per §1.4, and don't mirror
|
||||
repo/PR data into a datastore (app *config* goes in the store, §1.5). A new
|
||||
capability generally means: service method → HTTP handler → MCP tool → UI
|
||||
control.
|
||||
5. If it needs a **new dependency** or a **new top‑level folder**, propose it and
|
||||
wait for approval.
|
||||
6. Implement, run it in the **docker‑compose dev environment**, verify hot reload
|
||||
@@ -518,9 +546,22 @@ component carries its own context.
|
||||
*(Claude Code: surface these to the human at the first relevant moment; don't
|
||||
silently guess.)*
|
||||
|
||||
- **Repo discovery strategy:** recursive scan of `GIT_REPO_ROOTS` (max depth?
|
||||
ignore globs?) vs an explicit list of repo paths. Default assumption: recursive
|
||||
scan with a configurable depth.
|
||||
- ✅ **RESOLVED 2026-09-22:** **`.env` holds only the projects root; forges +
|
||||
identity live in a private SQLite store** (`internal/store`, §1.5), managed in
|
||||
Settings. **Projects are the subdirectories** of the single root
|
||||
(`REPOS_HOST_PATH` → `/repos`); each project's **forge is derived from its git
|
||||
remote** and shown next to its name. DB on a private `/data` volume (not
|
||||
bind‑mounted, no port). Forge is **multi‑host**; git auth sets an
|
||||
`http.extraheader` per forge.
|
||||
- **Mount constraint:** the container only sees host paths bind‑mounted at `up`
|
||||
time. All projects must live under the mounted root (`REPOS_HOST_PATH`); a
|
||||
project elsewhere needs its own compose mount + a widened/extra root.
|
||||
- **Token encryption at rest:** tokens are stored plaintext in the private DB
|
||||
(isolation is the control). Confirm before adding encryption‑at‑rest (a key
|
||||
would then need storing too).
|
||||
- **Repo discovery strategy:** recursive scan of the store's project directories
|
||||
(max depth / ignore from `.env` scan tuning). Default: recursive with a
|
||||
configurable depth.
|
||||
- **Background `fetch`:** off by default (no unsolicited network). Confirm whether
|
||||
it should be enabled, and the interval / rate limit, before turning it on.
|
||||
- **Git access library:** system `git` via `os/exec` is the locked default
|
||||
@@ -548,8 +589,12 @@ silently guess.)*
|
||||
branch delete) and how it is provisioned; document in `.env.example`.
|
||||
- **Listen address / exposure:** localhost‑only by default (covers `/mcp` too).
|
||||
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
|
||||
credential helper, for pushing/fetching from inside Docker.
|
||||
- ✅ **RESOLVED 2026-09-20:** **Container git auth = the Gitea token over HTTPS.**
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+124
@@ -201,3 +201,127 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
|
||||
- **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-22 — Config store: move forges/dirs/identity from .env to a private DB
|
||||
- **What:** Domain config now lives in a **private SQLite store** (`internal/store`,
|
||||
modernc.org/sqlite, pure Go) instead of `.env`: **forge hosts + tokens**
|
||||
(multi‑host), the **project directories** to scan, and the **git identity**.
|
||||
New Settings page (`/settings`) + `<settings-panel>` component + `<toast-host>`
|
||||
there; `/api/config/{forges,project-dirs,identity}` CRUD endpoints. The scanner
|
||||
now reads its roots **fresh from the store each cycle** (add/remove dirs without
|
||||
a restart); the service resolves a repo → forge by matching remotes against the
|
||||
stored forges (client cached per host) and reapplies per‑forge git auth
|
||||
(`http.<url>.extraheader`) on change; git identity comes from the store. On first
|
||||
run with an empty DB, the store is **seeded from `.env`** (`GITEA_*`,
|
||||
`GIT_REPO_ROOTS`, `GIT_USER_*`), so existing deploys keep working. The DB lives
|
||||
on a **private named Docker volume `/data`** — not bind‑mounted, no port — so
|
||||
credentials aren't reachable outside the container.
|
||||
- **Why:** Support multiple repos/forges and project directories with credentials,
|
||||
managed at runtime, without hand‑editing `.env` (user request). Overturns the
|
||||
old "no datastore" / "config via .env" laws — AGENT.md §0/§1.3/§1.5/§2/§3/§8.4
|
||||
updated.
|
||||
- **Affects:** `internal/store` (new), `internal/config`, `internal/repos`
|
||||
(scanner now dynamic), `internal/service` (store‑backed forges + config CRUD +
|
||||
`ApplyGitConfig`), `cmd/server/main.go`, `components/settings-panel` (new),
|
||||
`web/templates/{settings,index,help}.html`, `docker-compose.yml` (private
|
||||
`gmdata` volume + `GITMANAGER_DB`), `.env.example`, `go.mod`.
|
||||
- **Verified live:** seeded on first run; forges/dirs/identity served from the DB
|
||||
(tokens never returned); repos still discovered (now both GitManager and
|
||||
app-template under /repos); forge PRs work from the stored token; add/validate/
|
||||
delete of project dirs works; DB is not present in the project directory; the
|
||||
Settings UI renders and manages all three.
|
||||
- **Notes:** tokens stored plaintext relying on volume isolation (encryption‑at‑
|
||||
rest is an open item); scan tuning (interval/depth/ignore/fetch) stays in `.env`
|
||||
for now; container can only scan paths under a mounted base (mount constraint).
|
||||
|
||||
## 2026-09-22 — .env = projects root only; forge derived from git per project
|
||||
- **What:** Refined the config model. `.env` now holds **only the projects root**
|
||||
(`REPOS_HOST_PATH`); runtime bootstrap (listen/TLS/DB path/`PROJECTS_ROOT`/scan
|
||||
tuning) comes from compose + code defaults. The config store keeps **forges +
|
||||
tokens and git identity** only — the **project-directories** feature was removed
|
||||
(table, service methods, `/api/config/project-dirs`, and the Settings section):
|
||||
**projects are simply the subdirectories of the single root.** Each project's
|
||||
**forge is derived from its git remote** (matched to a configured forge, else the
|
||||
bare host) and shown as a pill next to its name (`repos.State.Forge` via the
|
||||
scanner's `ForgeFor` + `svc.ForgeDisplay`). First-run `.env` seeding was removed
|
||||
(forges/identity are added in Settings). `.env` reduced to one line;
|
||||
`forge.HostOf` added; scanner config gained `ForgeFor`.
|
||||
- **Why:** User: "the only thing in .env should be the root directory; each project
|
||||
has its own directory and shows which forge it belongs to — get it from git."
|
||||
- **Affects:** `internal/config`, `internal/store` (dropped project_dirs),
|
||||
`internal/repos` (State.Forge + ForgeFor), `internal/service` (dropped project-dir
|
||||
methods, added ForgeDisplay), `internal/forge` (HostOf), `cmd/server/main.go`,
|
||||
`components/{settings-panel,repo-list}`, `web/templates/{help}.html`,
|
||||
`docker-compose.yml`, `.env`/`.env.example`, `AGENT.md` (§1.3/§1.5/§3/§8.4/§11).
|
||||
|
||||
## 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).
|
||||
|
||||
+119
-15
@@ -9,6 +9,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
"gitmanager/internal/render"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
"gitmanager/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -47,29 +50,40 @@ func main() {
|
||||
log.Info("git detected", "version", v)
|
||||
}
|
||||
|
||||
// Start the read-only scanner in the background.
|
||||
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
|
||||
scanCtx, stopScan := context.WithCancel(context.Background())
|
||||
defer stopScan()
|
||||
go scanner.Run(scanCtx)
|
||||
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
|
||||
// Config store (forges + tokens, git identity) on a private volume (§1.3).
|
||||
st, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Error("failed to open config store", "path", cfg.DBPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
// 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 scanner walks the single projects root; each subdirectory is a project.
|
||||
// Each project's forge is derived from its git remote (svc.ForgeDisplay).
|
||||
var svc *service.Service
|
||||
scanCfg := func(ctx context.Context) repos.Config {
|
||||
return repos.Config{
|
||||
Roots: []string{cfg.ProjectsRoot},
|
||||
MaxDepth: cfg.ScanMaxDepth,
|
||||
Ignore: cfg.ScanIgnore,
|
||||
Fetch: cfg.ScanFetchEnabled,
|
||||
ForgeFor: func(remoteURL string) string { return svc.ForgeDisplay(ctx, remoteURL) },
|
||||
}
|
||||
}
|
||||
scanner := repos.NewScanner(g, log, scanCfg, cfg.ScanInterval)
|
||||
|
||||
// The one service layer both the HTTP API and the MCP server call (§1.7).
|
||||
// scanner.RefreshRepo lets a mutating action re-scan just that repo.
|
||||
svc := service.New(g, scanner.Index, feed, fg, scanner.RefreshRepo)
|
||||
svc = service.New(g, scanner.Index, feed, st, log, scanner.RefreshRepo)
|
||||
svc.ApplyGitConfig(context.Background()) // git identity + per-forge auth from the store
|
||||
|
||||
scanCtx, stopScan := context.WithCancel(context.Background())
|
||||
defer stopScan()
|
||||
go scanner.Run(scanCtx) // started after svc is set, so ForgeFor is ready
|
||||
log.Info("scanner started", "root", cfg.ProjectsRoot, "interval", cfg.ScanInterval.String())
|
||||
|
||||
tmpl, err := render.New("web/templates")
|
||||
if err != nil {
|
||||
@@ -83,6 +97,18 @@ func main() {
|
||||
e.Use(middleware.Recover())
|
||||
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.
|
||||
e.Static("/static", "web/static")
|
||||
e.Static("/components", "components")
|
||||
@@ -94,6 +120,9 @@ func main() {
|
||||
e.GET("/help", func(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "help.html", nil)
|
||||
})
|
||||
e.GET("/settings", func(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "settings.html", nil)
|
||||
})
|
||||
|
||||
// JSON API — components self-fetch from here.
|
||||
e.GET("/healthz", func(c echo.Context) error {
|
||||
@@ -162,6 +191,59 @@ func main() {
|
||||
return c.JSON(http.StatusOK, map[string]any{"pending": false})
|
||||
})
|
||||
|
||||
// Configuration store: forges, project directories, git identity (§1.3).
|
||||
e.GET("/api/config/forges", func(c echo.Context) error {
|
||||
forges, err := svc.Forges(c.Request().Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, forges)
|
||||
})
|
||||
e.POST("/api/config/forges", func(c echo.Context) error {
|
||||
var b struct{ Name, Kind, BaseURL, Token string }
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
||||
}
|
||||
f, err := svc.AddForge(c.Request().Context(), b.Name, b.Kind, b.BaseURL, b.Token)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, f)
|
||||
})
|
||||
e.PUT("/api/config/forges/:id", func(c echo.Context) error {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
var b struct{ Name, Kind, BaseURL, Token string }
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
||||
}
|
||||
if err := svc.UpdateForge(c.Request().Context(), id, b.Name, b.Kind, b.BaseURL, b.Token); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
||||
})
|
||||
e.DELETE("/api/config/forges/:id", func(c echo.Context) error {
|
||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err := svc.DeleteForge(c.Request().Context(), id); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
||||
})
|
||||
|
||||
e.GET("/api/config/identity", func(c echo.Context) error {
|
||||
name, email := svc.GitIdentity(c.Request().Context())
|
||||
return c.JSON(http.StatusOK, map[string]string{"name": name, "email": email})
|
||||
})
|
||||
e.PUT("/api/config/identity", func(c echo.Context) error {
|
||||
var b struct{ Name, Email string }
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
||||
}
|
||||
if err := svc.SetGitIdentity(c.Request().Context(), b.Name, b.Email); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
||||
})
|
||||
|
||||
// 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 {
|
||||
@@ -169,6 +251,7 @@ func main() {
|
||||
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"})
|
||||
@@ -187,6 +270,10 @@ func main() {
|
||||
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})
|
||||
}
|
||||
@@ -209,6 +296,23 @@ func main() {
|
||||
}
|
||||
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"`
|
||||
|
||||
@@ -10,6 +10,7 @@ class PRList extends HTMLElement {
|
||||
#controller = null;
|
||||
#onSelect = null;
|
||||
#path = '';
|
||||
#repo = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -18,7 +19,7 @@ class PRList extends HTMLElement {
|
||||
|
||||
connectedCallback() {
|
||||
this.#renderShell();
|
||||
this.#onSelect = (e) => this.#load(e.detail?.path);
|
||||
this.#onSelect = (e) => { this.#repo = e.detail; this.#load(e.detail?.path); };
|
||||
document.addEventListener('repo:select', this.#onSelect);
|
||||
}
|
||||
|
||||
@@ -58,9 +59,34 @@ class PRList extends HTMLElement {
|
||||
});
|
||||
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.#error(`Merge failed: ${err.message}`);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +128,13 @@ class PRList extends HTMLElement {
|
||||
: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 0 8px; }
|
||||
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; }
|
||||
@@ -124,9 +155,13 @@ class PRList extends HTMLElement {
|
||||
.error { color: var(--color-danger); }
|
||||
</style>
|
||||
<div class="box">
|
||||
<h3>Pull requests</h3>
|
||||
<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) {
|
||||
|
||||
@@ -13,10 +13,14 @@ naming the PR, base, and branch to be deleted.
|
||||
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()`.
|
||||
- **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
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
//
|
||||
// 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,
|
||||
// and cleans up on disconnect. It talks to the rest of the app only via a
|
||||
// bubbling/composed `repo:select` CustomEvent — no shared globals.
|
||||
// and cleans up on disconnect. It talks to the rest of the app only via
|
||||
// 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 {
|
||||
#refreshMs = 15000;
|
||||
@@ -11,6 +15,7 @@ class RepoList extends HTMLElement {
|
||||
#controller = null;
|
||||
#repos = [];
|
||||
#selected = null;
|
||||
#filters = { q: '', dirty: false, aheadBehind: false };
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -18,6 +23,7 @@ class RepoList extends HTMLElement {
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#loadFilters();
|
||||
this.#renderShell();
|
||||
this.#load();
|
||||
this.#timer = setInterval(() => this.#load(), this.#refreshMs);
|
||||
@@ -34,7 +40,8 @@ class RepoList extends HTMLElement {
|
||||
try {
|
||||
const res = await fetch('/api/repos', { signal: this.#controller.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this.#renderRepos(await res.json());
|
||||
this.#repos = (await res.json()) || [];
|
||||
this.#apply();
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') this.#renderError(err);
|
||||
}
|
||||
@@ -42,7 +49,7 @@ class RepoList extends HTMLElement {
|
||||
|
||||
#select(repo) {
|
||||
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).
|
||||
this.dispatchEvent(new CustomEvent('repo:select', {
|
||||
detail: repo, bubbles: true, composed: true,
|
||||
@@ -56,53 +63,46 @@ class RepoList extends HTMLElement {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
#renderShell() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||
li {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
li:hover { border-color: var(--border-strong); }
|
||||
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
|
||||
.name { font-weight: 600; }
|
||||
.branch { color: var(--color-fg-muted); }
|
||||
.spacer { margin-left: auto; }
|
||||
.badge {
|
||||
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
|
||||
.clean { color: var(--git-clean); border-color: var(--git-clean); }
|
||||
.ahead { color: var(--git-ahead); }
|
||||
.behind { color: var(--git-behind); }
|
||||
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
|
||||
.error { color: var(--color-danger); }
|
||||
</style>
|
||||
<div id="body"><p class="empty">Loading repositories…</p></div>
|
||||
`;
|
||||
#loadFilters() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}');
|
||||
this.#filters = { q: '', dirty: false, aheadBehind: false, ...saved };
|
||||
} catch { /* ignore — use defaults */ }
|
||||
}
|
||||
|
||||
#renderError(err) {
|
||||
this.shadowRoot.getElementById('body').innerHTML =
|
||||
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
|
||||
#saveFilters() {
|
||||
try { localStorage.setItem(FILTER_KEY, JSON.stringify(this.#filters)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
#renderRepos(repos) {
|
||||
this.#repos = repos || [];
|
||||
// #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 this.#repos) {
|
||||
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,
|
||||
@@ -116,6 +116,7 @@ class RepoList extends HTMLElement {
|
||||
});
|
||||
li.innerHTML = `
|
||||
<span class="name">${this.#esc(r.name)}</span>
|
||||
${r.forge ? `<span class="forge">${this.#esc(r.forge)}</span>` : ''}
|
||||
<span class="branch">${this.#esc(r.branch || '—')}</span>
|
||||
<span class="spacer"></span>
|
||||
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
|
||||
@@ -128,6 +129,97 @@ class RepoList extends HTMLElement {
|
||||
body.replaceChildren(ul);
|
||||
}
|
||||
|
||||
#renderError(err) {
|
||||
this.shadowRoot.getElementById('body').innerHTML =
|
||||
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
|
||||
}
|
||||
|
||||
#renderShell() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
: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; }
|
||||
li {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
li:hover { border-color: var(--border-strong); }
|
||||
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
|
||||
.name { font-weight: 600; }
|
||||
.forge { font-size: 11px; color: var(--fill-accent); border: 1px solid var(--border-strong);
|
||||
border-radius: 999px; padding: 0 8px; }
|
||||
.branch { color: var(--color-fg-muted); }
|
||||
.spacer { margin-left: auto; }
|
||||
.badge {
|
||||
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
|
||||
.clean { color: var(--git-clean); border-color: var(--git-clean); }
|
||||
.ahead { color: var(--git-ahead); }
|
||||
.behind { color: var(--git-behind); }
|
||||
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
|
||||
.error { color: var(--color-danger); }
|
||||
</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>
|
||||
`;
|
||||
|
||||
const search = this.shadowRoot.getElementById('search');
|
||||
const dirtyBtn = this.shadowRoot.getElementById('f-dirty');
|
||||
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);
|
||||
|
||||
search.addEventListener('input', () => {
|
||||
this.#filters.q = search.value;
|
||||
this.#saveFilters();
|
||||
this.#apply();
|
||||
});
|
||||
dirtyBtn.addEventListener('click', () => {
|
||||
this.#filters.dirty = !this.#filters.dirty;
|
||||
dirtyBtn.classList.toggle('active', this.#filters.dirty);
|
||||
this.#saveFilters();
|
||||
this.#apply();
|
||||
});
|
||||
abBtn.addEventListener('click', () => {
|
||||
this.#filters.aheadBehind = !this.#filters.aheadBehind;
|
||||
abBtn.classList.toggle('active', this.#filters.aheadBehind);
|
||||
this.#saveFilters();
|
||||
this.#apply();
|
||||
});
|
||||
}
|
||||
|
||||
#esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
|
||||
@@ -9,7 +9,11 @@ component pattern the rest of the UI follows.
|
||||
|
||||
## Public surface
|
||||
- **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
|
||||
aborted on refresh and on disconnect).
|
||||
- **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail`
|
||||
@@ -26,6 +30,10 @@ component pattern the rest of the UI follows.
|
||||
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).
|
||||
- 2026-09-22: show each project's forge (derived from its git remote,
|
||||
`State.Forge`) as a pill next to the name.
|
||||
|
||||
## Notes / gotchas
|
||||
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// `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).
|
||||
// It calls the same endpoints Claude uses via MCP (one service layer, §1.7);
|
||||
// "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 = [
|
||||
@@ -12,6 +13,8 @@ const ITEMS = [
|
||||
{ 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' },
|
||||
@@ -25,6 +28,7 @@ class RepoMenu extends HTMLElement {
|
||||
#onContext = null;
|
||||
#onDocClick = null;
|
||||
#onKey = null;
|
||||
#branchController = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -39,6 +43,7 @@ class RepoMenu extends HTMLElement {
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('repo:contextmenu', this.#onContext);
|
||||
this.#branchController?.abort();
|
||||
this.#teardownDismiss();
|
||||
}
|
||||
|
||||
@@ -48,25 +53,25 @@ class RepoMenu extends HTMLElement {
|
||||
const menu = this.shadowRoot.getElementById('menu');
|
||||
this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path);
|
||||
menu.hidden = false;
|
||||
// Position, clamped to the viewport.
|
||||
|
||||
// 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);
|
||||
|
||||
// Dismiss on next outside click, Esc, or scroll.
|
||||
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);
|
||||
window.addEventListener('scroll', this.#hideBound(), { once: true, capture: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
#hideBound() { return () => this.#hide(); }
|
||||
|
||||
#hide() {
|
||||
this.shadowRoot.getElementById('menu').hidden = true;
|
||||
this.#teardownDismiss();
|
||||
@@ -78,6 +83,30 @@ class RepoMenu extends HTMLElement {
|
||||
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);
|
||||
@@ -91,6 +120,11 @@ class RepoMenu extends HTMLElement {
|
||||
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` +
|
||||
@@ -101,16 +135,37 @@ class RepoMenu extends HTMLElement {
|
||||
}
|
||||
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); } catch { /* ignore */ }
|
||||
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', {
|
||||
@@ -120,11 +175,16 @@ class RepoMenu extends HTMLElement {
|
||||
});
|
||||
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) {
|
||||
window.alert(`${op} failed: ${err.message}`);
|
||||
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) });
|
||||
@@ -132,11 +192,19 @@ class RepoMenu extends HTMLElement {
|
||||
}
|
||||
|
||||
#renderShell() {
|
||||
const rows = ITEMS.map((it) => it.sep
|
||||
? '<hr>'
|
||||
: `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}">
|
||||
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''}
|
||||
</button>`).join('');
|
||||
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 {
|
||||
@@ -148,25 +216,43 @@ class RepoMenu extends HTMLElement {
|
||||
.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 { display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
background: none; border: none; color: var(--color-fg); font: inherit;
|
||||
text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
|
||||
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 { background: var(--fill-accent); color: #071019; }
|
||||
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; }
|
||||
button.danger:hover code { 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) this.#dispatch(btn.dataset.cmd);
|
||||
if (!btn) return;
|
||||
if (btn.dataset.branch !== undefined) { this.#checkoutBranch(btn.dataset.branch); return; }
|
||||
if (btn.dataset.cmd) this.#dispatch(btn.dataset.cmd);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
|
||||
- **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… / Discard all
|
||||
changes… → `POST /api/repo/git {path, op, message?}` (op: pull/push/fetch/
|
||||
commit/discard). "Save my work…" prompts for a message; "Discard all
|
||||
- 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.
|
||||
@@ -23,6 +26,10 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// <settings-panel> — manage the config store (AGENT.md §1.3): forge hosts +
|
||||
// tokens, project directories to scan, and the git commit identity. Replaces
|
||||
// editing .env for domain config.
|
||||
//
|
||||
// A self-contained control (§1.1): shadow DOM, fetches its own data, posts to the
|
||||
// /api/config/* endpoints, and reports results via `toast` events. Tokens are
|
||||
// write-only from here — the server never returns them (only whether one is set).
|
||||
|
||||
class SettingsPanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#renderShell();
|
||||
this.#loadForges();
|
||||
this.#loadIdentity();
|
||||
}
|
||||
|
||||
// --- forges ---------------------------------------------------------------
|
||||
|
||||
async #loadForges() {
|
||||
const box = this.shadowRoot.getElementById('forges');
|
||||
try {
|
||||
const forges = await this.#json('GET', '/api/config/forges');
|
||||
if (!forges || forges.length === 0) {
|
||||
box.innerHTML = `<p class="muted">No forges configured.</p>`;
|
||||
return;
|
||||
}
|
||||
box.replaceChildren(...forges.map((f) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<span class="k">${this.#esc(f.name || f.kind)}</span>
|
||||
<span class="url">${this.#esc(f.baseUrl)}</span>
|
||||
<span class="badge ${f.hasToken ? 'ok' : 'warn'}">${f.hasToken ? 'token set' : 'no token'}</span>
|
||||
</div>`;
|
||||
const del = document.createElement('button');
|
||||
del.className = 'danger';
|
||||
del.textContent = 'Remove';
|
||||
del.onclick = () => this.#delete(`/api/config/forges/${f.id}`, 'Forge removed', () => this.#loadForges());
|
||||
row.appendChild(del);
|
||||
return row;
|
||||
}));
|
||||
} catch (err) {
|
||||
box.innerHTML = `<p class="error">${this.#esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async #addForge() {
|
||||
const name = this.shadowRoot.getElementById('f-name').value.trim();
|
||||
const baseUrl = this.shadowRoot.getElementById('f-url').value.trim();
|
||||
const token = this.shadowRoot.getElementById('f-token').value;
|
||||
if (!baseUrl) { this.#toast('A base URL is required', 'error'); return; }
|
||||
try {
|
||||
await this.#json('POST', '/api/config/forges', { name, kind: 'gitea', baseUrl, token });
|
||||
this.shadowRoot.getElementById('f-name').value = '';
|
||||
this.shadowRoot.getElementById('f-url').value = '';
|
||||
this.shadowRoot.getElementById('f-token').value = '';
|
||||
this.#toast('Forge added', 'success');
|
||||
this.#loadForges();
|
||||
} catch (err) {
|
||||
this.#toast(`Add forge failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- git identity ---------------------------------------------------------
|
||||
|
||||
async #loadIdentity() {
|
||||
try {
|
||||
const id = await this.#json('GET', '/api/config/identity');
|
||||
this.shadowRoot.getElementById('i-name').value = id.name || '';
|
||||
this.shadowRoot.getElementById('i-email').value = id.email || '';
|
||||
} catch { /* leave blank */ }
|
||||
}
|
||||
|
||||
async #saveIdentity() {
|
||||
const name = this.shadowRoot.getElementById('i-name').value.trim();
|
||||
const email = this.shadowRoot.getElementById('i-email').value.trim();
|
||||
try {
|
||||
await this.#put('/api/config/identity', { name, email }, 'Identity saved');
|
||||
} catch (err) {
|
||||
this.#toast(`Save failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
async #json(method, url, body) {
|
||||
const opts = { method };
|
||||
if (body !== undefined) {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async #put(url, body, okMsg) {
|
||||
await this.#json('PUT', url, body);
|
||||
if (okMsg) this.#toast(okMsg, 'success');
|
||||
}
|
||||
|
||||
async #delete(url, okMsg, after) {
|
||||
if (!window.confirm('Remove this entry?')) return;
|
||||
try {
|
||||
await this.#json('DELETE', url);
|
||||
this.#toast(okMsg, 'success');
|
||||
after?.();
|
||||
} catch (err) {
|
||||
this.#toast(`Remove failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
#toast(message, kind) {
|
||||
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
|
||||
}
|
||||
|
||||
#renderShell() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
section { background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; }
|
||||
h2 { font-size: 15px; margin: 0 0 4px; }
|
||||
.hint { color: var(--color-fg-muted); font-size: 13px; margin: 0 0 12px; }
|
||||
.intro { color: var(--color-fg-muted); font-size: 13px; margin: 0 0 16px; }
|
||||
.intro code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-sm); }
|
||||
.row { display: flex; align-items: center; gap: 10px; padding: 6px 0;
|
||||
border-top: 1px solid var(--border); }
|
||||
.row:first-of-type { border-top: none; }
|
||||
.row > div:first-child, .dir { display: flex; align-items: center; gap: 10px; flex: 1; flex-wrap: wrap; }
|
||||
.k { font-weight: 600; }
|
||||
.url { color: var(--color-fg-muted); }
|
||||
code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-sm); }
|
||||
.badge { font-size: 11px; padding: 1px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
|
||||
.badge.ok { color: var(--color-success); border-color: var(--color-success); }
|
||||
.badge.warn { color: var(--color-warning); border-color: var(--color-warning); }
|
||||
.form { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
input[type=text], input[type=password] {
|
||||
font: inherit; background: var(--surface-2); color: var(--color-fg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 9px; }
|
||||
input.grow { flex: 1; min-width: 160px; }
|
||||
button { font: inherit; cursor: pointer; padding: 6px 12px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fill-accent); color: var(--fill-accent); background: transparent; }
|
||||
button:hover { background: var(--surface-2); }
|
||||
button.danger { border-color: var(--color-danger); color: var(--color-danger); margin-left: auto; }
|
||||
.muted { color: var(--color-fg-muted); }
|
||||
.error { color: var(--color-danger); }
|
||||
</style>
|
||||
|
||||
<p class="intro">Projects are the subdirectories of the root folder set in
|
||||
<code>.env</code>; each project's forge is detected from its git remote and
|
||||
shown next to its name on the dashboard.</p>
|
||||
|
||||
<section>
|
||||
<h2>Forges</h2>
|
||||
<p class="hint">Hosting servers (Gitea/Forgejo) and their access tokens.
|
||||
Tokens are stored in the private config database and never shown again.</p>
|
||||
<div id="forges"><p class="muted">Loading…</p></div>
|
||||
<div class="form">
|
||||
<input id="f-name" type="text" placeholder="Name (e.g. Gitea)">
|
||||
<input id="f-url" type="text" class="grow" placeholder="https://git.example.com">
|
||||
<input id="f-token" type="password" placeholder="Access token">
|
||||
<button id="f-add">Add forge</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Git identity</h2>
|
||||
<p class="hint">Author used for commits the app makes.</p>
|
||||
<div class="form">
|
||||
<input id="i-name" type="text" placeholder="Your Name">
|
||||
<input id="i-email" type="text" class="grow" placeholder="you@example.com">
|
||||
<button id="i-save">Save</button>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
this.shadowRoot.getElementById('f-add').onclick = () => this.#addForge();
|
||||
this.shadowRoot.getElementById('i-save').onclick = () => this.#saveIdentity();
|
||||
}
|
||||
|
||||
#esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('settings-panel', SettingsPanel);
|
||||
@@ -0,0 +1,28 @@
|
||||
# settings-panel
|
||||
|
||||
## Intent
|
||||
The UI for the config store (AGENT.md §1.5) — manage forge hosts + tokens and the
|
||||
git commit identity, replacing hand-editing `.env` for domain config. Projects
|
||||
themselves are the subdirectories of the single root set in `.env`, and each
|
||||
project's forge is derived from its git remote (not configured here). Served at
|
||||
`/settings`.
|
||||
|
||||
## Public surface
|
||||
- **Tag:** `<settings-panel>`
|
||||
- **Fetches:** `GET /api/config/forges`, `GET /api/config/identity`.
|
||||
- **Writes:**
|
||||
- Forges: `POST /api/config/forges` (add), `DELETE /api/config/forges/:id`.
|
||||
- Identity: `PUT /api/config/identity`.
|
||||
- **Reports:** results via `toast` CustomEvents (needs `<toast-host>` on the page).
|
||||
|
||||
## History
|
||||
- 2026-09-22: created — settings UI for the SQLite config store (forges, project
|
||||
dirs, git identity).
|
||||
- 2026-09-22: removed the Project directories section — projects are now the
|
||||
subdirectories of the single `.env` root; only Forges + Git identity remain.
|
||||
|
||||
## Notes / gotchas
|
||||
- Tokens are **write-only** from the client: the server returns only `hasToken`,
|
||||
never the value. Removing + re-adding a forge is how you rotate a token.
|
||||
- Adding/removing a forge reapplies git auth (the server sets an `http.extraheader`
|
||||
per forge); changes take effect immediately.
|
||||
@@ -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).
|
||||
+11
-2
@@ -18,8 +18,12 @@ services:
|
||||
- HTTPS_ADDR=0.0.0.0:8443
|
||||
- TLS_CERT_FILE=/app/certs/localhost.pem
|
||||
- TLS_KEY_FILE=/app/certs/localhost-key.pem
|
||||
# The scanner looks here; matches the volume mount below.
|
||||
- GIT_REPO_ROOTS=/repos
|
||||
# The single projects root inside the container (each subdirectory is a
|
||||
# project). The host folder is REPOS_HOST_PATH in .env, mounted here.
|
||||
- PROJECTS_ROOT=/repos
|
||||
# Config store (forges + tokens, git identity) on the PRIVATE volume below —
|
||||
# not bind-mounted into the project, no network port (§1.3).
|
||||
- GITMANAGER_DB=/data/gitmanager.db
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:8443:8443"
|
||||
@@ -28,6 +32,10 @@ services:
|
||||
- .:/app
|
||||
# Cache the Go module + build cache across restarts.
|
||||
- gomod:/go/pkg/mod
|
||||
# Config store — a PRIVATE named volume, deliberately NOT bind-mounted to
|
||||
# the host project and NOT exposed on any port, so the credentials it holds
|
||||
# are only reachable by the app inside the container (§1.3).
|
||||
- gmdata:/data
|
||||
# Your repositories. Set REPOS_HOST_PATH in .env (or your shell) to the
|
||||
# host folder that holds them; defaults to ./repos next to this file.
|
||||
- "${REPOS_HOST_PATH:-./repos}:/repos"
|
||||
@@ -43,3 +51,4 @@ services:
|
||||
|
||||
volumes:
|
||||
gomod:
|
||||
gmdata:
|
||||
|
||||
@@ -7,17 +7,22 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/labstack/echo/v4 v4.15.4
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/42wim/httpsig v1.2.4 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
@@ -26,8 +31,11 @@ require (
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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=
|
||||
@@ -14,8 +16,14 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||
@@ -24,12 +32,16 @@ github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c
|
||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
@@ -47,19 +59,21 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.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=
|
||||
@@ -70,7 +84,35 @@ golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
+14
-13
@@ -23,21 +23,25 @@ type Config struct {
|
||||
TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
|
||||
TLSKeyFile string // PEM private key
|
||||
|
||||
RepoRoots []string // roots to scan for git repositories
|
||||
GitBin string // path to the git binary
|
||||
// DBPath is the SQLite config store (forges + tokens, git identity). It lives
|
||||
// on a private volume, not accessible outside the container (§1.3).
|
||||
DBPath string
|
||||
|
||||
// ProjectsRoot is the single directory (inside the container) under which each
|
||||
// project lives in its own subdirectory. This is the one thing set in .env
|
||||
// (as REPOS_HOST_PATH on the host, mounted here); everything else is either a
|
||||
// compose/env default or lives in the config store.
|
||||
ProjectsRoot string
|
||||
|
||||
GitBin string // path to the git binary
|
||||
|
||||
ScanInterval time.Duration // scanner refresh interval
|
||||
ScanMaxDepth int // max discovery depth under each root
|
||||
ScanMaxDepth int // max discovery depth under the root
|
||||
ScanIgnore []string // directory names to skip during discovery
|
||||
ScanFetchEnabled bool // allow the scanner to run `git fetch`
|
||||
|
||||
Dev bool // readable console logging vs structured JSON
|
||||
LogFile string // optional file to also append logs to
|
||||
|
||||
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.
|
||||
@@ -47,20 +51,17 @@ func Load() (Config, error) {
|
||||
|
||||
c := Config{
|
||||
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
|
||||
DBPath: env("GITMANAGER_DB", "/data/gitmanager.db"),
|
||||
ProjectsRoot: env("PROJECTS_ROOT", "/repos"),
|
||||
HTTPSAddr: env("HTTPS_ADDR", ""),
|
||||
TLSCertFile: env("TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: env("TLS_KEY_FILE", ""),
|
||||
RepoRoots: splitList(env("GIT_REPO_ROOTS", "")),
|
||||
GitBin: env("GIT_BIN", "git"),
|
||||
ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4),
|
||||
ScanIgnore: splitList(env("SCAN_IGNORE", "node_modules,vendor,.cache")),
|
||||
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
|
||||
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
|
||||
LogFile: env("LOG_FILE", ""),
|
||||
GiteaURL: env("GITEA_URL", ""),
|
||||
GiteaToken: env("GITEA_TOKEN", ""),
|
||||
GitHubToken: env("GITHUB_TOKEN", ""),
|
||||
GitLabToken: env("GITLAB_TOKEN", ""),
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(env("SCAN_INTERVAL", "30s"))
|
||||
|
||||
@@ -48,14 +48,34 @@ type MergeResult struct {
|
||||
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)
|
||||
}
|
||||
|
||||
// HostOf returns the hostname of a base URL (e.g. "https://git.example.com:3000"
|
||||
// → "git.example.com"), or "" if it can't be parsed.
|
||||
func HostOf(rawURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -54,6 +54,36 @@ func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullR
|
||||
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).
|
||||
|
||||
@@ -69,6 +69,13 @@ func (c *CLI) Version(ctx context.Context) (string, error) {
|
||||
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.
|
||||
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
|
||||
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
@@ -148,6 +155,18 @@ 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.
|
||||
type Branch struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -81,6 +81,15 @@ type mergePRInput struct {
|
||||
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"`
|
||||
@@ -92,6 +101,18 @@ 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.
|
||||
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||
s := mcpsdk.NewServer(&mcpsdk.Implementation{
|
||||
@@ -184,6 +205,18 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||
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",
|
||||
@@ -253,6 +286,28 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
"gitmanager/internal/store"
|
||||
)
|
||||
|
||||
// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner ->
|
||||
@@ -39,10 +40,17 @@ func TestMCPRoundTrip(t *testing.T) {
|
||||
// Populate the index via the real scanner, then build service + MCP server.
|
||||
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 := repos.NewScanner(g, log, func(context.Context) repos.Config {
|
||||
return repos.Config{Roots: []string{root}, MaxDepth: 3, Fetch: false}
|
||||
}, time.Minute)
|
||||
scanner.Refresh(context.Background())
|
||||
|
||||
svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo)
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "config.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
svc := service.New(g, scanner.Index, activity.New(log, 200), st, log, scanner.RefreshRepo)
|
||||
srv := NewServer(svc, "test")
|
||||
|
||||
// Wire an in-memory client<->server session.
|
||||
|
||||
+59
-34
@@ -26,6 +26,7 @@ type State struct {
|
||||
Ahead int `json:"ahead"`
|
||||
Behind int `json:"behind"`
|
||||
Remotes []string `json:"remotes"`
|
||||
Forge string `json:"forge,omitempty"` // forge this project belongs to (derived from its remote)
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Error string `json:"error,omitempty"` // set if refreshing this repo failed
|
||||
}
|
||||
@@ -61,34 +62,41 @@ func (i *Index) List() []State {
|
||||
return out
|
||||
}
|
||||
|
||||
// Config is the per-cycle scan configuration. It is fetched fresh on every scan
|
||||
// (from the store), so changing project directories at runtime takes effect
|
||||
// without a restart.
|
||||
type Config struct {
|
||||
Roots []string
|
||||
MaxDepth int
|
||||
Ignore []string
|
||||
Fetch bool
|
||||
// ForgeFor maps a repo's origin remote URL to a display label for the forge it
|
||||
// belongs to (a configured forge's name, else the bare host, else ""). May be nil.
|
||||
ForgeFor func(remoteURL string) string
|
||||
}
|
||||
|
||||
// ConfigFunc supplies the current scan configuration.
|
||||
type ConfigFunc func(context.Context) Config
|
||||
|
||||
// Scanner discovers repositories and refreshes the index on an interval.
|
||||
type Scanner struct {
|
||||
git *git.CLI
|
||||
log *slog.Logger
|
||||
roots []string
|
||||
maxDepth int
|
||||
ignore map[string]struct{}
|
||||
interval time.Duration
|
||||
fetchEnabled bool
|
||||
git *git.CLI
|
||||
log *slog.Logger
|
||||
cfgFn ConfigFunc
|
||||
interval time.Duration
|
||||
|
||||
Index *Index
|
||||
}
|
||||
|
||||
// NewScanner builds a scanner. ignore is a set of directory names to skip.
|
||||
func NewScanner(g *git.CLI, log *slog.Logger, roots []string, maxDepth int, ignore []string, interval time.Duration, fetchEnabled bool) *Scanner {
|
||||
ig := make(map[string]struct{}, len(ignore))
|
||||
for _, name := range ignore {
|
||||
ig[name] = struct{}{}
|
||||
}
|
||||
// NewScanner builds a scanner. cfgFn supplies the roots/depth/ignore/fetch fresh
|
||||
// each cycle; interval is fixed for the life of the process.
|
||||
func NewScanner(g *git.CLI, log *slog.Logger, cfgFn ConfigFunc, interval time.Duration) *Scanner {
|
||||
return &Scanner{
|
||||
git: g,
|
||||
log: log,
|
||||
roots: roots,
|
||||
maxDepth: maxDepth,
|
||||
ignore: ig,
|
||||
interval: interval,
|
||||
fetchEnabled: fetchEnabled,
|
||||
Index: newIndex(),
|
||||
git: g,
|
||||
log: log,
|
||||
cfgFn: cfgFn,
|
||||
interval: interval,
|
||||
Index: newIndex(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,32 +118,34 @@ func (s *Scanner) Run(ctx context.Context) {
|
||||
// Refresh discovers repos and updates the index. Each repo is refreshed under
|
||||
// its own timeout so a single slow/unreachable repo cannot stall the rest.
|
||||
func (s *Scanner) Refresh(ctx context.Context) {
|
||||
paths := s.discover()
|
||||
s.log.Debug("scan discovered repositories", "count", len(paths))
|
||||
cfg := s.cfgFn(ctx)
|
||||
paths := discover(cfg)
|
||||
s.log.Debug("scan discovered repositories", "roots", cfg.Roots, "count", len(paths))
|
||||
for _, p := range paths {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.Index.set(s.refreshOne(ctx, p))
|
||||
s.Index.set(s.refreshOne(ctx, p, cfg.Fetch, cfg.ForgeFor))
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// full scan. It never fetches (network) — it only re-reads local state.
|
||||
func (s *Scanner) RefreshRepo(ctx context.Context, path string) {
|
||||
s.Index.set(s.refreshOne(ctx, path))
|
||||
cfg := s.cfgFn(ctx)
|
||||
s.Index.set(s.refreshOne(ctx, path, false, cfg.ForgeFor))
|
||||
}
|
||||
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string, fetch bool, forgeFor func(string) string) State {
|
||||
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
st := State{Path: path, Name: filepath.Base(path), UpdatedAt: time.Now()}
|
||||
|
||||
if s.fetchEnabled {
|
||||
if fetch {
|
||||
if err := s.git.Fetch(rctx, path); err != nil {
|
||||
s.log.Warn("scan fetch failed", "repo", path, "err", err)
|
||||
}
|
||||
@@ -156,8 +166,19 @@ func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||
|
||||
st.Ahead, st.Behind, _ = s.git.AheadBehind(rctx, path)
|
||||
|
||||
if remotes, err := s.git.Remotes(rctx, path); err == nil {
|
||||
st.Remotes = remotes
|
||||
// Remote names, plus the origin URL used to derive which forge this project
|
||||
// belongs to (§ "get it from git").
|
||||
if details, err := s.git.RemoteDetails(rctx, path); err == nil {
|
||||
var originURL string
|
||||
for _, rm := range details {
|
||||
st.Remotes = append(st.Remotes, rm.Name)
|
||||
if rm.Name == "origin" || originURL == "" {
|
||||
originURL = rm.URL
|
||||
}
|
||||
}
|
||||
if forgeFor != nil && originURL != "" {
|
||||
st.Forge = forgeFor(originURL)
|
||||
}
|
||||
}
|
||||
|
||||
return st
|
||||
@@ -166,11 +187,15 @@ func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||
// discover walks each root looking for directories that contain a .git entry,
|
||||
// recording the parent as a repo and not descending into it. Depth is measured
|
||||
// relative to each root; ignored directory names are skipped.
|
||||
func (s *Scanner) discover() []string {
|
||||
func discover(cfg Config) []string {
|
||||
ignore := make(map[string]struct{}, len(cfg.Ignore))
|
||||
for _, name := range cfg.Ignore {
|
||||
ignore[name] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
|
||||
for _, root := range s.roots {
|
||||
for _, root := range cfg.Roots {
|
||||
root = filepath.Clean(root)
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -182,12 +207,12 @@ func (s *Scanner) discover() []string {
|
||||
|
||||
name := d.Name()
|
||||
if path != root {
|
||||
if _, skip := s.ignore[name]; skip {
|
||||
if _, skip := ignore[name]; skip {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
|
||||
if depth(root, path) > s.maxDepth {
|
||||
if depth(root, path) > cfg.MaxDepth {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
|
||||
+250
-39
@@ -8,13 +8,16 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/forge"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/store"
|
||||
)
|
||||
|
||||
// Service holds the shared dependencies the capabilities need.
|
||||
@@ -22,15 +25,22 @@ type Service struct {
|
||||
git *git.CLI
|
||||
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)
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
|
||||
|
||||
mu sync.Mutex // guards forgeCache
|
||||
forgeCache map[string]*forge.Gitea // base_url -> provider
|
||||
}
|
||||
|
||||
// New builds a Service over the git boundary, the scanner's repo index, the
|
||||
// activity feed, (optionally) a forge provider, and a single-repo refresh hook
|
||||
// (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}
|
||||
// activity feed, the config store, and a single-repo refresh hook (may be nil)
|
||||
// used to re-scan a repo after a mutating action.
|
||||
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, st *store.Store, log *slog.Logger, refresh func(context.Context, string)) *Service {
|
||||
return &Service{
|
||||
git: g, index: index, feed: feed, store: st, log: log, refresh: refresh,
|
||||
forgeCache: make(map[string]*forge.Gitea),
|
||||
}
|
||||
}
|
||||
|
||||
// ListRepos returns a snapshot of every discovered repository.
|
||||
@@ -123,29 +133,48 @@ func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bo
|
||||
|
||||
// --- 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 }
|
||||
// ForgeConfigured reports whether any forge is configured in the store.
|
||||
func (s *Service) ForgeConfigured(ctx context.Context) bool {
|
||||
forges, err := s.store.ListForges(ctx)
|
||||
return err == nil && len(forges) > 0
|
||||
}
|
||||
|
||||
// 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.
|
||||
// when no forge is configured, or forge.ErrNotSupported when the repo's remote is
|
||||
// not on a configured host.
|
||||
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
|
||||
owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.forge.ListPullRequests(ctx, owner, repo)
|
||||
return prov.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) {
|
||||
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
|
||||
if err != nil {
|
||||
return forge.PullRequest{}, err
|
||||
}
|
||||
pr, err := prov.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)
|
||||
prov, 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)
|
||||
res, err := prov.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
|
||||
if err != nil {
|
||||
return forge.MergeResult{}, err
|
||||
}
|
||||
@@ -162,7 +191,7 @@ func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, rep
|
||||
// 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 string, run func(dir string) (string, error)) (string, error) {
|
||||
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)
|
||||
@@ -172,7 +201,10 @@ func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath,
|
||||
s.feed.Record(actor, kind, base.Path, "failed: "+err.Error())
|
||||
return "", err
|
||||
}
|
||||
s.feed.Record(actor, kind, base.Path, "ok")
|
||||
if okDetail == "" {
|
||||
okDetail = "ok"
|
||||
}
|
||||
s.feed.Record(actor, kind, base.Path, okDetail)
|
||||
if s.refresh != nil {
|
||||
s.refresh(ctx, base.Path)
|
||||
}
|
||||
@@ -182,19 +214,19 @@ func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath,
|
||||
// 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", func(d 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", func(d 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", func(d string) (string, error) {
|
||||
return s.gitAction(ctx, actor, repoPath, "git-push", "ok", func(d string) (string, error) {
|
||||
return s.git.Push(ctx, d)
|
||||
})
|
||||
}
|
||||
@@ -203,50 +235,229 @@ func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath,
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return "", fmt.Errorf("a commit message is required")
|
||||
}
|
||||
return s.gitAction(ctx, actor, repoPath, "git-commit", func(d string) (string, error) {
|
||||
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", func(d string) (string, error) {
|
||||
return s.gitAction(ctx, actor, repoPath, "git-discard", "ok", func(d string) (string, error) {
|
||||
return s.git.DiscardAll(ctx, d)
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 (provider, owner, repo) by matching its git
|
||||
// remotes (preferring "origin") against the forges configured in the store.
|
||||
func (s *Service) resolveForge(ctx context.Context, repoPath string) (*forge.Gitea, string, string, error) {
|
||||
forges, err := s.store.ListForges(ctx)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if len(forges) == 0 {
|
||||
return nil, "", "", forge.ErrNotConfigured
|
||||
}
|
||||
base, ok := s.index.Get(filepath.Clean(repoPath))
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown repository %q", repoPath)
|
||||
return nil, "", "", fmt.Errorf("unknown repository %q", repoPath)
|
||||
}
|
||||
remotes, err := s.git.RemoteDetails(ctx, base.Path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
match := func(remoteURL string) (*forge.Gitea, string, string, bool) {
|
||||
host, o, r, ok := forge.ParseRemote(remoteURL)
|
||||
if !ok {
|
||||
return nil, "", "", false
|
||||
}
|
||||
for _, f := range forges {
|
||||
prov, perr := s.forgeFor(f)
|
||||
if perr != nil {
|
||||
continue
|
||||
}
|
||||
if prov.Handles(host) {
|
||||
return prov, o, r, true
|
||||
}
|
||||
}
|
||||
return nil, "", "", false
|
||||
}
|
||||
|
||||
// Prefer origin, then any matching remote.
|
||||
var fallback [2]string
|
||||
haveFallback := false
|
||||
var fb *forge.Gitea
|
||||
var fo, fr string
|
||||
for _, rm := range remotes {
|
||||
host, o, r, ok := forge.ParseRemote(rm.URL)
|
||||
if !ok || !s.forge.Handles(host) {
|
||||
prov, o, r, ok := match(rm.URL)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if rm.Name == "origin" {
|
||||
return o, r, nil
|
||||
return prov, o, r, nil
|
||||
}
|
||||
if !haveFallback {
|
||||
fallback = [2]string{o, r}
|
||||
haveFallback = true
|
||||
if fb == nil {
|
||||
fb, fo, fr = prov, o, r
|
||||
}
|
||||
}
|
||||
if haveFallback {
|
||||
return fallback[0], fallback[1], nil
|
||||
if fb != nil {
|
||||
return fb, fo, fr, nil
|
||||
}
|
||||
return nil, "", "", forge.ErrNotSupported
|
||||
}
|
||||
|
||||
// forgeFor returns a cached *forge.Gitea for a configured forge, building it on
|
||||
// first use. The cache is cleared whenever forges change (invalidateForges).
|
||||
func (s *Service) forgeFor(f store.Forge) (*forge.Gitea, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if p, ok := s.forgeCache[f.BaseURL]; ok {
|
||||
return p, nil
|
||||
}
|
||||
p, err := forge.NewGitea(f.BaseURL, f.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p == nil {
|
||||
return nil, forge.ErrNotConfigured
|
||||
}
|
||||
s.forgeCache[f.BaseURL] = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) invalidateForges() {
|
||||
s.mu.Lock()
|
||||
s.forgeCache = make(map[string]*forge.Gitea)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- Configuration store (forges, project dirs, git identity) — §1.3 --------
|
||||
|
||||
// Forges lists configured forges (tokens are never included).
|
||||
func (s *Service) Forges(ctx context.Context) ([]store.Forge, error) {
|
||||
return s.store.ListForges(ctx)
|
||||
}
|
||||
|
||||
// AddForge adds a forge, then reapplies git auth so pushes to it work.
|
||||
func (s *Service) AddForge(ctx context.Context, name, kind, baseURL, token string) (store.Forge, error) {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
return store.Forge{}, fmt.Errorf("a base URL is required")
|
||||
}
|
||||
f, err := s.store.AddForge(ctx, store.Forge{Name: name, Kind: kind, BaseURL: strings.TrimRight(baseURL, "/"), Token: token})
|
||||
if err != nil {
|
||||
return store.Forge{}, err
|
||||
}
|
||||
s.invalidateForges()
|
||||
s.ApplyGitConfig(ctx)
|
||||
s.feed.Record(activity.ActorUser, "forge-added", "", baseURL)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// UpdateForge edits a forge (empty token keeps the existing one).
|
||||
func (s *Service) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, token string) error {
|
||||
if err := s.store.UpdateForge(ctx, id, name, kind, strings.TrimRight(baseURL, "/"), token); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidateForges()
|
||||
s.ApplyGitConfig(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteForge removes a forge.
|
||||
func (s *Service) DeleteForge(ctx context.Context, id int64) error {
|
||||
if err := s.store.DeleteForge(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidateForges()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitIdentity returns the configured commit identity.
|
||||
func (s *Service) GitIdentity(ctx context.Context) (name, email string) {
|
||||
name, _ = s.store.GetSetting(ctx, "git_user_name", "")
|
||||
email, _ = s.store.GetSetting(ctx, "git_user_email", "")
|
||||
return name, email
|
||||
}
|
||||
|
||||
// SetGitIdentity stores the commit identity and reapplies it to git config.
|
||||
func (s *Service) SetGitIdentity(ctx context.Context, name, email string) error {
|
||||
if err := s.store.SetSetting(ctx, "git_user_name", name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.SetSetting(ctx, "git_user_email", email); err != nil {
|
||||
return err
|
||||
}
|
||||
s.ApplyGitConfig(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForgeDisplay maps a repo's origin remote URL to a label for the forge it
|
||||
// belongs to: a configured forge's name (or its host) when the remote host
|
||||
// matches one, otherwise the bare remote host, otherwise "". Used by the scanner
|
||||
// to show each project's forge next to its name.
|
||||
func (s *Service) ForgeDisplay(ctx context.Context, remoteURL string) string {
|
||||
host, _, _, ok := forge.ParseRemote(remoteURL)
|
||||
if !ok || host == "" {
|
||||
return ""
|
||||
}
|
||||
forges, err := s.store.ListForges(ctx)
|
||||
if err == nil {
|
||||
for _, f := range forges {
|
||||
if strings.EqualFold(forge.HostOf(f.BaseURL), host) {
|
||||
if f.Name != "" {
|
||||
return f.Name
|
||||
}
|
||||
return host
|
||||
}
|
||||
}
|
||||
}
|
||||
return host // not a configured forge, but still informative
|
||||
}
|
||||
|
||||
// ApplyGitConfig writes the container's git global config from the store: a
|
||||
// commit identity, safe.directory for host-owned mounts, and an auth header per
|
||||
// forge so push/fetch/pull over HTTPS work. Called at startup and after changes.
|
||||
func (s *Service) ApplyGitConfig(ctx context.Context) {
|
||||
set := func(key, value string) {
|
||||
if err := s.git.SetGlobalConfig(ctx, key, value); err != nil && s.log != nil {
|
||||
s.log.Warn("git config failed", "key", key, "err", err)
|
||||
}
|
||||
}
|
||||
set("safe.directory", "*")
|
||||
if name, email := s.GitIdentity(ctx); name != "" || email != "" {
|
||||
if name != "" {
|
||||
set("user.name", name)
|
||||
}
|
||||
if email != "" {
|
||||
set("user.email", email)
|
||||
}
|
||||
}
|
||||
forges, err := s.store.ListForges(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, f := range forges {
|
||||
if f.Token == "" {
|
||||
continue
|
||||
}
|
||||
set("http."+f.BaseURL+".extraheader", "Authorization: token "+f.Token)
|
||||
}
|
||||
return "", "", forge.ErrNotSupported
|
||||
}
|
||||
|
||||
@@ -14,8 +14,27 @@ import (
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/store"
|
||||
)
|
||||
|
||||
// testScanner builds a scanner whose roots are fixed to the given paths.
|
||||
func testScanner(g *git.CLI, log *slog.Logger, roots ...string) *repos.Scanner {
|
||||
return repos.NewScanner(g, log, func(context.Context) repos.Config {
|
||||
return repos.Config{Roots: roots, MaxDepth: 3, Fetch: false}
|
||||
}, time.Minute)
|
||||
}
|
||||
|
||||
// testStore opens a throwaway SQLite store.
|
||||
func testStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "config.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
// TestGitActions exercises the mutating commands (commit, discard) on a throwaway
|
||||
// temp repo — never a real one.
|
||||
func TestGitActions(t *testing.T) {
|
||||
@@ -31,10 +50,10 @@ func TestGitActions(t *testing.T) {
|
||||
|
||||
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 := testScanner(g, log, root)
|
||||
scanner.Refresh(context.Background())
|
||||
feed := activity.New(log, 200)
|
||||
svc := New(g, scanner.Index, feed, nil, scanner.RefreshRepo)
|
||||
svc := New(g, scanner.Index, feed, testStore(t), log, scanner.RefreshRepo)
|
||||
ctx := context.Background()
|
||||
|
||||
// Commit a new file, then the repo should be clean in the index.
|
||||
@@ -65,6 +84,24 @@ func TestGitActions(t *testing.T) {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package store is GitManager's small SQLite-backed configuration store: forge
|
||||
// hosts + credentials, the project directories to scan, and a few settings
|
||||
// (git identity). It replaces the domain config that used to live in .env.
|
||||
//
|
||||
// The DB file lives on a PRIVATE named Docker volume (not bind-mounted into the
|
||||
// project, no network port), so it is not accessible outside the container
|
||||
// (AGENT.md §0/§1.3). Tokens are stored as-is, relying on that isolation. Pure-Go
|
||||
// driver (modernc.org/sqlite) so the static CGO_ENABLED=0 build keeps working.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Forge is a configured hosting provider (Gitea/Forgejo, later GitHub/GitLab).
|
||||
type Forge struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "gitea" (only kind for now)
|
||||
BaseURL string `json:"baseUrl"` // e.g. https://git.nilles.net
|
||||
Token string `json:"-"` // never serialized to the client
|
||||
HasToken bool `json:"hasToken"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Store wraps the SQLite connection.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite DB at path and runs migrations.
|
||||
func Open(path string) (*Store, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite: serialize writers to avoid "database is locked"
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS forges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'gitea',
|
||||
base_url TEXT NOT NULL UNIQUE,
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);`)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- forges ----------------------------------------------------------------
|
||||
|
||||
func (s *Store) ListForges(ctx context.Context) ([]Forge, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, name, kind, base_url, token, created_at FROM forges ORDER BY base_url`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Forge
|
||||
for rows.Next() {
|
||||
var f Forge
|
||||
var created string
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Kind, &f.BaseURL, &f.Token, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
f.HasToken = f.Token != ""
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AddForge(ctx context.Context, f Forge) (Forge, error) {
|
||||
if f.Kind == "" {
|
||||
f.Kind = "gitea"
|
||||
}
|
||||
f.CreatedAt = time.Now()
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO forges (name, kind, base_url, token, created_at) VALUES (?,?,?,?,?)`,
|
||||
f.Name, f.Kind, f.BaseURL, f.Token, f.CreatedAt.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return Forge{}, err
|
||||
}
|
||||
f.ID, _ = res.LastInsertId()
|
||||
f.HasToken = f.Token != ""
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// UpdateForge updates name/base_url/kind, and the token only when newToken != ""
|
||||
// (empty means "keep the existing token").
|
||||
func (s *Store) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, newToken string) error {
|
||||
if kind == "" {
|
||||
kind = "gitea"
|
||||
}
|
||||
if newToken != "" {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE forges SET name=?, kind=?, base_url=?, token=? WHERE id=?`,
|
||||
name, kind, baseURL, newToken, id)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE forges SET name=?, kind=?, base_url=? WHERE id=?`, name, kind, baseURL, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteForge(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM forges WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- settings (kv) ---------------------------------------------------------
|
||||
|
||||
func (s *Store) GetSetting(ctx context.Context, key, def string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=?`, key).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return def, nil
|
||||
}
|
||||
if err != nil {
|
||||
return def, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value) VALUES (?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
+32
-1
@@ -38,6 +38,14 @@
|
||||
<li>Run <code>docker compose up</code> and open the dashboard.</li>
|
||||
</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>
|
||||
<ul>
|
||||
<li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li>
|
||||
@@ -45,6 +53,25 @@
|
||||
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Projects & forges</h2>
|
||||
<p>
|
||||
Your projects are the subdirectories of one root folder (set once in
|
||||
<code>.env</code>). Each project shows which <strong>forge</strong> it belongs
|
||||
to next to its name — that's detected automatically from the project's git
|
||||
remote.
|
||||
</p>
|
||||
<h2>Settings</h2>
|
||||
<p>
|
||||
Open <a href="/settings">Settings</a> to manage your configuration (stored in
|
||||
a private database inside the app, not in files):
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Forges</strong> — add your Git hosting servers (Gitea) and their
|
||||
access tokens. Tokens are saved securely and never shown again; to change
|
||||
one, remove the forge and add it back.</li>
|
||||
<li><strong>Git identity</strong> — the name and email used for commits.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Right-click commands</h2>
|
||||
<p>
|
||||
Right-click any repository for a menu of plain-language commands — no git
|
||||
@@ -55,6 +82,8 @@
|
||||
<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
|
||||
@@ -86,7 +115,9 @@
|
||||
<h2>Pull requests: Merge & clean up</h2>
|
||||
<p>
|
||||
When a repository is hosted on your Gitea server, its open pull requests
|
||||
appear under the details panel. Each has a <strong>Merge & clean up</strong>
|
||||
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
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<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>
|
||||
header {
|
||||
display: flex;
|
||||
@@ -41,7 +42,7 @@
|
||||
<header>
|
||||
<h1>GitManager</h1>
|
||||
<span style="color: var(--color-fg-muted)">multi-repo dashboard</span>
|
||||
<nav><a href="/help">Help</a></nav>
|
||||
<nav><a href="/settings">Settings</a> · <a href="/help">Help</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<handoff-bar></handoff-bar>
|
||||
@@ -55,5 +56,6 @@
|
||||
<activity-feed></activity-feed>
|
||||
</main>
|
||||
<repo-menu></repo-menu>
|
||||
<toast-host></toast-host>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GitManager — Settings</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script type="module" src="/components/settings-panel/settings-panel.js"></script>
|
||||
<script type="module" src="/components/toast-host/toast-host.js"></script>
|
||||
<style>
|
||||
header {
|
||||
display: flex; align-items: baseline; gap: 16px;
|
||||
padding: 12px 20px; background: var(--surface-1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
header h1 { margin: 0; font-size: 16px; }
|
||||
header nav { margin-left: auto; }
|
||||
main { max-width: 820px; padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>GitManager</h1>
|
||||
<span style="color: var(--color-fg-muted)">settings</span>
|
||||
<nav><a href="/">← Dashboard</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<settings-panel></settings-panel>
|
||||
</main>
|
||||
<toast-host></toast-host>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user