Extends the existing dispatch_allow flag (which already injects .dispatch-allow,
#43/#51/#53) to also append owner/name to mathias/dispatch's git-tracked
dispatch-repos.txt (dispatch#19) — the second of the two required dispatch
gates. Being listed there is necessary but not sufficient on its own (the repo
still needs its own .dispatch-allow marker) — both are applied independently,
neither implies the other, matching dispatch#3's structural trust-zone design
where both must say yes.
Idempotent: a repo already listed (e.g. dispatch_allow re-requested on resume)
is a no-op, not a duplicate line. Failure is reported in its own
dispatch_allowlist_failure field, distinct from partial_failure (substitution)
and dispatch_allow_failure (the marker file) — the three gates can each fail
independently, never conflated (same principle as #51).
The allowlist owner/repo/path/branch are hardcoded to match mathias/dispatch's
own DISPATCH_ALLOWLIST_* env defaults, confirmed unoverridden in the live
CronJob (2026-07-06) before implementing.
Tests: fresh registration (existing entries preserved, not clobbered),
already-listed no-op (zero writes), allowlist-write failure as a distinct
field, dispatch_allow=false never touches the file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rename path (write new path, then delete old path) is two separate,
non-atomic API calls. If a prior partial run's write succeeded but the paired
delete failed — plausible under the same infra#179 flakiness resume (#50)
exists to work around — a resume would recompute the identical rename and
blind-create (no sha) at a path that already exists, hitting a conflict and
getting stuck needing another resume cycle just to re-report the same thing.
renameEntry now reads the new path first: if it already holds the correct
content (prior write succeeded), the write is skipped and only the
outstanding delete of the old path runs; if the new path exists but differs,
it's updated with the fetched sha instead of blind-created; if the old path
is already gone by delete time, that's treated as done, not a failure.
Mirrors injectDispatchAllow's (#51) read-before-write idempotency pattern.
Test: TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly — a stray
old path plus an already-correct new path resolves to a single delete, zero
redundant writes, no partial_failure. All prior create_project tests
unaffected (backward compatible).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Independent adversarial review of #52 (v0.8.0) caught a schema/implementation
mismatch: the advertised InputSchema marked "labels" as required, but Call
already treated labels/label_ids as either-or. An MCP client that validates
arguments against the advertised schema before dispatch would reject a
label_ids-only call as invalid even though the code was written to serve it —
and that path had zero test coverage either way.
Dropped "labels" from the required array (owner/repo/number remain required);
runtime validation already correctly requires at least one of labels/label_ids.
Added TestIssueLabelAppliesByIDOnly (asserts ListLabels is never called when
IDs are already known) and a schema-lock test for the fixed contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parallax#1 was the real-world evidence: infra#179's branch-writability stall
(>40s observed) blew past the tool's 5s budget, substitution ran zero files,
and the repo shipped genuinely broken (go.mod still read `module
__MODULE_PATH__`) — recoverable only via a manual clone/sed/git-mv/push.
Adds `resume: bool`. When true, skips template lookup and repo generation
entirely — the destination must already exist — and jumps straight to the
tree-walk substitution. This is safe to re-invoke repeatedly because
substituteEntry already compares against the branch's CURRENT state on every
call (GetTree is re-fetched fresh each time): a file already fixed in a prior
partial pass shows up already-correct or already-renamed and is a no-op. So the
actual blocker to resumability was purely the "destination must NOT exist"
guard on the create path — flipped it for resume, no change needed to the
substitution logic itself.
Consequences of resume existing:
- infra179FinalizeMessage (#46) now points at the concrete recovery — "call
this tool again with resume=true" — instead of manual clone/sed guidance.
- "no placeholders substituted" only loud-fails on a FRESH create; on resume,
finding nothing left to do is the expected steady state (success).
- dispatch_allow injection (#43) is now idempotent (read-before-write, update
with sha if present-but-different, skip if already correct) so a resumed
call with dispatch_allow=true doesn't error re-creating a path that already
exists (#51's root cause).
- #51's other ask: dispatch_allow failures now land in their own
dispatch_allow_failure field, never conflated with substitution's
partial_failure — the two can independently succeed/fail.
Tests: resume with no destination (error, names "nothing to resume"), resume
continuing a partial substitution (asserts /generate is never re-called, only
the still-wrong file is rewritten), resume when already fully done (success,
not the fresh-create loud-fail), dispatch_allow idempotent re-injection
(two-phase test capturing the real written content, no test-visible knowledge
of the internal constant), and dispatch_allow_failure as a distinct field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes#52 — unblocks parallax#3 dispatch labeling, which needs to both
discover a repo's label set and attach labels to an issue by name (the
CAD pipeline doesn't track Gitea's internal numeric label IDs).
- gitea.Client: ListLabels, AddIssueLabels (additive POST, matches
Gitea's own semantics — no delete-then-post needed); extend the
existing Label struct with Color for label_list's output.
- tools.LabelList (read-only, allowlisted): lists a repo's labels.
- tools.IssueLabel (allowlisted): resolves label names to IDs via
ListLabels, so callers pass names (the primary interface) instead of
hunting for numeric IDs; also accepts label_ids for callers that
already have them. An unknown name fails closed, naming exactly which
label wasn't found.
- Bump TestRegisteredToolCount 39 -> 41 in the same commit (this
project was bitten today by a locked count going stale silently).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mirror credential no longer has to ride the tool-call payload (which is
persisted to transcript → claudewatcher → brain → gitea history). Adds
remote_password_env: the name of a server-side env var the tool resolves at call
time, so the secret stays in the server process. An env name that resolves to
empty errors loudly rather than silently sending an empty password. Raw
remote_password still works but the schema/description now mark it DISCOURAGED.
Tests: password resolved from the env var (never in output); unset env var →
ErrValidation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A second tbd_ship for the same change no longer errors on "branch exists":
CreateBranch conflict is tolerated, and if a PR is already open for the head,
CreatePullRequest's conflict/validation error resolves it via ListPullRequests
(matching head.ref). Identical file content on the branch skips the write, so a
resume produces no redundant empty-diff commit. Then the same CI gate runs and
merges if now green — so "poll or re-invoke" (the #40 UX) actually works.
Test: TestTBDShip_Resume_ExistingBranchAndPR (branch+PR exist, content
unchanged → no write, merges when green). First-call paths unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The registered-tool-count lock still expected 38; tbd_ship makes 39. This is
why the v0.6.0 CD check job went red (build+deploy skipped, pod stayed on
v0.5.2). Count updated; task check green (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Call-level tests for the two remaining no-merge paths (green CI but the
base requires review, and green CI but the merge returns 409), completing the
acceptance matrix alongside the green/pending/red/no-CI cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An intent verb for the trunk-based loop: branch from base → write the file →
open a PR → auto-merge (squash) → delete the branch. One call instead of
orchestrating file_write_branch + pr_create + workflow_run_status + pr_merge +
branch_delete and remembering the conventions each time.
The load-bearing safety is a pure, fail-closed CI gate (evaluateShipGate):
merge=true ONLY when every workflow run for the PR head commit is
completed+success AND the base branch is not review-protected. Every other
state — CI pending / red / absent, review-required base, or an unclean merge —
fails closed to PR-only and returns the PR with a reason. A change with no CI
gate is never auto-merged to trunk (cf. agentsquad#36: non-compiling code
reviewer-approved straight to main with no CI wall).
- ci_timeout_seconds polls the head commit's runs to completion (default 0 =
snapshot, returns pending right after opening the PR).
- Derives a deterministic short-lived branch (tbd/<slug>-<hash>); handles new
and existing files (fetches the blob sha for updates).
- Adds head.sha + mergeable to the PR struct.
- Tests: full gate matrix (green/pending/red/cancelled/none/mixed/protected) +
Call happy-merge, no-CI fail-closed, red fail-closed, allowlist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The infra#179 partial_failure message and the tool descriptor told callers to
"Finalize locally with `hyperguild new-project`" — but that command does not
exist (the hyperguild CLI only has tier/brain/mode; it was specced, never built).
It pointed users at a dead end.
Extracted the message into a pure infra179FinalizeMessage() and reworded it to
name the actual remaining work — cloning the repo and substituting the leftover
__PROJECT_NAME__ / __MODULE_PATH__ placeholders, or retrying — with no reference
to any scaffolding CLI. Descriptor updated to match. Unit-tested the wording.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CallerMiddleware silently preferred X-Auth-Request-User over X-Forwarded-User
with no explanation and no signal when both were set. Documented the precedence
(X-Auth-Request-User is the verified OIDC identity oauth2-proxy sets, so it is
authoritative; X-Forwarded-User is a fallback), and it now takes a *slog.Logger
and warns when both headers are present and disagree, so a proxy
misconfiguration is visible instead of silently resolved. Table-driven tests
cover precedence (both/single/none) and the conflict-warning path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gitea host renamed (infra ADR-0004); the mcp-chassis dep already migrated
(3329ff3), so the sequencing gate is clear. `go mod edit -module` + bulk import
rewrite across all .go files. gitea-mcp is a server binary (not an imported
library), so no downstream consumers break. build + task check green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tool already commits directly to any existing branch (BranchExists→upsert,
no create — covered by TestFileWriteBranchSkipsCreateWhenBranchExists), so
`branch:"main"` is a one-call direct-to-main write. The descriptor said "feature
branch", understating it. pr_create/pr_merge/repo_list already exist, closing the
other two #35 gaps (PR loop + owner repo listing). Descriptor now states both
paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An empty required repo identifier built a trailing-empty path segment
(`/api/v1/repos/{owner}/`) and leaked gitea's bare 404. parseArgs now
validates, via reflection, that `repo`/`name` string args are non-empty and
returns a typed ErrValidation naming the field. Optional identifiers (e.g.
code_search's owner-wide fan-out `repo`) opt out with `,omitempty`. `owner` is
already enforced by the allowlist check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pr_merge was the only tool still advertising `index` as its id field while
every other issue/PR tool uses the canonical `number` (#38). Flipped its
schema property + required + struct field/tag `index` -> `number`; the existing
`index`->`number` shim keeps legacy `index` callers working, so no shim change
was needed (no canonical-`index` tool remains). Now the id arg is `number`
uniformly across all per-issue/PR tools.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every caller (claude.ai connector, LLMs primed on gitea/GitHub) sends the repo
identifier as `repo`, but the 33 per-repo identifier tools advertised `name`.
The v0.2.8 shim aliased repo->name so it worked, yet the advertised inputSchema
still said `name` — a misleading contract, with the shim load-bearing.
- Flip all 33 identifier tools: schema property + required + struct field/tag
from `name` to `repo`. A compliant `repo` caller now matches the struct
directly; the shim is pure back-compat.
- normalizeAliases is now bidirectional (name<->repo), so legacy `name` callers
still resolve, and repo_create / create_project_from_template — whose `name`
means "name of the NEW repo", not an existing-repo id — keep `name` and still
accept `repo`.
- `number`/`index` left as-is (out of scope; separate pre-existing quirk where
pr_merge advertises `index` rather than `number`).
- Tests: schema-canonical assertion + flipped alias round-trip (explicit `repo`
wins over `name`).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gitea's workflow_dispatch endpoint returns 204 No Content with no Location
header. DispatchWorkflow required that header, so every successful dispatch
errored with "missing Location header" and never yielded a run ID — making
the tool unusable for CAD dispatch.
- DispatchWorkflow now returns error-only; 204 = success, no Location needed.
Body already carried ref+inputs; kept and covered by test.
- Tool snapshots the newest existing workflow_dispatch run before dispatch,
then polls ListWorkflowRuns after and returns the newest run with ID above
that baseline (avoids returning a stale prior run). Falls back to an honest
"dispatched, run not yet registered" result rather than failing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Optional dispatch_allow bool (default false). When true, inject a
.dispatch-allow file at repo root on the resolved default branch after
substitution, marking the new project dispatch-eligible (dispatch#3)
without a manual follow-up commit.
Rides the existing upsertRetry path so injection inherits the infra#179
branch-readiness / partial_failure handling; a stalled injection degrades
exactly like substitution. Reported in files_substituted. false/omitted
is byte-for-byte unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gitea's template-generate is slow-async on this instance (repo not writable for
>40s; infra#179) — no synchronous MCP tool can wait that long, and the 60s retry
made the call hang until the client timed out. Bound the write-readiness retry to
5s (a healthy gitea commits in ~1s and this still catches it), and when the branch
isn't writable in time, return a clear partial_failure: repo created, substitution
deferred, finalize locally with `hyperguild new-project`. Substitution logic is
intact and completes automatically once generate is fast (infra#179). Tool
description updated to describe substitution as best-effort. Refs #42, infra#179.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BranchExists returns true before the generated branch is writable, so
waitForBranch didn't help and a 2.5s retry budget was too short (branch became
writable ~30s post-generate under load in live testing). Drop waitForBranch;
let upsertRetry be the gate — retry the write on the transient "branch does not
exist" not-found for up to 60s, early-exit on success. Once the first write
lands the branch is writable and the rest succeed immediately. Refs #42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live e2e surfaced a race unit tests couldn't (mocks are instant): gitea's
/generate returns and serves reads before the branch ref is writable, so the
first content writes 404 "branch does not exist" for a beat — aborting the
whole substitution pass. Add waitForBranch (poll BranchExists after generate)
+ upsertRetry (retry writes on the transient not-found). Test fake now serves
the branch readiness probe. Refs #42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_project_from_template returned files_substituted:null and produced a
non-building scaffold. Three root causes, all fixed:
1. Empty branch: /generate omits default_branch, so every SubstituteFile read
hit an empty ref and 404'd → nothing substituted. Resolve the branch
explicitly (re-fetch the repo; fall back to "main"). The old unit test hid
this by mocking default_branch:"main".
2. Incomplete + rename-incapable: substitution ran over a fixed 6-file list
that missed cmd/__PROJECT_NAME__/main.go and could not rename the
cmd/__PROJECT_NAME__/ directory. Replace with a recursive tree walk:
content-substitute every blob, and for any path carrying a placeholder,
rename it (POST-create new path + delete old).
3. Stale module host: __MODULE_PATH__ used gitea.d-ma.be (the pre-rename host,
which breaks `go mod download` downstream). Use git.d-ma.be.
Also: fail loud — if nothing was substituted, populate partial_failure instead
of returning silent success (the null that started this).
Tests rewritten to drive the tree-walk flow and assert: cmd/ rename (new path
POST + old path delete), git.d-ma.be module substitution, empty-generate-branch
fallback, and the loud-on-nothing path.
Closes#42.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-repo MCP tools 404'd while owner-level tools worked (#36). Root cause was
a parameter-name contract mismatch, not a routing fault: every per-repo tool
declares the repo identifier as 'name' (and issue/PR index as 'number'), but
every caller — the claude.ai connector, LLMs primed on gitea's own API — sends
'repo' and 'index'. The unmatched fields zero-valued the upstream path segment,
producing '/api/v1/repos/{owner}//...', which gitea answers with its generic
api-404 whose body points at /api/swagger. That swagger pointer is gitea boiler-
plate, not a misroute — the MCP dispatch was correct all along.
Two layers of defence:
- parseArgs aliases repo->name and index->number (explicit canonical wins;
alias key left intact so pr_merge's real 'index' field is unaffected). Kills
the recurrence by accepting the idiomatic argument names.
- the gitea client rejects any path with an empty segment before the HTTP call,
returning ErrValidation instead of forwarding a malformed path and surfacing
gitea's opaque swagger-404. Kills the silent-misleading-error class.
Closes#36
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main.go and tests now share one registration list so a tool wired in one
place cannot silently go missing from the other. Adds a dispatch round-trip
test asserting every registered tool resolves and ships a parseable schema.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds issue_edit, mapping to Gitea's PATCH /repos/{owner}/{repo}/issues/{index},
so an existing issue's title and/or body can be edited through the MCP surface.
Previously the only post-create mutation was issue_comment, which buries
backlinks in the thread instead of the canonical body.
Partial patch via pointer fields (omitempty): omitted fields are left
untouched, an explicit empty string clears a field. Body is sent verbatim —
no identity footer — so repeated edits are idempotent, matching the acceptance
criteria. Registered alongside the other issue tools for tool_search discovery.
Closes#34
Lists all comments on an issue or PR via GET /api/v1/repos/{owner}/{repo}/issues/{index}/comments.
Read-only, allowlist-gated. Extended IssueComment struct with user/timestamps populated by the list endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the *_list partners that the existing *_get tools have been
missing. Same pattern as repo_list — owner allowlisted, capLimit
helper for pagination, next_page surfaced when the page is full.
internal/gitea/issues.go:
- ListIssues(owner, repo, args) hitting
GET /api/v1/repos/{owner}/{repo}/issues with type=issues server-side
so PRs don't leak in (gitea conflates them on this endpoint).
- ListIssuesArgs struct: State, Labels, Since (ISO 8601), Page, Limit.
internal/gitea/workflows.go:
- ListWorkflowRuns(owner, repo, args) hitting
GET /api/v1/repos/{owner}/{repo}/actions/runs.
- Expanded WorkflowRun struct with DisplayTitle, Event, HeadSHA,
HeadBranch, WorkflowID, RunNumber, UpdatedAt, Actor so callers
can pin runs to a commit / branch without a second lookup.
- ListWorkflowRunsArgs: Branch, HeadSHA, Status, Event, Workflow,
Page, Limit. Status/Event 'all' treated as no-filter.
internal/tools/issue_list.go:
- Default state=open, default limit=30 (matches repo_list).
- next_page returned only when len(issues) == limit.
internal/tools/workflow_run_list.go:
- Default limit=10 (most common use is 'what just happened',
not paging).
- Returns runs + total + optional next_page.
Tests: table-driven for both — happy path, empty result, filter
combinations, allowlist rejection. workflow_run_list also asserts
the 'status=all is no-op' behavior (no query param emitted).
Closes#28Closes#29
Adds two MCP tools that PATCH /api/v1/repos/{owner}/{name}/issues/{number}
with {"state":"closed"} or {"state":"open"}. Both use a shared
SetIssueState helper on the gitea client.
- internal/gitea/issues.go: SetIssueState method using the existing
PatchJSON + MapStatus + json.Unmarshal pattern from GetIssue.
- internal/tools/issue_close.go: IssueClose tool. owner+name+number
args. Owner allowlist enforced. Returns the updated issue. Reversible
via issue_reopen, classified LOW risk.
- internal/tools/issue_reopen.go: mirror of IssueClose with
state="open". Same risk profile.
- Registered both tools in cmd/gitea-mcp/main.go.
- Tests for both: success (asserts PATCH method, path, body), 404,
and allowlist rejection — same shape as issue_get_test.go.
Closes#30
Adds a repo_update tool exposing PATCH /api/v1/repos/{owner}/{name}
with optional pointer fields (archived, description, private,
website, template). Only fields set by the caller are sent on the
wire, so the server patches exactly what was asked for.
Originally needed to archive ingestion-svc cleanly instead of
leaving a README tombstone, and to flip template-go-{agent,web}
to template=true so create_project_from_template stops failing
the "is not marked as template" guard.
Wire-level enforcement of "at least one field" returns ErrValidation
before any network call, preventing no-op PATCHes.
private=false (making a repo public) is allowed but flagged in the
tool description with a "verify intent before calling" warning.
The earlier issue draft suggested an ntfy confirmation hook for
that path — out of scope for this PR; the warning string is the
minimum that fits inside the tool surface today.
Wires NewRepoUpdate into cmd/gitea-mcp/main.go alongside the rest
of the repo_* family.
Closes#12
The template name was hardcoded into the binary at startup via
NewCreateProjectFromTemplate("mathias", "template-go-web"), so
generating from a different template (e.g. template-go-agent)
required a code change and restart. The constructor already
parameterised it correctly — the gap was at the tool's input
schema, which never exposed template_name to the caller.
Adds an optional template_name input field. When set, it overrides
the server-configured default for that call only; when omitted,
behavior is unchanged. Template owner stays server-configured —
only the repo name is per-call.
Server-side validation already verifies the resolved template
exists and is marked as a template repo, so no enum constraint
is added — keeps the door open for future templates (go-ml,
go-service, ...) without redeploys.
Adds TestCreateProjectTemplateNameOverride verifying the override
directs both the template lookup and the /generate POST.
Closes#24
splitUnifiedDiff used bytes.Buffer to accumulate each file's diff,
then stored buf.Bytes() into the result map and called buf.Reset()
to start the next file. bytes.Buffer.Bytes() returns the buffer's
internal backing slice; Reset() resets length to 0 but reuses the
same backing array. As a result, every map entry aliased the same
storage, so all files ended up showing the LAST file's diff content.
Fix: copy the bytes into a fresh slice before storing in the map.
Adds TestPRFilesDiffPerFileIsolation as a regression test that
asserts each file entry contains its OWN diff --git header and
none of the other files' headers. Verified failing on the prior
code, passing after the fix.
Closes#25
issue_get: GET /repos/{owner}/{repo}/issues/{number} — full issue with labels, assignees, comment count
release_create: POST /repos/{owner}/{repo}/releases — create release and tag in one call
repo_delete: DELETE /repos/{owner}/{repo} — confirm=<repo name> required, blocks accidents
repo_tree: GET /git/trees/{ref}?recursive=1 — full recursive file tree
repo_topics_update: PUT /repos/{owner}/{repo}/topics — replace topic list
file_read: detect array response and return descriptive error for dir paths
repo_create: POST /user/repos or /orgs/{org}/repos, is_org flag routes
repo_update: PATCH /repos/{owner}/{repo}, confirm required when private=false
repo_mirror_push: add/list/delete push mirrors, password never returned