feat: unify to local base-action (closes #1)#2
Conversation
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.
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
There was a problem hiding this comment.
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 singlerun.tsentrypoint and wires new base-action inputs (show_full_output,plugins,plugin_marketplaces,display_report) end-to-end. - Refactors “modes” from
Modeobjects/registry toprepareTagMode/prepareAgentModeasync 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
buildClaudeArgsandclaude_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); |
There was a problem hiding this comment.
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).
| 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); | |
| } |
| currentBranch: baseBranch, | ||
| claudeBranch, | ||
| }, | ||
| mcpConfig, | ||
| }; |
There was a problem hiding this comment.
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.
| 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] || ""; |
There was a problem hiding this comment.
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.
| 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] || ""; |
[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.
Closes #1.
What this does
Replaces the former two-step wiring (
src/entrypoints/prepare.ts+ externaluses: anthropics/claude-code-base-action@v0.0.63) with a single in-process orchestrator atsrc/entrypoints/run.tsthat 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 thanuses: ./base-action(no composite-step boundary).claude_argsproduces the same invocation + regression tests (buildClaudeArgsin run.ts + 5 cases intest/build-claude-args.test.ts).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_reportnow live (previously stubbed env vars; now reach the SDK).-33deleted Mode-abstraction tests,+8new regression tests forbuildClaudeArgsandclaude_env).MIGRATION.md(2026-Q2section).Key architectural changes
Entrypoints.
run.tsis the unified orchestrator: parse context, setup token, check trigger, callprepareTagModeorprepareAgentMode, install Claude Code CLI, restore config on PR events, callrunClaude(SDK-based via./base-action/src/run-claude.ts), emit outputs, write step summary.prepare.tsis deleted.Modes. Converted from
Modeobject abstraction (tagMode/agentModesingletons) to free async functions (prepareTagMode/prepareAgentMode) that return{commentId, branchInfo, mcpConfig}. Deletessrc/modes/registry.tsandsrc/modes/types.ts. Matches upstream architecture; will simplify future upstream merges.Base-action. Adopted upstream's 25-line
run-claude.tswrapper (parseSdkOptions → runClaudeWithSdk) replacing the old ~300-line CLI subprocess path.ClaudeOptionsextended withclaudeEnv(upstream dropped it; we keep it as a Gitea-fork delta) which injects KEY: value pairs intoprocess.envbefore SDK invocation. Droppedbase-action/test/run-claude.test.ts(tested the removedprepareRunConfigCLI helper).Action.yml. Three steps reduced to two logical phases:
Install Bun+Install Dependencies+ unifiedRun Claude Code Action(run.ts) +Update comment with job link(cleanup,if: always()).Tsconfig.
include: ["src/**/*", "base-action/src/**/*", "test/**/*"]; excludes now limited tonode_modules. Full tree typechecks; no more hiddensrc/entrypoints/run.tsorbase-action/**/*exclusions.Gitea-specific adaptations preserved
mcp__gitea__*/mcp__local_git_ops__*MCP tool names (never fell through to upstream'smcp__github_*naming).GITEA_SERVER_URL-based noreply email derivation for bot commits (inlined intorun.ts'semitBotNoreplyOutputs, mirrorsconfigureGitAuth).client.api(GiteaApiClient) abstraction used throughout — no rawoctokit.restcalls, no GraphQL.validateBranchName+fix,bot_identityGitea noreply logic,create_pull_request_reviewMCP tool — all preserved.configureGitAuthparam type widened fromParsedGitHubContexttoGitHubContextso agent mode (automation events) can call it; also now usesserverUrl.protocolinstead of hardcodedhttps://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:
E2E validation
Gitea E2E harness (
scripts/e2e-gitea/) run against a fresh stack with a realCLAUDE_CODE_OAUTH_TOKEN:@claude reply PONGon an issuePONGexactly@claude create NOTES.md, commit, open PRon an issue@claude reviewon a PR with planted bugs (off-by-one, null-deref, SQL injection, wrong percent formula)suggestionblocks; tracking comment holds the summary and points to the inline reviewAdditional UX fixes surfaced by E2E
Committed in this same PR (same session) because the E2E run against the unified flow exposed them:
feat(prompt)): the tag-mode prompt now carves outmcp__gitea__create_pull_request_reviewfrom the "never create new comments" rule and adds an explicit protocol: summary in the tracking comment, onecreate_pull_request_reviewcall with one inline comment per finding. Prior to this the prompt banned the review tool even though it was inBASE_ALLOWED_TOOLS.fix(comment)):createInitialCommentnow 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).errorDetailsalso get a fallback "See the job log for details." pointer instead of an empty body.claude-botuser →contributor(plus.bob-token→.contributor-tokenon disk). The old name confused every reader about why "two claude users" existed. Only the bot isclaude;contributorsimulates a non-admin developer who opens PRs.Out of scope
classify_inline_commentsGitea-native rewrite — was originally listed as follow-up in Switch from anthropics/claude-code-base-action@v0.0.63 pin to local ./base-action/ #1, but the Copilot-style PR review protocol added here covers the practical UX need. True upstream parity remains a separate work item.Follow-up deferred