Skip to content

feat: unify to local base-action (closes #1)#2

Merged
nllptrx merged 20 commits into
giteafrom
feat/local-base-action
Apr 22, 2026
Merged

feat: unify to local base-action (closes #1)#2
nllptrx merged 20 commits into
giteafrom
feat/local-base-action

Conversation

@nllptrx

@nllptrx nllptrx commented Apr 22, 2026

Copy link
Copy Markdown
Owner

Closes #1.

What this does

Replaces the former two-step wiring (src/entrypoints/prepare.ts + external uses: anthropics/claude-code-base-action@v0.0.63) with a single in-process orchestrator at src/entrypoints/run.ts that imports directly from the local ./base-action/src/*. The external pin is gone; the ./base-action/ directory that was previously kept in sync but unused is now the live execution path.

Net diff: +1.3k / −1.4k, 16 commits.

Issue #1 acceptance criteria

  • ./base-action/ used (no external pin). In-process import path — stricter than uses: ./base-action (no composite-step boundary).
  • claude_args produces the same invocation + regression tests (buildClaudeArgs in run.ts + 5 cases in test/build-claude-args.test.ts).
  • Existing Gitea inputs preserved: max_turns, timeout_minutes, model, mcp_config, system_prompt, append_system_prompt, fallback_model, claude_env, use_bedrock, use_vertex, mode, ssh_signing_key, bot_id, bot_name, etc.
  • show_full_output, plugins, plugin_marketplaces, display_report now live (previously stubbed env vars; now reach the SDK).
  • Tests pass: 548 pass / 0 fail (was 581 baseline; -33 deleted Mode-abstraction tests, +8 new regression tests for buildClaudeArgs and claude_env).
  • Migration note added to MIGRATION.md (2026-Q2 section).

Key architectural changes

Entrypoints. run.ts is the unified orchestrator: parse context, setup token, check trigger, call prepareTagMode or prepareAgentMode, install Claude Code CLI, restore config on PR events, call runClaude (SDK-based via ./base-action/src/run-claude.ts), emit outputs, write step summary. prepare.ts is deleted.

Modes. Converted from Mode object abstraction (tagMode/agentMode singletons) to free async functions (prepareTagMode/prepareAgentMode) that return {commentId, branchInfo, mcpConfig}. Deletes src/modes/registry.ts and src/modes/types.ts. Matches upstream architecture; will simplify future upstream merges.

Base-action. Adopted upstream's 25-line run-claude.ts wrapper (parseSdkOptions → runClaudeWithSdk) replacing the old ~300-line CLI subprocess path. ClaudeOptions extended with claudeEnv (upstream dropped it; we keep it as a Gitea-fork delta) which injects KEY: value pairs into process.env before SDK invocation. Dropped base-action/test/run-claude.test.ts (tested the removed prepareRunConfig CLI helper).

Action.yml. Three steps reduced to two logical phases: Install Bun + Install Dependencies + unified Run Claude Code Action (run.ts) + Update comment with job link (cleanup, if: always()).

Tsconfig. include: ["src/**/*", "base-action/src/**/*", "test/**/*"]; excludes now limited to node_modules. Full tree typechecks; no more hidden src/entrypoints/run.ts or base-action/**/* exclusions.

Gitea-specific adaptations preserved

  • mcp__gitea__* / mcp__local_git_ops__* MCP tool names (never fell through to upstream's mcp__github_* naming).
  • GITEA_SERVER_URL-based noreply email derivation for bot commits (inlined into run.ts's emitBotNoreplyOutputs, mirrors configureGitAuth).
  • client.api (GiteaApiClient) abstraction used throughout — no raw octokit.rest calls, no GraphQL.
  • validateBranchName + fix, bot_identity Gitea noreply logic, create_pull_request_review MCP tool — all preserved.
  • configureGitAuth param type widened from ParsedGitHubContext to GitHubContext so agent mode (automation events) can call it; also now uses serverUrl.protocol instead of hardcoded https:// so local Gitea (HTTP) works.

Code review trail

9 sequential codex review passes ran against this branch during development. Each caught real regressions the prior pass missed. All addressed:

Pass Findings Resolution
1 Trigger semantics, SDK engine swap Fixed in 98d7654
2 Agent mode unreachable, MCP config dropped, USE_BEDROCK naming, timeout_minutes, execution_file on failure Fixed in 98d7654
3 claude_env dropped, timeout not cancelling, agent override_prompt variables Fixed in ff9e3b9
4 Prompt alias, agent baseBranch Fixed in 5412d90
5 setupBranch agent, timeout outputs, claude_git_* stranded Fixed in e88388c
6 claude_comment_id emission, agent PR base, SDK partial flush Fixed in f40eb86
7 timeout/max_turns strict validation Fixed in dfc40c0
8 BASE_BRANCH emission, writeStepSummary on timeout Fixed in eb1ecb6
9 Prepare process.exit → throw, incremental SDK flush Fixed in 781fdcd

E2E validation

Gitea E2E harness (scripts/e2e-gitea/) run against a fresh stack with a real CLAUDE_CODE_OAUTH_TOKEN:

Scenario Outcome
Simple @claude reply PONG on an issue Claude posted PONG exactly
@claude create NOTES.md, commit, open PR on an issue Branch created, file committed with exact content, PR opened, URL reported in tracking comment
@claude review on a PR with planted bugs (off-by-one, null-deref, SQL injection, wrong percent formula) REQUEST_CHANGES review with inline per-line comments + suggestion blocks; tracking comment holds the summary and points to the inline review

Additional UX fixes surfaced by E2E

Committed in this same PR (same session) because the E2E run against the unified flow exposed them:

  • Copilot-style PR review protocol (feat(prompt)): the tag-mode prompt now carves out mcp__gitea__create_pull_request_review from the "never create new comments" rule and adds an explicit protocol: summary in the tracking comment, one create_pull_request_review call with one inline comment per finding. Prior to this the prompt banned the review tool even though it was in BASE_ALLOWED_TOOLS.
  • Tracking comment reuse (fix(comment)): createInitialComment now finds the prior Claude tracking comment and resets its body to the "working" placeholder instead of POSTing a new comment every run. Prevents Claude from copying the prior run's full review into the new tracking comment as "reference material". One stable tracking-comment id per PR (GitHub check-run UX).
  • Stale "I'll analyze this…" placeholder: now stripped when the terminal comment is rewritten. Claude-side failures with no structured errorDetails also get a fallback "See the job log for details." pointer instead of an empty body.
  • E2E harness rename: claude-bot user → contributor (plus .bob-token.contributor-token on disk). The old name confused every reader about why "two claude users" existed. Only the bot is claude; contributor simulates a non-admin developer who opens PRs.

Out of scope

Follow-up deferred

  • Tracking comment still sometimes echoes findings in prose (low-severity; inline review remains the actionable surface).
  • Action.yml duplication in prompt sections (two "Important Notes" blocks with overlap) — pre-existing, untouched by this PR.

nllptrx added 17 commits April 22, 2026 17:07
Required before creating a worktree for the base-action unification work
(issue #1).
Closes issue #1 (switch from external anthropics/claude-code-base-action@v0.0.63
pin to local ./base-action/).

## What changed

action.yml
- Replace 3-step wiring (prepare.ts -> external @v0.0.63 -> update-comment-link)
  with 4 steps: install-bun, install-deps, run.ts, update-comment-link.
- `uses: anthropics/claude-code-base-action@v0.0.63` removed. No external pins
  besides setup-bun.
- Activate `show_full_output`, `plugins`, `plugin_marketplaces`, `display_report`
  inputs (previously stubbed env vars).

Entrypoints
- `src/entrypoints/run.ts`: unified orchestrator. Imports base-action/src/*
  directly (validateEnvironmentVariables, setupClaudeCodeSettings, installPlugins,
  preparePrompt, runClaude). Calls prepareTagMode or prepareAgentMode based on
  the `mode` input, then runs Claude via the SDK-backed runClaude.
- `src/entrypoints/prepare.ts`: deleted. Its entity/automation logic is now
  inside prepareTagMode/prepareAgentMode.
- `src/entrypoints/collect-inputs.ts`: ported from upstream.

Modes
- `src/modes/tag/index.ts`: free function `prepareTagMode({ context, client,
  githubToken })` replacing the Mode-object `tagMode`. Returns
  `{ commentId, branchInfo, mcpConfig }`.
- `src/modes/agent/index.ts`: free function `prepareAgentMode` same shape.
  Uses existing `createAgentPrompt` (directPrompt/overridePrompt) and
  `configureTools` to keep ALLOWED_TOOLS/DISALLOWED_TOOLS env-var exports.
- `src/modes/agent/parse-tools.ts`: ported from upstream (used by claudeArgs
  parsing; kept since it may be used by future work).
- `src/modes/registry.ts`, `src/modes/types.ts`: deleted. Mode selection is now
  `context.inputs.mode` directly.

Create-prompt
- `createPrompt` signature is now positional:
  `(commentId, baseBranch, claudeBranch, githubData, context)`. Dropped
  mode-dispatch; agent mode writes its prompt via the exported
  `createAgentPrompt`.
- `configureTools(context)` no longer takes a Mode.

Base-action
- `base-action/src/run-claude.ts` replaced with upstream's SDK wrapper
  (25 LOC, delegates to runClaudeWithSdk). Drops ~300 lines of CLI spawn/pipe
  logic. `ClaudeOptions` gains `claudeArgs`, `pathToClaudeCodeExecutable`,
  `showFullOutput` fields the existing parseSdkOptions already consumed.
- `base-action/test/run-claude.test.ts`: deleted (tested the removed
  `prepareRunConfig` CLI helper).

Tsconfig
- `include` adds `base-action/src/**/*`; `exclude` no longer hides
  `src/entrypoints/run.ts` or `base-action/**/*`. Full tree typechecks.

## Gitea-specific preservations

- MCP tool names kept on `mcp__gitea__*` / `mcp__local_git_ops__*`
  (BASE_ALLOWED_TOOLS unchanged).
- `client.api` abstraction used throughout (no raw octokit.rest).
- GITEA_SERVER_URL noreply email derivation inlined into run.ts's
  `emitBotNoreplyOutputs` (mirrors configureGitAuth's logic).
- `configureGitAuth` param type widened from `ParsedGitHubContext` to
  `GitHubContext` so agent mode (automation events) can call it.
- validateBranchName `+` fix, create_pull_request_review MCP tool, bot_identity
  Gitea noreply logic — all preserved.

## Verification

- bun test: 538 pass / 0 fail (was 581 pass; -43 are the three deleted
  mode tests and one deleted CLI-spawn test).
- tsc --noEmit: 0 errors across src/, base-action/src/, test/.
- Smoke boot: `bun run src/entrypoints/run.ts` with minimal env reaches the
  trigger-check phase and fails cleanly with outputs (no crashes).

## Still pending

- E2E harness validation (scripts/e2e-gitea).
Codex review (base=gitea) flagged five real regressions in the initial
unification commit 835a758. All five addressed here.

[P1] Agent mode unreachable
- Trigger check in run.ts looked at `context.inputs.prompt`, but
  prepareAgentMode -> createAgentPrompt only accepts `direct_prompt` /
  `override_prompt`. Agent runs would either skip (no trigger) or throw
  (createAgentPrompt rejects missing directPrompt/overridePrompt). Switch
  the trigger source to match what the prompt builder actually consumes.
  (src/entrypoints/run.ts)

[P1] Generated MCP servers dropped from Claude's toolset
- runClaude now flows through the SDK, and parseSdkOptions only honors
  --mcp-config when it appears inside the `claudeArgs` string. Passing
  the prepareMcpConfig JSON via the separate `mcpConfig` field was a
  no-op, so Claude never saw our gitea / local_git_ops MCP servers -
  no tool access for comments, commits, PRs. Embed the generated config
  as a `--mcp-config '<escaped json>'` flag at the head of claudeArgs.
  (src/entrypoints/run.ts)

[P2] Cloud-provider flags miswired
- base-action/validate-env keys off `CLAUDE_CODE_USE_BEDROCK` /
  `CLAUDE_CODE_USE_VERTEX`, but the old wrapper exported
  `USE_BEDROCK` / `USE_VERTEX`. Workflows setting `use_bedrock: true`
  without an Anthropic key failed validateEnvironmentVariables. Also
  conditionally emit the 1/'' pattern so any non-truthy value doesn't
  accidentally flip the provider flag.
  (action.yml)

[P2] timeout_minutes input dropped
- Upstream's 25-line runClaude wrapper drops the per-invocation timeout.
  Honor the published `timeout_minutes` input by racing the SDK call
  against a setTimeout-based deadline in run.ts.
  (src/entrypoints/run.ts)

[P2] execution_file lost on Claude failure
- runClaudeWithSdk writes the execution file at line 184 but then throws
  before returning on non-success results. Wrap the SDK call; on catch,
  capture the known EXECUTION_FILE path (`${RUNNER_TEMP}/claude-execution-output.json`)
  so update-comment-link and step-summary still see the debug log.
  (src/entrypoints/run.ts)

Verification:
- bun test: 538 pass / 0 fail
- tsc --noEmit: 0 errors
- Extract buildClaudeArgs helper in run.ts with regression test coverage
  (5 cases in test/build-claude-args.test.ts): verifies --mcp-config is
  embedded at head, shell single-quote escaping works, user claude_args
  append correctly, and both --mcp-config flags survive merge when user
  also passes one (parseSdkOptions merges them).
- Add "2026-Q2" section to MIGRATION.md documenting the unified
  entrypoint, new inputs (show_full_output, plugins, plugin_marketplaces,
  display_report), agent-mode trigger source (direct_prompt/override_prompt,
  not prompt), timeout_minutes via Promise.race, execution_file-on-failure
  behavior, CLAUDE_CODE_USE_* flag rename, and removal of the Mode
  abstraction.

Verification: 543 pass / 0 fail, tsc clean.
Codex review pass 2 caught three more regressions after the first round
of fixes. All addressed here.

[P1] Restore claude_env
- Upstream's SDK wrapper dropped claude_env support (they expect users to
  migrate to settings.env). Re-add it as a delta on our wrapper:
  ClaudeOptions.claudeEnv parses KEY: value YAML-lite format into
  process.env before parseSdkOptions seeds the SDK environment. Existing
  Gitea workflows that rely on claude_env for credentials / feature flags
  keep working without a migration.
  (base-action/src/run-claude.ts, src/entrypoints/run.ts)
- 3 new test cases: base-action/test/claude-env.test.ts.

[P1] Hard-kill on timeout_minutes
- The previous Promise.race only rejected the outer await; the SDK query
  generator kept running in the background. Replace with a plain setTimeout
  that calls core.setFailed + process.exit(124). Matches the v0.0.63
  subprocess semantics (exit code 124 is what `timeout(1)` returns on
  SIGTERM). Clear the handle on both success and failure paths.
  (src/entrypoints/run.ts)

[P2] Pass entity githubData into createAgentPrompt
- Regression: createAgentPrompt(undefined, context) for mode:agent runs on
  issue/PR events left $PR_NUMBER / $CHANGED_FILES / etc. placeholders in
  override_prompt templates unsubstituted. Fetch githubData first when
  isEntityContext, leave undefined for automation events.
  (src/modes/agent/index.ts)

Verification: 546 pass / 0 fail (+3), tsc clean.
Codex pass 3 flagged three items. Two are real regressions in published
input semantics; one was a false positive (verified against E2E logs).

[P1] Honor documented \`prompt\` input in agent mode
- action.yml declares \`prompt\` as a published alternate to
  \`direct_prompt\`/\`override_prompt\`, but the trigger check and
  createAgentPrompt only looked at the latter pair. Workflows that set
  only \`prompt:\` were silently skipped with "No trigger found".
- Promote \`prompt\` → \`directPrompt\` at the head of prepareAgentMode
  and extend the trigger check in run.ts to include it.
- (src/entrypoints/run.ts, src/modes/agent/index.ts)

[P2] Resolve baseBranch before prompt substitution on entity events
- For \`mode: agent\` runs on issue/issue_comment events with
  override_prompt, createAgentPrompt → prepareContext throws
  "BASE_BRANCH is required for issues event" when the user didn't pass
  an explicit \`base_branch\`. Tag mode sidesteps this via
  setupBranch; agent mode has no branching step, so default to
  context.repository.default_branch (or GITHUB_REF_NAME / "main" as
  progressive fallbacks) before calling createAgentPrompt.
- (src/modes/agent/index.ts)

[Non-issue] GITHUB_TOKEN output for update-comment-link
- Codex flagged this as missing, but \`setupGitHubToken\` already calls
  \`core.setOutput("GITHUB_TOKEN", token)\` in both providedToken and
  workflowToken branches. E2E run #1 confirmed update-comment-link
  fetches + PATCHes the tracking comment successfully with the token
  in scope. No code change required.

Verification: 546 pass / 0 fail, tsc clean.
…thor)

[P1] setupBranch for entity-triggered agent mode
- mode:agent on issue/PR events previously went through prepare.ts's
  entity path, which always called setupBranch. The refactor's
  prepareAgentMode only invented a baseBranch from repo metadata, so
  Claude's local git tools would commit/push from HEAD (or the base
  branch) instead of a proper claude-branch.
- Split prepareAgentMode by context type: entity events now fetch
  githubData then setupBranch (same as tag mode); automation events
  keep the metadata-derived branch (no entity to branch from).
- (src/modes/agent/index.ts)

[P2] Emit outputs from the timeout callback before process.exit
- process.exit(124) bypasses the surrounding try/finally, so workflows
  that hit timeout_minutes lost execution_file, claude_success,
  branch_name, and the step summary — exactly the debug trail the
  migration notes promised to keep. Inline the relevant core.setOutput
  calls in the timeout callback itself (the SDK writes the execution
  file synchronously on its way to the throw, so it's already on disk
  when the timeout fires).
- (src/entrypoints/run.ts)

[P2] Honor claude_git_name / claude_git_email in configureGitAuth
- ensureGitUserConfigured in local-git-ops-server is a NO-OP when
  git config user.name/email is already set, and the unified
  entrypoint now calls configureGitAuth up-front — so the
  claude_git_* action inputs were stranded. Add them as priority #3
  in configureGitAuth (after explicit user, after bot_id/bot_name,
  before the github-actions[bot] hardcoded fallback).
- (src/github/operations/git-config.ts)

Verification: 546 pass / 0 fail, tsc clean.
New input from the local-base-action switch (issue #1). Setting to
"false" lets the Gitea harness prove the env var plumbs end-to-end
without needing a real Anthropic token — run.ts's writeStepSummary
gate short-circuits on DISPLAY_REPORT=='false' before Claude is
invoked.
Codex pass 6 (fresh account) flagged three more regressions in failure /
error-handling paths. All addressed here.

[P2] Emit claude_comment_id at creation site
- prepareTagMode creates the tracking comment but the setOutput for
  claude_comment_id lived at the caller in run.ts, AFTER the full
  prep chain returned. Any failure after createInitialComment (fetch,
  setupBranch, git config, createPrompt) stranded the comment in its
  "Claude is working..." placeholder because update-comment-link was
  gated on the unemitted output.
- Move the emit into prepareTagMode immediately after
  createInitialComment returns. Drop the now-redundant emit in run.ts.
- (src/modes/tag/index.ts, src/entrypoints/run.ts)

[P2] Agent base branch mismatch for PRs on non-default targets
- For mode:agent on PR events, setupBranch resolves the PR's real base
  branch, but createAgentPrompt -> prepareContext reads
  context.inputs.baseBranch, which was pre-filled with the repo's
  default_branch. PRs targeting a non-default branch got the wrong
  $BASE_BRANCH substitution in override_prompt templates.
- After setupBranch, overwrite context.inputs.baseBranch with the
  resolved value so prompt substitution sees the real target.
- (src/modes/agent/index.ts)

[P2] Flush partial SDK messages on query() exception
- runClaudeWithSdk writes the execution file AFTER the for-await loop
  exits cleanly. Transport / runtime errors thrown from query()
  re-raise immediately, so ${RUNNER_TEMP}/claude-execution-output.json
  is never created. run.ts's catch block assumes the file exists and
  the step summary / update-comment-link lose the main debug artifact.
- Add a writeFile in the catch block that flushes accumulated
  messages[] before re-throwing. Symmetric with the success-path
  write below.
- (base-action/src/run-claude-sdk.ts)

Verification: 546 pass / 0 fail, tsc clean.
Published safety controls must fail fast on bad input, not silently
disable themselves. Pre-refactor both inputs were validated.

- timeout_minutes: non-empty values that don't parse to a positive
  integer throw immediately. 0 or NaN previously skipped the timer,
  letting misconfigured workflows run unbounded.
- max_turns: same shape. Non-empty and must parse to a positive
  integer. parseSdkOptions would otherwise treat 0/NaN as falsy and
  drop the turn cap.
- Empty string still means "use runner/SDK default" (unchanged).
…t (codex pass 8)

[P2] BASE_BRANCH/CLAUDE_BRANCH emission mirrors claude_comment_id pattern
- Tag mode now emits both outputs inside prepareTagMode right after
  setupBranch resolves them. Previously a failure in configureGitAuth or
  createPrompt after setupBranch succeeded would leave update-comment-link
  with an empty BASE_BRANCH, making it fall back to 'main' and produce
  wrong compare/cleanup links on repos whose real base is different.
- run.ts still handles the agent-mode path where outputs come from the
  returned branchInfo rather than from in-function setOutput calls.

[P3] Preserve display_report step summary on timeout
- process.exit(124) in the timeout callback bypassed the outer finally,
  so writeStepSummary never ran for timed-out executions — the debug
  trail users need most. Invoke writeStepSummary from the callback
  before process.exit, gated on DISPLAY_REPORT and the SDK having
  already flushed claude-execution-output.json.

Verification: 546 pass / 0 fail, tsc clean.
… pass 9)

[P2] Convert prepare-phase process.exit to throw
- setupGitHubToken, setupBranch, prepareMcpConfig, createPrompt all used
  core.setFailed + process.exit(1), which bypasses the unified run.ts
  catch/finally and suppresses prepare_success=false / prepare_error.
  update-comment-link then treated the run as successful and left the
  tracking comment in its "Claude is working..." placeholder.
- Re-throw instead so run.ts's outer handler publishes the right
  outputs before exit. Test expectation for the \"no tokens\" path in
  token.test.ts updated accordingly (rejects with gitea_token guidance;
  setFailed / exit NOT called).
- (src/github/token.ts, src/github/operations/branch.ts,
   src/mcp/install-mcp-server.ts, src/create-prompt/index.ts,
   test/token.test.ts)

[P2] Incremental flush of SDK execution log
- runClaudeWithSdk previously serialized claude-execution-output.json
  only after the async iterator naturally finished or threw. On
  timeout_minutes (our external setTimeout calls process.exit), that
  file didn't exist yet, so update-comment-link + display_report lost
  every turn of the partial run.
- Checkpoint after every message in the for-await loop. Small JSON
  blob per turn; cost negligible vs. SDK latency. Timeout callback
  now reliably finds a populated execution file on disk.
- (base-action/src/run-claude-sdk.ts)

Verification: 546 pass / 0 fail, tsc clean.
…failure

Observed on the pre-token E2E run (issue #1 in the harness): the
terminal comment showed 'Claude encountered an error — View job'
followed by the residual 'I'll analyze this and get back to you.'
line from the initial placeholder. Reads as a contradiction.

- common.ts now exports INITIAL_COMMENT_PLACEHOLDER so the prose
  has one canonical source.
- comment-logic.ts strips the placeholder in addition to the
  spinner line when rebuilding the terminal comment.
- Claude-side failures with no PREPARE_ERROR (e.g. when
  validateEnvironmentVariables throws inside base-action for a
  missing API key) now append 'See the job log for details.'
  so the comment never ships with an empty error body.
- +2 test cases in comment-logic.test.ts cover both paths.
…ttps

Gitea dev instances listen on HTTP only. configureGitAuth rewrote the
origin remote to https://<host>/... regardless of GITEA_SERVER_URL, so
the next 'git fetch' hit a TLS handshake error against an HTTP port.

Not user-visible before the unification because the external
claude-code-base-action@v0.0.63 didn't call restoreConfigFromBase.
The unified run.ts calls it on every PR-event run, which is how the
E2E 'review this PR' scenario (issue_comment on a PR) surfaced the
bug: 'gnutls_handshake() failed: An unexpected TLS packet'.

Fix: derive the protocol from the parsed GITEA_SERVER_URL instead of
hardcoding. Works unchanged for https://github.com (URL.protocol =
'https:') and enables http://gitea:3000 dev setups.

E2E repro chain:
  admin user opens issue -> '@claude create HELLO.md + PR'  -> success (run #5)
  admin comments on Claude's PR -> '@claude review'          -> failure (run #7, this bug)
  after fix (pending re-run)                                 -> expected success
Before this change, the tag-mode prompt rule "Never create new
comments. Only update the existing comment via
mcp__gitea__update_issue_comment" forbade Claude from using the
mcp__gitea__create_pull_request_review tool even though it was in
BASE_ALLOWED_TOOLS. Result: on PR reviews Claude bundled every
finding into one markdown wall in the tracking comment — observed
in the E2E buggy-PR run (PR #5 run anthropics#11 on the harness stack).

This commit carves out the one exception and adds an explicit
PR REVIEW PROTOCOL section:

1. Tracking comment holds the SUMMARY (what reviewed, verdict).
2. ONE call to mcp__gitea__create_pull_request_review filed with
   event=REQUEST_CHANGES (when bugs/regressions/security found) or
   COMMENT (non-blocking feedback); comments[] has ONE entry per
   concrete finding.
3. Each inline comment carries path + new_position or old_position
   + a concise problem + suggested fix (prefer \`\`\`suggestion\`\`\` blocks).
4. Inline findings do NOT duplicate into the tracking comment —
   the tracking comment keeps the narrative, inline comments hold
   the per-line feedback. Mirrors GitHub-native review UX.
5. Zero findings → skip the review call; tracking comment only.

Scope note: this emerged during E2E validation in the same session
as the unification work. The user observed current behavior
(single comment bundling all bugs) and requested Copilot-style
multi-comment output explicitly — folded in rather than deferred.

Verification: bun test 581/0, tsc clean. E2E re-run pending.
… new ones

Root cause of duplicate-findings + stale-run-link behavior observed
on PR #5 run anthropics#13: createInitialComment always POSTed a fresh
comment, leaving the prior 'Claude finished…' comment visible in
the thread. Claude's fetchGitHubData then pulled the old body into
context and copied it verbatim into the new tracking comment.

Fix (per codex exec): look up the most recent Claude-authored
tracking comment for the issue/PR and PATCH it back to the
'Claude Code is working…' placeholder. If none exists, POST as
before. Result:

- One stable tracking-comment id per issue/PR (GitHub check-run UX).
- No stale body for Claude to plagiarize — on a re-trigger it sees
  only the fresh placeholder.
- Prompt rule 'do not duplicate inline findings inside the tracking
  comment' stays, now defense-in-depth rather than sole guardrail.

Heuristic: isClaudeTrackingComment matches the stable header
strings we emit ('Claude Code is working', 'Claude finished',
'Claude encountered an error'). Safe pre-filter since those
strings are never produced by a human by accident.

PR review comments (replies to a specific code-line thread) keep
the old POST-a-reply path — each diff-line comment owns its own
thread and doesn't get reused.

Verification: 548 pass / 0 fail, tsc clean. E2E re-run pending.
The E2E harness creates two users whose names both start with "claude"
(claude + claude-bot). Readers reasonably ask why two bots are needed.
The answer is that only `claude` is the bot — claude-bot is a
non-admin PR author used by \`trigger push-pr\` to simulate a real
developer opening a PR against the test repo. The "-bot" suffix is a
historical leftover that misleads every new reader.

Changes:
- CLAUDE_BOT_USER / CLAUDE_BOT_PASSWORD → CONTRIBUTOR_USER /
  CONTRIBUTOR_PASSWORD (values "claude-bot" → "contributor",
  "claudebot123" → "contrib123").
- TOKEN_PATHS.claudeBot → TOKEN_PATHS.contributor. Token filename
  .bob-token (double-renamed residue: bob → claude-bot → contributor
  with the filename never updated) → .contributor-token.
- Updated all references in e2e.ts, workflow install log, README
  fixture table, and the trigger push-pr help.
- .gitignore updated.
- \`down\` now best-effort removes the legacy .bob-token file so users
  upgrading across the rename don't leave stale state.

No behavior change — same three non-admin users (claude, contributor,
bob) serving the same three distinct roles (bot identity, PR author,
non-write user for the allowed_non_write_users bypass test). Only
names moved.

Verification: bun test 548/0, tsc clean, fresh down+up cycle brings
the stack up with the renamed user.
Copilot AI review requested due to automatic review settings April 22, 2026 19:35
Three CI-reported failures, all fixed:

[ci / prettier] 6 files had Prettier-flagged formatting issues after
the PR-review + comment-reuse commits. Ran \`bun run format\` to
apply the project style.

[ci / typecheck] base-action/src/parse-sdk-options.ts imports
\`shell-quote\`, declared only in base-action/package.json. CI's
typecheck job runs \`bun install\` at the repo root + \`tsc --noEmit\`
— base-action's node_modules is never installed, so tsc cannot
resolve the module. Hoist \`shell-quote\` and \`@types/shell-quote\`
to the root package.json. base-action continues to declare them as
well for standalone use of that subdir.

[ci / test] test/install-mcp-server.test.ts > "returns base gitea
and local git MCP servers" failed in CI (passed locally). Root
cause: \`GITEA_API_URL\` in src/github/api/config.ts is a
module-level const frozen at first import. Tests mutate
process.env.GITEA_API_URL in beforeEach, but if the config module
was imported by a prior test file before the mutation, the const
is already baked. Local test ordering happened to work; CI
ordering did not. Fix: in install-mcp-server.ts, call a new
\`deriveApiUrlAtRuntime()\` helper that reads process.env at call
time (with the same default derivation as config.ts). No other
callers need changing — the test exercised this exact path.

Verification:
- bun run format:check: all clean
- tsc --noEmit: 0 errors
- bun test test/install-mcp-server.test.ts: 2/2 pass in isolation
- bun test (full): 548/0 pass

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR switches the action from an external pinned anthropics/claude-code-base-action@v0.0.63 invocation to a unified in-process src/entrypoints/run.ts orchestrator that imports and executes the local ./base-action/src/* code directly, while preserving the existing Gitea-oriented input surface and updating tests/migration notes accordingly.

Changes:

  • Replaces the old prepare + external uses: base-action flow with a single run.ts entrypoint and wires new base-action inputs (show_full_output, plugins, plugin_marketplaces, display_report) end-to-end.
  • Refactors “modes” from Mode objects/registry to prepareTagMode / prepareAgentMode async functions and removes the old registry/types + tests.
  • Improves tracking-comment behavior (reuse/reset) and comment body rewriting (strip placeholder + add fallback error pointer), adds regression tests for buildClaudeArgs and claude_env.

Reviewed changes

Copilot reviewed 34 out of 37 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
action.yml Switches execution to unified run.ts, installs base-action deps, and wires local-base-action inputs/outputs.
src/entrypoints/run.ts New unified orchestrator: prepares context, installs CLI, invokes SDK wrapper, handles outputs/step summary/timeout.
src/modes/tag/index.ts Implements tag-mode preparation as a function (comment/branch/prompt/MCP setup).
src/modes/agent/index.ts Implements agent-mode preparation as a function (prompt/tools/MCP setup).
src/github/operations/comments/create-initial.ts Reuses existing tracking comment where possible and resets it to “working” state.
src/github/operations/comment-logic.ts + tests Strips stale placeholder prose and adds fallback error pointer; adds tests.
base-action/src/run-claude.ts + tests Switches to SDK wrapper and preserves claude_env behavior via env injection; adds tests.
src/entrypoints/collect-inputs.ts Adds input-presence collection for base-action env propagation.
tsconfig.json, MIGRATION.md, e2e harness files Typecheck scope update, migration note, and e2e harness rename/wiring.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// Immediately edit the comment to the actual working status body
// Always PATCH the body to the fresh working state — covers both
// newly-created comments and reused prior ones.
await api.updateIssueComment(owner, repo, commentId, workingBody);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For pull_request_review_comment events, this function creates the tracking comment via the pulls/comments replies endpoint, but then always updates the body via api.updateIssueComment (issues/comments endpoint). PR review comment IDs typically aren’t valid issue-comment IDs, so this PATCH can fail and leave the tracking comment stuck at PREVIEW_PLACEHOLDER. Update the body using the pulls-comments PATCH endpoint when isPullRequestReviewCommentEvent(context) is true (similar to updateTrackingComment/update-comment-link).

Suggested change
await api.updateIssueComment(owner, repo, commentId, workingBody);
if (isPullRequestReviewCommentEvent(context)) {
await api.customRequest(
"PATCH",
`/repos/${owner}/${repo}/pulls/comments/${commentId}`,
{ body: workingBody },
);
} else {
await api.updateIssueComment(owner, repo, commentId, workingBody);
}

Copilot uses AI. Check for mistakes.
Comment thread src/modes/agent/index.ts Outdated
Comment on lines +128 to +132
currentBranch: baseBranch,
claudeBranch,
},
mcpConfig,
};

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepareAgentMode computes a currentBranch variable (and passes it into prepareMcpConfig), but the returned branchInfo.currentBranch is set to baseBranch. This makes the returned branchInfo internally inconsistent and can break downstream consumers that rely on currentBranch representing the checked-out branch (e.g., future logic or outputs). Return currentBranch here instead of baseBranch.

Copilot uses AI. Check for mistakes.
Comment thread src/entrypoints/collect-inputs.ts Outdated
Comment on lines +28 to +45
const allInputsJson = process.env.ALL_INPUTS;
if (!allInputsJson) {
console.log("ALL_INPUTS environment variable not found");
return JSON.stringify({});
}

let allInputs: Record<string, string>;
try {
allInputs = JSON.parse(allInputsJson);
} catch (e) {
console.error("Failed to parse ALL_INPUTS JSON:", e);
return JSON.stringify({});
}

const presentInputs: Record<string, boolean> = {};

for (const [name, defaultValue] of Object.entries(inputDefaults)) {
const actualValue = allInputs[name] || "";

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collectActionInputsPresence() depends on an ALL_INPUTS env var containing JSON, but this repo doesn’t set ALL_INPUTS anywhere (including action.yml). As a result this will always return {} in real runs, and INPUT_ACTION_INPUTS_PRESENT won’t reflect actual input presence. Either build the map from the existing INPUT_* env vars, or wire ALL_INPUTS to the run step in action.yml.

Suggested change
const allInputsJson = process.env.ALL_INPUTS;
if (!allInputsJson) {
console.log("ALL_INPUTS environment variable not found");
return JSON.stringify({});
}
let allInputs: Record<string, string>;
try {
allInputs = JSON.parse(allInputsJson);
} catch (e) {
console.error("Failed to parse ALL_INPUTS JSON:", e);
return JSON.stringify({});
}
const presentInputs: Record<string, boolean> = {};
for (const [name, defaultValue] of Object.entries(inputDefaults)) {
const actualValue = allInputs[name] || "";
const presentInputs: Record<string, boolean> = {};
for (const [name, defaultValue] of Object.entries(inputDefaults)) {
const envName = `INPUT_${name.replace(/-/g, "_").toUpperCase()}`;
const actualValue = process.env[envName] || "";

Copilot uses AI. Check for mistakes.
nllptrx added 2 commits April 22, 2026 21:46
[P1] Route PATCH via pulls/comments for PR-review-comment events
(src/github/operations/comments/create-initial.ts)

For pull_request_review_comment events the tracking comment is
created via POST /repos/{o}/{r}/pulls/{n}/comments/{id}/replies (own
ID namespace). The subsequent PATCH to update it to the working
body was always going through api.updateIssueComment (issues/comments
endpoint) regardless of event type. PR-review-comment IDs aren't
valid issue-comment IDs, so the PATCH would 404 and leave the
tracking comment stuck at PREVIEW_PLACEHOLDER.

Now routed through the pulls/comments PATCH endpoint for PR review
comment events; issue comments keep the existing path. Pre-existing
bug — my refactor preserved the old code structure; Copilot spotted
it on this PR's review.

[P2] Return resolved currentBranch from prepareAgentMode
(src/modes/agent/index.ts)

Returned branchInfo.currentBranch was set to baseBranch instead of
the `currentBranch` local I'd just computed. Copy-paste error in the
automation-events branch. Downstream consumers expecting currentBranch
to match the actually-checked-out branch were silently reading the
base.

[P2] Read input env vars directly in collectActionInputsPresence
(src/entrypoints/collect-inputs.ts)

The function expected a repo-wide ALL_INPUTS env var containing
JSON-encoded input values. That env var is never set anywhere —
not in action.yml, not in the workflow template. Return value was
always {} in production, which defeated the purpose of
INPUT_ACTION_INPUTS_PRESENT being plumbed to base-action.

GitHub Actions already exposes each declared input as
INPUT_<NAME_IN_UPPER_SNAKE_CASE>. Read those directly. No action.yml
changes needed.

Verification: bun test 548/0, tsc clean, prettier clean.
…tea)

The prior commit (e545b84) applied Copilot's suggestion to split
the tracking-comment PATCH by event type — issue_comment events
PATCH /issues/comments/{id}; pull_request_review_comment events
PATCH /pulls/comments/{id}. That pattern is correct for GitHub,
where PR review comments live in their own ID namespace.

Gitea behaves differently. Verified via deepwiki against go-gitea/gitea:

1. Gitea's REST API does NOT expose PATCH /repos/{o}/{r}/pulls/comments/{id}.
   The only routes on that path are POST .../resolve and POST .../unresolve.
2. Gitea's issues_model.Comment table stores BOTH issue comments and PR
   review comments (discriminated by CommentTypeCode), and the single
   PATCH /repos/{o}/{r}/issues/comments/{id} endpoint (handled by
   repo.EditIssueComment) edits either kind by id.

Keeping Copilot's suggestion would have made the new code 404 every
PR-review-comment trigger. Restore the unconditional updateIssueComment
call and leave an explicit comment explaining the Gitea deviation so a
future reader doesn't 're-fix' this.

The other two fixes from e545b84 (currentBranch copy-paste in
prepareAgentMode; ALL_INPUTS → INPUT_* direct read in collect-inputs.ts)
are real and stay.

Verification: bun test 548/0, tsc clean.
@nllptrx nllptrx merged commit 5b37ae0 into gitea Apr 22, 2026
6 of 37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Switch from anthropics/claude-code-base-action@v0.0.63 pin to local ./base-action/

2 participants