Commit Graph
71 Commits
Author SHA1 Message Date
mathiasandClaude Sonnet 5 43714047be fix(auth): owner allowlist trusts pass-through-authenticated callers (#59)
CD / Lint / Test / Vet (push) Successful in 9s
CD / Build & Import (push) Successful in 25s
CD / Deploy via GitOps (push) Has been skipped
Allowlist.Check now takes ctx: when the caller authenticated with
their own Gitea PAT (pass-through, v0.12.0), it skips the static
GITEA_MCP_ALLOWED_OWNERS check entirely — Gitea's own permission
model already gates that caller's access more precisely than a coarse
owner-name list can. The static list still applies unchanged for the
shared static-token/JWT path, where it's the only defense against the
service token's blast radius.

Mechanical: every tool call site already had ctx in scope, so this is
a signature-only change at 41 call sites, no other tool behavior
changes. Closes the "Deferred" item from #59.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:31:43 +02:00
mathiasandClaude Opus 4.8 a16c5b0537 fix(code_search): replace the fantasy REST endpoint with a real client-side grep
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Has been skipped
code_search was calling GET /api/v1/repos/{owner}/{repo}/search?type=code — an
endpoint that does not exist. Confirmed against a live Gitea 1.25.5 instance's
swagger spec: only /repos/search, /repos/issues/search, /topics/search etc are
real. Gitea's own web UI code search falls back to server-side `git grep`
because no Repository Indexer is enabled here, and that fallback is an
HTML-only route, not JSON API — so no REST endpoint exists to call, working or
not. Every call to code_search 404'd on the real server.

This went undetected because the existing tests mocked the fantasy endpoint
directly (asserting the request path was .../search?type=code and handing back
a canned JSON envelope) — a textbook case of tests validating a fake instead of
the real system, giving false confidence the tool worked.

SearchCode now does the search itself: resolves the default branch, walks the
tree (GetTree), and substring-matches q (case-insensitive, literal — not a
regex, to keep behavior predictable and avoid a ReDoS surface from
user-supplied input) against fetched file contents (GetFileContents) — the same
approach Gitea's own indexer-less fallback uses, just client-side. Bounded by:
- codeSearchMaxFiles (2000) — files scanned per call
- codeSearchMaxFileSize (512KB, checked via the tree listing's own Size field,
  before any fetch) — skip large blobs
- a binary-extension denylist checked before fetch, plus a null-byte content
  check after fetch, for extensions the denylist misses

Pagination is over the full sorted result set, recomputed each call (no
server-side index to page through incrementally) — acceptable for the repo
sizes this targets; documented as a known limitation, not a hidden footgun.

The tool layer (internal/tools/code_search.go) is UNCHANGED — SearchCode's
signature and the []CodeSearchHit contract are identical, so this is fully
isolated to the client layer + its tests.

Tests: match found in tree, case-insensitive matching, binary extension
skipped WITHOUT fetching (asserted via the fake's fetch log, not just absent
from results), oversized file skipped without fetching, null-byte content
skipped after fetching, pagination across a full sorted set, empty-query
validation — plus the tool-layer single-repo and fan-out tests rewritten
against the same real endpoints (GetRepo/GetTree/GetFileContents +
ListRepos), replacing their fantasy-endpoint fakes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:29:05 +02:00
mathiasandClaude Opus 4.8 09a7fad6ba feat(create_project): dispatch_allow also registers the repo into dispatch's allowlist (#54)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
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>
2026-07-07 00:33:39 +02:00
mathiasandClaude Opus 4.8 51b823ae79 fix(create_project): idempotent rename write completes a stray write+delete split (#53)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Has been skipped
CD / Build & Import (push) Successful in 22s
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>
2026-07-04 14:09:26 +02:00
mathiasandClaude Opus 4.8 ac337955e7 fix(issue_label): schema wrongly required labels, blocking label_ids-only callers (#52 review finding)
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
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>
2026-07-04 13:57:05 +02:00
mathiasandClaude Opus 4.8 b288462a1c feat(create_project): resume=true makes substitution durable against infra#179 (#50, #51)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
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>
2026-07-04 13:49:38 +02:00
mathiasandClaude Opus 4.8 82823aad1e feat(labels): add label_list + issue_label tools (#52)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Has been skipped
CD / Build & Import (push) Successful in 22s
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>
2026-07-04 13:44:14 +02:00
mathiasandClaude Opus 4.8 64176fe6d7 fix(repo_mirror_push): resolve mirror credential from server env, not the payload (#49)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
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>
2026-07-04 09:00:07 +02:00
mathiasandClaude Opus 4.8 6d344c74a8 feat(tbd_ship): idempotent re-invoke — resume existing branch/PR (#48)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Has been skipped
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>
2026-07-04 08:30:30 +02:00
mathiasandClaude Opus 4.8 f0527c94bc fix(test): bump TestRegisteredToolCount to 39 for tbd_ship (#40)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 4s
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>
2026-07-04 00:49:26 +02:00
mathiasandClaude Opus 4.8 c40a9d9003 test(tbd_ship): cover review-protected + merge-conflict fail-closed paths (#40)
CD / Lint / Test / Vet (push) Failing after 5s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
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>
2026-07-04 00:41:12 +02:00
mathiasandClaude Opus 4.8 8ebad95adf feat(tools): add tbd_ship — CI-gated trunk-based ship (#40)
CD / Build & Import (push) Has been skipped
CD / Lint / Test / Vet (push) Failing after 5s
CD / Deploy via GitOps (push) Has been skipped
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>
2026-07-04 00:40:02 +02:00
mathiasandClaude Opus 4.8 0a12e905c9 fix(create_project): drop defunct hyperguild new-project from finalize guidance (#46)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
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>
2026-07-03 23:45:25 +02:00
mathiasandClaude Opus 4.8 9b7f53bdda feat(auth): document caller header precedence + warn on conflict (#10)
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>
2026-07-03 22:57:24 +02:00
mathiasandClaude Opus 4.8 4c1f36f9eb chore(rename): module gitea.d-ma.be → git.d-ma.be/mathias/gitea-mcp (#39)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 23s
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>
2026-07-03 22:48:26 +02:00
mathiasandClaude Opus 4.8 99586984bf docs(file_write_branch): clarify direct-to-main + PR-flow in descriptor (#35)
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>
2026-07-03 22:47:37 +02:00
mathiasandClaude Opus 4.8 e0bede0547 fix(tools): reject empty repo/name identifier at tool layer (#37)
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>
2026-07-03 22:47:05 +02:00
mathiasandClaude Opus 4.8 9f12db3d94 fix(pr_merge): advertise canonical number, demote index to alias (#45)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
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>
2026-07-03 21:58:05 +02:00
mathiasandClaude Opus 4.8 93c32edbb5 feat(tools): make repo the canonical repo-identifier arg; name now an alias (#38)
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
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>
2026-07-03 21:42:18 +02:00
mathiasandClaude Opus 4.8 4ebea7d023 fix(workflow_run_trigger): 204 dispatch is success; resolve run via listing (#41)
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
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>
2026-07-03 21:12:45 +02:00
mathiasandClaude Opus 4.8 711dc46e5e feat(create_project): add dispatch_allow to inject .dispatch-allow (#43)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Has been skipped
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>
2026-07-03 20:57:39 +02:00
mathiasandClaude Opus 4.8 039598855c fix(create_project): fast raw create + honest partial_failure (#42, infra#179)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Successful in 6s
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>
2026-07-01 23:38:13 +02:00
mathiasandClaude Opus 4.8 d45ba712ce fix(create_project): make the write the branch-readiness gate (#42)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Deploy via GitOps (push) Successful in 4s
CD / Build & Import (push) Successful in 22s
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>
2026-07-01 23:17:11 +02:00
mathiasandClaude Opus 4.8 4d658004ae fix(create_project): handle gitea generate-async branch race (#42)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 4s
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>
2026-07-01 23:11:49 +02:00
mathias e30951ad72 fix(create_project): use fmt.Fprintf in test fake (lint QF1012)
Follow-up to e3cdd23 — staticcheck QF1012 flagged w.Write([]byte(fmt.Sprintf(...)))
in the rewritten test's fake server. Behaviour unchanged; lint gate green.
2026-07-01 22:53:15 +02:00
mathiasandClaude Opus 4.8 e3cdd23260 fix(create_project): substitute the whole tree, rename cmd dir, resolve branch (#42)
CD / Lint / Test / Vet (push) Failing after 5s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
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>
2026-07-01 22:39:48 +02:00
mathiasandClaude Opus 4.8 184d5a95dd fix(tools,gitea): alias repo/index args and reject empty path segments
CD / Deploy via GitOps (push) Has been skipped
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 23s
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>
2026-06-10 10:16:33 +02:00
mathiasandClaude Opus 4.8 e2594f45f2 refactor(tools): extract RegisterAll shared tool registration
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>
2026-06-10 09:52:29 +02:00
mathias 8a751741a4 feat: add issue_edit tool (#34)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Failing after 0s
CD / Deploy via GitOps (push) Has been skipped
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
2026-06-02 16:11:33 +02:00
mathiasandClaude Opus 4.7 3c1ca7d3db feat: add issue_list_comments tool (closes #32)
CD / Lint / Test / Vet (push) Successful in 17s
CD / Build & Import (push) Successful in 19s
CD / Deploy via GitOps (push) Successful in 4s
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>
2026-05-27 22:01:34 +02:00
mathias 60212fc5d2 feat: issue_list + workflow_run_list tools (#28, #29)
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
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 #28
Closes #29
2026-05-18 08:06:11 +02:00
mathias dc907fb7e0 feat: issue_close + issue_reopen tools (#30)
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
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
2026-05-18 07:51:17 +02:00
mathiasandmathias 3648373333 fix: merge repo_update — add archived+template, keep default_branch+confirm from main 2026-05-16 23:54:16 +02:00
mathias eeefc626ed feat(repo_update): tool for archiving + metadata patches
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
2026-05-16 23:54:16 +02:00
mathias 5545d6ab4b fix(create_project_from_template): accept per-call template_name override
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
2026-05-16 23:24:16 +02:00
mathias 9013c8ff9c fix(pr_files_diff): copy per-file diff bytes to break buffer aliasing
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
2026-05-16 23:24:16 +02:00
mathias d4dddbdb6c feat(tools): issue_get, release_create, repo_delete (#11, #17, #20)
CD / Build & Import (pull_request) Has been skipped
CD / Lint / Test / Vet (pull_request) Successful in 7s
CD / Deploy via GitOps (pull_request) Has been skipped
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
2026-05-15 13:59:06 +02:00
mathias 5f3ad99122 feat(tools): repo_tree, repo_topics_update, file_read dir fix (#14, #15, #18)
CD / Lint / Test / Vet (pull_request) Successful in 7s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
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
2026-05-15 10:23:31 +02:00
mathias e2da495581 feat(tools): add repo_create, repo_update, repo_mirror_push (#12, #13, #16)
CD / Lint / Test / Vet (pull_request) Successful in 7s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
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
2026-05-15 10:14:18 +02:00
mathias 0c5903a196 feat(tools): repo_status 2026-05-06 22:59:51 +02:00
mathias 839fc93dcd feat(tools): tag_create 2026-05-06 22:54:22 +02:00
mathias 5dac4856bd feat(tools): file_delete 2026-05-06 22:51:21 +02:00
mathias 0eb9ebcafd feat(tools): dir_list 2026-05-06 22:49:50 +02:00
mathias 284d5e19f6 feat(tools): pr_merge 2026-05-06 22:48:02 +02:00
mathias 388131c8cd feat(tools): pr_list 2026-05-06 22:46:11 +02:00
mathias ddfcc32afd feat(tools): branch_protection_get 2026-05-06 22:44:24 +02:00
mathias 9e4251c1a7 feat(tools): branch_delete 2026-05-06 22:42:38 +02:00
mathias 06882d185e fix(tools): branch_list schema constraints 2026-05-06 22:41:05 +02:00
mathias 073d88b29a feat(tools): branch_list 2026-05-06 22:38:15 +02:00
gitea-mcp-bot d35ff9781c test(file_write_branch): assert branch and commit_sha on PUT path for parity
CD / Lint / Test / Vet (pull_request) Successful in 5s
CD / Build & Import (pull_request) Has been skipped
CD / Deploy via GitOps (pull_request) Has been skipped
2026-05-06 14:35:20 +00:00