diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..5d86aba --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: grunt + steps: + - name: Checkout + uses: https://${{ secrets.READ_REPO_TOKEN }}@git.wxs.ro/xternal/checkout@v6.0.2 + with: + fetch-depth: 1 + + - name: Install Bun + run: | + if command -v bun &> /dev/null; then + echo "Bun already installed: $(bun --version)" + else + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" + fi + + - name: Install dependencies + run: bun install + + - name: Run tests + # Unit tests assume GitHub context. Override Forgejo runner's + # GITHUB_SERVER_URL so module-level constants (config.ts) and + # detectPlatform() default to GitHub. Forgejo-specific tests + # explicitly set PLATFORM=forgejo in their own beforeEach. + # Inline export because Forgejo runner re-injects GITHUB_SERVER_URL + # after step-level env:, overriding it. + run: | + export GITHUB_SERVER_URL=https://github.com + bun test + + prettier: + runs-on: grunt + steps: + - name: Checkout + uses: https://${{ secrets.READ_REPO_TOKEN }}@git.wxs.ro/xternal/checkout@v6.0.2 + with: + fetch-depth: 1 + + - name: Install Bun + run: | + if command -v bun &> /dev/null; then + echo "Bun already installed: $(bun --version)" + else + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" + fi + + - name: Install dependencies + run: bun install + + - name: Run prettier check + run: bun run format:check + + typecheck: + runs-on: grunt + steps: + - name: Checkout + uses: https://${{ secrets.READ_REPO_TOKEN }}@git.wxs.ro/xternal/checkout@v6.0.2 + with: + fetch-depth: 1 + + - name: Install Bun + run: | + if command -v bun &> /dev/null; then + echo "Bun already installed: $(bun --version)" + else + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" + fi + + - name: Install dependencies + run: bun install + + - name: Run TypeScript type check + run: bun run typecheck diff --git a/.forgejo/workflows/test-action.yml b/.forgejo/workflows/test-action.yml new file mode 100644 index 0000000..cd444d5 --- /dev/null +++ b/.forgejo/workflows/test-action.yml @@ -0,0 +1,80 @@ +name: Test Action (Forgejo) + +on: + schedule: + # Weekly Monday at 06:00 UTC + - cron: "0 6 * * 1" + pull_request: + branches: + - main + paths: + - "src/**" + - "action.yml" + - ".forgejo/**" + - "base-action/**" + workflow_dispatch: + +concurrency: + group: test-action-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-agent-mode: + name: Test Agent Mode on Forgejo + runs-on: grunt + steps: + - name: Checkout + uses: https://${{ secrets.READ_REPO_TOKEN }}@git.wxs.ro/xternal/checkout@v6.0.2 + with: + fetch-depth: 1 + + - name: Run Claude Code Action (agent mode) + id: claude + uses: ./ + with: + prompt: "Respond with exactly the text FORGEJO_PLATFORM_TEST_OK and nothing else. Do not use any tools." + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.FORGEJO_CLAUDE_TOKEN }} + platform: "forgejo" + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Verify execution output exists + run: | + EXEC_FILE="${{ steps.claude.outputs.execution_file }}" + if [ -z "$EXEC_FILE" ]; then + echo "FAIL: execution_file output is empty" + exit 1 + fi + if [ ! -f "$EXEC_FILE" ]; then + echo "FAIL: execution file does not exist at: $EXEC_FILE" + exit 1 + fi + echo "OK: execution file exists at: $EXEC_FILE" + + # Check that the output contains our test marker + if grep -q "FORGEJO_PLATFORM_TEST_OK" "$EXEC_FILE"; then + echo "OK: Output contains expected test marker" + else + echo "WARN: Test marker not found in output (Claude may have formatted differently)" + echo "--- Output contents (first 50 lines) ---" + head -50 "$EXEC_FILE" + fi + + - name: Debug on failure + if: failure() + run: | + echo "=== Debug Info ===" + echo "Platform env: PLATFORM=$PLATFORM FORGEJO=$FORGEJO" + echo "Server URL: $GITHUB_SERVER_URL" + echo "API URL: $GITHUB_API_URL" + echo "Event: ${{ github.event_name }}" + echo "Actor: ${{ github.actor }}" + echo "" + EXEC_FILE="${{ steps.claude.outputs.execution_file }}" + if [ -f "$EXEC_FILE" ]; then + echo "--- Execution file contents ---" + cat "$EXEC_FILE" + else + echo "No execution file found" + fi diff --git a/.forgejo/workflows/upstream-sync.yml b/.forgejo/workflows/upstream-sync.yml new file mode 100644 index 0000000..2015826 --- /dev/null +++ b/.forgejo/workflows/upstream-sync.yml @@ -0,0 +1,176 @@ +name: Upstream Sync Check + +on: + schedule: + # Weekly Monday at 08:00 UTC + - cron: "0 8 * * 1" + workflow_dispatch: + +jobs: + check-upstream: + name: Check for upstream updates + runs-on: grunt + steps: + - name: Checkout + uses: https://${{ secrets.READ_REPO_TOKEN }}@git.wxs.ro/xternal/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Configure upstream remote + run: | + if ! git remote get-url upstream &> /dev/null; then + git remote add upstream https://github.com/anthropics/claude-code-action.git + fi + + - name: Fetch upstream + run: git fetch upstream main --quiet + + - name: Compare with upstream + id: compare + run: | + BEHIND_COUNT=$(git rev-list main..upstream/main --count) + AHEAD_COUNT=$(git rev-list upstream/main..main --count) + + echo "behind=$BEHIND_COUNT" >> "$GITHUB_OUTPUT" + echo "ahead=$AHEAD_COUNT" >> "$GITHUB_OUTPUT" + + echo "Our main is $BEHIND_COUNT commits behind and $AHEAD_COUNT commits ahead of upstream" + + if [ "$BEHIND_COUNT" -gt 0 ]; then + echo "needs_sync=true" >> "$GITHUB_OUTPUT" + + # Generate diff summary + echo "--- New upstream commits ---" + git log main..upstream/main --oneline | head -30 + + echo "" + echo "--- Files changed ---" + DIFF_STAT=$(git diff --stat main...upstream/main) + echo "$DIFF_STAT" + + # Save for issue body (escape newlines for multiline output) + { + echo "diff_stat<> "$GITHUB_OUTPUT" + + { + echo "commit_log<> "$GITHUB_OUTPUT" + else + echo "needs_sync=false" >> "$GITHUB_OUTPUT" + echo "Already up to date with upstream" + fi + + - name: Check for existing sync issue + if: steps.compare.outputs.needs_sync == 'true' + id: existing + run: | + # Search for open issues with the upstream-sync label + RESPONSE=$(curl -s \ + -H "Authorization: token ${{ github.token }}" \ + -H "Content-Type: application/json" \ + "${GITHUB_API_URL}/repos/${{ github.repository }}/issues?state=open&labels=upstream-sync&limit=1") + + ISSUE_COUNT=$(echo "$RESPONSE" | jq 'length') + + if [ "$ISSUE_COUNT" -gt 0 ]; then + ISSUE_NUMBER=$(echo "$RESPONSE" | jq '.[0].number') + echo "issue_exists=true" >> "$GITHUB_OUTPUT" + echo "issue_number=$ISSUE_NUMBER" >> "$GITHUB_OUTPUT" + echo "Found existing sync issue #$ISSUE_NUMBER" + else + echo "issue_exists=false" >> "$GITHUB_OUTPUT" + echo "No existing sync issue found" + fi + + - name: Create upstream-sync label + if: steps.compare.outputs.needs_sync == 'true' + run: | + # Create label if it doesn't exist (ignore 409 conflict) + curl -s -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Authorization: token ${{ github.token }}" \ + -H "Content-Type: application/json" \ + -d '{"name":"upstream-sync","color":"#0075ca","description":"Upstream sync tracking"}' \ + "${GITHUB_API_URL}/repos/${{ github.repository }}/labels" || true + + - name: Create or update sync issue + if: steps.compare.outputs.needs_sync == 'true' + run: | + BEHIND="${{ steps.compare.outputs.behind }}" + AHEAD="${{ steps.compare.outputs.ahead }}" + COMMIT_LOG=$(cat <<'LOG_EOF' + ${{ steps.compare.outputs.commit_log }} + LOG_EOF + ) + DIFF_STAT=$(cat <<'DIFF_EOF' + ${{ steps.compare.outputs.diff_stat }} + DIFF_EOF + ) + + BODY=$(cat < `FORGEJO` env > `GITHUB_SERVER_URL` heuristic) +- **`src/forgejo/token.ts`** — Direct token handling (skips OIDC exchange) +- **`src/forgejo/data/fetcher.ts`** — REST-based data fetching (replaces 3 GraphQL queries with 5-8 parallel REST calls) +- **`src/forgejo/comment-logic.ts`** — Forgejo URL patterns for tracking comments + +All other `src/github/` modules work on Forgejo via Octokit REST as-is (permissions, comments, branches, git config, context, formatter). + +**Key integration points** (these files have platform branches and are merge-conflict surface): + +- `src/entrypoints/run.ts` — Platform branch at top for token setup +- `src/modes/tag/index.ts` — Data fetcher swap (`fetchForgejoData` vs `fetchGitHubData`) +- `src/github/operations/comments/create-initial.ts` — Platform-aware spinner/URL format +- `src/github/operations/comments/update-with-branch.ts` — Forgejo PR compare URL +- `src/github/validation/actor.ts` — Skipped on Forgejo (no bot accounts) +- `src/mcp/install-mcp-server.ts` — Skips GitHub public MCP server on Forgejo + +**MCP server names stay the same** (`github_comment`, `github_ci`, etc.) even on Forgejo — changing them would require updating hardcoded tool name lists in `src/modes/tag/index.ts`. + +**CI**: `.forgejo/workflows/` contains `ci.yml`, `test-action.yml`, and `upstream-sync.yml`. Unit tests run with `GITHUB_SERVER_URL=https://github.com` override on Forgejo runners. + ## Code Conventions - Runtime is Bun, not Node. Use `bun test`, not `jest`. diff --git a/README.md b/README.md index b8301f7..67a52f7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Claude Code Action -A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, Google Vertex AI, and Microsoft Foundry. +A general-purpose [Claude Code](https://claude.ai/code) action for GitHub and Forgejo PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, Google Vertex AI, and Microsoft Foundry. ## Features @@ -16,6 +16,7 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an - 📊 **Structured Outputs**: Get validated JSON results that automatically become GitHub Action outputs for complex automations - 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider) - ⚙️ **Simplified Configuration**: Unified `prompt` and `claude_args` inputs provide clean, powerful configuration aligned with Claude Code SDK +- 🔀 **Forgejo Support**: Runs on [Forgejo](https://forgejo.org/) with automatic platform detection — same action, same features, different git host ## 📦 Upgrading from v0.x? @@ -60,6 +61,7 @@ Each solution includes complete working examples, configuration details, and exp - [Cloud Providers](./docs/cloud-providers.md) - AWS Bedrock, Google Vertex AI, and Microsoft Foundry setup - [Capabilities & Limitations](./docs/capabilities-and-limitations.md) - What Claude can and cannot do - [Security](./docs/security.md) - Access control, permissions, and commit signing +- [Forgejo Setup](./docs/forgejo.md) - Running on Forgejo instances with platform detection and REST-only APIs - [FAQ](./docs/faq.md) - Common questions and troubleshooting ## 📚 FAQ diff --git a/action.yml b/action.yml index 34633cf..419bef3 100644 --- a/action.yml +++ b/action.yml @@ -138,6 +138,12 @@ inputs: required: false default: "" + # Platform configuration + platform: + description: "Platform to run on: 'auto' (default auto-detects), 'github', or 'forgejo'" + required: false + default: "auto" + outputs: execution_file: description: "Path to the Claude Code execution output file" @@ -158,12 +164,39 @@ outputs: runs: using: "composite" steps: + - name: Detect Platform + id: detect-platform + shell: bash + run: | + if [ "${{ inputs.platform }}" = "forgejo" ]; then + DETECTED="forgejo" + elif [ "${{ inputs.platform }}" = "github" ]; then + DETECTED="github" + elif [ "${FORGEJO}" = "true" ]; then + DETECTED="forgejo" + elif [ -n "${GITHUB_SERVER_URL}" ] && echo "${GITHUB_SERVER_URL}" | grep -qv "github.com"; then + DETECTED="forgejo" + else + DETECTED="github" + fi + echo "platform=${DETECTED}" >> "$GITHUB_OUTPUT" + echo "Detected platform: ${DETECTED}" + + # NOTE: upstream uses oven-sh/setup-bun here, but Forgejo runners + # pre-fetch all `uses:` refs even when `if:` would skip them, causing + # failures. The curl install works on both platforms and avoids the + # external action dependency. When merging upstream, keep this block. - name: Install Bun if: inputs.path_to_bun_executable == '' - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2 - with: - bun-version: 1.3.6 - token: ${{ inputs.github_token || github.token }} + shell: bash + run: | + if command -v bun &> /dev/null; then + echo "Bun is already installed: $(bun --version)" + else + echo "Installing Bun via curl..." + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" + fi - name: Setup Custom Bun Path if: inputs.path_to_bun_executable != '' @@ -188,6 +221,9 @@ runs: run: | bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts env: + # Platform detection + PLATFORM: ${{ steps.detect-platform.outputs.platform }} + # Prepare inputs MODE: ${{ inputs.mode }} PROMPT: ${{ inputs.prompt }} @@ -283,7 +319,7 @@ runs: bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts - name: Revoke app token - if: always() && inputs.github_token == '' && steps.run.outputs.skipped_due_to_workflow_validation_mismatch != 'true' + if: always() && inputs.github_token == '' && steps.run.outputs.skipped_due_to_workflow_validation_mismatch != 'true' && steps.detect-platform.outputs.platform != 'forgejo' shell: bash run: | curl -L \ diff --git a/docs/faq.md b/docs/faq.md index b491343..a78f149 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -251,6 +251,28 @@ For specialized environments like Nix, NixOS, or custom container setups where y - The action will skip automatic installation when custom paths are provided - Ensure the custom executables are available in your GitHub Actions environment +## Forgejo-Specific Questions + +### Can I use this action on Forgejo? + +Yes. Set `platform: "forgejo"` in your workflow and provide a `github_token` with repo access. See the [Forgejo Setup Guide](./forgejo.md) for complete instructions. + +### Why is GraphQL not used on Forgejo? + +Forgejo doesn't have a GraphQL API. The action uses parallel REST API calls instead, which provides the same data with slightly more HTTP requests. This is handled automatically — no configuration needed. + +### Why is actor validation skipped on Forgejo? + +Forgejo has no bot or app accounts, so the bot/app check that GitHub uses is unnecessary. Additionally, the `read:user` scope required for `getByUsername()` isn't available with standard Forgejo workflow tokens. + +### Why does `oven-sh/setup-bun` fail on my Forgejo runner? + +Forgejo runners pre-fetch all `uses:` action references before evaluating `if:` conditions. If `oven-sh/setup-bun` isn't mirrored to your Forgejo instance, the prefetch fails even when the step would be skipped. This fork replaces it with a universal `curl` install that works on both platforms. + +### How do I keep my Forgejo fork in sync with upstream? + +The included `upstream-sync.yml` workflow runs weekly and creates an issue when upstream has new commits. It includes merge instructions and lists the conflict-prone files. See [Merging Upstream Changes](./forgejo.md#merging-upstream-changes). + ## Best Practices 1. **Always specify permissions explicitly** in your workflow file diff --git a/docs/forgejo.md b/docs/forgejo.md new file mode 100644 index 0000000..9b771cf --- /dev/null +++ b/docs/forgejo.md @@ -0,0 +1,231 @@ +# Forgejo Platform Support + +This action supports [Forgejo](https://forgejo.org/) (and Forgejo Actions) as an alternative to GitHub. The same action code runs on both platforms — platform detection is automatic or explicitly configured. + +## Prerequisites + +- **Forgejo instance** with [Forgejo Actions](https://forgejo.org/docs/latest/user/actions/) enabled +- **Forgejo runner** registered and operational (e.g., a `grunt` or `ubuntu-latest` runner) +- **Anthropic API key** or **Claude Code OAuth token** for Claude API access +- **Forgejo token** with repo access (automatically available as `github.token` in workflows, or use a dedicated token) + +## Quick Start + +### 1. Add Secrets + +In your Forgejo repository, go to **Settings > Actions > Secrets** and add: + +- `ANTHROPIC_API_KEY` — Your Anthropic API key (starting with `sk-ant-`) + +If you need a dedicated token for Claude's GitHub operations (recommended for tag mode): + +- `FORGEJO_CLAUDE_TOKEN` — A Forgejo personal access token with `repo` scope + +### 2. Create Workflow + +Create `.forgejo/workflows/claude.yml`: + +```yaml +name: Claude Assistant +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned, labeled] + pull_request_review: + types: [submitted] + +jobs: + claude-response: + runs-on: grunt # Your Forgejo runner label + steps: + - uses: https://git.wxs.ro/actions/claude-code-action@v1 # Or your mirror URL + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + platform: "forgejo" + github_token: ${{ secrets.FORGEJO_CLAUDE_TOKEN }} +``` + +### 3. Agent Mode (Automation) + +For automation workflows triggered by `workflow_dispatch`, push events, or schedules: + +```yaml +name: Claude Automation +on: + workflow_dispatch: + inputs: + task: + description: "Task for Claude" + required: true + +jobs: + run-claude: + runs-on: grunt + steps: + - uses: https://git.wxs.ro/actions/claude-code-action@v1 + with: + prompt: ${{ github.event.inputs.task }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + platform: "forgejo" + github_token: ${{ secrets.FORGEJO_CLAUDE_TOKEN }} +``` + +## Platform Detection + +The action detects the platform using a three-tier strategy: + +| Priority | Method | Details | +| -------- | --------------------------------- | -------------------------------------------------------- | +| 1 | **Explicit input** | `platform: "forgejo"` in workflow — most reliable | +| 2 | **`FORGEJO` env var** | Set to `"true"` by Forgejo runner v7.0+ | +| 3 | **`GITHUB_SERVER_URL` heuristic** | If the URL doesn't contain `github.com`, assumes Forgejo | + +**Recommendation**: Always set `platform: "forgejo"` explicitly. Auto-detection works but the explicit input eliminates ambiguity. + +## Differences from GitHub + +### Authentication + +| Aspect | GitHub | Forgejo | +| ------------------------ | ------------------------------- | --------------------------------------- | +| **Default auth** | GitHub App OIDC token exchange | Direct `GITHUB_TOKEN` / `FORGEJO_TOKEN` | +| **Token setup** | Automatic via Claude GitHub App | Manual — provide `github_token` input | +| **App token revocation** | Automatic cleanup step | Skipped (no app token) | +| **Bot identity** | `claude[bot]` (ID: 41898282) | Token owner's identity | + +On Forgejo, there is no OIDC token exchange with Anthropic. The action uses the workflow token (`github.token`) or a user-provided `github_token` directly. + +### API Differences + +| Feature | GitHub | Forgejo | +| --------------------- | ------------------------------------------- | ------------------------------------------------------------------- | +| **GraphQL API** | Used for PR/issue data fetching | Not available — REST API only | +| **Data fetching** | Single GraphQL query per entity | 5-8 parallel REST API calls | +| **Actor validation** | Checks for bot/app accounts | Skipped — no bot accounts on Forgejo | +| **GitHub MCP server** | `ghcr.io/github/github-mcp-server` included | Skipped — GitHub-specific | +| **CI/Actions MCP** | Full GitHub Actions API | Works via Octokit REST + `GITHUB_API_URL` | +| **Permission check** | `getCollaboratorPermissionLevel()` | `repos.get()` fallback (workflow tokens lack collaborator endpoint) | + +### Bun Installation + +The upstream action uses `oven-sh/setup-bun`. On Forgejo, this is replaced with a universal `curl` install because Forgejo runners pre-fetch all `uses:` action references before evaluating `if:` conditions, causing failures for GitHub-hosted actions that aren't mirrored. + +### Comment URLs + +Comment tracking URLs (job links, branch links, PR creation links) use Forgejo URL patterns instead of GitHub patterns. This is handled automatically. + +## Action Inputs + +The `platform` input is the only Forgejo-specific addition: + +```yaml +platform: + description: "Platform to run on: 'auto' (default auto-detects), 'github', or 'forgejo'" + required: false + default: "auto" +``` + +All other inputs work identically on both platforms. See the [Usage Guide](./usage.md) for the full list. + +## Included CI Workflows + +This fork includes three Forgejo-native workflows in `.forgejo/workflows/`: + +### `ci.yml` — Tests, Formatting, Type Checking + +Runs on push to `main` and pull requests. Three parallel jobs: + +- `test` — `bun test` (with `GITHUB_SERVER_URL` override so GitHub-context unit tests pass) +- `prettier` — `bun run format:check` +- `typecheck` — `bun run typecheck` + +### `test-action.yml` — Action Self-Test + +Tests the action itself on Forgejo in agent mode. Runs weekly and on PRs that modify `src/`, `action.yml`, or `.forgejo/`. Validates: + +- Platform detection returns "forgejo" +- Token authentication works +- Claude Code executes successfully +- Output file is produced + +### `upstream-sync.yml` — Upstream Sync Check + +Runs weekly. Compares `main` with `upstream/main` (anthropics/claude-code-action). If upstream has new commits: + +- Creates or updates an issue with the `upstream-sync` label +- Includes commit log, diff stats, and merge instructions +- Lists conflict-prone files + +## Merging Upstream Changes + +This fork is designed for easy upstream merges. Only 4-5 files have Forgejo-specific modifications, and changes are placed at function entry points using an early-return pattern. + +### Merge Process + +```bash +# Fetch latest upstream +git fetch upstream main + +# Merge into your main branch +git merge upstream/main + +# If conflicts occur, they'll be in these files: +# - src/entrypoints/run.ts (platform branch at top) +# - src/modes/tag/index.ts (data fetcher swap) +# - src/mcp/install-mcp-server.ts (platform routing) +# - action.yml (platform input, bun install) + +# Resolve conflicts, keeping both the upstream changes and +# the Forgejo platform branches at function entry points + +# Run tests to verify +bun test + +# Push +git push origin main +``` + +### Conflict Mitigation + +All Forgejo changes follow the **early-return pattern** — platform-specific logic is placed at the top of functions, before the existing GitHub code. This means: + +- Upstream changes to the GitHub code path rarely conflict with Forgejo additions +- When conflicts do occur, they're localized to the few lines where the platform branch starts +- Resolution is straightforward: keep both the platform check and the upstream changes + +### What Happens When Upstream Adds New Features + +New upstream features that use GraphQL or GitHub-specific APIs won't automatically work on Forgejo. The Forgejo data fetcher (`src/forgejo/data/fetcher.ts`) would need REST equivalents added. The action will still run — features that depend on missing data will simply lack that context. + +## Known Limitations + +- **Tag mode (issue/PR triggers)**: Code is wired but only agent mode (workflow_dispatch) has been E2E tested on Forgejo. Tag mode should work but hasn't been validated with real issue/PR webhooks yet. +- **`pull_request_target` event**: May not exist in Forgejo. The action degrades gracefully (maps to `pull_request`). +- **GitHub MCP server**: The `ghcr.io/github/github-mcp-server` Docker image is GitHub-specific and is skipped on Forgejo. +- **Commit signing**: SSH signing has not been tested on Forgejo. The `use_commit_signing` option (GitHub API signing) won't work since it uses GitHub's commit API. +- **`downloadJobLogForWorkflowRun`**: Forgejo's Actions API may lack this endpoint. The CI MCP server attempts it via Octokit REST but may return limited data. +- **Bot filtering**: `allowed_bots` checks won't match `claude[bot]` since Forgejo has no bot accounts. Actor validation is skipped entirely on Forgejo. + +## Troubleshooting + +### "Resource not accessible by integration" errors + +This usually means the token lacks permissions. On Forgejo: + +- Use a personal access token with `repo` scope via the `github_token` input +- The default `github.token` in Forgejo workflows may have limited permissions depending on your runner configuration + +### Tests fail with platform detection errors + +Unit tests assume GitHub context by default. When running on a Forgejo runner, set `GITHUB_SERVER_URL=https://github.com` before running tests (the CI workflow handles this automatically). + +### Bun installation fails + +If `curl -fsSL https://bun.sh/install | bash` fails on your runner, pre-install Bun on the runner image or use the `path_to_bun_executable` input to point to a custom Bun binary. + +### Upstream workflows run and fail + +If `.github/workflows/` from upstream are executing on your Forgejo runner, configure the runner to only read from `.forgejo/workflows/`. This is a runner-level setting — see your Forgejo instance's runner configuration. diff --git a/docs/usage.md b/docs/usage.md index 3e55a3d..cc8ec24 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -81,6 +81,7 @@ jobs: | `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" | | `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" | | `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" | +| `platform` | Platform to run on: `auto` (default, auto-detects), `github`, or `forgejo`. See [Forgejo Setup](./forgejo.md) | No | `auto` | ### Deprecated Inputs diff --git a/prds/1-forgejo-platform-support.md b/prds/1-forgejo-platform-support.md deleted file mode 100644 index 0cb050d..0000000 --- a/prds/1-forgejo-platform-support.md +++ /dev/null @@ -1,335 +0,0 @@ -# PRD #1: Forgejo Platform Support with Upstream-Compatible Architecture - -**Issue**: [#1](https://git.wxs.ro/actions/claude-code-action/issues/1) -**Priority**: High -**Created**: 2026-02-25 -**Revised**: 2026-02-25 (post senior engineer review) - ---- - -## Problem - -The `claude-code-action` is tightly coupled to GitHub APIs (Octokit REST, GraphQL, `@actions/github`, OIDC token exchange). This makes it unusable with Forgejo Actions, despite Forgejo being workflow-compatible with GitHub Actions. We need Forgejo support while keeping upstream merges as painless as possible. - -## Solution - -Adopt a **lean adapter approach**: only create new files for functionality that is genuinely different on Forgejo, and **re-use existing `src/github/` code wherever Octokit REST is already compatible** with Forgejo's API. Make minimal, surgical modifications to entry points and `action.yml`. - -### Key Insight: Octokit REST Works with Forgejo - -Octokit REST methods (comments, permissions, branches, actor validation) work with Forgejo when `baseUrl` is set to the Forgejo instance's `/api/v1` endpoint. Forgejo mirrors GitHub's REST API paths. This means most of `src/github/` can be re-used directly — **we do NOT need to duplicate 20+ files**. - -### What Truly Needs Forgejo-Specific Code - -Only **4 areas** require new Forgejo implementations: - -| Area | Why It's Different | New File | -|------|--------------------|----------| -| **Token handling** | No OIDC exchange with Anthropic; use `GITHUB_TOKEN`/`FORGEJO_TOKEN` directly | `src/forgejo/token.ts` | -| **Data fetching** | Forgejo has no GraphQL; must replace 3 GraphQL queries with multiple REST calls | `src/forgejo/data/fetcher.ts` | -| **Comment URL formatting** | Forgejo URLs differ from GitHub URLs (job links, PR links, branch links) | `src/forgejo/comment-logic.ts` | -| **CI status / Actions API** | Forgejo Actions API is less mature; `downloadJobLogForWorkflowRun` may not exist | `src/mcp/forgejo-actions-server.ts` | - -### What Re-Uses Existing Code (No Duplication) - -These `src/github/` modules work with Forgejo via Octokit REST as-is: - -| Module | Why It Works | -|--------|-------------| -| `validation/permissions.ts` | `octokit.repos.getCollaboratorPermissionLevel()` exists in Forgejo API | -| `validation/actor.ts` | `octokit.users.getByUsername()` works on Forgejo | -| `validation/trigger.ts` | Pure logic — reads webhook payload fields, no API calls | -| `operations/comments/create-initial.ts` | `octokit.rest.issues.createComment()` works on Forgejo | -| `operations/comments/update-claude-comment.ts` | `octokit.rest.issues.updateComment()` works on Forgejo | -| `operations/branch.ts` | `octokit.rest.git.getRef()`, `octokit.rest.repos.get()` work on Forgejo | -| `operations/git-config.ts` | Uses `GITHUB_SERVER_URL` for noreply domain — works since Forgejo sets this var | -| `utils/sanitizer.ts` | Pure logic, no API dependency | -| `utils/image-downloader.ts` | Uses Octokit REST for fetching — should work | -| `context.ts` | `@actions/github` reads `GITHUB_EVENT_PATH` — Forgejo sets this | -| `data/formatter.ts` | Pure formatting logic | -| `api/config.ts` | Reads `GITHUB_API_URL` / `GITHUB_SERVER_URL` — Forgejo sets these | - -### Architecture: Lean Adapter - -``` -src/ -├── platform/ -│ └── detector.ts # NEW - Platform detection (+ action.yml `platform` input) -├── forgejo/ -│ ├── token.ts # NEW - Direct token (skip OIDC) -│ ├── data/ -│ │ └── fetcher.ts # NEW - REST-based data fetching (replaces GraphQL) -│ └── comment-logic.ts # NEW - Forgejo URL formatting for comments -├── mcp/ -│ ├── forgejo-actions-server.ts # NEW - Forgejo CI status MCP server -│ └── install-mcp-server.ts # MODIFIED - platform routing for MCP config -├── github/ # REUSED - existing code, no modifications -│ └── ... -├── entrypoints/ -│ └── run.ts # MODIFIED - platform branch for token setup -├── modes/ -│ ├── tag/index.ts # MODIFIED - swap data fetcher + comment logic -│ └── agent/index.ts # MODIFIED - swap token setup -action.yml # MODIFIED - platform input, conditional steps -``` - -**~8 new/modified files** instead of ~25. The existing `src/github/` code is re-used, not duplicated. - -### Why This Approach - -| Approach | Upstream Merge Effort | Maintenance | Chosen? | -|----------|----------------------|-------------|---------| -| **Lean adapter (reuse + minimal new files)** | Low (3-4 conflict-prone files) | Low | **Yes** | -| Full abstraction layer (20+ new files) | Near-zero conflicts but high maintenance syncing duplicated code | High | No | -| Modify GitHub files in-place | High conflict rate on every upstream pull | Low | No | -| Complete fork with divergence | No merges possible after a while | Very high | No | - -### Honest Merge-Conflict Assessment - -These files **will be modified** and are the merge-conflict surface when pulling upstream: - -| File | What We Change | Conflict Risk | -|------|---------------|---------------| -| `src/entrypoints/run.ts` | Platform branch at top for token/client setup | **Medium** - upstream's most active file | -| `src/modes/tag/index.ts` | Swap `fetchGitHubData` call + comment logic based on platform | **Medium** | -| `src/modes/agent/index.ts` | Swap token setup based on platform | **Low** - simpler file | -| `src/mcp/install-mcp-server.ts` | Platform routing for MCP server selection | **Low-Medium** | -| `action.yml` | Add `platform` input, conditional Forgejo steps | **Low** - additive changes | - -**Mitigation strategy**: Keep all platform changes at the **top** of functions (early return / branch pattern). Avoid restructuring existing code flow. When upstream modifies these files, conflicts will be localized to our small additions, not interleaved throughout. - -## Technical Analysis - -### Platform Detection Strategy - -Use an **explicit `platform` input** (most reliable) with env-var auto-detection as fallback: - -```typescript -// src/platform/detector.ts -export function detectPlatform(): "github" | "forgejo" { - // 1. Explicit input (most reliable) - const platformInput = process.env.PLATFORM; - if (platformInput === "forgejo") return "forgejo"; - if (platformInput === "github") return "github"; - - // 2. Auto-detect from environment - // FORGEJO is set by Forgejo runner v7.0+ (not in older runners) - if (process.env.FORGEJO === "true") return "forgejo"; - - // Check GITHUB_SERVER_URL — if not github.com, likely Forgejo/Gitea - const serverUrl = process.env.GITHUB_SERVER_URL || ""; - if (serverUrl && !serverUrl.includes("github.com")) { - return "forgejo"; - } - - return "github"; -} -``` - -New `action.yml` input: -```yaml -platform: - description: "Platform to run on: 'auto' (default), 'github', or 'forgejo'" - required: false - default: "auto" -``` - -### Key Technical Differences - -1. **No GraphQL**: Forgejo is REST-only. The 3 GraphQL queries in `src/github/api/queries/github.ts` (PR_QUERY, ISSUE_QUERY, USER_QUERY) must be replaced with **5-8 separate paginated REST calls**: - - `GET /repos/{owner}/{repo}/pulls/{index}` - PR details - - `GET /repos/{owner}/{repo}/pulls/{index}/files` - changed files (paginated) - - `GET /repos/{owner}/{repo}/pulls/{index}/commits` - commits (paginated) - - `GET /repos/{owner}/{repo}/issues/{index}/comments` - issue-level comments (paginated) - - `GET /repos/{owner}/{repo}/pulls/{index}/reviews` - reviews (paginated) - - For each review: `GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments` - - `GET /repos/{owner}/{repo}/issues/{index}` - issue details - - Use `Promise.all()` to run independent calls in parallel and mitigate latency. - -2. **API base URL**: Forgejo uses `/api/v1` (vs GitHub's `/api/v3`). Octokit REST methods work when `baseUrl` is set correctly since Forgejo mirrors GitHub's endpoint paths. - -3. **Token handling**: No OIDC token exchange with Anthropic. The existing `OVERRIDE_GITHUB_TOKEN` code path in `token.ts` (line 130-135) already short-circuits the OIDC flow — for Forgejo we just need to use `GITHUB_TOKEN` or `FORGEJO_TOKEN` directly. - -4. **Bot identity**: No GitHub App bot. The action runs as the token owner. Default `bot_id` (41898282) is meaningless on Forgejo — need sensible defaults. - -5. **`pull_request_target` event**: May not exist in Forgejo. The existing `context.ts` maps it to `pull_request` (line 188), so it degrades gracefully. - -6. **GitHub MCP Server**: The `ghcr.io/github/github-mcp-server` Docker image referenced in `install-mcp-server.ts` (line 214) won't work. Skip on Forgejo — it's only included when `hasGitHubMcpTools` is true. - -7. **Forgejo Actions API gaps**: `actions.downloadJobLogForWorkflowRun()` used in the CI MCP server may not exist. Need to verify and implement alternative (direct API call or skip). - -### `action.yml` Required Modifications - -| Line(s) | Current | Problem | Fix | -|----------|---------|---------|-----| -| 103 | `default: "41898282"` (bot_id) | Meaningless on Forgejo | Make platform-aware; use token owner identity on Forgejo | -| 107 | `default: "claude[bot]"` (bot_name) | Doesn't exist on Forgejo | Same — detect from token or use configurable default | -| 163-166 | `uses: oven-sh/setup-bun@...` | References GitHub release; may fail on isolated Forgejo runners | Add conditional: if bun already available, skip; otherwise use direct curl install | -| 166 | `token: ${{ inputs.github_token \|\| github.token }}` | Used to fetch bun release from GitHub | On Forgejo, `github.token` is the Forgejo token — may not auth to GitHub; provide fallback | -| 285-294 | "Revoke app token" step | Calls `${GITHUB_API_URL}/installation/token` which doesn't exist on Forgejo | Make conditional: `if: platform != 'forgejo'` or detect and skip | -| N/A | Missing | No `platform` input | Add `platform` input (auto/github/forgejo) | - -### `base-action/` Directory - -`run.ts` imports directly from `base-action/src/`: -- `validate-env.ts` - checks env vars for Claude CLI (platform-agnostic) -- `setup-claude-code-settings.ts` - writes Claude settings (platform-agnostic) -- `install-plugins.ts` - installs Claude plugins (platform-agnostic) -- `prepare-prompt.ts` - prepares prompt file (platform-agnostic) -- `run-claude.ts` / `run-claude-sdk.ts` - runs Claude CLI (platform-agnostic) - -**Conclusion**: `base-action/` is platform-independent. No modifications needed. - -### MCP Server Naming Strategy - -**Keep existing server names** (`github_comment`, `github_ci`, `github_file_ops`, `github_inline_comment`) even for Forgejo implementations. These are internal identifiers referenced in hardcoded tool name lists in `src/modes/tag/index.ts` (lines 127-152): - -```typescript -"mcp__github_comment__update_claude_comment", -"mcp__github_ci__get_ci_status", -"mcp__github_file_ops__commit_files", -``` - -Renaming would require modifying the tool name lists — more merge-conflict surface for zero user benefit. The MCP server config in `install-mcp-server.ts` will point these names to either GitHub or Forgejo server implementations based on platform. - -## CI/CD Workflows - -### Handling Upstream `.github/workflows/` - -The upstream repo has 13 workflows in `.github/workflows/`. Strategy: - -- **Do NOT delete or modify** `.github/workflows/` files (merge conflicts) -- Create `.forgejo/workflows/` with our custom workflows -- **Configure Forgejo runner** to only read from `.forgejo/workflows/` (runner-level `DEFAULT_ACTIONS_URL` and workflow directory settings) -- **Document this as a setup requirement** — if runners aren't configured, upstream workflows will attempt to run and fail loudly (missing secrets, OIDC endpoints, etc.) - -### Custom Forgejo Workflows - -#### 1. CI Test Workflow (`.forgejo/workflows/ci.yml`) -- Triggers on push to main and PRs -- Runs `bun install` and `bun test` -- Lints with biome (matching upstream's toolchain) - -#### 2. Action Self-Test Workflow (`.forgejo/workflows/test-action.yml`) -- **Tests the action itself** on Forgejo -- Creates a test issue via API, triggers the action with a simple prompt -- Verifies: platform detection returns "forgejo", comment is created on the issue, action completes successfully -- Validates end-to-end: token handling, Octokit REST calls, comment posting -- Runs on a schedule (weekly) and on PRs that modify `src/`, `action.yml`, or `.forgejo/` - -#### 3. Upstream Sync Check (`.forgejo/workflows/upstream-sync.yml`) -- Scheduled workflow (weekly) + manual trigger -- Fetches `upstream/main`, compares with our `main` using `git rev-list` -- Creates an issue if upstream has new commits we haven't merged -- Includes commit count and summary of changed files to help assess merge effort - -## Milestones - -### Phase 1 - Foundation (parallel, no dependencies) - -- [ ] **M1: Platform detection + `action.yml` modifications** — Add `platform` input to `action.yml`. Create `src/platform/detector.ts` with explicit input + env-var auto-detection. Add conditional logic to skip "Revoke app token" step on Forgejo. Fix bun installation to handle Forgejo runners (direct curl fallback). - - Files: `action.yml` (MODIFIED), `src/platform/detector.ts` (NEW) - - Success: `detectPlatform()` returns "forgejo" in Forgejo runner, "github" in GitHub runner. `action.yml` doesn't error on Forgejo. - -- [ ] **M2: Forgejo token handling** — Create `src/forgejo/token.ts` that returns `GITHUB_TOKEN` / `FORGEJO_TOKEN` directly (no OIDC, no Anthropic exchange). Wire into `run.ts` with an early platform branch before the existing `setupGitHubToken()` call. - - Files: `src/forgejo/token.ts` (NEW), `src/entrypoints/run.ts` (MODIFIED — surgical addition at top of `run()`) - - Depends on: M1 - - Success: Token is available without OIDC on Forgejo. GitHub path unchanged. - -- [ ] **M3: Forgejo CI workflows** — Create `.forgejo/workflows/` with CI, self-test, and upstream sync workflows. Document runner configuration requirements. - - Files: `.forgejo/workflows/ci.yml`, `.forgejo/workflows/test-action.yml`, `.forgejo/workflows/upstream-sync.yml` (all NEW) - - Success: CI runs tests on push. Upstream `.github/workflows/` don't interfere. - -### Phase 2 - Core Data Fetching (the hard part) - -- [ ] **M4a: Forgejo REST data fetching — PR/Issue basics** — Implement REST-based fetching for PR details, issue details, changed files, and commits. Produce output matching the `FetchDataResult` type that existing code expects. - - Files: `src/forgejo/data/fetcher.ts` (NEW) - - Depends on: M1, M2 - - Success: Can fetch PR/issue title, body, author, state, files, commits from Forgejo REST API. - -- [ ] **M4b: Forgejo REST data fetching — Comments, reviews, inline comments** — Extend the fetcher with paginated comment/review fetching, including nested inline review comments (requires per-review API calls). Implement TOCTOU filtering using the same `filterCommentsToTriggerTime` / `filterReviewsToTriggerTime` functions (reuse from `src/github/data/fetcher.ts` — these are pure logic). Use `Promise.all()` for parallel API calls. - - Files: `src/forgejo/data/fetcher.ts` (extend from M4a) - - Depends on: M4a - - Success: Full parity with GitHub GraphQL data fetch. All comments, reviews, and inline comments included with proper TOCTOU filtering. - -- [ ] **M5: Forgejo comment URL formatting** — Create `src/forgejo/comment-logic.ts` with Forgejo-specific URL patterns for job links, branch links, and PR creation links (used in tracking comment updates). - - Files: `src/forgejo/comment-logic.ts` (NEW) - - Depends on: M1 - - Success: Comment formatting produces correct Forgejo URLs. - -### Phase 3 - Integration (wiring it together) - -- [ ] **M6: MCP server platform routing** — Modify `install-mcp-server.ts` to select GitHub or Forgejo server implementations based on platform. Create `src/mcp/forgejo-actions-server.ts` for CI status (may be a subset of GitHub's if some Actions API endpoints don't exist in Forgejo). Keep MCP server names identical (`github_comment`, `github_ci`, etc.) to avoid touching tool name lists. For comment/file-ops/inline-comment servers, the existing GitHub implementations likely work via Octokit REST — verify and use directly. - - Files: `src/mcp/install-mcp-server.ts` (MODIFIED), `src/mcp/forgejo-actions-server.ts` (NEW, possibly) - - Depends on: M4b, M5 - - Success: Claude can update comments, query CI status on Forgejo. MCP tools work end-to-end. - -- [ ] **M7: Entry point + mode handler integration** — Wire platform detection into mode handlers. In `tag/index.ts`: swap `fetchGitHubData` for Forgejo fetcher + comment logic. In `agent/index.ts`: swap token setup. Keep changes surgical — platform branch at the top of functions, existing flow untouched. - - Files: `src/modes/tag/index.ts` (MODIFIED), `src/modes/agent/index.ts` (MODIFIED) - - Depends on: M4b, M5, M6 - - Success: Action runs end-to-end on Forgejo for both tag and agent modes. - -### Phase 4 - Validation & Polish - -- [ ] **M8: End-to-end testing on Forgejo** — Test complete workflows: issue trigger (@claude mention), PR review, agent mode (workflow_dispatch with prompt), tag mode. Verify comment updates, branch creation, file commits, CI status queries. - - Depends on: M7 - - Success: All supported trigger types work on Forgejo. Self-test workflow passes. - -- [ ] **M9: Documentation & upstream merge validation** — Document Forgejo-specific setup (runner configuration, token setup, `platform: forgejo` input). Create `CLAUDE.md` for the project. Perform a fresh `git fetch upstream && git merge upstream/main` to verify conflicts are minimal and easily resolvable. Document the merge process. - - Depends on: M8 - - Success: Clean or trivially-resolvable upstream merge. Clear setup docs. - -### Milestone Dependency Graph - -``` -M1 ──> M2 ──> M4a ──> M4b ──┬──> M6 ──> M7 ──> M8 ──> M9 -M1 ──> M5 ───────────────────┘ -M3 (independent, anytime) -``` - -**Parallel opportunities:** -- M3 is fully independent — can run anytime -- M5 and M2 can run in parallel (both only depend on M1) -- M4a can start as soon as M2 is done -- M6 and M7 are sequential (M7 depends on M6) - -## Execution Plan - -| Phase | Milestones | Files Touched | Parallel? | -|-------|-----------|---------------|-----------| -| 1 | M1, M2, M3 | `action.yml`, `src/platform/detector.ts`, `src/forgejo/token.ts`, `src/entrypoints/run.ts`, `.forgejo/workflows/*` | M1 first, then M2+M3 in parallel | -| 2 | M4a, M4b, M5 | `src/forgejo/data/fetcher.ts`, `src/forgejo/comment-logic.ts` | M5 parallel with M4a/M4b | -| 3 | M6, M7 | `src/mcp/install-mcp-server.ts`, `src/mcp/forgejo-actions-server.ts`, `src/modes/tag/index.ts`, `src/modes/agent/index.ts` | Sequential | -| 4 | M8, M9 | Tests, docs, `CLAUDE.md` | Sequential | - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Upstream refactors `run.ts` or mode handlers | Medium | High | Keep entry point changes to <10 lines; use early-return pattern; platform branch at top of functions | -| Octokit REST methods behave differently on Forgejo | Low | Medium | Test each used method early in M4a; fall back to raw `fetch()` if needed | -| Forgejo webhook payloads differ from `@octokit/webhooks-types` | Medium | Medium | Test with real Forgejo webhooks; key check: `payload.issue.pull_request` field for `is_pull` detection | -| `pull_request_target` event missing in Forgejo | High | Low | Already handled — `context.ts:188` maps it to `pull_request` | -| Forgejo Actions API missing endpoints (downloadJobLog) | Medium | Medium | Degrade gracefully — CI MCP server may offer fewer tools on Forgejo | -| `oven-sh/setup-bun` action fails on isolated Forgejo runners | Medium | Medium | Add direct curl bun install as fallback in `action.yml` | -| Upstream `.github/workflows/` run and fail on Forgejo | Medium | Low | Document runner configuration; provide `.forgejo/workflows/` overrides | -| Upstream adds new features requiring new GraphQL fields | Certain | Low | New features won't have Forgejo support until we add REST equivalents to `forgejo/data/fetcher.ts` | - -## Success Criteria - -1. Action runs on Forgejo for issue, PR, and workflow_dispatch triggers -2. Claude can read issue/PR context, post comments, create branches, and commit files on Forgejo -3. `git fetch upstream && git merge upstream/main` produces conflicts only in the 3-4 files we modified, and those conflicts are resolvable in <15 minutes -4. Self-test workflow validates the action works on every PR -5. Upstream sync workflow alerts when new upstream changes are available -6. Total new files: ~6-8 (not 25+) - -## Out of Scope (for now) - -- GitHub Enterprise Server support -- Gitea support (similar to Forgejo but may diverge) -- `pull_request_target` event support -- SSH signing on Forgejo (can be added later) -- GitHub MCP Server equivalent for Forgejo (the `ghcr.io/github/github-mcp-server` image) -- Full Forgejo Actions API parity (downloadJobLog may be missing) diff --git a/prds/done/1-forgejo-platform-support.md b/prds/done/1-forgejo-platform-support.md new file mode 100644 index 0000000..8484db9 --- /dev/null +++ b/prds/done/1-forgejo-platform-support.md @@ -0,0 +1,353 @@ +# PRD #1: Forgejo Platform Support with Upstream-Compatible Architecture + +**Issue**: [#1](https://git.wxs.ro/actions/claude-code-action/issues/1) +**Priority**: High +**Created**: 2026-02-25 +**Revised**: 2026-02-25 (post senior engineer review) + +--- + +## Problem + +The `claude-code-action` is tightly coupled to GitHub APIs (Octokit REST, GraphQL, `@actions/github`, OIDC token exchange). This makes it unusable with Forgejo Actions, despite Forgejo being workflow-compatible with GitHub Actions. We need Forgejo support while keeping upstream merges as painless as possible. + +## Solution + +Adopt a **lean adapter approach**: only create new files for functionality that is genuinely different on Forgejo, and **re-use existing `src/github/` code wherever Octokit REST is already compatible** with Forgejo's API. Make minimal, surgical modifications to entry points and `action.yml`. + +### Key Insight: Octokit REST Works with Forgejo + +Octokit REST methods (comments, permissions, branches, actor validation) work with Forgejo when `baseUrl` is set to the Forgejo instance's `/api/v1` endpoint. Forgejo mirrors GitHub's REST API paths. This means most of `src/github/` can be re-used directly — **we do NOT need to duplicate 20+ files**. + +### What Truly Needs Forgejo-Specific Code + +Only **4 areas** require new Forgejo implementations: + +| Area | Why It's Different | New File | +| --------------------------- | -------------------------------------------------------------------------------- | ----------------------------------- | +| **Token handling** | No OIDC exchange with Anthropic; use `GITHUB_TOKEN`/`FORGEJO_TOKEN` directly | `src/forgejo/token.ts` | +| **Data fetching** | Forgejo has no GraphQL; must replace 3 GraphQL queries with multiple REST calls | `src/forgejo/data/fetcher.ts` | +| **Comment URL formatting** | Forgejo URLs differ from GitHub URLs (job links, PR links, branch links) | `src/forgejo/comment-logic.ts` | +| **CI status / Actions API** | Forgejo Actions API is less mature; `downloadJobLogForWorkflowRun` may not exist | `src/mcp/forgejo-actions-server.ts` | + +### What Re-Uses Existing Code (No Duplication) + +These `src/github/` modules work with Forgejo via Octokit REST as-is: + +| Module | Why It Works | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `validation/permissions.ts` | `octokit.repos.getCollaboratorPermissionLevel()` exists in Forgejo API | +| `validation/actor.ts` | **Skipped entirely on Forgejo** — `read:user` scope required for `getByUsername()` but unavailable; Forgejo has no bot/app accounts so check is unnecessary | +| `validation/trigger.ts` | Pure logic — reads webhook payload fields, no API calls | +| `operations/comments/create-initial.ts` | `octokit.rest.issues.createComment()` works on Forgejo | +| `operations/comments/update-claude-comment.ts` | `octokit.rest.issues.updateComment()` works on Forgejo | +| `operations/branch.ts` | `octokit.rest.git.getRef()`, `octokit.rest.repos.get()` work on Forgejo | +| `operations/git-config.ts` | Uses `GITHUB_SERVER_URL` for noreply domain — works since Forgejo sets this var | +| `utils/sanitizer.ts` | Pure logic, no API dependency | +| `utils/image-downloader.ts` | Uses Octokit REST for fetching — should work | +| `context.ts` | `@actions/github` reads `GITHUB_EVENT_PATH` — Forgejo sets this | +| `data/formatter.ts` | Pure formatting logic | +| `api/config.ts` | Reads `GITHUB_API_URL` / `GITHUB_SERVER_URL` — Forgejo sets these | + +### Architecture: Lean Adapter + +``` +src/ +├── platform/ +│ └── detector.ts # NEW - Platform detection (+ action.yml `platform` input) +├── forgejo/ +│ ├── token.ts # NEW - Direct token (skip OIDC) +│ ├── data/ +│ │ └── fetcher.ts # NEW - REST-based data fetching (replaces GraphQL) +│ └── comment-logic.ts # NEW - Forgejo URL formatting for comments +├── mcp/ +│ ├── forgejo-actions-server.ts # NEW - Forgejo CI status MCP server +│ └── install-mcp-server.ts # MODIFIED - platform routing for MCP config +├── github/ # REUSED - existing code, no modifications +│ └── ... +├── entrypoints/ +│ └── run.ts # MODIFIED - platform branch for token setup +├── modes/ +│ ├── tag/index.ts # MODIFIED - swap data fetcher + comment logic +│ └── agent/index.ts # MODIFIED - swap token setup +action.yml # MODIFIED - platform input, conditional steps +``` + +**~8 new/modified files** instead of ~25. The existing `src/github/` code is re-used, not duplicated. + +### Why This Approach + +| Approach | Upstream Merge Effort | Maintenance | Chosen? | +| -------------------------------------------- | ---------------------------------------------------------------- | ----------- | ------- | +| **Lean adapter (reuse + minimal new files)** | Low (3-4 conflict-prone files) | Low | **Yes** | +| Full abstraction layer (20+ new files) | Near-zero conflicts but high maintenance syncing duplicated code | High | No | +| Modify GitHub files in-place | High conflict rate on every upstream pull | Low | No | +| Complete fork with divergence | No merges possible after a while | Very high | No | + +### Honest Merge-Conflict Assessment + +These files **will be modified** and are the merge-conflict surface when pulling upstream: + +| File | What We Change | Conflict Risk | +| ------------------------------- | ------------------------------------------------------------- | ---------------------------------------- | +| `src/entrypoints/run.ts` | Platform branch at top for token/client setup | **Medium** - upstream's most active file | +| `src/modes/tag/index.ts` | Swap `fetchGitHubData` call + comment logic based on platform | **Medium** | +| `src/modes/agent/index.ts` | Swap token setup based on platform | **Low** - simpler file | +| `src/mcp/install-mcp-server.ts` | Platform routing for MCP server selection | **Low-Medium** | +| `action.yml` | Add `platform` input, conditional Forgejo steps | **Low** - additive changes | + +**Mitigation strategy**: Keep all platform changes at the **top** of functions (early return / branch pattern). Avoid restructuring existing code flow. When upstream modifies these files, conflicts will be localized to our small additions, not interleaved throughout. + +## Technical Analysis + +### Platform Detection Strategy + +Use an **explicit `platform` input** (most reliable) with env-var auto-detection as fallback: + +```typescript +// src/platform/detector.ts +export function detectPlatform(): "github" | "forgejo" { + // 1. Explicit input (most reliable) + const platformInput = process.env.PLATFORM; + if (platformInput === "forgejo") return "forgejo"; + if (platformInput === "github") return "github"; + + // 2. Auto-detect from environment + // FORGEJO is set by Forgejo runner v7.0+ (not in older runners) + if (process.env.FORGEJO === "true") return "forgejo"; + + // Check GITHUB_SERVER_URL — if not github.com, likely Forgejo/Gitea + const serverUrl = process.env.GITHUB_SERVER_URL || ""; + if (serverUrl && !serverUrl.includes("github.com")) { + return "forgejo"; + } + + return "github"; +} +``` + +New `action.yml` input: + +```yaml +platform: + description: "Platform to run on: 'auto' (default), 'github', or 'forgejo'" + required: false + default: "auto" +``` + +### Key Technical Differences + +1. **No GraphQL**: Forgejo is REST-only. The 3 GraphQL queries in `src/github/api/queries/github.ts` (PR_QUERY, ISSUE_QUERY, USER_QUERY) must be replaced with **5-8 separate paginated REST calls**: + + - `GET /repos/{owner}/{repo}/pulls/{index}` - PR details + - `GET /repos/{owner}/{repo}/pulls/{index}/files` - changed files (paginated) + - `GET /repos/{owner}/{repo}/pulls/{index}/commits` - commits (paginated) + - `GET /repos/{owner}/{repo}/issues/{index}/comments` - issue-level comments (paginated) + - `GET /repos/{owner}/{repo}/pulls/{index}/reviews` - reviews (paginated) + - For each review: `GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments` + - `GET /repos/{owner}/{repo}/issues/{index}` - issue details + + Use `Promise.all()` to run independent calls in parallel and mitigate latency. + +2. **API base URL**: Forgejo uses `/api/v1` (vs GitHub's `/api/v3`). Octokit REST methods work when `baseUrl` is set correctly since Forgejo mirrors GitHub's endpoint paths. + +3. **Token handling**: No OIDC token exchange with Anthropic. The existing `OVERRIDE_GITHUB_TOKEN` code path in `token.ts` (line 130-135) already short-circuits the OIDC flow — for Forgejo we just need to use `GITHUB_TOKEN` or `FORGEJO_TOKEN` directly. + +4. **Bot identity**: No GitHub App bot. The action runs as the token owner. Default `bot_id` (41898282) is meaningless on Forgejo — need sensible defaults. + +5. **`pull_request_target` event**: May not exist in Forgejo. The existing `context.ts` maps it to `pull_request` (line 188), so it degrades gracefully. + +6. **GitHub MCP Server**: The `ghcr.io/github/github-mcp-server` Docker image referenced in `install-mcp-server.ts` (line 214) won't work. Skip on Forgejo — it's only included when `hasGitHubMcpTools` is true. + +7. **Forgejo Actions API gaps**: `actions.downloadJobLogForWorkflowRun()` used in the CI MCP server may not exist. Need to verify and implement alternative (direct API call or skip). + +### `action.yml` Required Modifications + +| Line(s) | Current | Problem | Fix | +| ------- | ----------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| 103 | `default: "41898282"` (bot_id) | Meaningless on Forgejo | Make platform-aware; use token owner identity on Forgejo | +| 107 | `default: "claude[bot]"` (bot_name) | Doesn't exist on Forgejo | Same — detect from token or use configurable default | +| 163-166 | `uses: oven-sh/setup-bun@...` | References GitHub release; may fail on isolated Forgejo runners | Add conditional: if bun already available, skip; otherwise use direct curl install | +| 166 | `token: ${{ inputs.github_token \|\| github.token }}` | Used to fetch bun release from GitHub | On Forgejo, `github.token` is the Forgejo token — may not auth to GitHub; provide fallback | +| 285-294 | "Revoke app token" step | Calls `${GITHUB_API_URL}/installation/token` which doesn't exist on Forgejo | Make conditional: `if: platform != 'forgejo'` or detect and skip | +| N/A | Missing | No `platform` input | Add `platform` input (auto/github/forgejo) | + +### `base-action/` Directory + +`run.ts` imports directly from `base-action/src/`: + +- `validate-env.ts` - checks env vars for Claude CLI (platform-agnostic) +- `setup-claude-code-settings.ts` - writes Claude settings (platform-agnostic) +- `install-plugins.ts` - installs Claude plugins (platform-agnostic) +- `prepare-prompt.ts` - prepares prompt file (platform-agnostic) +- `run-claude.ts` / `run-claude-sdk.ts` - runs Claude CLI (platform-agnostic) + +**Conclusion**: `base-action/` is platform-independent. No modifications needed. + +### MCP Server Naming Strategy + +**Keep existing server names** (`github_comment`, `github_ci`, `github_file_ops`, `github_inline_comment`) even for Forgejo implementations. These are internal identifiers referenced in hardcoded tool name lists in `src/modes/tag/index.ts` (lines 127-152): + +```typescript +"mcp__github_comment__update_claude_comment", +"mcp__github_ci__get_ci_status", +"mcp__github_file_ops__commit_files", +``` + +Renaming would require modifying the tool name lists — more merge-conflict surface for zero user benefit. The MCP server config in `install-mcp-server.ts` will point these names to either GitHub or Forgejo server implementations based on platform. + +## CI/CD Workflows + +### Handling Upstream `.github/workflows/` + +The upstream repo has 13 workflows in `.github/workflows/`. Strategy: + +- **Do NOT delete or modify** `.github/workflows/` files (merge conflicts) +- Create `.forgejo/workflows/` with our custom workflows +- **Configure Forgejo runner** to only read from `.forgejo/workflows/` (runner-level `DEFAULT_ACTIONS_URL` and workflow directory settings) +- **Document this as a setup requirement** — if runners aren't configured, upstream workflows will attempt to run and fail loudly (missing secrets, OIDC endpoints, etc.) + +### Custom Forgejo Workflows + +#### 1. CI Test Workflow (`.forgejo/workflows/ci.yml`) + +- Triggers on push to main and PRs +- Runs `bun install` and `bun test` +- Lints with biome (matching upstream's toolchain) + +#### 2. Action Self-Test Workflow (`.forgejo/workflows/test-action.yml`) + +- **Tests the action itself** on Forgejo +- Creates a test issue via API, triggers the action with a simple prompt +- Verifies: platform detection returns "forgejo", comment is created on the issue, action completes successfully +- Validates end-to-end: token handling, Octokit REST calls, comment posting +- Runs on a schedule (weekly) and on PRs that modify `src/`, `action.yml`, or `.forgejo/` + +#### 3. Upstream Sync Check (`.forgejo/workflows/upstream-sync.yml`) + +- Scheduled workflow (weekly) + manual trigger +- Fetches `upstream/main`, compares with our `main` using `git rev-list` +- Creates an issue if upstream has new commits we haven't merged +- Includes commit count and summary of changed files to help assess merge effort + +## Milestones + +### Phase 1 - Foundation (parallel, no dependencies) + +- [x] **M1: Platform detection + `action.yml` modifications** — Add `platform` input to `action.yml`. Create `src/platform/detector.ts` with explicit input + env-var auto-detection. Add conditional logic to skip "Revoke app token" step on Forgejo. Fix bun installation to handle Forgejo runners (direct curl fallback). + + - Files: `action.yml` (MODIFIED), `src/platform/detector.ts` (NEW) + - Success: `detectPlatform()` returns "forgejo" in Forgejo runner, "github" in GitHub runner. `action.yml` doesn't error on Forgejo. + +- [x] **M2: Forgejo token handling** — Create `src/forgejo/token.ts` that returns `GITHUB_TOKEN` / `FORGEJO_TOKEN` directly (no OIDC, no Anthropic exchange). Wire into `run.ts` with an early platform branch before the existing `setupGitHubToken()` call. + + - Files: `src/forgejo/token.ts` (NEW), `src/entrypoints/run.ts` (MODIFIED — surgical addition at top of `run()`) + - Depends on: M1 + - Success: Token is available without OIDC on Forgejo. GitHub path unchanged. + +- [x] **M3: Forgejo CI workflows** — Created `.forgejo/workflows/` with three workflows adapted for Forgejo `grunt` runner: `ci.yml` (3 separate jobs: test, prettier, typecheck using curl-based bun install + `xternal/checkout`), `test-action.yml` (agent-mode self-test with `FORGEJO_PLATFORM_TEST_OK` marker, runs weekly + on PRs to src/action.yml/.forgejo), `upstream-sync.yml` (weekly upstream check, creates/updates issue with `upstream-sync` label including commit log, diff stat, and merge instructions). + + - Files: `.forgejo/workflows/ci.yml`, `.forgejo/workflows/test-action.yml`, `.forgejo/workflows/upstream-sync.yml` (all NEW) + - Success: CI runs tests on push. Upstream `.github/workflows/` don't interfere. + +### Phase 2 - Core Data Fetching (the hard part) + +- [x] **M4a: Forgejo REST data fetching — PR/Issue basics** — Implement REST-based fetching for PR details, issue details, changed files, and commits. Produce output matching the `FetchDataResult` type that existing code expects. + + - Files: `src/forgejo/data/fetcher.ts` (NEW) + - Depends on: M1, M2 + - Success: Can fetch PR/issue title, body, author, state, files, commits from Forgejo REST API. + +- [x] **M4b: Forgejo REST data fetching — Comments, reviews, inline comments** — Extend the fetcher with paginated comment/review fetching, including nested inline review comments (requires per-review API calls). Implement TOCTOU filtering using the same `filterCommentsToTriggerTime` / `filterReviewsToTriggerTime` functions (reuse from `src/github/data/fetcher.ts` — these are pure logic). Use `Promise.all()` for parallel API calls. + + - Files: `src/forgejo/data/fetcher.ts` (extend from M4a) + - Depends on: M4a + - Success: Full parity with GitHub GraphQL data fetch. All comments, reviews, and inline comments included with proper TOCTOU filtering. + +- [x] **M5: Forgejo comment URL formatting** — Create `src/forgejo/comment-logic.ts` with Forgejo-specific URL patterns for job links, branch links, and PR creation links (used in tracking comment updates). + - Files: `src/forgejo/comment-logic.ts` (NEW) + - Depends on: M1 + - Success: Comment formatting produces correct Forgejo URLs. + +### Phase 3 - Integration (wiring it together) + +- [x] **M6: MCP server platform routing** — Modified `install-mcp-server.ts` to add platform detection. Skips GitHub public MCP server (`ghcr.io/github/github-mcp-server`) on Forgejo. Skips `checkActionsReadPermission` on Forgejo (workflow token has full repo access). Did NOT create `src/mcp/forgejo-actions-server.ts` — existing `github-actions-server.ts` works on Forgejo via Octokit REST + `GITHUB_API_URL` (zero duplication, easier maintenance). Server names remain `github_comment`, `github_ci`, etc. + + - Files: `src/mcp/install-mcp-server.ts` (MODIFIED), `test/install-mcp-server.test.ts` (MODIFIED — 6 new Forgejo tests) + - Depends on: M4b, M5 + - Success: All MCP servers route correctly on both platforms. 772 tests pass. + +- [x] **M7: Entry point + mode handler integration** — Wired platform detection into mode handlers. In `tag/index.ts`: platform branch routes to `fetchForgejoData` vs `fetchGitHubData`. `createInitialComment` auto-detects platform for Forgejo spinner/URL format. `update-comment-link.ts` uses `updateForgejoCommentBody` + Forgejo PR compare URL on Forgejo. Agent mode needed NO changes (token already in `run.ts`, rest uses Octokit REST). 11 new integration tests in `test/m7-integration.test.ts`. + + - Files: `src/modes/tag/index.ts` (MODIFIED), `src/entrypoints/update-comment-link.ts` (MODIFIED), `src/github/operations/comments/create-initial.ts` (MODIFIED), `src/github/operations/comments/update-with-branch.ts` (MODIFIED), `test/m7-integration.test.ts` (NEW) + - Depends on: M4b, M5, M6 + - Success: All platform routing correct on both platforms. 785 tests pass. + +### Phase 4 - Validation & Polish + +- [x] **M8: End-to-end testing on Forgejo** — Tested agent mode (workflow_dispatch) end-to-end on Forgejo runner. Fixed multiple issues discovered during E2E: permission check switched to `repos.get()` (Forgejo lacks collaborator permission endpoint for workflow tokens), actor check skipped entirely on Forgejo (no bot accounts + `read:user` scope unavailable), `oven-sh/setup-bun` replaced with universal curl install in `action.yml` (Forgejo runner pre-fetches all `uses:` refs even when `if:` would skip them), CI test env override for `GITHUB_SERVER_URL` (module-level constants evaluated at import time). Self-test workflow passes: platform detection, token auth, actor skip, Claude Code execution, output validation all green. CI tests: 794 pass, 0 fail. + + - Files: `action.yml` (MODIFIED — curl bun install), `src/github/validation/actor.ts` (MODIFIED — Forgejo skip), `test/actor.test.ts` (MODIFIED — Forgejo tests), `.forgejo/workflows/ci.yml` (MODIFIED — inline export fix) + - Depends on: M7 + - Success: Agent mode works on Forgejo. Self-test workflow passes. CI tests pass. Note: tag mode (issue trigger, PR review) not yet E2E tested — code paths are wired but await real-world validation. + +- [x] **M9: Documentation & upstream merge validation** — Created comprehensive `docs/forgejo.md` setup guide covering prerequisites, quick start (tag + agent mode), platform detection, GitHub vs Forgejo differences (auth, API, bun install, comment URLs), CI workflows, upstream merge process with conflict mitigation, known limitations, and troubleshooting. Updated `README.md` (Forgejo in description, feature bullet, docs link), `CLAUDE.md` (Forgejo architecture section with key files and integration points), `docs/usage.md` (`platform` input in table), `docs/faq.md` (5 Forgejo FAQ entries). Upstream merge validation: `git merge upstream/main` → zero conflicts — branch based on latest upstream `ade221f`. All 794 tests pass. + + - Files: `docs/forgejo.md` (NEW), `README.md` (MODIFIED), `CLAUDE.md` (MODIFIED), `docs/usage.md` (MODIFIED), `docs/faq.md` (MODIFIED) + - Depends on: M8 + - Success: Zero-conflict upstream merge. Complete Forgejo setup documentation with troubleshooting guide. + +### Milestone Dependency Graph + +``` +M1 ──> M2 ──> M4a ──> M4b ──┬──> M6 ──> M7 ──> M8 ──> M9 +M1 ──> M5 ───────────────────┘ +M3 (independent, anytime) +``` + +**Parallel opportunities:** + +- M3 is fully independent — can run anytime +- M5 and M2 can run in parallel (both only depend on M1) +- M4a can start as soon as M2 is done +- M6 and M7 are sequential (M7 depends on M6) + +## Execution Plan + +| Phase | Milestones | Files Touched | Parallel? | +| ----- | ------------ | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| 1 | M1, M2, M3 | `action.yml`, `src/platform/detector.ts`, `src/forgejo/token.ts`, `src/entrypoints/run.ts`, `.forgejo/workflows/*` | M1 first, then M2+M3 in parallel | +| 2 | M4a, M4b, M5 | `src/forgejo/data/fetcher.ts`, `src/forgejo/comment-logic.ts` | M5 parallel with M4a/M4b | +| 3 | M6, M7 | `src/mcp/install-mcp-server.ts`, `src/mcp/forgejo-actions-server.ts`, `src/modes/tag/index.ts`, `src/modes/agent/index.ts` | Sequential | +| 4 | M8, M9 | Tests, docs, `CLAUDE.md` | Sequential | + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +| -------------------------------------------------------------- | ------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Upstream refactors `run.ts` or mode handlers | Medium | High | Keep entry point changes to <10 lines; use early-return pattern; platform branch at top of functions | +| Octokit REST methods behave differently on Forgejo | Low | Medium | Test each used method early in M4a; fall back to raw `fetch()` if needed | +| Forgejo webhook payloads differ from `@octokit/webhooks-types` | Medium | Medium | Test with real Forgejo webhooks; key check: `payload.issue.pull_request` field for `is_pull` detection | +| `pull_request_target` event missing in Forgejo | High | Low | Already handled — `context.ts:188` maps it to `pull_request` | +| Forgejo Actions API missing endpoints (downloadJobLog) | Medium | Medium | Degrade gracefully — CI MCP server may offer fewer tools on Forgejo | +| `oven-sh/setup-bun` action fails on isolated Forgejo runners | **RESOLVED** | Medium | **Fixed**: Replaced `oven-sh/setup-bun` entirely with universal curl install in `action.yml`. Forgejo runner pre-fetches all `uses:` refs before evaluating `if:` conditions, so conditional skip was insufficient. Mirror created at `xternal/setup-bun` for future reference. | +| Upstream `.github/workflows/` run and fail on Forgejo | Medium | Low | Document runner configuration; provide `.forgejo/workflows/` overrides | +| Upstream adds new features requiring new GraphQL fields | Certain | Low | New features won't have Forgejo support until we add REST equivalents to `forgejo/data/fetcher.ts` | + +## Success Criteria + +1. Action runs on Forgejo for issue, PR, and workflow_dispatch triggers +2. Claude can read issue/PR context, post comments, create branches, and commit files on Forgejo +3. `git fetch upstream && git merge upstream/main` produces conflicts only in the 3-4 files we modified, and those conflicts are resolvable in <15 minutes +4. Self-test workflow validates the action works on every PR +5. Upstream sync workflow alerts when new upstream changes are available +6. Total new files: ~6-8 (not 25+) + +## Out of Scope (for now) + +- GitHub Enterprise Server support +- Gitea support (similar to Forgejo but may diverge) +- `pull_request_target` event support +- SSH signing on Forgejo (can be added later) +- GitHub MCP Server equivalent for Forgejo (the `ghcr.io/github/github-mcp-server` image) +- Full Forgejo Actions API parity (downloadJobLog may be missing) diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 8afc79d..b79d7bb 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -12,6 +12,8 @@ import { spawn } from "child_process"; import { appendFile } from "fs/promises"; import { existsSync, readFileSync } from "fs"; import { setupGitHubToken, WorkflowValidationSkipError } from "../github/token"; +import { setupForgejoToken } from "../forgejo/token"; +import { detectPlatform } from "../platform/detector"; import { checkWritePermissions } from "../github/validation/permissions"; import { createOctokit } from "../github/api/client"; import type { Octokits } from "../github/api/client"; @@ -145,15 +147,23 @@ async function run() { `Auto-detected mode: ${modeName} for event: ${context.eventName}`, ); - try { - githubToken = await setupGitHubToken(); - } catch (error) { - if (error instanceof WorkflowValidationSkipError) { - core.setOutput("skipped_due_to_workflow_validation_mismatch", "true"); - console.log("Exiting due to workflow validation skip"); - return; + // Platform-aware token setup + const platform = detectPlatform(); + console.log(`Platform detected: ${platform}`); + + if (platform === "forgejo") { + githubToken = setupForgejoToken(); + } else { + try { + githubToken = await setupGitHubToken(); + } catch (error) { + if (error instanceof WorkflowValidationSkipError) { + core.setOutput("skipped_due_to_workflow_validation_mismatch", "true"); + console.log("Exiting due to workflow validation skip"); + return; + } + throw error; } - throw error; } octokit = createOctokit(githubToken); diff --git a/src/entrypoints/update-comment-link.ts b/src/entrypoints/update-comment-link.ts index c7bd8d6..3dc6e71 100644 --- a/src/entrypoints/update-comment-link.ts +++ b/src/entrypoints/update-comment-link.ts @@ -7,6 +7,11 @@ import { updateCommentBody, type CommentUpdateInput, } from "../github/operations/comment-logic"; +import { + updateForgejoCommentBody, + createForgejoPrCompareUrl, +} from "../forgejo/comment-logic"; +import { detectPlatform } from "../platform/detector"; import { parseGitHubContext, isPullRequestReviewCommentEvent, @@ -46,6 +51,7 @@ export async function updateCommentLink( } = params; const { owner, repo } = context.repository; + const platform = detectPlatform(); const serverUrl = GITHUB_SERVER_URL; const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; @@ -145,13 +151,26 @@ export async function updateCommentLink( (comparison.files && comparison.files.length > 0) ) { const entityType = context.isPR ? "PR" : "Issue"; - const prTitle = encodeURIComponent( - `${entityType} #${context.entityNumber}: Changes from Claude`, - ); - const prBody = encodeURIComponent( - `This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`, - ); - const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`; + const titleText = `${entityType} #${context.entityNumber}: Changes from Claude`; + const bodyText = `This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`; + + let prUrl: string; + if (platform === "forgejo") { + // Forgejo doesn't support quick_pull=1 + prUrl = createForgejoPrCompareUrl( + serverUrl, + owner, + repo, + baseBranch, + claudeBranch, + titleText, + bodyText, + ); + } else { + const prTitle = encodeURIComponent(titleText); + const prBody = encodeURIComponent(bodyText); + prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`; + } prLink = `\n[Create a PR](${prUrl})`; } } catch (error) { @@ -217,7 +236,10 @@ export async function updateCommentLink( errorDetails, }; - const updatedBody = updateCommentBody(commentInput); + const updatedBody = + platform === "forgejo" + ? updateForgejoCommentBody(commentInput) + : updateCommentBody(commentInput); try { await updateClaudeComment(octokit.rest, { diff --git a/src/forgejo/comment-logic.ts b/src/forgejo/comment-logic.ts new file mode 100644 index 0000000..9b83b9a --- /dev/null +++ b/src/forgejo/comment-logic.ts @@ -0,0 +1,288 @@ +/** + * Forgejo-specific comment URL formatting and body construction. + * + * Mirrors the GitHub versions in: + * - src/github/operations/comments/common.ts (spinner, link builders, initial comment) + * - src/github/operations/comment-logic.ts (updateCommentBody for final comment) + * + * Differences from GitHub: + * - Unicode spinner instead of GitHub CDN image (CDN URLs don't resolve on Forgejo) + * - PR compare URL omits `quick_pull=1` (GitHub-specific parameter) + * - Dynamic server URL extraction instead of hardcoded github.com regex + */ + +import { ensureProperlyEncodedUrl } from "../github/operations/comment-logic"; +import type { CommentUpdateInput } from "../github/operations/comment-logic"; + +// Re-export types so consumers don't need to import from two places +export type { + CommentUpdateInput, + ExecutionDetails, +} from "../github/operations/comment-logic"; +export { ensureProperlyEncodedUrl } from "../github/operations/comment-logic"; + +/** + * Read the server URL at call time (not module load time). + * + * The GitHub module uses a top-level constant from config.ts, which is fine + * when GITHUB_SERVER_URL doesn't change. For Forgejo we read from env at + * call time so that: + * 1. Tests can override the env var and see the effect immediately. + * 2. The same module works regardless of import order. + */ +function getServerUrl(): string { + return process.env.GITHUB_SERVER_URL || "https://github.com"; +} + +// --------------------------------------------------------------------------- +// Spinner +// --------------------------------------------------------------------------- + +/** + * Unicode spinner for Forgejo comments. + * The GitHub version uses a CDN-hosted animated GIF that won't load on Forgejo. + */ +export const FORGEJO_SPINNER_HTML = "⏳"; + +// --------------------------------------------------------------------------- +// URL Builders +// --------------------------------------------------------------------------- + +export function createForgejoJobRunLink( + owner: string, + repo: string, + runId: string, +): string { + const jobRunUrl = `${getServerUrl()}/${owner}/${repo}/actions/runs/${runId}`; + return `[View job run](${jobRunUrl})`; +} + +export function createForgejoBranchLink( + owner: string, + repo: string, + branchName: string, +): string { + const branchUrl = `${getServerUrl()}/${owner}/${repo}/tree/${branchName}`; + return `\n[View branch](${branchUrl})`; +} + +/** + * Build a Forgejo PR compare URL for "Create a PR" links. + * + * Forgejo's compare page pre-fills title/body from query params but does not + * support GitHub's `quick_pull=1` parameter. + */ +export function createForgejoPrCompareUrl( + serverUrl: string, + owner: string, + repo: string, + baseBranch: string, + headBranch: string, + title: string, + body: string, +): string { + const prTitle = encodeURIComponent(title); + const prBody = encodeURIComponent(body); + return `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${headBranch}?expand=1&title=${prTitle}&body=${prBody}`; +} + +// --------------------------------------------------------------------------- +// Initial comment body (shown while Claude is working) +// --------------------------------------------------------------------------- + +export function createForgejoCommentBody( + jobRunLink: string, + branchLink: string = "", +): string { + return `Claude Code is working… ${FORGEJO_SPINNER_HTML} + +I'll analyze this and get back to you. + +${jobRunLink}${branchLink}`; +} + +// --------------------------------------------------------------------------- +// Owner/repo extraction helper +// --------------------------------------------------------------------------- + +/** + * Extract owner/repo from a URL using the configured server URL. + * + * Unlike the GitHub version (which hardcodes `github.com`), this dynamically + * matches against any server URL — works for both GitHub and Forgejo. + */ +export function extractOwnerRepoFromUrl( + url: string, + serverUrl: string = getServerUrl(), +): { owner: string; repo: string } | null { + try { + const parsedServer = new URL(serverUrl); + const parsedUrl = new URL(url); + + // Host must match (handles both github.com and git.wxs.ro etc.) + if (parsedUrl.host !== parsedServer.host) { + return null; + } + + // Extract first two path segments as owner/repo + const segments = parsedUrl.pathname.split("/").filter(Boolean); + const owner = segments[0]; + const repo = segments[1]; + if (owner && repo) { + return { owner, repo }; + } + } catch { + // Invalid URLs — fall through to null + } + return null; +} + +// --------------------------------------------------------------------------- +// Final comment body (shown after Claude finishes) +// --------------------------------------------------------------------------- + +/** + * Forgejo version of updateCommentBody. + * + * Identical logic to src/github/operations/comment-logic.ts:updateCommentBody + * with one critical fix: uses extractOwnerRepoFromUrl() for dynamic server URL + * extraction instead of the hardcoded `github.com` regex. + */ +export function updateForgejoCommentBody(input: CommentUpdateInput): string { + const originalBody = input.currentBody; + const { + executionDetails, + jobUrl, + branchLink, + prLink, + actionFailed, + branchName, + triggerUsername, + errorDetails, + } = input; + + // Extract content from the original comment body + // First, remove the "Claude Code is working…" or "Claude Code is working..." message + const workingPattern = /Claude Code is working[…\.]{1,3}(?:\s*]*>)?/i; + let bodyContent = originalBody.replace(workingPattern, "").trim(); + + // Also remove the Unicode spinner used on Forgejo + bodyContent = bodyContent.replace(/⏳/g, "").trim(); + + // Check if there's a PR link in the content + let prLinkFromContent = ""; + + // Match the entire markdown link structure + const prLinkPattern = /\[Create .* PR\]\((.*)\)$/m; + const prLinkMatch = bodyContent.match(prLinkPattern); + + if (prLinkMatch && prLinkMatch[1]) { + const encodedUrl = ensureProperlyEncodedUrl(prLinkMatch[1]); + if (encodedUrl) { + prLinkFromContent = encodedUrl; + // Remove the PR link from the content + bodyContent = bodyContent.replace(prLinkMatch[0], "").trim(); + } + } + + // Calculate duration string if available + let durationStr = ""; + if (executionDetails?.duration_ms !== undefined) { + const totalSeconds = Math.round(executionDetails.duration_ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; + } + + // Build the header + let header = ""; + + if (actionFailed) { + header = "**Claude encountered an error"; + if (durationStr) { + header += ` after ${durationStr}`; + } + header += "**"; + } else { + // Get the username from triggerUsername or extract from content + const usernameMatch = bodyContent.match(/@([a-zA-Z0-9-]+)/); + const username = + triggerUsername || (usernameMatch ? usernameMatch[1] : "user"); + + header = `**Claude finished @${username}'s task`; + if (durationStr) { + header += ` in ${durationStr}`; + } + header += "**"; + } + + // Add links section + let links = ` —— [View job](${jobUrl})`; + + // Add branch name with link + if (branchName || branchLink) { + let finalBranchName = branchName; + let branchUrl = ""; + + if (branchLink) { + // Extract the branch URL from the link + const urlMatch = branchLink.match(/\((https?:\/\/.*)\)/); + if (urlMatch && urlMatch[1]) { + branchUrl = urlMatch[1]; + } + + // Extract branch name from link if not provided + if (!finalBranchName) { + const branchNameMatch = branchLink.match(/tree\/([^"'\)]+)/); + if (branchNameMatch) { + finalBranchName = branchNameMatch[1]; + } + } + } + + // If we don't have a URL yet but have a branch name, construct it + // FIX: Uses extractOwnerRepoFromUrl instead of hardcoded github.com regex + if (!branchUrl && finalBranchName) { + const extracted = extractOwnerRepoFromUrl(jobUrl); + if (extracted) { + branchUrl = `${getServerUrl()}/${extracted.owner}/${extracted.repo}/tree/${finalBranchName}`; + } + } + + if (finalBranchName && branchUrl) { + links += ` • [\`${finalBranchName}\`](${branchUrl})`; + } else if (finalBranchName) { + links += ` • \`${finalBranchName}\``; + } + } + + // Add PR link (either from content or provided) + const prUrl = + prLinkFromContent || (prLink ? prLink.match(/\(([^)]+)\)/)?.[1] : ""); + if (prUrl) { + links += ` • [Create PR ➔](${prUrl})`; + } + + // Build the new body with blank line between header and separator + let newBody = `${header}${links}`; + + // Add error details if available + if (actionFailed && errorDetails) { + newBody += `\n\n\`\`\`\n${errorDetails}\n\`\`\``; + } + + newBody += `\n\n---\n`; + + // Clean up the body content + // Remove any existing View job run, branch links from the bottom + bodyContent = bodyContent.replace(/\n?\[View job run\]\([^\)]+\)/g, ""); + bodyContent = bodyContent.replace(/\n?\[View branch\]\([^\)]+\)/g, ""); + + // Remove any existing duration info at the bottom + bodyContent = bodyContent.replace(/\n*---\n*Duration: [0-9]+m? [0-9]+s/g, ""); + + // Add the cleaned body content + newBody += bodyContent; + + return newBody.trim(); +} diff --git a/src/forgejo/data/fetcher.ts b/src/forgejo/data/fetcher.ts new file mode 100644 index 0000000..2fd7bc8 --- /dev/null +++ b/src/forgejo/data/fetcher.ts @@ -0,0 +1,518 @@ +/** + * Forgejo REST-based data fetching. + * + * Replaces GitHub's GraphQL queries (PR_QUERY, ISSUE_QUERY, USER_QUERY) + * with Octokit REST calls that work on Forgejo's /api/v1 endpoint. + * + * M4a: PR/issue details, files, commits, user display name. + * M4b: Comments, reviews, inline comments, TOCTOU filtering, actor filtering, image processing. + */ + +import { execFileSync } from "child_process"; +import type { Octokits } from "../../github/api/client"; +import type { + GitHubComment, + GitHubFile, + GitHubIssue, + GitHubPullRequest, + GitHubReview, + GitHubReviewComment, +} from "../../github/types"; +import type { + FetchDataResult, + GitHubFileWithSHA, +} from "../../github/data/fetcher"; +import { + filterCommentsToTriggerTime, + filterReviewsToTriggerTime, + filterCommentsByActor, + isBodySafeToUse, +} from "../../github/data/fetcher"; +import type { CommentWithImages } from "../../github/utils/image-downloader"; +import { downloadCommentImages } from "../../github/utils/image-downloader"; + +export type FetchForgejoDataParams = { + octokits: Octokits; + repository: string; + prNumber: string; + isPR: boolean; + triggerUsername?: string; + triggerTime?: string; + originalTitle?: string; + originalBody?: string | null; + includeCommentsByActor?: string; + excludeCommentsByActor?: string; +}; + +/** + * Maps Forgejo/GitHub REST file status strings to GraphQL-style uppercase enums. + * REST returns: "added", "removed", "modified", "renamed", "copied", "changed", "unchanged" + * GraphQL uses: "ADDED", "DELETED", "MODIFIED", "RENAMED", "COPIED" + */ +export function mapFileStatus(status: string): string { + const statusMap: Record = { + added: "ADDED", + removed: "DELETED", + modified: "MODIFIED", + renamed: "RENAMED", + copied: "COPIED", + changed: "MODIFIED", + unchanged: "MODIFIED", + }; + return statusMap[status] || status.toUpperCase(); +} + +/** + * Maps REST issue/PR comment objects to GitHubComment type. + */ +export function mapRestComments( + comments: Array<{ + id: number; + body?: string | null; + user?: { login?: string } | null; + created_at: string; + updated_at: string; + }>, +): GitHubComment[] { + return comments.map((c) => ({ + id: c.id.toString(), + databaseId: c.id.toString(), + body: c.body ?? "", + author: { login: c.user?.login ?? "unknown" }, + createdAt: c.created_at, + updatedAt: c.updated_at, + lastEditedAt: undefined, // Forgejo REST doesn't expose lastEditedAt + isMinimized: false, // Forgejo REST doesn't have minimized comments + })); +} + +/** + * Maps REST inline review comment objects to GitHubReviewComment type, + * preserving the review ID for grouping. + */ +export function mapRestReviewComments( + comments: Array<{ + id: number; + body?: string | null; + user?: { login?: string } | null; + path: string; + line?: number | null; + original_line?: number | null; + created_at: string; + updated_at: string; + pull_request_review_id?: number | null; + }>, +): (GitHubReviewComment & { reviewId: number | null })[] { + return comments.map((c) => ({ + id: c.id.toString(), + databaseId: c.id.toString(), + body: c.body ?? "", + author: { login: c.user?.login ?? "unknown" }, + createdAt: c.created_at, + updatedAt: c.updated_at, + lastEditedAt: undefined, + isMinimized: false, + path: c.path, + line: c.line ?? c.original_line ?? null, + reviewId: c.pull_request_review_id ?? null, + })); +} + +/** + * Maps REST review objects to GitHubReview type, grouping inline comments + * by their pull_request_review_id. + */ +export function mapRestReviews( + reviews: Array<{ + id: number; + body?: string | null; + user?: { login?: string } | null; + state: string; + submitted_at?: string; + }>, + inlineComments: (GitHubReviewComment & { reviewId: number | null })[], +): GitHubReview[] { + // Group inline comments by review ID + const commentsByReview = new Map(); + for (const comment of inlineComments) { + if (comment.reviewId != null) { + const existing = commentsByReview.get(comment.reviewId) ?? []; + // Strip reviewId before storing (it's not part of GitHubReviewComment) + const { reviewId: _, ...reviewComment } = comment; + existing.push(reviewComment); + commentsByReview.set(comment.reviewId, existing); + } + } + + return reviews.map((r) => ({ + id: r.id.toString(), + databaseId: r.id.toString(), + author: { login: r.user?.login ?? "unknown" }, + body: r.body ?? "", + state: r.state.toUpperCase(), // Normalize to uppercase for consistency + submittedAt: r.submitted_at ?? new Date().toISOString(), + updatedAt: r.submitted_at, // Forgejo has no separate updatedAt for reviews + lastEditedAt: undefined, + comments: { + nodes: commentsByReview.get(r.id) ?? [], + }, + })); +} + +/** + * Fetch user display name via REST (replaces GraphQL USER_QUERY). + */ +async function fetchUserDisplayNameRest( + octokits: Octokits, + login: string, +): Promise { + try { + const { data } = await octokits.rest.users.getByUsername({ + username: login, + }); + return data.name || null; + } catch (error) { + console.warn(`Failed to fetch user display name for ${login}:`, error); + return null; + } +} + +/** + * Fetch PR or issue data from Forgejo using REST API calls. + * + * For PRs, fetches details + files + commits + comments + reviews + inline + * comments in parallel via Promise.all(). For issues, fetches details + comments + * in parallel. Produces output matching FetchDataResult so downstream code + * (formatter, prompt builder, branch setup) works unchanged. + */ +export async function fetchForgejoData({ + octokits, + repository, + prNumber, + isPR, + triggerUsername, + triggerTime, + originalTitle, + originalBody, + includeCommentsByActor, + excludeCommentsByActor, +}: FetchForgejoDataParams): Promise { + const [owner, repo] = repository.split("/"); + if (!owner || !repo) { + throw new Error("Invalid repository format. Expected 'owner/repo'."); + } + + const number = parseInt(prNumber); + let contextData: GitHubPullRequest | GitHubIssue; + let changedFiles: GitHubFile[] = []; + let changedFilesWithSHA: GitHubFileWithSHA[] = []; + let rawComments: GitHubComment[] = []; + let rawReviews: GitHubReview[] = []; + + if (isPR) { + // Fetch PR details, files, commits, comments, reviews, and inline comments in parallel + const [ + prResponse, + filesResponse, + commitsResponse, + commentsResponse, + reviewsResponse, + reviewCommentsResponse, + ] = await Promise.all([ + octokits.rest.pulls.get({ owner, repo, pull_number: number }), + octokits.rest.pulls.listFiles({ + owner, + repo, + pull_number: number, + per_page: 100, + }), + octokits.rest.pulls.listCommits({ + owner, + repo, + pull_number: number, + per_page: 100, + }), + octokits.rest.issues.listComments({ + owner, + repo, + issue_number: number, + per_page: 100, + }), + octokits.rest.pulls.listReviews({ + owner, + repo, + pull_number: number, + per_page: 100, + }), + octokits.rest.pulls.listReviewComments({ + owner, + repo, + pull_number: number, + per_page: 100, + }), + ]); + + const pr = prResponse.data; + const files = filesResponse.data; + const commits = commitsResponse.data; + + // Map REST responses to internal types + rawComments = mapRestComments(commentsResponse.data); + const mappedInlineComments = mapRestReviewComments( + reviewCommentsResponse.data, + ); + rawReviews = mapRestReviews(reviewsResponse.data, mappedInlineComments); + + // Map REST file objects to GitHubFile type + changedFiles = files.map((f) => ({ + path: f.filename, + additions: f.additions, + deletions: f.deletions, + changeType: mapFileStatus(f.status), + })); + + // Map REST response to GitHubPullRequest type + contextData = { + title: pr.title, + body: pr.body ?? "", + author: { login: pr.user?.login ?? "unknown" }, + baseRefName: pr.base.ref, + headRefName: pr.head.ref, + headRefOid: pr.head.sha, + createdAt: pr.created_at, + updatedAt: pr.updated_at, + // REST doesn't expose lastEditedAt — TOCTOU filtering falls back to updatedAt + lastEditedAt: undefined, + additions: pr.additions, + deletions: pr.deletions, + state: pr.state, + labels: { + nodes: (pr.labels ?? []).map((l) => ({ + name: typeof l === "string" ? l : (l.name ?? ""), + })), + }, + commits: { + totalCount: commits.length, + nodes: commits.map((c) => ({ + commit: { + oid: c.sha, + message: c.commit.message, + author: { + name: c.commit.author?.name ?? "unknown", + email: c.commit.author?.email ?? "", + }, + }, + })), + }, + files: { nodes: changedFiles }, + comments: { nodes: rawComments }, + reviews: { nodes: rawReviews }, + }; + + console.log(`Successfully fetched PR #${prNumber} data`); + } else { + // Fetch issue details and comments in parallel + const [issueResponse, commentsResponse] = await Promise.all([ + octokits.rest.issues.get({ + owner, + repo, + issue_number: number, + }), + octokits.rest.issues.listComments({ + owner, + repo, + issue_number: number, + per_page: 100, + }), + ]); + + const issue = issueResponse.data; + rawComments = mapRestComments(commentsResponse.data); + + // Map REST response to GitHubIssue type + contextData = { + title: issue.title, + body: issue.body ?? "", + author: { login: issue.user?.login ?? "unknown" }, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + lastEditedAt: undefined, + state: issue.state ?? "open", + labels: { + nodes: (issue.labels ?? []).map((l) => ({ + name: typeof l === "string" ? l : (l.name ?? ""), + })), + }, + comments: { nodes: rawComments }, + }; + + console.log(`Successfully fetched issue #${prNumber} data`); + } + + // Compute SHAs for changed files (same logic as GitHub fetcher) + if (isPR && changedFiles.length > 0) { + changedFilesWithSHA = changedFiles.map((file) => { + if (file.changeType === "DELETED") { + return { ...file, sha: "deleted" }; + } + + try { + const sha = execFileSync("git", ["hash-object", file.path], { + encoding: "utf-8", + }).trim(); + return { ...file, sha }; + } catch { + console.warn(`Failed to compute SHA for ${file.path}`); + return { ...file, sha: "unknown" }; + } + }); + } + + // TOCTOU protection: use original body/title from webhook payload if provided + if (originalBody !== undefined) { + contextData.body = originalBody ?? ""; + } + + if (originalTitle !== undefined) { + contextData.title = originalTitle; + } + + // Apply TOCTOU + actor filtering to issue-level comments + const comments = filterCommentsByActor( + filterCommentsToTriggerTime(rawComments, triggerTime), + includeCommentsByActor, + excludeCommentsByActor, + ); + + // Build reviewData with filtering (PRs only) + let reviewData: { nodes: GitHubReview[] } | null = null; + if (isPR) { + // Deep-clone reviews so filtering doesn't mutate contextData + let filteredReviews = rawReviews.map((r) => ({ + ...r, + comments: { nodes: [...r.comments.nodes] }, + })); + + // Actor-filter reviews by review author + filteredReviews = filterCommentsByActor( + filteredReviews, + includeCommentsByActor, + excludeCommentsByActor, + ); + + // Actor-filter inline comments within each review + for (const review of filteredReviews) { + review.comments.nodes = filterCommentsByActor( + review.comments.nodes, + includeCommentsByActor, + excludeCommentsByActor, + ); + } + + reviewData = { nodes: filteredReviews }; + } + + // --- Image processing --- + // Prepare issue-level comments for image URL extraction + const issueComments: CommentWithImages[] = comments + .filter((c) => c.body && !c.isMinimized) + .map((c) => ({ + type: "issue_comment" as const, + id: c.databaseId, + body: c.body, + })); + + // Filter review bodies to trigger time for image processing + const filteredReviewBodies = reviewData?.nodes + ? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter( + (r) => r.body, + ) + : []; + + const reviewBodies: CommentWithImages[] = filteredReviewBodies.map((r) => ({ + type: "review_body" as const, + id: r.databaseId, + pullNumber: prNumber, + body: r.body, + })); + + // Filter inline review comments to trigger time for image processing + const allReviewComments = + reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? []; + const filteredReviewComments = filterCommentsToTriggerTime( + allReviewComments, + triggerTime, + ); + + const reviewCommentImages: CommentWithImages[] = filteredReviewComments + .filter((c) => c.body && !c.isMinimized) + .map((c) => ({ + type: "review_comment" as const, + id: c.databaseId, + body: c.body, + })); + + // Process main body for images (with TOCTOU check) + let mainBody: CommentWithImages[] = []; + if (contextData.body) { + if ( + originalBody !== undefined || + isBodySafeToUse(contextData, triggerTime) + ) { + mainBody = [ + isPR + ? { + type: "pr_body" as const, + pullNumber: prNumber, + body: contextData.body, + } + : { + type: "issue_body" as const, + issueNumber: prNumber, + body: contextData.body, + }, + ]; + } else { + console.warn( + `Security: ${isPR ? "PR" : "Issue"} #${prNumber} body was edited after the trigger event. ` + + `Excluding body content to prevent potential injection attacks.`, + ); + } + } + + const allCommentsForImages = [ + ...mainBody, + ...issueComments, + ...reviewBodies, + ...reviewCommentImages, + ]; + + // Download images — uses Octokit REST. On Forgejo the GitHub-specific URL + // patterns won't match, so this is effectively a no-op for Forgejo-hosted + // images. Safe to call regardless. + const imageUrlMap = await downloadCommentImages( + octokits, + owner, + repo, + allCommentsForImages, + ); + + // Fetch trigger user display name via REST (replaces GraphQL USER_QUERY) + let triggerDisplayName: string | null | undefined; + if (triggerUsername) { + triggerDisplayName = await fetchUserDisplayNameRest( + octokits, + triggerUsername, + ); + } + + return { + contextData, + comments, + changedFiles, + changedFilesWithSHA, + reviewData, + imageUrlMap, + triggerDisplayName, + }; +} diff --git a/src/forgejo/token.ts b/src/forgejo/token.ts new file mode 100644 index 0000000..ffa7f3b --- /dev/null +++ b/src/forgejo/token.ts @@ -0,0 +1,28 @@ +/** + * Forgejo token handling. + * + * Unlike GitHub (which uses OIDC → Anthropic exchange for an app token), + * Forgejo uses the runner's workflow token directly. No exchange needed. + */ + +export function setupForgejoToken(): string { + // 1. User-provided token (via github_token input → OVERRIDE_GITHUB_TOKEN) + const providedToken = process.env.OVERRIDE_GITHUB_TOKEN; + if (providedToken) { + console.log("Using provided token for Forgejo authentication"); + return providedToken; + } + + // 2. Runner's default workflow token (github.token → DEFAULT_WORKFLOW_TOKEN) + const defaultToken = process.env.DEFAULT_WORKFLOW_TOKEN; + if (defaultToken) { + console.log("Using default workflow token for Forgejo authentication"); + return defaultToken; + } + + throw new Error( + "No authentication token available for Forgejo. " + + "Provide a token via the 'github_token' input or ensure " + + "the workflow has a valid GITHUB_TOKEN.", + ); +} diff --git a/src/github/operations/comments/create-initial.ts b/src/github/operations/comments/create-initial.ts index 1243035..5ee0024 100644 --- a/src/github/operations/comments/create-initial.ts +++ b/src/github/operations/comments/create-initial.ts @@ -7,6 +7,11 @@ import { appendFileSync } from "fs"; import { createJobRunLink, createCommentBody } from "./common"; +import { + createForgejoJobRunLink, + createForgejoCommentBody, +} from "../../../forgejo/comment-logic"; +import { detectPlatform } from "../../../platform/detector"; import { isPullRequestReviewCommentEvent, isPullRequestEvent, @@ -21,9 +26,16 @@ export async function createInitialComment( context: ParsedGitHubContext, ) { const { owner, repo } = context.repository; + const platform = detectPlatform(); - const jobRunLink = createJobRunLink(owner, repo, context.runId); - const initialBody = createCommentBody(jobRunLink); + const jobRunLink = + platform === "forgejo" + ? createForgejoJobRunLink(owner, repo, context.runId) + : createJobRunLink(owner, repo, context.runId); + const initialBody = + platform === "forgejo" + ? createForgejoCommentBody(jobRunLink) + : createCommentBody(jobRunLink); try { let response; diff --git a/src/github/operations/comments/update-with-branch.ts b/src/github/operations/comments/update-with-branch.ts index 838b154..6059f72 100644 --- a/src/github/operations/comments/update-with-branch.ts +++ b/src/github/operations/comments/update-with-branch.ts @@ -10,6 +10,12 @@ import { createBranchLink, createCommentBody, } from "./common"; +import { + createForgejoJobRunLink, + createForgejoBranchLink, + createForgejoCommentBody, +} from "../../../forgejo/comment-logic"; +import { detectPlatform } from "../../../platform/detector"; import { type Octokits } from "../../api/client"; import { isPullRequestReviewCommentEvent, @@ -24,16 +30,26 @@ export async function updateTrackingComment( branch?: string, ) { const { owner, repo } = context.repository; + const platform = detectPlatform(); - const jobRunLink = createJobRunLink(owner, repo, context.runId); + const jobRunLink = + platform === "forgejo" + ? createForgejoJobRunLink(owner, repo, context.runId) + : createJobRunLink(owner, repo, context.runId); // Add branch link for issues (not PRs) let branchLink = ""; if (branch && !context.isPR) { - branchLink = createBranchLink(owner, repo, branch); + branchLink = + platform === "forgejo" + ? createForgejoBranchLink(owner, repo, branch) + : createBranchLink(owner, repo, branch); } - const updatedBody = createCommentBody(jobRunLink, branchLink); + const updatedBody = + platform === "forgejo" + ? createForgejoCommentBody(jobRunLink, branchLink) + : createCommentBody(jobRunLink, branchLink); // Update the existing comment with the branch link try { diff --git a/src/github/validation/actor.ts b/src/github/validation/actor.ts index 36be2ff..ed7f5b4 100644 --- a/src/github/validation/actor.ts +++ b/src/github/validation/actor.ts @@ -7,11 +7,21 @@ import type { Octokit } from "@octokit/rest"; import type { GitHubContext } from "../context"; +import { detectPlatform } from "../../platform/detector"; export async function checkHumanActor( octokit: Octokit, githubContext: GitHubContext, ) { + // Forgejo doesn't have GitHub-style bot/app accounts, and the user API + // requires read:user scope which may not be available. Skip entirely. + if (detectPlatform() === "forgejo") { + console.log( + `Skipping human actor check on Forgejo for: ${githubContext.actor}`, + ); + return; + } + // Fetch user information from GitHub API const { data: userData } = await octokit.users.getByUsername({ username: githubContext.actor, @@ -21,8 +31,7 @@ export async function checkHumanActor( console.log(`Actor type: ${actorType}`); - // Check bot permissions if actor is not a User - if (actorType !== "User") { + if (actorType && actorType !== "User") { const allowedBots = githubContext.inputs.allowedBots; // Check if all bots are allowed diff --git a/src/github/validation/permissions.ts b/src/github/validation/permissions.ts index 731fcd4..59a6742 100644 --- a/src/github/validation/permissions.ts +++ b/src/github/validation/permissions.ts @@ -1,6 +1,7 @@ import * as core from "@actions/core"; import type { ParsedGitHubContext } from "../context"; import type { Octokit } from "@octokit/rest"; +import { detectPlatform } from "../../platform/detector"; /** * Check if the actor has write permissions to the repository @@ -49,6 +50,15 @@ export async function checkWritePermissions( return true; } + const platform = detectPlatform(); + + if (platform === "forgejo") { + // Forgejo's getCollaboratorPermissionLevel requires admin access. + // Use GET /repos/{owner}/{repo} instead — it returns a `permissions` + // object for the authenticated token (push = write access). + return await checkForgejoPermissions(octokit, repository, actor); + } + // Check permissions directly using the permission endpoint const response = await octokit.repos.getCollaboratorPermissionLevel({ owner: repository.owner, @@ -71,3 +81,43 @@ export async function checkWritePermissions( throw new Error(`Failed to check permissions for ${actor}: ${error}`); } } + +/** + * Forgejo permission check using the repo endpoint. + * GET /repos/{owner}/{repo} returns a `permissions` object for the + * authenticated token with { admin, push, pull } booleans. We check + * that the token has push (write) access. + */ +async function checkForgejoPermissions( + octokit: Octokit, + repository: { owner: string; repo: string }, + actor: string, +): Promise { + const response = await octokit.repos.get({ + owner: repository.owner, + repo: repository.repo, + }); + + const permissions = (response.data as any).permissions as + | { admin?: boolean; push?: boolean; pull?: boolean } + | undefined; + + if (!permissions) { + core.warning( + `Forgejo did not return permissions for repo ${repository.owner}/${repository.repo}`, + ); + return false; + } + + if (permissions.admin || permissions.push) { + core.info( + `Forgejo token has write access (admin=${permissions.admin}, push=${permissions.push}) for actor: ${actor}`, + ); + return true; + } + + core.warning( + `Forgejo token has insufficient permissions (admin=${permissions.admin}, push=${permissions.push}) for actor: ${actor}`, + ); + return false; +} diff --git a/src/mcp/install-mcp-server.ts b/src/mcp/install-mcp-server.ts index d5fac34..733152c 100644 --- a/src/mcp/install-mcp-server.ts +++ b/src/mcp/install-mcp-server.ts @@ -4,6 +4,7 @@ import type { GitHubContext } from "../github/context"; import { isEntityContext } from "../github/context"; import { Octokit } from "@octokit/rest"; import type { AutoDetectedMode } from "../modes/detector"; +import { detectPlatform } from "../platform/detector"; type PrepareConfigParams = { githubToken: string; @@ -66,6 +67,10 @@ export async function prepareMcpConfig( try { const allowedToolsList = allowedTools || []; + // Detect platform (github vs forgejo) + const platform = detectPlatform(); + const isForgejo = platform === "forgejo"; + // Detect if we're in agent mode (explicit prompt provided) const isAgentMode = mode === "agent"; @@ -167,21 +172,31 @@ export async function prepareMcpConfig( hasWorkflowToken; if (shouldIncludeCIServer) { - // Verify the token actually has actions:read permission - const actuallyHasPermission = await checkActionsReadPermission( - process.env.DEFAULT_WORKFLOW_TOKEN || "", - owner, - repo, - ); + // On Forgejo, the workflow token has full repo access — skip the + // GitHub-specific permission probe which checks for 'actions: read'. + // On GitHub, verify the token actually has actions:read permission. + let includeCIServer = isForgejo; - if (!actuallyHasPermission) { - core.warning( - "The github_ci MCP server requires 'actions: read' permission. " + - "Skipping CI server installation. " + - "To enable CI status checks, add 'actions: read' to your workflow permissions. " + - "See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token", + if (!includeCIServer) { + const actuallyHasPermission = await checkActionsReadPermission( + process.env.DEFAULT_WORKFLOW_TOKEN || "", + owner, + repo, ); - } else { + + if (!actuallyHasPermission) { + core.warning( + "The github_ci MCP server requires 'actions: read' permission. " + + "Skipping CI server installation. " + + "To enable CI status checks, add 'actions: read' to your workflow permissions. " + + "See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token", + ); + } else { + includeCIServer = true; + } + } + + if (includeCIServer) { baseMcpConfig.mcpServers.github_ci = { command: "bun", args: [ @@ -200,7 +215,9 @@ export async function prepareMcpConfig( } } - if (hasGitHubMcpTools) { + // The GitHub public MCP server (ghcr.io/github/github-mcp-server) is + // GitHub-only — it doesn't work with Forgejo instances. + if (hasGitHubMcpTools && !isForgejo) { baseMcpConfig.mcpServers.github = { command: "docker", args: [ diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index 0adf746..3aa16fe 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -12,6 +12,8 @@ import { extractOriginalTitle, extractOriginalBody, } from "../../github/data/fetcher"; +import { fetchForgejoData } from "../../forgejo/data/fetcher"; +import { detectPlatform } from "../../platform/detector"; import { createPrompt } from "../../create-prompt"; import { isEntityContext } from "../../github/context"; import type { GitHubContext } from "../../github/context"; @@ -41,7 +43,7 @@ export async function prepareTagMode({ // Check if actor is human await checkHumanActor(octokit.rest, context); - // Create initial tracking comment + // Create initial tracking comment (auto-detects platform for spinner/URL format) const commentData = await createInitialComment(octokit.rest, context); const commentId = commentData.id; @@ -49,7 +51,9 @@ export async function prepareTagMode({ const originalTitle = extractOriginalTitle(context); const originalBody = extractOriginalBody(context); - const githubData = await fetchGitHubData({ + // Platform-aware data fetching: Forgejo uses REST-only, GitHub uses GraphQL + const platform = detectPlatform(); + const fetchParams = { octokits: octokit, repository: `${context.repository.owner}/${context.repository.repo}`, prNumber: context.entityNumber.toString(), @@ -60,7 +64,11 @@ export async function prepareTagMode({ originalBody, includeCommentsByActor: context.inputs.includeCommentsByActor, excludeCommentsByActor: context.inputs.excludeCommentsByActor, - }); + }; + const githubData = + platform === "forgejo" + ? await fetchForgejoData(fetchParams) + : await fetchGitHubData(fetchParams); // Setup branch const branchInfo = await setupBranch(octokit, githubData, context); diff --git a/src/platform/detector.ts b/src/platform/detector.ts new file mode 100644 index 0000000..a40a30f --- /dev/null +++ b/src/platform/detector.ts @@ -0,0 +1,29 @@ +/** + * Platform detection for GitHub vs Forgejo environments. + * + * Detection priority: + * 1. PLATFORM env var (set by action.yml detect step or explicit input) + * 2. FORGEJO env var (set by Forgejo runner v7.0+) + * 3. GITHUB_SERVER_URL heuristic (non-github.com = Forgejo) + * 4. Default to GitHub + */ + +export type Platform = "github" | "forgejo"; + +export function detectPlatform(): Platform { + // 1. Explicit platform from action.yml detect step or user input + const platformEnv = process.env.PLATFORM; + if (platformEnv === "forgejo") return "forgejo"; + if (platformEnv === "github") return "github"; + + // 2. FORGEJO env var (set by Forgejo runner v7.0+) + if (process.env.FORGEJO === "true") return "forgejo"; + + // 3. GITHUB_SERVER_URL heuristic — if not github.com, likely Forgejo/Gitea + const serverUrl = process.env.GITHUB_SERVER_URL || ""; + if (serverUrl && !serverUrl.includes("github.com")) { + return "forgejo"; + } + + return "github"; +} diff --git a/test/actor.test.ts b/test/actor.test.ts index 4c9d206..946347a 100644 --- a/test/actor.test.ts +++ b/test/actor.test.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { describe, test, expect } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { checkHumanActor } from "../src/github/validation/actor"; import type { Octokit } from "@octokit/rest"; import { createMockContext } from "./mockContext"; @@ -93,4 +93,79 @@ describe("checkHumanActor", () => { "Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.", ); }); + + test("should treat undefined actor type as human (GitHub compatibility)", async () => { + // Edge case: GitHub API returns no `type` field + const mockOctokit = { + users: { + getByUsername: async () => ({ + data: {}, + }), + }, + } as unknown as Octokit; + const context = createMockContext(); + context.actor = "unknown-user"; + + await expect( + checkHumanActor(mockOctokit, context), + ).resolves.toBeUndefined(); + }); + + describe("Forgejo platform", () => { + let savedPlatform: string | undefined; + let savedServerUrl: string | undefined; + + beforeEach(() => { + savedPlatform = process.env.PLATFORM; + savedServerUrl = process.env.GITHUB_SERVER_URL; + process.env.PLATFORM = "forgejo"; + }); + + afterEach(() => { + if (savedPlatform !== undefined) process.env.PLATFORM = savedPlatform; + else delete process.env.PLATFORM; + if (savedServerUrl !== undefined) + process.env.GITHUB_SERVER_URL = savedServerUrl; + else delete process.env.GITHUB_SERVER_URL; + }); + + test("should skip actor check entirely on Forgejo", async () => { + let apiCalled = false; + const mockOctokit = { + users: { + getByUsername: async () => { + apiCalled = true; + return { data: {} }; + }, + }, + } as unknown as Octokit; + const context = createMockContext(); + context.actor = "forgejo-user"; + + await expect( + checkHumanActor(mockOctokit, context), + ).resolves.toBeUndefined(); + expect(apiCalled).toBe(false); + }); + + test("should skip actor check for bot-like names on Forgejo", async () => { + let apiCalled = false; + const mockOctokit = { + users: { + getByUsername: async () => { + apiCalled = true; + return { data: { type: "Bot" } }; + }, + }, + } as unknown as Octokit; + const context = createMockContext(); + context.actor = "some-bot[bot]"; + context.inputs.allowedBots = ""; + + await expect( + checkHumanActor(mockOctokit, context), + ).resolves.toBeUndefined(); + expect(apiCalled).toBe(false); + }); + }); }); diff --git a/test/forgejo-comment-logic.test.ts b/test/forgejo-comment-logic.test.ts new file mode 100644 index 0000000..e77b8dc --- /dev/null +++ b/test/forgejo-comment-logic.test.ts @@ -0,0 +1,595 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { + FORGEJO_SPINNER_HTML, + createForgejoJobRunLink, + createForgejoBranchLink, + createForgejoPrCompareUrl, + createForgejoCommentBody, + extractOwnerRepoFromUrl, + updateForgejoCommentBody, + type CommentUpdateInput, +} from "../src/forgejo/comment-logic"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const FORGEJO_SERVER = "https://git.wxs.ro"; +const GITHUB_SERVER = "https://github.com"; + +/** + * Set GITHUB_SERVER_URL to a Forgejo instance URL. + * The module reads it at call time via the config export. + */ +function setServerUrl(url: string) { + process.env.GITHUB_SERVER_URL = url; +} + +// --------------------------------------------------------------------------- +// FORGEJO_SPINNER_HTML +// --------------------------------------------------------------------------- + +describe("FORGEJO_SPINNER_HTML", () => { + it("is a Unicode character, not a GitHub CDN URL", () => { + expect(FORGEJO_SPINNER_HTML).not.toContain("github.com"); + expect(FORGEJO_SPINNER_HTML).not.toContain(" { + beforeEach(() => setServerUrl(FORGEJO_SERVER)); + afterEach(() => setServerUrl(GITHUB_SERVER)); + + it("creates a job run link with Forgejo server URL", () => { + const result = createForgejoJobRunLink("wxs", "myrepo", "42"); + expect(result).toBe( + "[View job run](https://git.wxs.ro/wxs/myrepo/actions/runs/42)", + ); + }); + + it("uses /actions/runs/ path (same as GitHub)", () => { + const result = createForgejoJobRunLink("org", "repo", "999"); + expect(result).toContain("/actions/runs/999"); + }); +}); + +// --------------------------------------------------------------------------- +// createForgejoBranchLink +// --------------------------------------------------------------------------- + +describe("createForgejoBranchLink", () => { + beforeEach(() => setServerUrl(FORGEJO_SERVER)); + afterEach(() => setServerUrl(GITHUB_SERVER)); + + it("creates a branch link with /tree/ path", () => { + const result = createForgejoBranchLink("wxs", "myrepo", "feature-branch"); + expect(result).toBe( + "\n[View branch](https://git.wxs.ro/wxs/myrepo/tree/feature-branch)", + ); + }); + + it("handles branch names with slashes", () => { + const result = createForgejoBranchLink( + "wxs", + "myrepo", + "claude/issue-5-20240101-1200", + ); + expect(result).toContain("/tree/claude/issue-5-20240101-1200"); + }); +}); + +// --------------------------------------------------------------------------- +// createForgejoPrCompareUrl +// --------------------------------------------------------------------------- + +describe("createForgejoPrCompareUrl", () => { + it("builds compare URL without quick_pull=1", () => { + const result = createForgejoPrCompareUrl( + FORGEJO_SERVER, + "wxs", + "myrepo", + "main", + "feature-branch", + "Fix bug", + "Fixes issue #1", + ); + + expect(result).toBe( + `${FORGEJO_SERVER}/wxs/myrepo/compare/main...feature-branch?expand=1&title=Fix%20bug&body=Fixes%20issue%20%231`, + ); + expect(result).not.toContain("quick_pull"); + }); + + it("uses expand=1 parameter for Forgejo", () => { + const result = createForgejoPrCompareUrl( + FORGEJO_SERVER, + "wxs", + "myrepo", + "main", + "feature", + "Title", + "Body", + ); + expect(result).toContain("expand=1"); + }); + + it("URL-encodes title and body", () => { + const result = createForgejoPrCompareUrl( + FORGEJO_SERVER, + "org", + "repo", + "main", + "fix-branch", + "fix: important bug fix", + "Fixes #123\n\nGenerated with Claude Code", + ); + + expect(result).toContain("title=fix%3A%20important%20bug%20fix"); + expect(result).toContain( + "body=Fixes%20%23123%0A%0AGenerated%20with%20Claude%20Code", + ); + }); + + it("uses three dots between branches", () => { + const result = createForgejoPrCompareUrl( + FORGEJO_SERVER, + "o", + "r", + "main", + "dev", + "t", + "b", + ); + expect(result).toContain("/compare/main...dev?"); + }); +}); + +// --------------------------------------------------------------------------- +// createForgejoCommentBody +// --------------------------------------------------------------------------- + +describe("createForgejoCommentBody", () => { + beforeEach(() => setServerUrl(FORGEJO_SERVER)); + afterEach(() => setServerUrl(GITHUB_SERVER)); + + it("uses Forgejo spinner instead of GitHub CDN image", () => { + const result = createForgejoCommentBody( + "[View job run](https://git.wxs.ro/wxs/myrepo/actions/runs/1)", + ); + expect(result).toContain(FORGEJO_SPINNER_HTML); + expect(result).not.toContain("github.com/user-attachments"); + expect(result).not.toContain(" { + const jobLink = + "[View job run](https://git.wxs.ro/wxs/myrepo/actions/runs/42)"; + const result = createForgejoCommentBody(jobLink); + expect(result).toContain("Claude Code is working…"); + expect(result).toContain("I'll analyze this and get back to you."); + expect(result).toContain(jobLink); + }); + + it("includes branch link when provided", () => { + const jobLink = + "[View job run](https://git.wxs.ro/wxs/myrepo/actions/runs/42)"; + const branchLink = + "\n[View branch](https://git.wxs.ro/wxs/myrepo/tree/feature)"; + const result = createForgejoCommentBody(jobLink, branchLink); + expect(result).toContain(branchLink); + }); +}); + +// --------------------------------------------------------------------------- +// extractOwnerRepoFromUrl +// --------------------------------------------------------------------------- + +describe("extractOwnerRepoFromUrl", () => { + it("extracts owner/repo from Forgejo job URL", () => { + const result = extractOwnerRepoFromUrl( + "https://git.wxs.ro/wxs/myrepo/actions/runs/42", + FORGEJO_SERVER, + ); + expect(result).toEqual({ owner: "wxs", repo: "myrepo" }); + }); + + it("extracts owner/repo from GitHub job URL", () => { + const result = extractOwnerRepoFromUrl( + "https://github.com/anthropics/claude-code-action/actions/runs/123", + GITHUB_SERVER, + ); + expect(result).toEqual({ owner: "anthropics", repo: "claude-code-action" }); + }); + + it("extracts owner/repo from branch URL", () => { + const result = extractOwnerRepoFromUrl( + "https://git.wxs.ro/wxs/myrepo/tree/feature-branch", + FORGEJO_SERVER, + ); + expect(result).toEqual({ owner: "wxs", repo: "myrepo" }); + }); + + it("returns null when hosts don't match", () => { + const result = extractOwnerRepoFromUrl( + "https://github.com/owner/repo/actions/runs/1", + FORGEJO_SERVER, + ); + expect(result).toBeNull(); + }); + + it("returns null for URLs with less than 2 path segments", () => { + const result = extractOwnerRepoFromUrl( + "https://git.wxs.ro/only-one-segment", + FORGEJO_SERVER, + ); + expect(result).toBeNull(); + }); + + it("returns null for invalid URLs", () => { + const result = extractOwnerRepoFromUrl("not-a-url", FORGEJO_SERVER); + expect(result).toBeNull(); + }); + + it("handles server URLs with ports", () => { + const result = extractOwnerRepoFromUrl( + "https://forgejo.local:3000/org/repo/actions/runs/1", + "https://forgejo.local:3000", + ); + expect(result).toEqual({ owner: "org", repo: "repo" }); + }); + + it("handles http:// server URLs", () => { + const result = extractOwnerRepoFromUrl( + "http://forgejo.local/org/repo/tree/main", + "http://forgejo.local", + ); + expect(result).toEqual({ owner: "org", repo: "repo" }); + }); + + it("defaults serverUrl to GITHUB_SERVER_URL env var", () => { + const originalUrl = process.env.GITHUB_SERVER_URL; + process.env.GITHUB_SERVER_URL = FORGEJO_SERVER; + try { + const result = extractOwnerRepoFromUrl( + "https://git.wxs.ro/wxs/myrepo/actions/runs/42", + ); + expect(result).toEqual({ owner: "wxs", repo: "myrepo" }); + } finally { + process.env.GITHUB_SERVER_URL = originalUrl; + } + }); +}); + +// --------------------------------------------------------------------------- +// updateForgejoCommentBody +// --------------------------------------------------------------------------- + +describe("updateForgejoCommentBody", () => { + const forgejoBaseInput: CommentUpdateInput = { + currentBody: "Initial comment body", + actionFailed: false, + executionDetails: null, + jobUrl: "https://git.wxs.ro/wxs/myrepo/actions/runs/42", + branchName: undefined, + triggerUsername: undefined, + }; + + beforeEach(() => setServerUrl(FORGEJO_SERVER)); + afterEach(() => setServerUrl(GITHUB_SERVER)); + + describe("working message replacement", () => { + it("removes working message with GitHub spinner", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: + 'Claude Code is working… ', + triggerUsername: "testuser", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain("**Claude finished @testuser's task**"); + expect(result).not.toContain("Claude Code is working"); + expect(result).not.toContain(" { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: "Claude Code is working… ⏳", + triggerUsername: "testuser", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain("**Claude finished @testuser's task**"); + expect(result).not.toContain("Claude Code is working"); + expect(result).not.toContain("⏳"); + }); + + it("includes success header with duration", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: "Claude Code is working…", + executionDetails: { duration_ms: 74000 }, + triggerUsername: "trigger-user", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "**Claude finished @trigger-user's task in 1m 14s**", + ); + }); + + it("includes error header with duration", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: "Claude Code is working...", + actionFailed: true, + executionDetails: { duration_ms: 45000 }, + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain("**Claude encountered an error after 45s**"); + }); + + it("includes error details when provided", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: "Claude Code is working...", + actionFailed: true, + executionDetails: { duration_ms: 45000 }, + errorDetails: "Failed to fetch issue data", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain("**Claude encountered an error after 45s**"); + expect(result).toContain("[View job]"); + expect(result).toContain("```\nFailed to fetch issue data\n```"); + }); + }); + + describe("job link", () => { + it("includes Forgejo job link in header", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: "Some comment", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain(`—— [View job](${forgejoBaseInput.jobUrl})`); + }); + }); + + describe("branch link — critical regex fix", () => { + it("constructs branch URL from Forgejo job URL when branchName is provided without branchLink", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + branchName: "claude/issue-5-20240101-1200", + }; + + const result = updateForgejoCommentBody(input); + // This is the critical fix: GitHub's version uses a hardcoded github.com regex + // which fails for Forgejo URLs. Our version uses extractOwnerRepoFromUrl. + expect(result).toContain( + "• [`claude/issue-5-20240101-1200`](https://git.wxs.ro/wxs/myrepo/tree/claude/issue-5-20240101-1200)", + ); + }); + + it("falls back to plain branch name when URL extraction fails", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + jobUrl: "invalid-url", + branchName: "feature-branch", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain("• `feature-branch`"); + expect(result).not.toContain("[`feature-branch`]"); + }); + + it("uses branchLink URL when provided", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + branchName: "feature-branch", + branchLink: + "\n[View branch](https://git.wxs.ro/wxs/myrepo/tree/feature-branch)", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "• [`feature-branch`](https://git.wxs.ro/wxs/myrepo/tree/feature-branch)", + ); + }); + + it("extracts branch name from branchLink when branchName not provided", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + branchLink: + "\n[View branch](https://git.wxs.ro/wxs/myrepo/tree/auto-detected-branch)", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "• [`auto-detected-branch`](https://git.wxs.ro/wxs/myrepo/tree/auto-detected-branch)", + ); + }); + }); + + describe("PR link", () => { + it("adds PR link to header when provided", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + prLink: + "\n[Create a PR](https://git.wxs.ro/wxs/myrepo/compare/main...feature)", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "• [Create PR ➔](https://git.wxs.ro/wxs/myrepo/compare/main...feature)", + ); + }); + + it("moves PR link from body to header", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: + "Some comment with [Create a PR](https://git.wxs.ro/wxs/myrepo/compare/main...fix)", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "• [Create PR ➔](https://git.wxs.ro/wxs/myrepo/compare/main...fix)", + ); + expect(result).not.toContain("[Create a PR]"); + }); + + it("handles Forgejo PR URLs with expand=1 parameter", () => { + const forgejoUrl = + "https://git.wxs.ro/wxs/myrepo/compare/main...feature?expand=1&title=Fix%20bug&body=Desc"; + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: `Done!\n\n[Create a PR](${forgejoUrl})`, + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain(`• [Create PR ➔](${forgejoUrl})`); + }); + }); + + describe("execution details", () => { + it("formats duration in minutes and seconds", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + executionDetails: { duration_ms: 75000 }, + triggerUsername: "testuser", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain( + "**Claude finished @testuser's task in 1m 15s**", + ); + }); + + it("formats seconds-only duration", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + executionDetails: { duration_ms: 31000 }, + triggerUsername: "testuser", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain("**Claude finished @testuser's task in 31s**"); + }); + + it("handles missing duration gracefully", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + executionDetails: { total_cost_usd: 0.25 }, + triggerUsername: "testuser", + }; + + const result = updateForgejoCommentBody(input); + expect(result).toContain("**Claude finished @testuser's task**"); + expect(result).not.toContain(" in "); + }); + }); + + describe("combined updates", () => { + it("combines all updates in correct order", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: + "Claude Code is working… ⏳\n\n### Todo List:\n- [x] Read README.md\n- [x] Add disclaimer", + actionFailed: false, + branchName: "claude-branch-123", + prLink: + "\n[Create a PR](https://git.wxs.ro/wxs/myrepo/compare/main...claude-branch-123)", + executionDetails: { duration_ms: 65000 }, + triggerUsername: "trigger-user", + }; + + const result = updateForgejoCommentBody(input); + + // Check header + expect(result).toContain( + "**Claude finished @trigger-user's task in 1m 5s**", + ); + expect(result).toContain("—— [View job]"); + expect(result).toContain( + "• [`claude-branch-123`](https://git.wxs.ro/wxs/myrepo/tree/claude-branch-123)", + ); + expect(result).toContain("• [Create PR ➔]"); + + // Check order + const headerIndex = result.indexOf("**Claude finished"); + const separatorIndex = result.indexOf("---"); + const todoIndex = result.indexOf("### Todo List:"); + + expect(headerIndex).toBeLessThan(separatorIndex); + expect(separatorIndex).toBeLessThan(todoIndex); + + // Check content preserved + expect(result).toContain("### Todo List:"); + expect(result).toContain("- [x] Read README.md"); + expect(result).toContain("- [x] Add disclaimer"); + + // Spinner fully removed + expect(result).not.toContain("⏳"); + expect(result).not.toContain("Claude Code is working"); + }); + + it("should not show branch name when branch doesn't exist remotely", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: "@claude can you help with this?", + executionDetails: { duration_ms: 90000 }, + branchLink: "", + branchName: undefined, + triggerUsername: "claude", + prLink: "", + }; + + const result = updateForgejoCommentBody(input); + + expect(result).toContain("Claude finished @claude's task in 1m 30s"); + expect(result).toContain(`[View job](${forgejoBaseInput.jobUrl})`); + expect(result).not.toContain("claude/issue-123"); + expect(result).not.toContain("tree/claude/issue-123"); + }); + }); + + describe("comparison with GitHub version — behavior parity", () => { + it("removes old View job run links from body", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: + "Some work done\n[View job run](https://git.wxs.ro/wxs/myrepo/actions/runs/1)", + triggerUsername: "user", + }; + + const result = updateForgejoCommentBody(input); + expect(result).not.toContain("[View job run]"); + expect(result).toContain("[View job]"); + }); + + it("removes old View branch links from body", () => { + const input: CommentUpdateInput = { + ...forgejoBaseInput, + currentBody: + "Some work done\n[View branch](https://git.wxs.ro/wxs/myrepo/tree/old-branch)", + branchName: "new-branch", + triggerUsername: "user", + }; + + const result = updateForgejoCommentBody(input); + // Old link in body removed + expect(result).not.toContain("[View branch]"); + // New branch in header + expect(result).toContain("`new-branch`"); + }); + }); +}); diff --git a/test/forgejo-data-fetcher.test.ts b/test/forgejo-data-fetcher.test.ts new file mode 100644 index 0000000..f20cb20 --- /dev/null +++ b/test/forgejo-data-fetcher.test.ts @@ -0,0 +1,1345 @@ +import { describe, expect, it, jest, mock } from "bun:test"; +import { + fetchForgejoData, + mapFileStatus, + mapRestComments, + mapRestReviewComments, + mapRestReviews, +} from "../src/forgejo/data/fetcher"; +import type { Octokits } from "../src/github/api/client"; +import type { GitHubReviewComment } from "../src/github/types"; + +// Mock image downloader — returns empty map (no-op on Forgejo) +mock.module("../src/github/utils/image-downloader", () => ({ + downloadCommentImages: jest.fn().mockResolvedValue(new Map()), +})); + +// Helper to build a mock Octokits with REST methods +function createMockOctokits({ + prData, + filesData, + commitsData, + commentsData, + reviewsData, + reviewCommentsData, + issueData, + userData, +}: { + prData?: Record; + filesData?: Record[]; + commitsData?: Record[]; + commentsData?: Record[]; + reviewsData?: Record[]; + reviewCommentsData?: Record[]; + issueData?: Record; + userData?: Record; +} = {}) { + return { + rest: { + pulls: { + get: jest.fn().mockResolvedValue({ data: prData ?? {} }), + listFiles: jest.fn().mockResolvedValue({ data: filesData ?? [] }), + listCommits: jest.fn().mockResolvedValue({ data: commitsData ?? [] }), + listReviews: jest.fn().mockResolvedValue({ data: reviewsData ?? [] }), + listReviewComments: jest + .fn() + .mockResolvedValue({ data: reviewCommentsData ?? [] }), + }, + issues: { + get: jest.fn().mockResolvedValue({ data: issueData ?? {} }), + listComments: jest.fn().mockResolvedValue({ data: commentsData ?? [] }), + }, + users: { + getByUsername: jest + .fn() + .mockResolvedValue({ data: userData ?? { name: null } }), + }, + }, + graphql: jest.fn(), + } as unknown as Octokits; +} + +// Minimal PR REST response matching Forgejo/GitHub REST shape +const mockPrRestResponse = { + title: "Fix memory leak in user service", + body: "This PR fixes a critical memory leak.", + user: { login: "test-author" }, + base: { ref: "main" }, + head: { ref: "fix/memory-leak", sha: "abc123def456" }, + created_at: "2024-01-15T10:00:00Z", + updated_at: "2024-01-15T12:00:00Z", + additions: 42, + deletions: 15, + state: "open", + labels: [{ name: "bug" }, { name: "priority:high" }], +}; + +const mockFilesRestResponse = [ + { filename: "src/user.ts", additions: 30, deletions: 10, status: "modified" }, + { filename: "src/new-file.ts", additions: 12, deletions: 0, status: "added" }, + { + filename: "src/old-file.ts", + additions: 0, + deletions: 5, + status: "removed", + }, +]; + +const mockCommitsRestResponse = [ + { + sha: "commit1sha", + commit: { + message: "fix: patch memory leak", + author: { name: "Test Author", email: "test@example.com" }, + }, + }, + { + sha: "commit2sha", + commit: { + message: "test: add regression test", + author: { name: "Test Author", email: "test@example.com" }, + }, + }, +]; + +const mockIssueRestResponse = { + title: "Bug: memory leak in user service", + body: "Steps to reproduce...", + user: { login: "reporter" }, + created_at: "2024-01-10T08:00:00Z", + updated_at: "2024-01-14T09:00:00Z", + state: "open", + labels: [{ name: "bug" }], +}; + +describe("fetchForgejoData", () => { + describe("PR data fetching", () => { + it("should fetch PR details, files, commits, comments, reviews, and inline comments in parallel", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: mockFilesRestResponse, + commitsData: mockCommitsRestResponse, + }); + + await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + // Verify all six REST calls were made + expect(mockOctokits.rest.pulls.get).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + pull_number: 42, + }); + expect(mockOctokits.rest.pulls.listFiles).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + pull_number: 42, + per_page: 100, + }); + expect(mockOctokits.rest.pulls.listCommits).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + pull_number: 42, + per_page: 100, + }); + expect(mockOctokits.rest.issues.listComments).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 42, + per_page: 100, + }); + expect(mockOctokits.rest.pulls.listReviews).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + pull_number: 42, + per_page: 100, + }); + expect(mockOctokits.rest.pulls.listReviewComments).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + pull_number: 42, + per_page: 100, + }); + }); + + it("should map PR fields to GitHubPullRequest type", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: mockFilesRestResponse, + commitsData: mockCommitsRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + const pr = result.contextData as any; + expect(pr.title).toBe("Fix memory leak in user service"); + expect(pr.body).toBe("This PR fixes a critical memory leak."); + expect(pr.author.login).toBe("test-author"); + expect(pr.baseRefName).toBe("main"); + expect(pr.headRefName).toBe("fix/memory-leak"); + expect(pr.headRefOid).toBe("abc123def456"); + expect(pr.createdAt).toBe("2024-01-15T10:00:00Z"); + expect(pr.updatedAt).toBe("2024-01-15T12:00:00Z"); + expect(pr.lastEditedAt).toBeUndefined(); + expect(pr.additions).toBe(42); + expect(pr.deletions).toBe(15); + expect(pr.state).toBe("open"); + }); + + it("should map PR labels correctly", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + const pr = result.contextData as any; + expect(pr.labels.nodes).toEqual([ + { name: "bug" }, + { name: "priority:high" }, + ]); + }); + + it("should handle string labels", async () => { + const mockOctokits = createMockOctokits({ + prData: { ...mockPrRestResponse, labels: ["bug", "fix"] }, + filesData: [], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + const pr = result.contextData as any; + expect(pr.labels.nodes).toEqual([{ name: "bug" }, { name: "fix" }]); + }); + + it("should map commits to GitHubCommit structure", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: mockCommitsRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + const pr = result.contextData as any; + expect(pr.commits.totalCount).toBe(2); + expect(pr.commits.nodes[0].commit.oid).toBe("commit1sha"); + expect(pr.commits.nodes[0].commit.message).toBe("fix: patch memory leak"); + expect(pr.commits.nodes[0].commit.author.name).toBe("Test Author"); + expect(pr.commits.nodes[0].commit.author.email).toBe("test@example.com"); + }); + }); + + describe("file status mapping", () => { + it("should map REST file statuses to GraphQL-style enums", async () => { + const files = [ + { filename: "a.ts", additions: 1, deletions: 0, status: "added" }, + { filename: "b.ts", additions: 0, deletions: 1, status: "removed" }, + { filename: "c.ts", additions: 1, deletions: 1, status: "modified" }, + { filename: "d.ts", additions: 1, deletions: 0, status: "renamed" }, + { filename: "e.ts", additions: 0, deletions: 0, status: "copied" }, + { filename: "f.ts", additions: 1, deletions: 0, status: "changed" }, + ]; + + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: files, + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.changedFiles.map((f) => f.changeType)).toEqual([ + "ADDED", + "DELETED", + "MODIFIED", + "RENAMED", + "COPIED", + "MODIFIED", + ]); + }); + + it("should uppercase unknown file statuses", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [ + { + filename: "a.ts", + additions: 0, + deletions: 0, + status: "unknown_status", + }, + ], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.changedFiles[0]!.changeType).toBe("UNKNOWN_STATUS"); + }); + }); + + describe("SHA computation", () => { + it("should mark deleted files with sha 'deleted'", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [ + { + filename: "deleted.ts", + additions: 0, + deletions: 10, + status: "removed", + }, + ], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.changedFilesWithSHA[0]!.sha).toBe("deleted"); + }); + + it("should use 'unknown' for files that fail SHA computation", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [ + { + filename: "nonexistent-file.ts", + additions: 1, + deletions: 0, + status: "added", + }, + ], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + // File doesn't exist on disk, so SHA computation fails gracefully + expect(result.changedFilesWithSHA[0]!.sha).toBe("unknown"); + }); + + it("should not compute SHAs for issue (no changed files)", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + expect(result.changedFilesWithSHA).toEqual([]); + }); + }); + + describe("issue data fetching", () => { + it("should fetch issue details and comments in parallel", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + expect(mockOctokits.rest.issues.get).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 10, + }); + expect(mockOctokits.rest.issues.listComments).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 10, + per_page: 100, + }); + + // Should NOT call PR endpoints + expect(mockOctokits.rest.pulls.get).not.toHaveBeenCalled(); + expect(mockOctokits.rest.pulls.listFiles).not.toHaveBeenCalled(); + expect(mockOctokits.rest.pulls.listCommits).not.toHaveBeenCalled(); + expect(mockOctokits.rest.pulls.listReviews).not.toHaveBeenCalled(); + expect(mockOctokits.rest.pulls.listReviewComments).not.toHaveBeenCalled(); + }); + + it("should map issue fields to GitHubIssue type", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + const issue = result.contextData as any; + expect(issue.title).toBe("Bug: memory leak in user service"); + expect(issue.body).toBe("Steps to reproduce..."); + expect(issue.author.login).toBe("reporter"); + expect(issue.createdAt).toBe("2024-01-10T08:00:00Z"); + expect(issue.updatedAt).toBe("2024-01-14T09:00:00Z"); + expect(issue.lastEditedAt).toBeUndefined(); + expect(issue.state).toBe("open"); + expect(issue.labels.nodes).toEqual([{ name: "bug" }]); + }); + + it("should return empty changedFiles for issues", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + expect(result.changedFiles).toEqual([]); + expect(result.changedFilesWithSHA).toEqual([]); + }); + + it("should return null reviewData for issues", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + expect(result.reviewData).toBeNull(); + }); + }); + + describe("TOCTOU protection", () => { + it("should override body with originalBody when provided", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + originalBody: "Original body from webhook", + }); + + expect(result.contextData.body).toBe("Original body from webhook"); + }); + + it("should override body with empty string when originalBody is null", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + originalBody: null, + }); + + expect(result.contextData.body).toBe(""); + }); + + it("should override title with originalTitle when provided", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + originalTitle: "Original title from webhook", + }); + + expect(result.contextData.title).toBe("Original title from webhook"); + }); + + it("should keep fetched body when originalBody is not provided", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + expect(result.contextData.body).toBe("Steps to reproduce..."); + }); + }); + + describe("user display name", () => { + it("should fetch display name via REST when triggerUsername provided", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + userData: { name: "Test User Display Name" }, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + triggerUsername: "testuser", + }); + + expect(mockOctokits.rest.users.getByUsername).toHaveBeenCalledWith({ + username: "testuser", + }); + expect(result.triggerDisplayName).toBe("Test User Display Name"); + }); + + it("should return null when user has no display name", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + userData: { name: null }, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + triggerUsername: "testuser", + }); + + expect(result.triggerDisplayName).toBeNull(); + }); + + it("should return null on user fetch failure", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + (mockOctokits.rest.users.getByUsername as any).mockRejectedValue( + new Error("User not found"), + ); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + triggerUsername: "nonexistent", + }); + + expect(result.triggerDisplayName).toBeNull(); + }); + + it("should not fetch display name when triggerUsername is not provided", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + expect(mockOctokits.rest.users.getByUsername).not.toHaveBeenCalled(); + expect(result.triggerDisplayName).toBeUndefined(); + }); + }); + + describe("comments and reviews (M4b)", () => { + const mockCommentsData = [ + { + id: 100, + body: "First comment", + user: { login: "commenter1" }, + created_at: "2024-01-15T09:00:00Z", + updated_at: "2024-01-15T09:00:00Z", + }, + { + id: 101, + body: "Second comment", + user: { login: "commenter2" }, + created_at: "2024-01-15T09:30:00Z", + updated_at: "2024-01-15T09:30:00Z", + }, + ]; + + const mockReviewsData = [ + { + id: 200, + body: "LGTM", + user: { login: "reviewer" }, + state: "APPROVED", + submitted_at: "2024-01-15T10:00:00Z", + }, + ]; + + const mockReviewCommentsData = [ + { + id: 300, + body: "Fix this line", + user: { login: "reviewer" }, + path: "src/main.ts", + line: 42, + original_line: 42, + created_at: "2024-01-15T10:00:00Z", + updated_at: "2024-01-15T10:00:00Z", + pull_request_review_id: 200, + }, + ]; + + it("should fetch and return comments for PRs", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + commentsData: mockCommentsData, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.comments).toHaveLength(2); + expect(result.comments[0]!.id).toBe("100"); + expect(result.comments[0]!.body).toBe("First comment"); + expect(result.comments[0]!.author.login).toBe("commenter1"); + expect(result.comments[1]!.body).toBe("Second comment"); + }); + + it("should fetch and return reviews with grouped inline comments", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + reviewsData: mockReviewsData, + reviewCommentsData: mockReviewCommentsData, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.reviewData).not.toBeNull(); + expect(result.reviewData!.nodes).toHaveLength(1); + expect(result.reviewData!.nodes[0]!.body).toBe("LGTM"); + expect(result.reviewData!.nodes[0]!.state).toBe("APPROVED"); + expect(result.reviewData!.nodes[0]!.comments.nodes).toHaveLength(1); + expect(result.reviewData!.nodes[0]!.comments.nodes[0]!.body).toBe( + "Fix this line", + ); + expect(result.reviewData!.nodes[0]!.comments.nodes[0]!.path).toBe( + "src/main.ts", + ); + }); + + it("should populate contextData.comments.nodes and contextData.reviews.nodes", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + commentsData: mockCommentsData, + reviewsData: mockReviewsData, + reviewCommentsData: mockReviewCommentsData, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + const pr = result.contextData as any; + expect(pr.comments.nodes).toHaveLength(2); + expect(pr.reviews.nodes).toHaveLength(1); + }); + + it("should return empty comments/reviewData when no data", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + commentsData: [], + reviewsData: [], + reviewCommentsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.comments).toEqual([]); + expect(result.reviewData).toEqual({ nodes: [] }); + }); + + it("should fetch and return comments for issues", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + commentsData: mockCommentsData, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + expect(result.comments).toHaveLength(2); + expect(result.comments[0]!.body).toBe("First comment"); + }); + + it("should apply TOCTOU filtering to comments", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + commentsData: [ + { + id: 1, + body: "Before trigger", + user: { login: "alice" }, + created_at: "2024-01-15T09:00:00Z", + updated_at: "2024-01-15T09:00:00Z", + }, + { + id: 2, + body: "After trigger", + user: { login: "bob" }, + created_at: "2024-01-15T11:00:00Z", + updated_at: "2024-01-15T11:00:00Z", + }, + ], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + triggerTime: "2024-01-15T10:00:00Z", + }); + + // Only the comment before trigger time should be included + expect(result.comments).toHaveLength(1); + expect(result.comments[0]!.body).toBe("Before trigger"); + }); + + it("should apply actor filtering to comments", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + commentsData: [ + { + id: 1, + body: "Human comment", + user: { login: "alice" }, + created_at: "2024-01-15T09:00:00Z", + updated_at: "2024-01-15T09:00:00Z", + }, + { + id: 2, + body: "Bot comment", + user: { login: "dependabot[bot]" }, + created_at: "2024-01-15T09:00:00Z", + updated_at: "2024-01-15T09:00:00Z", + }, + ], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + excludeCommentsByActor: "*[bot]", + }); + + expect(result.comments).toHaveLength(1); + expect(result.comments[0]!.author.login).toBe("alice"); + }); + + it("should actor-filter reviews without mutating contextData", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + reviewsData: [ + { + id: 200, + body: "Bot review", + user: { login: "bot[bot]" }, + state: "COMMENTED", + submitted_at: "2024-01-15T10:00:00Z", + }, + { + id: 201, + body: "Human review", + user: { login: "alice" }, + state: "APPROVED", + submitted_at: "2024-01-15T10:30:00Z", + }, + ], + reviewCommentsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + excludeCommentsByActor: "*[bot]", + }); + + // contextData should still have both reviews (unfiltered) + const pr = result.contextData as any; + expect(pr.reviews.nodes).toHaveLength(2); + + // reviewData should only have the human review (filtered) + expect(result.reviewData!.nodes).toHaveLength(1); + expect(result.reviewData!.nodes[0]!.author.login).toBe("alice"); + }); + + it("should actor-filter inline comments within reviews", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + reviewsData: [ + { + id: 200, + body: "Review", + user: { login: "alice" }, + state: "COMMENTED", + submitted_at: "2024-01-15T10:00:00Z", + }, + ], + reviewCommentsData: [ + { + id: 300, + body: "Human inline", + user: { login: "alice" }, + path: "file.ts", + line: 10, + original_line: 10, + created_at: "2024-01-15T10:00:00Z", + updated_at: "2024-01-15T10:00:00Z", + pull_request_review_id: 200, + }, + { + id: 301, + body: "Bot inline", + user: { login: "bot[bot]" }, + path: "file.ts", + line: 20, + original_line: 20, + created_at: "2024-01-15T10:00:00Z", + updated_at: "2024-01-15T10:00:00Z", + pull_request_review_id: 200, + }, + ], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + excludeCommentsByActor: "*[bot]", + }); + + expect(result.reviewData!.nodes[0]!.comments.nodes).toHaveLength(1); + expect(result.reviewData!.nodes[0]!.comments.nodes[0]!.author.login).toBe( + "alice", + ); + }); + + it("should return imageUrlMap (empty on Forgejo due to URL pattern mismatch)", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + expect(result.imageUrlMap.size).toBe(0); + }); + }); + + describe("TOCTOU filtering for issue comments", () => { + it("should exclude comments edited after trigger time", async () => { + const mockOctokits = createMockOctokits({ + issueData: mockIssueRestResponse, + commentsData: [ + { + id: 1, + body: "Early comment", + user: { login: "alice" }, + created_at: "2024-01-15T08:00:00Z", + updated_at: "2024-01-15T08:00:00Z", + }, + { + id: 2, + body: "Comment edited after trigger", + user: { login: "bob" }, + created_at: "2024-01-15T08:30:00Z", + updated_at: "2024-01-15T11:00:00Z", + }, + { + id: 3, + body: "Late comment", + user: { login: "charlie" }, + created_at: "2024-01-15T11:00:00Z", + updated_at: "2024-01-15T11:00:00Z", + }, + ], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "5", + isPR: false, + triggerTime: "2024-01-15T10:00:00Z", + }); + + // Only the early, unedited comment should be included + expect(result.comments).toHaveLength(1); + expect(result.comments[0]!.body).toBe("Early comment"); + }); + }); + + describe("error handling", () => { + it("should throw on invalid repository format", async () => { + const mockOctokits = createMockOctokits(); + + expect( + fetchForgejoData({ + octokits: mockOctokits, + repository: "invalid-format", + prNumber: "1", + isPR: false, + }), + ).rejects.toThrow("Invalid repository format"); + }); + + it("should handle null PR body gracefully", async () => { + const mockOctokits = createMockOctokits({ + prData: { ...mockPrRestResponse, body: null }, + filesData: [], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.contextData.body).toBe(""); + }); + + it("should handle null issue body gracefully", async () => { + const mockOctokits = createMockOctokits({ + issueData: { ...mockIssueRestResponse, body: null }, + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "10", + isPR: false, + }); + + expect(result.contextData.body).toBe(""); + }); + + it("should handle null PR user gracefully", async () => { + const mockOctokits = createMockOctokits({ + prData: { ...mockPrRestResponse, user: null }, + filesData: [], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.contextData.author.login).toBe("unknown"); + }); + + it("should handle missing labels gracefully", async () => { + const mockOctokits = createMockOctokits({ + prData: { ...mockPrRestResponse, labels: undefined }, + filesData: [], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + const pr = result.contextData as any; + expect(pr.labels.nodes).toEqual([]); + }); + + it("should handle missing commit author gracefully", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [ + { + sha: "abc", + commit: { + message: "test", + author: null, + }, + }, + ], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + const pr = result.contextData as any; + expect(pr.commits.nodes[0].commit.author.name).toBe("unknown"); + expect(pr.commits.nodes[0].commit.author.email).toBe(""); + }); + }); +}); + +describe("mapFileStatus", () => { + it("maps REST file statuses to GraphQL-style enums", () => { + expect(mapFileStatus("added")).toBe("ADDED"); + expect(mapFileStatus("removed")).toBe("DELETED"); + expect(mapFileStatus("modified")).toBe("MODIFIED"); + expect(mapFileStatus("renamed")).toBe("RENAMED"); + expect(mapFileStatus("copied")).toBe("COPIED"); + expect(mapFileStatus("changed")).toBe("MODIFIED"); + expect(mapFileStatus("unchanged")).toBe("MODIFIED"); + }); + + it("uppercases unknown statuses", () => { + expect(mapFileStatus("something_else")).toBe("SOMETHING_ELSE"); + }); +}); + +describe("mapRestComments", () => { + it("maps REST comment objects to GitHubComment type", () => { + const result = mapRestComments([ + { + id: 42, + body: "Hello", + user: { login: "alice" }, + created_at: "2024-01-15T10:00:00Z", + updated_at: "2024-01-15T11:00:00Z", + }, + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: "42", + databaseId: "42", + body: "Hello", + author: { login: "alice" }, + createdAt: "2024-01-15T10:00:00Z", + updatedAt: "2024-01-15T11:00:00Z", + lastEditedAt: undefined, + isMinimized: false, + }); + }); + + it("handles null body and user", () => { + const result = mapRestComments([ + { + id: 1, + body: null, + user: null, + created_at: "2024-01-15T10:00:00Z", + updated_at: "2024-01-15T10:00:00Z", + }, + ]); + + expect(result[0]!.body).toBe(""); + expect(result[0]!.author.login).toBe("unknown"); + }); + + it("handles empty array", () => { + expect(mapRestComments([])).toEqual([]); + }); +}); + +describe("mapRestReviewComments", () => { + it("maps inline review comments with reviewId", () => { + const result = mapRestReviewComments([ + { + id: 100, + body: "Fix this", + user: { login: "bob" }, + path: "src/main.ts", + line: 42, + original_line: 40, + created_at: "2024-01-15T10:00:00Z", + updated_at: "2024-01-15T10:00:00Z", + pull_request_review_id: 200, + }, + ]); + + expect(result).toHaveLength(1); + expect(result[0]!.path).toBe("src/main.ts"); + expect(result[0]!.line).toBe(42); + expect(result[0]!.reviewId).toBe(200); + }); + + it("falls back to original_line when line is null", () => { + const result = mapRestReviewComments([ + { + id: 101, + body: "comment", + user: { login: "bob" }, + path: "file.ts", + line: null, + original_line: 10, + created_at: "2024-01-15T10:00:00Z", + updated_at: "2024-01-15T10:00:00Z", + }, + ]); + + expect(result[0]!.line).toBe(10); + }); + + it("returns null line when both line and original_line are null", () => { + const result = mapRestReviewComments([ + { + id: 102, + body: "comment", + user: { login: "bob" }, + path: "file.ts", + line: null, + original_line: null, + created_at: "2024-01-15T10:00:00Z", + updated_at: "2024-01-15T10:00:00Z", + }, + ]); + + expect(result[0]!.line).toBeNull(); + }); +}); + +describe("mapRestReviews", () => { + it("groups inline comments by review ID", () => { + const inlineComments: (GitHubReviewComment & { + reviewId: number | null; + })[] = [ + { + id: "100", + databaseId: "100", + body: "Comment for review 200", + author: { login: "alice" }, + createdAt: "2024-01-15T10:00:00Z", + updatedAt: "2024-01-15T10:00:00Z", + path: "file.ts", + line: 10, + reviewId: 200, + }, + { + id: "101", + databaseId: "101", + body: "Another for review 200", + author: { login: "alice" }, + createdAt: "2024-01-15T10:00:00Z", + updatedAt: "2024-01-15T10:00:00Z", + path: "file.ts", + line: 20, + reviewId: 200, + }, + ]; + + const result = mapRestReviews( + [ + { + id: 200, + body: "Review", + user: { login: "alice" }, + state: "COMMENTED", + submitted_at: "2024-01-15T10:00:00Z", + }, + ], + inlineComments, + ); + + expect(result[0]!.comments.nodes).toHaveLength(2); + }); + + it("uppercases review state", () => { + const result = mapRestReviews( + [ + { + id: 200, + body: "", + user: { login: "alice" }, + state: "changes_requested", + submitted_at: "2024-01-15T10:00:00Z", + }, + ], + [], + ); + + expect(result[0]!.state).toBe("CHANGES_REQUESTED"); + }); + + it("excludes orphan inline comments (null reviewId)", () => { + const inlineComments: (GitHubReviewComment & { + reviewId: number | null; + })[] = [ + { + id: "100", + databaseId: "100", + body: "orphan", + author: { login: "alice" }, + createdAt: "2024-01-15T10:00:00Z", + updatedAt: "2024-01-15T10:00:00Z", + path: "file.ts", + line: 1, + reviewId: null, + }, + ]; + + const result = mapRestReviews( + [ + { + id: 200, + body: "", + user: { login: "alice" }, + state: "COMMENTED", + submitted_at: "2024-01-15T10:00:00Z", + }, + ], + inlineComments, + ); + + expect(result[0]!.comments.nodes).toEqual([]); + }); + + it("strips reviewId from stored comments", () => { + const inlineComments: (GitHubReviewComment & { + reviewId: number | null; + })[] = [ + { + id: "100", + databaseId: "100", + body: "inline", + author: { login: "alice" }, + createdAt: "2024-01-15T10:00:00Z", + updatedAt: "2024-01-15T10:00:00Z", + path: "file.ts", + line: 5, + reviewId: 200, + }, + ]; + + const result = mapRestReviews( + [ + { + id: 200, + body: "", + user: { login: "alice" }, + state: "COMMENTED", + submitted_at: "2024-01-15T10:00:00Z", + }, + ], + inlineComments, + ); + + expect((result[0]!.comments.nodes[0] as any).reviewId).toBeUndefined(); + }); +}); diff --git a/test/forgejo-token.test.ts b/test/forgejo-token.test.ts new file mode 100644 index 0000000..b3d5d6c --- /dev/null +++ b/test/forgejo-token.test.ts @@ -0,0 +1,62 @@ +import { setupForgejoToken } from "../src/forgejo/token"; +import { describe, it, expect, beforeEach } from "bun:test"; + +describe("setupForgejoToken", () => { + beforeEach(() => { + delete process.env.OVERRIDE_GITHUB_TOKEN; + delete process.env.DEFAULT_WORKFLOW_TOKEN; + }); + + describe("OVERRIDE_GITHUB_TOKEN (user-provided via github_token input)", () => { + it("should return OVERRIDE_GITHUB_TOKEN when set", () => { + process.env.OVERRIDE_GITHUB_TOKEN = "user-provided-token"; + expect(setupForgejoToken()).toBe("user-provided-token"); + }); + + it("should take precedence over DEFAULT_WORKFLOW_TOKEN", () => { + process.env.OVERRIDE_GITHUB_TOKEN = "override-token"; + process.env.DEFAULT_WORKFLOW_TOKEN = "default-token"; + expect(setupForgejoToken()).toBe("override-token"); + }); + + it("should not use empty OVERRIDE_GITHUB_TOKEN", () => { + process.env.OVERRIDE_GITHUB_TOKEN = ""; + process.env.DEFAULT_WORKFLOW_TOKEN = "default-token"; + expect(setupForgejoToken()).toBe("default-token"); + }); + }); + + describe("DEFAULT_WORKFLOW_TOKEN (runner's github.token)", () => { + it("should return DEFAULT_WORKFLOW_TOKEN when no override is set", () => { + process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token"; + expect(setupForgejoToken()).toBe("workflow-token"); + }); + + it("should not use empty DEFAULT_WORKFLOW_TOKEN", () => { + process.env.DEFAULT_WORKFLOW_TOKEN = ""; + expect(() => setupForgejoToken()).toThrow( + "No authentication token available for Forgejo", + ); + }); + }); + + describe("error handling", () => { + it("should throw when no token is available", () => { + expect(() => setupForgejoToken()).toThrow( + "No authentication token available for Forgejo", + ); + }); + + it("should throw when both env vars are empty strings", () => { + process.env.OVERRIDE_GITHUB_TOKEN = ""; + process.env.DEFAULT_WORKFLOW_TOKEN = ""; + expect(() => setupForgejoToken()).toThrow( + "No authentication token available for Forgejo", + ); + }); + + it("should include guidance in the error message", () => { + expect(() => setupForgejoToken()).toThrow("github_token"); + }); + }); +}); diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index 152d2be..5e30cbd 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -313,4 +313,173 @@ describe("prepareMcpConfig", () => { const parsed = JSON.parse(result); expect(parsed.mcpServers.github_ci).not.toBeDefined(); }); + + describe("Forgejo platform routing", () => { + let savedPlatform: string | undefined; + let savedForgejo: string | undefined; + let savedServerUrl: string | undefined; + + beforeEach(() => { + savedPlatform = process.env.PLATFORM; + savedForgejo = process.env.FORGEJO; + savedServerUrl = process.env.GITHUB_SERVER_URL; + // Set platform to forgejo for all tests in this block + process.env.PLATFORM = "forgejo"; + }); + + afterEach(() => { + if (savedPlatform !== undefined) { + process.env.PLATFORM = savedPlatform; + } else { + delete process.env.PLATFORM; + } + if (savedForgejo !== undefined) { + process.env.FORGEJO = savedForgejo; + } else { + delete process.env.FORGEJO; + } + if (savedServerUrl !== undefined) { + process.env.GITHUB_SERVER_URL = savedServerUrl; + } else { + delete process.env.GITHUB_SERVER_URL; + } + }); + + test("should skip GitHub public MCP server on Forgejo", async () => { + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: ["mcp__github__create_issue", "mcp__github__create_pr"], + mode: "tag", + context: mockContext, + }); + + const parsed = JSON.parse(result); + // The Docker-based GitHub MCP server should NOT be included on Forgejo + expect(parsed.mcpServers.github).not.toBeDefined(); + }); + + test("should include CI server on Forgejo without permission check", async () => { + process.env.DEFAULT_WORKFLOW_TOKEN = "forgejo-workflow-token"; + // Simulate a 403 from the permission check — on Forgejo this should be + // skipped entirely, so the CI server is still included. + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + message: "Resource not accessible by integration", + }), + { status: 403 }, + ), + ); + + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: [], + mode: "tag", + context: mockPRContext, + }); + + const parsed = JSON.parse(result); + // CI server should be included on Forgejo even though the permission + // check would fail — the check is skipped entirely. + expect(parsed.mcpServers.github_ci).toBeDefined(); + expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe( + "forgejo-workflow-token", + ); + + delete process.env.DEFAULT_WORKFLOW_TOKEN; + }); + + test("should not call checkActionsReadPermission on Forgejo", async () => { + process.env.DEFAULT_WORKFLOW_TOKEN = "forgejo-workflow-token"; + fetchSpy.mockClear(); + + await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: [], + mode: "tag", + context: mockPRContext, + }); + + // fetch should not have been called — the permission check is skipped + expect(fetchSpy).not.toHaveBeenCalled(); + + delete process.env.DEFAULT_WORKFLOW_TOKEN; + }); + + test("should keep server names as github_* on Forgejo", async () => { + process.env.DEFAULT_WORKFLOW_TOKEN = "forgejo-workflow-token"; + + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: [], + mode: "tag", + context: { + ...mockPRContext, + inputs: { ...mockPRContext.inputs, useCommitSigning: true }, + }, + }); + + const parsed = JSON.parse(result); + // Server names must stay as github_* to match hardcoded tool names + expect(parsed.mcpServers.github_comment).toBeDefined(); + expect(parsed.mcpServers.github_file_ops).toBeDefined(); + expect(parsed.mcpServers.github_ci).toBeDefined(); + + delete process.env.DEFAULT_WORKFLOW_TOKEN; + }); + + test("should include comment server on Forgejo in tag mode", async () => { + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: [], + mode: "tag", + context: mockContext, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_comment).toBeDefined(); + expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe( + "test-token", + ); + }); + + test("should include inline comment server on Forgejo for PRs", async () => { + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: ["mcp__github_inline_comment__create_inline_comment"], + mode: "tag", + context: mockPRContext, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_inline_comment).toBeDefined(); + expect(parsed.mcpServers.github_inline_comment.env.GITHUB_TOKEN).toBe( + "test-token", + ); + }); + }); }); diff --git a/test/m7-integration.test.ts b/test/m7-integration.test.ts new file mode 100644 index 0000000..35178ec --- /dev/null +++ b/test/m7-integration.test.ts @@ -0,0 +1,232 @@ +/** + * M7 Integration Tests: Entry point + mode handler platform routing. + * + * Verifies that: + * - Tag mode routes to fetchForgejoData on Forgejo and fetchGitHubData on GitHub + * - createInitialComment uses Forgejo spinner/URL format on Forgejo + * - update-comment-link uses updateForgejoCommentBody on Forgejo + * - update-comment-link uses Forgejo PR compare URL format on Forgejo + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { detectPlatform } from "../src/platform/detector"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let savedEnv: Record; + +function saveEnv(...keys: string[]) { + savedEnv = {}; + for (const key of keys) { + savedEnv[key] = process.env[key]; + } +} + +function restoreEnv() { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +// --------------------------------------------------------------------------- +// Platform detection routing (verifies the branch logic used in M7) +// --------------------------------------------------------------------------- + +describe("M7: Platform routing for mode handlers", () => { + beforeEach(() => { + saveEnv("PLATFORM", "FORGEJO", "GITHUB_SERVER_URL"); + }); + + afterEach(() => { + restoreEnv(); + }); + + describe("detectPlatform() — used by tag mode and update-comment-link", () => { + it('returns "forgejo" when PLATFORM=forgejo', () => { + process.env.PLATFORM = "forgejo"; + expect(detectPlatform()).toBe("forgejo"); + }); + + it('returns "github" when PLATFORM=github', () => { + process.env.PLATFORM = "github"; + expect(detectPlatform()).toBe("github"); + }); + + it('returns "forgejo" when FORGEJO=true', () => { + delete process.env.PLATFORM; + process.env.FORGEJO = "true"; + expect(detectPlatform()).toBe("forgejo"); + }); + + it('returns "forgejo" when GITHUB_SERVER_URL is not github.com', () => { + delete process.env.PLATFORM; + delete process.env.FORGEJO; + process.env.GITHUB_SERVER_URL = "https://git.wxs.ro"; + expect(detectPlatform()).toBe("forgejo"); + }); + + it('returns "github" by default', () => { + delete process.env.PLATFORM; + delete process.env.FORGEJO; + process.env.GITHUB_SERVER_URL = "https://github.com"; + expect(detectPlatform()).toBe("github"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Initial comment platform-aware body +// --------------------------------------------------------------------------- + +describe("M7: createInitialComment platform-aware body", () => { + beforeEach(() => { + saveEnv("PLATFORM", "GITHUB_SERVER_URL"); + }); + + afterEach(() => { + restoreEnv(); + }); + + it("Forgejo comment body helpers produce Unicode spinner, not GitHub CDN", async () => { + const { + createForgejoJobRunLink, + createForgejoCommentBody, + FORGEJO_SPINNER_HTML, + } = await import("../src/forgejo/comment-logic"); + + const link = createForgejoJobRunLink("owner", "repo", "123"); + const body = createForgejoCommentBody(link); + + // Uses Unicode spinner, not an tag + expect(body).toContain(FORGEJO_SPINNER_HTML); + expect(body).not.toContain(" { + const { createJobRunLink, createCommentBody, SPINNER_HTML } = await import( + "../src/github/operations/comments/common" + ); + + const link = createJobRunLink("owner", "repo", "123"); + const body = createCommentBody(link); + + expect(body).toContain(SPINNER_HTML); + expect(body).toContain(" { + it("uses expand=1 instead of quick_pull=1", async () => { + const { createForgejoPrCompareUrl } = await import( + "../src/forgejo/comment-logic" + ); + + const url = createForgejoPrCompareUrl( + "https://git.wxs.ro", + "owner", + "repo", + "main", + "claude/fix-bug", + "Issue #42: Changes from Claude", + "This PR addresses issue #42", + ); + + expect(url).toContain("expand=1"); + expect(url).not.toContain("quick_pull=1"); + expect(url).toContain("git.wxs.ro"); + expect(url).toContain("compare/main...claude/fix-bug"); + }); +}); + +// --------------------------------------------------------------------------- +// update-comment-link: platform-aware final comment body +// --------------------------------------------------------------------------- + +describe("M7: updateForgejoCommentBody vs updateCommentBody", () => { + it("Forgejo version strips Unicode spinner", async () => { + const { updateForgejoCommentBody } = await import( + "../src/forgejo/comment-logic" + ); + + const result = updateForgejoCommentBody({ + currentBody: + "Claude Code is working… ⏳\n\nI'll analyze this and get back to you.\n\n[View job run](https://git.wxs.ro/owner/repo/actions/runs/123)", + actionFailed: false, + executionDetails: { duration_ms: 30000 }, + jobUrl: "https://git.wxs.ro/owner/repo/actions/runs/123", + triggerUsername: "testuser", + }); + + expect(result).not.toContain("⏳"); + expect(result).not.toContain("Claude Code is working"); + expect(result).toContain("@testuser"); + expect(result).toContain("30s"); + }); + + it("GitHub version strips CDN spinner image", async () => { + const { updateCommentBody } = await import( + "../src/github/operations/comment-logic" + ); + + const result = updateCommentBody({ + currentBody: + 'Claude Code is working… \n\nI\'ll analyze this and get back to you.\n\n[View job run](https://github.com/owner/repo/actions/runs/123)', + actionFailed: false, + executionDetails: { duration_ms: 30000 }, + jobUrl: "https://github.com/owner/repo/actions/runs/123", + triggerUsername: "testuser", + }); + + expect(result).not.toContain(" { + it("fetchForgejoData and fetchGitHubData both produce FetchDataResult type", async () => { + // Verify both functions are importable and are functions + // (actual execution requires mock Octokit — covered by their respective test files) + const { fetchForgejoData } = await import("../src/forgejo/data/fetcher"); + const { fetchGitHubData } = await import("../src/github/data/fetcher"); + + expect(typeof fetchForgejoData).toBe("function"); + expect(typeof fetchGitHubData).toBe("function"); + }); + + it("tag mode imports both fetchers", async () => { + // Verify the tag mode module imports work without errors + const tagMode = await import("../src/modes/tag"); + expect(typeof tagMode.prepareTagMode).toBe("function"); + }); +}); + +// --------------------------------------------------------------------------- +// Agent mode: no changes needed (verify no regression) +// --------------------------------------------------------------------------- + +describe("M7: Agent mode — no platform changes needed", () => { + it("prepareAgentMode is still exported and functional", async () => { + const agentMode = await import("../src/modes/agent"); + expect(typeof agentMode.prepareAgentMode).toBe("function"); + }); +}); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index cf2efdb..240356c 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -302,4 +302,143 @@ describe("checkWritePermissions", () => { ); }); }); + + describe("Forgejo permission check", () => { + let savedPlatform: string | undefined; + let savedForgejo: string | undefined; + let savedServerUrl: string | undefined; + + beforeEach(() => { + savedPlatform = process.env.PLATFORM; + savedForgejo = process.env.FORGEJO; + savedServerUrl = process.env.GITHUB_SERVER_URL; + process.env.PLATFORM = "forgejo"; + }); + + afterEach(() => { + if (savedPlatform !== undefined) process.env.PLATFORM = savedPlatform; + else delete process.env.PLATFORM; + if (savedForgejo !== undefined) process.env.FORGEJO = savedForgejo; + else delete process.env.FORGEJO; + if (savedServerUrl !== undefined) + process.env.GITHUB_SERVER_URL = savedServerUrl; + else delete process.env.GITHUB_SERVER_URL; + }); + + const createForgejoOctokit = (permissions: { + admin?: boolean; + push?: boolean; + pull?: boolean; + }) => { + return { + repos: { + get: async () => ({ + data: { permissions }, + }), + getCollaboratorPermissionLevel: async () => { + throw new Error("Should not be called on Forgejo"); + }, + }, + } as any; + }; + + test("should return true when Forgejo token has push access", async () => { + const mockOctokit = createForgejoOctokit({ + admin: false, + push: true, + pull: true, + }); + const context = createContext(); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + expect(coreInfoSpy).toHaveBeenCalledWith( + expect.stringContaining("Forgejo token has write access"), + ); + }); + + test("should return true when Forgejo token has admin access", async () => { + const mockOctokit = createForgejoOctokit({ + admin: true, + push: false, + pull: true, + }); + const context = createContext(); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should return false when Forgejo token has only pull access", async () => { + const mockOctokit = createForgejoOctokit({ + admin: false, + push: false, + pull: true, + }); + const context = createContext(); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + expect(coreWarningSpy).toHaveBeenCalledWith( + expect.stringContaining("insufficient permissions"), + ); + }); + + test("should return false when permissions object is missing", async () => { + const mockOctokit = { + repos: { + get: async () => ({ data: {} }), + }, + } as any; + const context = createContext(); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + expect(coreWarningSpy).toHaveBeenCalledWith( + expect.stringContaining("did not return permissions"), + ); + }); + + test("should not call getCollaboratorPermissionLevel on Forgejo", async () => { + let collaboratorCalled = false; + const mockOctokit = { + repos: { + get: async () => ({ + data: { permissions: { admin: false, push: true, pull: true } }, + }), + getCollaboratorPermissionLevel: async () => { + collaboratorCalled = true; + return { data: { permission: "write" } }; + }, + }, + } as any; + const context = createContext(); + + await checkWritePermissions(mockOctokit, context); + + expect(collaboratorCalled).toBe(false); + }); + + test("should still bypass for allowed_non_write_users on Forgejo", async () => { + const mockOctokit = createForgejoOctokit({ + admin: false, + push: false, + pull: true, + }); + const context = createContext(); + + const result = await checkWritePermissions( + mockOctokit, + context, + "test-user", + true, + ); + + expect(result).toBe(true); + }); + }); }); diff --git a/test/platform-detector.test.ts b/test/platform-detector.test.ts new file mode 100644 index 0000000..961dc5b --- /dev/null +++ b/test/platform-detector.test.ts @@ -0,0 +1,94 @@ +import { detectPlatform } from "../src/platform/detector"; +import { describe, it, expect, beforeEach } from "bun:test"; + +describe("detectPlatform", () => { + beforeEach(() => { + // Reset relevant env vars before each test + delete process.env.PLATFORM; + delete process.env.FORGEJO; + delete process.env.GITHUB_SERVER_URL; + }); + + // Restore original env after all tests in this file + // (bun:test doesn't have afterAll cleanup issues like jest, but let's be safe) + + describe("explicit PLATFORM env var", () => { + it("should return 'forgejo' when PLATFORM is 'forgejo'", () => { + process.env.PLATFORM = "forgejo"; + expect(detectPlatform()).toBe("forgejo"); + }); + + it("should return 'github' when PLATFORM is 'github'", () => { + process.env.PLATFORM = "github"; + expect(detectPlatform()).toBe("github"); + }); + + it("should take precedence over FORGEJO env var", () => { + process.env.PLATFORM = "github"; + process.env.FORGEJO = "true"; + expect(detectPlatform()).toBe("github"); + }); + + it("should take precedence over GITHUB_SERVER_URL", () => { + process.env.PLATFORM = "github"; + process.env.GITHUB_SERVER_URL = "https://git.example.com"; + expect(detectPlatform()).toBe("github"); + }); + + it("should ignore unrecognized PLATFORM values and fall through", () => { + process.env.PLATFORM = "gitlab"; + // No other env vars set, should default to github + expect(detectPlatform()).toBe("github"); + }); + }); + + describe("FORGEJO env var", () => { + it("should return 'forgejo' when FORGEJO is 'true'", () => { + process.env.FORGEJO = "true"; + expect(detectPlatform()).toBe("forgejo"); + }); + + it("should not detect forgejo when FORGEJO is 'false'", () => { + process.env.FORGEJO = "false"; + expect(detectPlatform()).toBe("github"); + }); + + it("should not detect forgejo when FORGEJO is empty", () => { + process.env.FORGEJO = ""; + expect(detectPlatform()).toBe("github"); + }); + }); + + describe("GITHUB_SERVER_URL heuristic", () => { + it("should return 'forgejo' for non-github.com server URLs", () => { + process.env.GITHUB_SERVER_URL = "https://git.wxs.ro"; + expect(detectPlatform()).toBe("forgejo"); + }); + + it("should return 'forgejo' for gitea/forgejo instances", () => { + process.env.GITHUB_SERVER_URL = "https://forgejo.example.org"; + expect(detectPlatform()).toBe("forgejo"); + }); + + it("should return 'github' for github.com", () => { + process.env.GITHUB_SERVER_URL = "https://github.com"; + expect(detectPlatform()).toBe("github"); + }); + + it("should return 'github' for GitHub Enterprise (contains github.com)", () => { + process.env.GITHUB_SERVER_URL = "https://github.com/enterprise"; + expect(detectPlatform()).toBe("github"); + }); + + it("should return 'github' when GITHUB_SERVER_URL is empty", () => { + process.env.GITHUB_SERVER_URL = ""; + expect(detectPlatform()).toBe("github"); + }); + }); + + describe("default behavior", () => { + it("should return 'github' when no env vars are set", () => { + expect(detectPlatform()).toBe("github"); + }); + }); +});