Merge pull request 'feat(prd-1): Forgejo platform support' (#3) from feature/prd-1-forgejo-platform-support into main

This commit is contained in:
Vlad Mocanu committed 2026-02-25 17:58:17 +00:00
commit ef8657f2de
31 files changed
+4761 -361

No files matched your search

+92
View File
@@ -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
+80
View File
@@ -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
+176
View File
@@ -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<<DIFF_EOF"
echo "$DIFF_STAT"
echo "DIFF_EOF"
} >> "$GITHUB_OUTPUT"
{
echo "commit_log<<LOG_EOF"
git log main..upstream/main --oneline | head -30
echo "LOG_EOF"
} >> "$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 <<BODY_EOF
## Upstream Sync Required
Our \`main\` branch is **${BEHIND} commits behind** upstream (\`anthropics/claude-code-action\`).
We are **${AHEAD} commits ahead** (our Forgejo-specific changes).
### New upstream commits (latest ${BEHIND}, max 30 shown)
\`\`\`
${COMMIT_LOG}
\`\`\`
### Files changed
\`\`\`
${DIFF_STAT}
\`\`\`
### How to merge
\`\`\`bash
git fetch upstream main
git merge upstream/main
# Resolve any conflicts in the 3-4 modified files (run.ts, tag/index.ts, etc.)
bun test
git push origin main
\`\`\`
### Conflict-prone files
These files have Forgejo modifications and may conflict:
- \`src/entrypoints/run.ts\`
- \`src/modes/tag/index.ts\`
- \`src/mcp/install-mcp-server.ts\`
- \`action.yml\`
_Auto-generated by upstream-sync workflow on $(date -u +%Y-%m-%d)_
BODY_EOF
)
TITLE="Upstream sync: ${BEHIND} new commits from anthropics/claude-code-action"
if [ "${{ steps.existing.outputs.issue_exists }}" = "true" ]; then
# Update existing issue
ISSUE_NUMBER="${{ steps.existing.outputs.issue_number }}"
curl -s -o /dev/null \
-X PATCH \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg title "$TITLE" --arg body "$BODY" '{title: $title, body: $body}')" \
"${GITHUB_API_URL}/repos/${{ github.repository }}/issues/${ISSUE_NUMBER}"
echo "Updated existing issue #${ISSUE_NUMBER}"
else
# Create new issue
curl -s -o /dev/null \
-X POST \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg title "$TITLE" --arg body "$BODY" '{title: $title, body: $body, labels: [{"name": "upstream-sync"}]}')" \
"${GITHUB_API_URL}/repos/${{ github.repository }}/issues"
echo "Created new upstream sync issue"
fi
+24
View File
@@ -36,6 +36,30 @@ Single entrypoint: `src/entrypoints/run.ts` orchestrates everything — prepare
- **`action.yml` outputs reference step IDs**: Outputs like `execution_file`, `branch_name`, `github_token` reference `steps.run.outputs.*`. If you rename the step ID, update the outputs section too.
- **Integration testing** happens in a separate repo (`install-test`), not here. The tests in this repo are unit tests.
## Forgejo Platform Support
This fork adds Forgejo support via a lean adapter pattern — only 4 genuinely different areas have Forgejo-specific code:
- **`src/platform/detector.ts`** — Three-tier platform detection (explicit input > `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`.
+3 -1
View File
@@ -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
+41 -5
View File
@@ -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 \
+22
View File
@@ -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
+231
View File
@@ -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.
+1
View File
@@ -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
-335
View File
@@ -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)
+353
View File
@@ -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)
+10
View File
@@ -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,6 +147,13 @@ async function run() {
`Auto-detected mode: ${modeName} for event: ${context.eventName}`,
);
// Platform-aware token setup
const platform = detectPlatform();
console.log(`Platform detected: ${platform}`);
if (platform === "forgejo") {
githubToken = setupForgejoToken();
} else {
try {
githubToken = await setupGitHubToken();
} catch (error) {
@@ -155,6 +164,7 @@ async function run() {
}
throw error;
}
}
octokit = createOctokit(githubToken);
+29 -7
View File
@@ -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 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,
);
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}`;
} 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, {
+288
View File
@@ -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*<img[^>]*>)?/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();
}
+518
View File
@@ -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<string, string> = {
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<number, GitHubReviewComment[]>();
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<string | null> {
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<FetchDataResult> {
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,
};
}
+28
View File
@@ -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.",
);
}
@@ -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;
@@ -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 {
+11 -2
View File
@@ -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
+50
View File
@@ -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<boolean> {
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;
}
+19 -2
View File
@@ -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,7 +172,12 @@ export async function prepareMcpConfig(
hasWorkflowToken;
if (shouldIncludeCIServer) {
// Verify the token actually has actions:read permission
// 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 (!includeCIServer) {
const actuallyHasPermission = await checkActionsReadPermission(
process.env.DEFAULT_WORKFLOW_TOKEN || "",
owner,
@@ -182,6 +192,11 @@ export async function prepareMcpConfig(
"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: [
+11 -3
View File
@@ -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);
+29
View File
@@ -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";
}
+76 -1
View File
@@ -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);
});
});
});
+595
View File
@@ -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("<img");
expect(FORGEJO_SPINNER_HTML).toBe("⏳");
});
});
// ---------------------------------------------------------------------------
// createForgejoJobRunLink
// ---------------------------------------------------------------------------
describe("createForgejoJobRunLink", () => {
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("<img");
});
it("includes working message and job run link", () => {
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… <img src="https://github.com/user-attachments/assets/5ac382c7" width="14px" />',
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("<img");
});
it("removes working message with Forgejo Unicode 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("⏳");
});
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`");
});
});
});
File diff suppressed because it is too large. Load diff
+62
View File
@@ -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");
});
});
});
+169
View File
@@ -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",
);
});
});
});
+232
View File
@@ -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<string, string | undefined>;
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 <img> tag
expect(body).toContain(FORGEJO_SPINNER_HTML);
expect(body).not.toContain("<img");
expect(body).not.toContain("github.com/user-attachments");
// Contains the job run link
expect(body).toContain("[View job run]");
});
it("GitHub comment body helpers produce CDN spinner image", async () => {
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("<img");
expect(body).toContain("[View job run]");
});
});
// ---------------------------------------------------------------------------
// update-comment-link: Forgejo PR compare URL format
// ---------------------------------------------------------------------------
describe("M7: Forgejo PR compare URL format", () => {
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… <img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" />\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("<img");
expect(result).not.toContain("Claude Code is working");
expect(result).toContain("@testuser");
expect(result).toContain("30s");
});
});
// ---------------------------------------------------------------------------
// Tag mode: data fetcher selection
// ---------------------------------------------------------------------------
describe("M7: Tag mode data fetcher selection", () => {
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");
});
});
+139
View File
@@ -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);
});
});
});
+94
View File
@@ -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");
});
});
});