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
+4782
-382
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.
|
- **`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.
|
- **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
|
## Code Conventions
|
||||||
|
|
||||||
- Runtime is Bun, not Node. Use `bun test`, not `jest`.
|
- Runtime is Bun, not Node. Use `bun test`, not `jest`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code Action
|
# 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
|
## 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
|
- 📊 **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)
|
- 🏃 **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
|
- ⚙️ **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?
|
## 📦 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
|
- [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
|
- [Capabilities & Limitations](./docs/capabilities-and-limitations.md) - What Claude can and cannot do
|
||||||
- [Security](./docs/security.md) - Access control, permissions, and commit signing
|
- [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](./docs/faq.md) - Common questions and troubleshooting
|
||||||
|
|
||||||
## 📚 FAQ
|
## 📚 FAQ
|
||||||
|
|||||||
+41
-5
@@ -138,6 +138,12 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
|
||||||
|
# Platform configuration
|
||||||
|
platform:
|
||||||
|
description: "Platform to run on: 'auto' (default auto-detects), 'github', or 'forgejo'"
|
||||||
|
required: false
|
||||||
|
default: "auto"
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
description: "Path to the Claude Code execution output file"
|
description: "Path to the Claude Code execution output file"
|
||||||
@@ -158,12 +164,39 @@ outputs:
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
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
|
- name: Install Bun
|
||||||
if: inputs.path_to_bun_executable == ''
|
if: inputs.path_to_bun_executable == ''
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
bun-version: 1.3.6
|
if command -v bun &> /dev/null; then
|
||||||
token: ${{ inputs.github_token || github.token }}
|
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
|
- name: Setup Custom Bun Path
|
||||||
if: inputs.path_to_bun_executable != ''
|
if: inputs.path_to_bun_executable != ''
|
||||||
@@ -188,6 +221,9 @@ runs:
|
|||||||
run: |
|
run: |
|
||||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts
|
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts
|
||||||
env:
|
env:
|
||||||
|
# Platform detection
|
||||||
|
PLATFORM: ${{ steps.detect-platform.outputs.platform }}
|
||||||
|
|
||||||
# Prepare inputs
|
# Prepare inputs
|
||||||
MODE: ${{ inputs.mode }}
|
MODE: ${{ inputs.mode }}
|
||||||
PROMPT: ${{ inputs.prompt }}
|
PROMPT: ${{ inputs.prompt }}
|
||||||
@@ -283,7 +319,7 @@ runs:
|
|||||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts
|
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts
|
||||||
|
|
||||||
- name: Revoke app token
|
- 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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -L \
|
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
|
- The action will skip automatic installation when custom paths are provided
|
||||||
- Ensure the custom executables are available in your GitHub Actions environment
|
- 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
|
## Best Practices
|
||||||
|
|
||||||
1. **Always specify permissions explicitly** in your workflow file
|
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 | "" |
|
| `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 | "" |
|
| `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 | "" |
|
| `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
|
### 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)
|
||||||
+18
-8
@@ -12,6 +12,8 @@ import { spawn } from "child_process";
|
|||||||
import { appendFile } from "fs/promises";
|
import { appendFile } from "fs/promises";
|
||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, readFileSync } from "fs";
|
||||||
import { setupGitHubToken, WorkflowValidationSkipError } from "../github/token";
|
import { setupGitHubToken, WorkflowValidationSkipError } from "../github/token";
|
||||||
|
import { setupForgejoToken } from "../forgejo/token";
|
||||||
|
import { detectPlatform } from "../platform/detector";
|
||||||
import { checkWritePermissions } from "../github/validation/permissions";
|
import { checkWritePermissions } from "../github/validation/permissions";
|
||||||
import { createOctokit } from "../github/api/client";
|
import { createOctokit } from "../github/api/client";
|
||||||
import type { Octokits } from "../github/api/client";
|
import type { Octokits } from "../github/api/client";
|
||||||
@@ -145,15 +147,23 @@ async function run() {
|
|||||||
`Auto-detected mode: ${modeName} for event: ${context.eventName}`,
|
`Auto-detected mode: ${modeName} for event: ${context.eventName}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
// Platform-aware token setup
|
||||||
githubToken = await setupGitHubToken();
|
const platform = detectPlatform();
|
||||||
} catch (error) {
|
console.log(`Platform detected: ${platform}`);
|
||||||
if (error instanceof WorkflowValidationSkipError) {
|
|
||||||
core.setOutput("skipped_due_to_workflow_validation_mismatch", "true");
|
if (platform === "forgejo") {
|
||||||
console.log("Exiting due to workflow validation skip");
|
githubToken = setupForgejoToken();
|
||||||
return;
|
} else {
|
||||||
|
try {
|
||||||
|
githubToken = await setupGitHubToken();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WorkflowValidationSkipError) {
|
||||||
|
core.setOutput("skipped_due_to_workflow_validation_mismatch", "true");
|
||||||
|
console.log("Exiting due to workflow validation skip");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
octokit = createOctokit(githubToken);
|
octokit = createOctokit(githubToken);
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
updateCommentBody,
|
updateCommentBody,
|
||||||
type CommentUpdateInput,
|
type CommentUpdateInput,
|
||||||
} from "../github/operations/comment-logic";
|
} from "../github/operations/comment-logic";
|
||||||
|
import {
|
||||||
|
updateForgejoCommentBody,
|
||||||
|
createForgejoPrCompareUrl,
|
||||||
|
} from "../forgejo/comment-logic";
|
||||||
|
import { detectPlatform } from "../platform/detector";
|
||||||
import {
|
import {
|
||||||
parseGitHubContext,
|
parseGitHubContext,
|
||||||
isPullRequestReviewCommentEvent,
|
isPullRequestReviewCommentEvent,
|
||||||
@@ -46,6 +51,7 @@ export async function updateCommentLink(
|
|||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const { owner, repo } = context.repository;
|
const { owner, repo } = context.repository;
|
||||||
|
const platform = detectPlatform();
|
||||||
|
|
||||||
const serverUrl = GITHUB_SERVER_URL;
|
const serverUrl = GITHUB_SERVER_URL;
|
||||||
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
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)
|
(comparison.files && comparison.files.length > 0)
|
||||||
) {
|
) {
|
||||||
const entityType = context.isPR ? "PR" : "Issue";
|
const entityType = context.isPR ? "PR" : "Issue";
|
||||||
const prTitle = encodeURIComponent(
|
const titleText = `${entityType} #${context.entityNumber}: Changes from Claude`;
|
||||||
`${entityType} #${context.entityNumber}: Changes from Claude`,
|
const bodyText = `This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`;
|
||||||
);
|
|
||||||
const prBody = encodeURIComponent(
|
let prUrl: string;
|
||||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
|
if (platform === "forgejo") {
|
||||||
);
|
// Forgejo doesn't support quick_pull=1
|
||||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
prUrl = createForgejoPrCompareUrl(
|
||||||
|
serverUrl,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
baseBranch,
|
||||||
|
claudeBranch,
|
||||||
|
titleText,
|
||||||
|
bodyText,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const prTitle = encodeURIComponent(titleText);
|
||||||
|
const prBody = encodeURIComponent(bodyText);
|
||||||
|
prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||||
|
}
|
||||||
prLink = `\n[Create a PR](${prUrl})`;
|
prLink = `\n[Create a PR](${prUrl})`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -217,7 +236,10 @@ export async function updateCommentLink(
|
|||||||
errorDetails,
|
errorDetails,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedBody = updateCommentBody(commentInput);
|
const updatedBody =
|
||||||
|
platform === "forgejo"
|
||||||
|
? updateForgejoCommentBody(commentInput)
|
||||||
|
: updateCommentBody(commentInput);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateClaudeComment(octokit.rest, {
|
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 { appendFileSync } from "fs";
|
||||||
import { createJobRunLink, createCommentBody } from "./common";
|
import { createJobRunLink, createCommentBody } from "./common";
|
||||||
|
import {
|
||||||
|
createForgejoJobRunLink,
|
||||||
|
createForgejoCommentBody,
|
||||||
|
} from "../../../forgejo/comment-logic";
|
||||||
|
import { detectPlatform } from "../../../platform/detector";
|
||||||
import {
|
import {
|
||||||
isPullRequestReviewCommentEvent,
|
isPullRequestReviewCommentEvent,
|
||||||
isPullRequestEvent,
|
isPullRequestEvent,
|
||||||
@@ -21,9 +26,16 @@ export async function createInitialComment(
|
|||||||
context: ParsedGitHubContext,
|
context: ParsedGitHubContext,
|
||||||
) {
|
) {
|
||||||
const { owner, repo } = context.repository;
|
const { owner, repo } = context.repository;
|
||||||
|
const platform = detectPlatform();
|
||||||
|
|
||||||
const jobRunLink = createJobRunLink(owner, repo, context.runId);
|
const jobRunLink =
|
||||||
const initialBody = createCommentBody(jobRunLink);
|
platform === "forgejo"
|
||||||
|
? createForgejoJobRunLink(owner, repo, context.runId)
|
||||||
|
: createJobRunLink(owner, repo, context.runId);
|
||||||
|
const initialBody =
|
||||||
|
platform === "forgejo"
|
||||||
|
? createForgejoCommentBody(jobRunLink)
|
||||||
|
: createCommentBody(jobRunLink);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
let response;
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
createBranchLink,
|
createBranchLink,
|
||||||
createCommentBody,
|
createCommentBody,
|
||||||
} from "./common";
|
} from "./common";
|
||||||
|
import {
|
||||||
|
createForgejoJobRunLink,
|
||||||
|
createForgejoBranchLink,
|
||||||
|
createForgejoCommentBody,
|
||||||
|
} from "../../../forgejo/comment-logic";
|
||||||
|
import { detectPlatform } from "../../../platform/detector";
|
||||||
import { type Octokits } from "../../api/client";
|
import { type Octokits } from "../../api/client";
|
||||||
import {
|
import {
|
||||||
isPullRequestReviewCommentEvent,
|
isPullRequestReviewCommentEvent,
|
||||||
@@ -24,16 +30,26 @@ export async function updateTrackingComment(
|
|||||||
branch?: string,
|
branch?: string,
|
||||||
) {
|
) {
|
||||||
const { owner, repo } = context.repository;
|
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)
|
// Add branch link for issues (not PRs)
|
||||||
let branchLink = "";
|
let branchLink = "";
|
||||||
if (branch && !context.isPR) {
|
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
|
// Update the existing comment with the branch link
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,11 +7,21 @@
|
|||||||
|
|
||||||
import type { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
import type { GitHubContext } from "../context";
|
import type { GitHubContext } from "../context";
|
||||||
|
import { detectPlatform } from "../../platform/detector";
|
||||||
|
|
||||||
export async function checkHumanActor(
|
export async function checkHumanActor(
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
githubContext: GitHubContext,
|
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
|
// Fetch user information from GitHub API
|
||||||
const { data: userData } = await octokit.users.getByUsername({
|
const { data: userData } = await octokit.users.getByUsername({
|
||||||
username: githubContext.actor,
|
username: githubContext.actor,
|
||||||
@@ -21,8 +31,7 @@ export async function checkHumanActor(
|
|||||||
|
|
||||||
console.log(`Actor type: ${actorType}`);
|
console.log(`Actor type: ${actorType}`);
|
||||||
|
|
||||||
// Check bot permissions if actor is not a User
|
if (actorType && actorType !== "User") {
|
||||||
if (actorType !== "User") {
|
|
||||||
const allowedBots = githubContext.inputs.allowedBots;
|
const allowedBots = githubContext.inputs.allowedBots;
|
||||||
|
|
||||||
// Check if all bots are allowed
|
// Check if all bots are allowed
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import type { ParsedGitHubContext } from "../context";
|
import type { ParsedGitHubContext } from "../context";
|
||||||
import type { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
|
import { detectPlatform } from "../../platform/detector";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the actor has write permissions to the repository
|
* Check if the actor has write permissions to the repository
|
||||||
@@ -49,6 +50,15 @@ export async function checkWritePermissions(
|
|||||||
return true;
|
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
|
// Check permissions directly using the permission endpoint
|
||||||
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
||||||
owner: repository.owner,
|
owner: repository.owner,
|
||||||
@@ -71,3 +81,43 @@ export async function checkWritePermissions(
|
|||||||
throw new Error(`Failed to check permissions for ${actor}: ${error}`);
|
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 { isEntityContext } from "../github/context";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import type { AutoDetectedMode } from "../modes/detector";
|
import type { AutoDetectedMode } from "../modes/detector";
|
||||||
|
import { detectPlatform } from "../platform/detector";
|
||||||
|
|
||||||
type PrepareConfigParams = {
|
type PrepareConfigParams = {
|
||||||
githubToken: string;
|
githubToken: string;
|
||||||
@@ -66,6 +67,10 @@ export async function prepareMcpConfig(
|
|||||||
try {
|
try {
|
||||||
const allowedToolsList = allowedTools || [];
|
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)
|
// Detect if we're in agent mode (explicit prompt provided)
|
||||||
const isAgentMode = mode === "agent";
|
const isAgentMode = mode === "agent";
|
||||||
|
|
||||||
@@ -167,21 +172,31 @@ export async function prepareMcpConfig(
|
|||||||
hasWorkflowToken;
|
hasWorkflowToken;
|
||||||
|
|
||||||
if (shouldIncludeCIServer) {
|
if (shouldIncludeCIServer) {
|
||||||
// Verify the token actually has actions:read permission
|
// On Forgejo, the workflow token has full repo access — skip the
|
||||||
const actuallyHasPermission = await checkActionsReadPermission(
|
// GitHub-specific permission probe which checks for 'actions: read'.
|
||||||
process.env.DEFAULT_WORKFLOW_TOKEN || "",
|
// On GitHub, verify the token actually has actions:read permission.
|
||||||
owner,
|
let includeCIServer = isForgejo;
|
||||||
repo,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!actuallyHasPermission) {
|
if (!includeCIServer) {
|
||||||
core.warning(
|
const actuallyHasPermission = await checkActionsReadPermission(
|
||||||
"The github_ci MCP server requires 'actions: read' permission. " +
|
process.env.DEFAULT_WORKFLOW_TOKEN || "",
|
||||||
"Skipping CI server installation. " +
|
owner,
|
||||||
"To enable CI status checks, add 'actions: read' to your workflow permissions. " +
|
repo,
|
||||||
"See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token",
|
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
|
if (!actuallyHasPermission) {
|
||||||
|
core.warning(
|
||||||
|
"The github_ci MCP server requires 'actions: read' permission. " +
|
||||||
|
"Skipping CI server installation. " +
|
||||||
|
"To enable CI status checks, add 'actions: read' to your workflow permissions. " +
|
||||||
|
"See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
includeCIServer = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeCIServer) {
|
||||||
baseMcpConfig.mcpServers.github_ci = {
|
baseMcpConfig.mcpServers.github_ci = {
|
||||||
command: "bun",
|
command: "bun",
|
||||||
args: [
|
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 = {
|
baseMcpConfig.mcpServers.github = {
|
||||||
command: "docker",
|
command: "docker",
|
||||||
args: [
|
args: [
|
||||||
|
|||||||
+11
-3
@@ -12,6 +12,8 @@ import {
|
|||||||
extractOriginalTitle,
|
extractOriginalTitle,
|
||||||
extractOriginalBody,
|
extractOriginalBody,
|
||||||
} from "../../github/data/fetcher";
|
} from "../../github/data/fetcher";
|
||||||
|
import { fetchForgejoData } from "../../forgejo/data/fetcher";
|
||||||
|
import { detectPlatform } from "../../platform/detector";
|
||||||
import { createPrompt } from "../../create-prompt";
|
import { createPrompt } from "../../create-prompt";
|
||||||
import { isEntityContext } from "../../github/context";
|
import { isEntityContext } from "../../github/context";
|
||||||
import type { GitHubContext } from "../../github/context";
|
import type { GitHubContext } from "../../github/context";
|
||||||
@@ -41,7 +43,7 @@ export async function prepareTagMode({
|
|||||||
// Check if actor is human
|
// Check if actor is human
|
||||||
await checkHumanActor(octokit.rest, context);
|
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 commentData = await createInitialComment(octokit.rest, context);
|
||||||
const commentId = commentData.id;
|
const commentId = commentData.id;
|
||||||
|
|
||||||
@@ -49,7 +51,9 @@ export async function prepareTagMode({
|
|||||||
const originalTitle = extractOriginalTitle(context);
|
const originalTitle = extractOriginalTitle(context);
|
||||||
const originalBody = extractOriginalBody(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,
|
octokits: octokit,
|
||||||
repository: `${context.repository.owner}/${context.repository.repo}`,
|
repository: `${context.repository.owner}/${context.repository.repo}`,
|
||||||
prNumber: context.entityNumber.toString(),
|
prNumber: context.entityNumber.toString(),
|
||||||
@@ -60,7 +64,11 @@ export async function prepareTagMode({
|
|||||||
originalBody,
|
originalBody,
|
||||||
includeCommentsByActor: context.inputs.includeCommentsByActor,
|
includeCommentsByActor: context.inputs.includeCommentsByActor,
|
||||||
excludeCommentsByActor: context.inputs.excludeCommentsByActor,
|
excludeCommentsByActor: context.inputs.excludeCommentsByActor,
|
||||||
});
|
};
|
||||||
|
const githubData =
|
||||||
|
platform === "forgejo"
|
||||||
|
? await fetchForgejoData(fetchParams)
|
||||||
|
: await fetchGitHubData(fetchParams);
|
||||||
|
|
||||||
// Setup branch
|
// Setup branch
|
||||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
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
|
#!/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 { checkHumanActor } from "../src/github/validation/actor";
|
||||||
import type { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
import { createMockContext } from "./mockContext";
|
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.",
|
"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);
|
const parsed = JSON.parse(result);
|
||||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
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