Merge pull request 'feat(prd-1): Forgejo platform support' (#3) from feature/prd-1-forgejo-platform-support into main
This commit is contained in:
commit
ef8657f2de
31 files changed
+4761
-361
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user