From d0ea2497352e3b9db802a361b535192e5615087c Mon Sep 17 00:00:00 2001 From: Darin Truckenmiller Date: Sat, 11 Apr 2026 19:52:57 -0700 Subject: [PATCH 1/5] feat: forge-agnostic workflow commands for Gitea and GitLab Add forge detection, variable injection ($FORGE_TYPE, $FORGE_API_BASE, $FORGE_CLI), and a TypeScript CLI wrapper (forge-cli.ts) that routes pr/issue/label/repo commands to gh, Gitea API, or GitLab API based on the detected forge. Bash nodes use it deterministically; AI prompts get an auto-injected preamble for non-GitHub forges. Also fixes DATABASE_URL pollution from target repo .env files in CLI. Closes #1101 Co-Authored-By: Claude Opus 4.6 (1M context) --- .archon/commands/defaults/archon-create-pr.md | 3 + .../commands/defaults/archon-finalize-pr.md | 3 + .../defaults/archon-investigate-issue.md | 3 + .archon/scripts/forge-cli.ts | 556 ++++++++++++++++++ .../defaults/archon-create-issue.yaml | 4 +- .../defaults/archon-fix-github-issue.yaml | 2 +- .../defaults/archon-validate-pr.yaml | 8 +- eslint.config.mjs | 1 + packages/cli/src/cli.ts | 5 + packages/core/src/utils/env-allowlist.ts | 9 + packages/git/src/exec.ts | 2 +- packages/git/src/forge.test.ts | 110 ++++ packages/git/src/forge.ts | 104 ++++ packages/git/src/index.ts | 4 + packages/workflows/src/dag-executor.test.ts | 272 ++++++++- packages/workflows/src/dag-executor.ts | 107 +++- packages/workflows/src/executor-shared.ts | 43 +- packages/workflows/src/executor.test.ts | 5 +- packages/workflows/src/executor.ts | 23 +- 19 files changed, 1234 insertions(+), 30 deletions(-) create mode 100644 .archon/scripts/forge-cli.ts create mode 100644 packages/git/src/forge.test.ts create mode 100644 packages/git/src/forge.ts diff --git a/.archon/commands/defaults/archon-create-pr.md b/.archon/commands/defaults/archon-create-pr.md index c64651d403..b3a1790e87 100644 --- a/.archon/commands/defaults/archon-create-pr.md +++ b/.archon/commands/defaults/archon-create-pr.md @@ -7,9 +7,12 @@ argument-hint: [base-branch] (default: auto-detected from config or repo) **Base branch override**: $ARGUMENTS **Default base branch**: $BASE_BRANCH +**Forge**: $FORGE_TYPE > If a base branch was provided as argument above, use it for `--base`. Otherwise use the default base branch. +> **Forge note**: When `$FORGE_TYPE` is not `github`, use `bun "$FORGE_CLI"` instead of `gh` for all forge operations (e.g., `"$FORGE_CLI" pr create`, `"$FORGE_CLI" pr list`). The tool handles authentication and API differences automatically. + --- ## Pre-flight: Check for Existing PRs diff --git a/.archon/commands/defaults/archon-finalize-pr.md b/.archon/commands/defaults/archon-finalize-pr.md index 54f7edce8d..4a9a616bae 100644 --- a/.archon/commands/defaults/archon-finalize-pr.md +++ b/.archon/commands/defaults/archon-finalize-pr.md @@ -6,6 +6,9 @@ argument-hint: (no arguments - reads from workflow artifacts) # Finalize Pull Request **Workflow ID**: $WORKFLOW_ID +**Forge**: $FORGE_TYPE + +> **Forge note**: When `$FORGE_TYPE` is not `github`, use `bun "$FORGE_CLI"` instead of `gh` for all forge operations (e.g., `"$FORGE_CLI" pr create`, `"$FORGE_CLI" pr list`, `"$FORGE_CLI" pr edit`). The tool handles authentication and API differences automatically. --- diff --git a/.archon/commands/defaults/archon-investigate-issue.md b/.archon/commands/defaults/archon-investigate-issue.md index 2d3f88cdd5..09a361acfb 100644 --- a/.archon/commands/defaults/archon-investigate-issue.md +++ b/.archon/commands/defaults/archon-investigate-issue.md @@ -6,6 +6,9 @@ argument-hint: # Investigate Issue **Input**: $ARGUMENTS +**Forge**: $FORGE_TYPE + +> **Forge note**: When `$FORGE_TYPE` is not `github`, use `bun "$FORGE_CLI"` instead of `gh` for all forge operations (e.g., `"$FORGE_CLI" issue view`, `"$FORGE_CLI" issue comment`). The tool handles authentication and API differences automatically. --- diff --git a/.archon/scripts/forge-cli.ts b/.archon/scripts/forge-cli.ts new file mode 100644 index 0000000000..6b72d20b3a --- /dev/null +++ b/.archon/scripts/forge-cli.ts @@ -0,0 +1,556 @@ +#!/usr/bin/env bun +/** + * forge-cli.ts — Forge-agnostic CLI wrapper for GitHub, Gitea, and GitLab + * + * Routes pr/issue/label/repo commands to the appropriate forge API. + * Reads FORGE_TYPE, FORGE_API_BASE, and token env vars set by the workflow executor. + * + * Usage: forge-cli.ts [args...] + * Resources: pr, issue, label, repo + * + * For GitHub, delegates to `gh` CLI. For Gitea/GitLab, uses fetch() against their REST APIs. + * No external dependencies beyond Bun. + */ + +import { execFileSync } from 'child_process'; + +const FORGE_TYPE = process.env.FORGE_TYPE ?? 'github'; +const FORGE_API_BASE = (process.env.FORGE_API_BASE ?? 'https://api.github.com').replace(/\/+$/, ''); +const GITEA_TOKEN = process.env.GITEA_TOKEN ?? ''; +const GITLAB_TOKEN = process.env.GITLAB_TOKEN ?? ''; +const GH_TOKEN = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? ''; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function getOwnerRepo(): string { + try { + const url = execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' }).trim(); + const cleaned = url.replace(/\.git$/, ''); + + // HTTPS: https://host/owner/repo or https://token@host/owner/repo + const httpsMatch = cleaned.match(/https?:\/\/(?:[^@]+@)?[^/]+\/(.+)/); + if (httpsMatch) return httpsMatch[1]; + + // SSH: git@host:owner/repo + const sshMatch = cleaned.match(/@[^:]+:(.+)/); + if (sshMatch) return sshMatch[1]; + + throw new Error(`Cannot parse owner/repo from: ${url}`); + } catch (e) { + console.error(`error: ${(e as Error).message}`); + process.exit(1); + } +} + +function authHeaders(): Record { + switch (FORGE_TYPE) { + case 'github': + return { Authorization: `token ${GH_TOKEN}` }; + case 'gitea': + return { Authorization: `token ${GITEA_TOKEN}` }; + case 'gitlab': + return { 'PRIVATE-TOKEN': GITLAB_TOKEN }; + default: + console.error(`error: unsupported FORGE_TYPE=${FORGE_TYPE}`); + process.exit(1); + } +} + +async function apiGet(url: string): Promise { + const res = await fetch(url, { headers: authHeaders() }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`API ${res.status}: ${text}`); + } + return res.json(); +} + +async function apiPost(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`API ${res.status}: ${text}`); + } + return res.json(); +} + +async function apiPatch(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: 'PATCH', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`API ${res.status}: ${text}`); + } + return res.json(); +} + +/** Run gh CLI and return stdout */ +function gh(args: string[]): string { + return execFileSync('gh', args, { encoding: 'utf8' }); +} + +/** Get GitLab project ID from owner/repo path */ +async function gitlabProjectId(ownerRepo: string): Promise { + const encoded = encodeURIComponent(ownerRepo); + const data = (await apiGet(`${FORGE_API_BASE}/projects/${encoded}`)) as { id: number }; + return String(data.id); +} + +/** Parse CLI args into a map */ +function parseArgs(args: string[]): { positional: string[]; flags: Record } { + const positional: string[] = []; + const flags: Record = {}; + let i = 0; + while (i < args.length) { + if (args[i].startsWith('--')) { + const key = args[i]; + const next = args[i + 1]; + if (next && !next.startsWith('--')) { + flags[key] = next; + i += 2; + } else { + flags[key] = true; + i += 1; + } + } else { + positional.push(args[i]); + i += 1; + } + } + return { positional, flags }; +} + +// ─── PR / Merge Request ──────────────────────────────────────────────────── + +async function prView(args: string[]): Promise { + const { positional, flags } = parseArgs(args); + const number = positional[0]; + if (!number) { console.error('error: pr view requires a number'); process.exit(1); } + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': { + const ghArgs = ['pr', 'view', number]; + if (flags['--json']) ghArgs.push('--json', flags['--json'] as string); + console.log(gh(ghArgs)); + break; + } + case 'gitea': { + const data = await apiGet(`${FORGE_API_BASE}/repos/${ownerRepo}/pulls/${number}`); + console.log(JSON.stringify(data, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const data = await apiGet(`${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}`); + console.log(JSON.stringify(data, null, 2)); + break; + } + } +} + +async function prCreate(args: string[]): Promise { + const { flags } = parseArgs(args); + const title = flags['--title'] as string ?? ''; + const body = flags['--body'] as string ?? ''; + const base = flags['--base'] as string ?? ''; + const draft = flags['--draft'] === true; + const ownerRepo = getOwnerRepo(); + const head = execFileSync('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim(); + + switch (FORGE_TYPE) { + case 'github': { + const ghArgs = ['pr', 'create', '--title', title, '--body', body]; + if (base) ghArgs.push('--base', base); + if (draft) ghArgs.push('--draft'); + console.log(gh(ghArgs)); + break; + } + case 'gitea': { + const data = await apiPost(`${FORGE_API_BASE}/repos/${ownerRepo}/pulls`, { + title, body, head, base, + }); + console.log(JSON.stringify(data, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const data = await apiPost(`${FORGE_API_BASE}/projects/${pid}/merge_requests`, { + title, description: body, source_branch: head, target_branch: base, draft, + }); + console.log(JSON.stringify(data, null, 2)); + break; + } + } +} + +async function prComment(args: string[]): Promise { + const { positional, flags } = parseArgs(args); + const number = positional[0]; + if (!number) { console.error('error: pr comment requires a number'); process.exit(1); } + const body = flags['--body'] as string ?? ''; + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': + console.log(gh(['pr', 'comment', number, '--body', body])); + break; + case 'gitea': { + // Gitea uses the issues endpoint for PR comments + const data = await apiPost( + `${FORGE_API_BASE}/repos/${ownerRepo}/issues/${number}/comments`, + { body }, + ); + console.log(JSON.stringify(data, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const data = await apiPost( + `${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}/notes`, + { body }, + ); + console.log(JSON.stringify(data, null, 2)); + break; + } + } +} + +async function prList(args: string[]): Promise { + const { flags } = parseArgs(args); + const head = flags['--head'] as string | undefined; + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': { + const ghArgs = ['pr', 'list']; + if (head) ghArgs.push('--head', head); + if (flags['--json']) ghArgs.push('--json', 'number,url,headRefName,state'); + console.log(gh(ghArgs)); + break; + } + case 'gitea': { + let url = `${FORGE_API_BASE}/repos/${ownerRepo}/pulls?state=open`; + if (head) url += `&head=${ownerRepo.split('/')[0]}:${head}`; + const data = await apiGet(url); + console.log(JSON.stringify(data, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + let url = `${FORGE_API_BASE}/projects/${pid}/merge_requests?state=opened`; + if (head) url += `&source_branch=${head}`; + const data = await apiGet(url); + console.log(JSON.stringify(data, null, 2)); + break; + } + } +} + +async function prDiff(args: string[]): Promise { + const number = args[0]; + if (!number) { console.error('error: pr diff requires a number'); process.exit(1); } + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': + console.log(gh(['pr', 'diff', number])); + break; + case 'gitea': { + const res = await fetch(`${FORGE_API_BASE}/repos/${ownerRepo}/pulls/${number}.diff`, { + headers: { ...authHeaders(), Accept: 'text/plain' }, + }); + console.log(await res.text()); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const mr = (await apiGet( + `${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}`, + )) as { source_branch: string; target_branch: string }; + const compare = (await apiGet( + `${FORGE_API_BASE}/projects/${pid}/repository/compare?from=${mr.target_branch}&to=${mr.source_branch}`, + )) as { diffs: Array<{ old_path: string; new_path: string; diff: string }> }; + for (const d of compare.diffs) { + console.log(`diff --git a/${d.old_path} b/${d.new_path}\n${d.diff}`); + } + break; + } + } +} + +// ─── Issue ────────────────────────────────────────────────────────────────── + +async function issueView(args: string[]): Promise { + const { positional, flags } = parseArgs(args); + const number = positional[0]; + if (!number) { console.error('error: issue view requires a number'); process.exit(1); } + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': { + const ghArgs = ['issue', 'view', number]; + if (flags['--json']) ghArgs.push('--json', flags['--json'] as string); + console.log(gh(ghArgs)); + break; + } + case 'gitea': { + const data = await apiGet(`${FORGE_API_BASE}/repos/${ownerRepo}/issues/${number}`); + console.log(JSON.stringify(data, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const data = await apiGet(`${FORGE_API_BASE}/projects/${pid}/issues/${number}`); + console.log(JSON.stringify(data, null, 2)); + break; + } + } +} + +async function issueCreate(args: string[]): Promise { + const { flags } = parseArgs(args); + const title = flags['--title'] as string ?? ''; + const body = flags['--body'] as string ?? ''; + const ownerRepo = getOwnerRepo(); + + // Collect --label flags (can appear multiple times) + const labels: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--label' && args[i + 1]) labels.push(args[i + 1]); + } + + switch (FORGE_TYPE) { + case 'github': { + const ghArgs = ['issue', 'create', '--title', title, '--body', body]; + for (const l of labels) ghArgs.push('--label', l); + console.log(gh(ghArgs)); + break; + } + case 'gitea': { + // Gitea uses label IDs — resolve from names + let labelIds: number[] = []; + if (labels.length > 0) { + const allLabels = (await apiGet( + `${FORGE_API_BASE}/repos/${ownerRepo}/labels`, + )) as Array<{ id: number; name: string }>; + labelIds = labels + .map((name) => allLabels.find((l) => l.name === name)?.id) + .filter((id): id is number => id !== undefined); + } + const data = await apiPost(`${FORGE_API_BASE}/repos/${ownerRepo}/issues`, { + title, body, labels: labelIds, + }); + console.log(JSON.stringify(data, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const data = await apiPost(`${FORGE_API_BASE}/projects/${pid}/issues`, { + title, description: body, labels: labels.join(','), + }); + console.log(JSON.stringify(data, null, 2)); + break; + } + } +} + +async function issueComment(args: string[]): Promise { + const { positional, flags } = parseArgs(args); + const number = positional[0]; + if (!number) { console.error('error: issue comment requires a number'); process.exit(1); } + const body = flags['--body'] as string ?? ''; + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': + console.log(gh(['issue', 'comment', number, '--body', body])); + break; + case 'gitea': { + const data = await apiPost( + `${FORGE_API_BASE}/repos/${ownerRepo}/issues/${number}/comments`, + { body }, + ); + console.log(JSON.stringify(data, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const data = await apiPost( + `${FORGE_API_BASE}/projects/${pid}/issues/${number}/notes`, + { body }, + ); + console.log(JSON.stringify(data, null, 2)); + break; + } + } +} + +async function issueList(args: string[]): Promise { + const { flags } = parseArgs(args); + const search = flags['--search'] as string | undefined; + const state = (flags['--state'] as string) ?? 'open'; + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': { + const ghArgs = ['issue', 'list', '--state', state]; + if (search) ghArgs.push('--search', search); + if (flags['--json']) ghArgs.push('--json', 'number,title,url,labels,state'); + console.log(gh(ghArgs)); + break; + } + case 'gitea': { + let url = `${FORGE_API_BASE}/repos/${ownerRepo}/issues?state=${state}&type=issues`; + if (search) url += `&q=${encodeURIComponent(search)}`; + const data = await apiGet(url); + console.log(JSON.stringify(data, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const glState = state === 'open' ? 'opened' : state; + let url = `${FORGE_API_BASE}/projects/${pid}/issues?state=${glState}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + const data = await apiGet(url); + console.log(JSON.stringify(data, null, 2)); + break; + } + } +} + +// ─── Labels ───────────────────────────────────────────────────────────────── + +async function labelList(args: string[]): Promise { + const { flags } = parseArgs(args); + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': { + const ghArgs = ['label', 'list']; + if (flags['--json']) ghArgs.push('--json', 'name'); + console.log(gh(ghArgs)); + break; + } + case 'gitea': { + const data = await apiGet(`${FORGE_API_BASE}/repos/${ownerRepo}/labels`); + console.log(JSON.stringify(data, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const data = await apiGet(`${FORGE_API_BASE}/projects/${pid}/labels`); + console.log(JSON.stringify(data, null, 2)); + break; + } + } +} + +// ─── Repo ─────────────────────────────────────────────────────────────────── + +async function repoInfo(): Promise { + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': + console.log(gh(['repo', 'view', '--json', 'nameWithOwner,defaultBranchRef'])); + break; + case 'gitea': { + const data = (await apiGet(`${FORGE_API_BASE}/repos/${ownerRepo}`)) as { + full_name: string; + default_branch: string; + }; + console.log(JSON.stringify({ + nameWithOwner: data.full_name, + defaultBranch: data.default_branch, + }, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const data = (await apiGet(`${FORGE_API_BASE}/projects/${pid}`)) as { + path_with_namespace: string; + default_branch: string; + }; + console.log(JSON.stringify({ + nameWithOwner: data.path_with_namespace, + defaultBranch: data.default_branch, + }, null, 2)); + break; + } + } +} + +// ─── Main Dispatch ────────────────────────────────────────────────────────── + +const [resource, action, ...rest] = process.argv.slice(2); + +async function main(): Promise { + try { + switch (resource) { + case 'pr': + switch (action) { + case 'view': await prView(rest); break; + case 'create': await prCreate(rest); break; + case 'comment': await prComment(rest); break; + case 'list': await prList(rest); break; + case 'diff': await prDiff(rest); break; + default: + console.error(`error: unknown pr action: ${action}`); + console.error('Usage: forge-cli.ts pr {view|create|comment|list|diff} [args...]'); + process.exit(1); + } + break; + case 'issue': + switch (action) { + case 'view': await issueView(rest); break; + case 'create': await issueCreate(rest); break; + case 'comment': await issueComment(rest); break; + case 'list': await issueList(rest); break; + default: + console.error(`error: unknown issue action: ${action}`); + console.error('Usage: forge-cli.ts issue {view|create|comment|list} [args...]'); + process.exit(1); + } + break; + case 'label': + if (action === 'list') await labelList(rest); + else { console.error(`error: unknown label action: ${action}`); process.exit(1); } + break; + case 'repo': + if (action === 'info') await repoInfo(); + else { console.error(`error: unknown repo action: ${action}`); process.exit(1); } + break; + default: + console.error('forge-cli.ts — Forge-agnostic CLI for GitHub, Gitea, and GitLab'); + console.error(''); + console.error('Usage: forge-cli.ts [args...]'); + console.error(''); + console.error('Resources:'); + console.error(' pr {view|create|comment|list|diff}'); + console.error(' issue {view|create|comment|list}'); + console.error(' label {list}'); + console.error(' repo {info}'); + console.error(''); + console.error('Environment:'); + console.error(' FORGE_TYPE github|gitea|gitlab (default: github)'); + console.error(' FORGE_API_BASE API base URL'); + console.error(' GITEA_TOKEN Gitea API token (when FORGE_TYPE=gitea)'); + console.error(' GITLAB_TOKEN GitLab API token (when FORGE_TYPE=gitlab)'); + console.error(' GH_TOKEN GitHub token (when FORGE_TYPE=github)'); + process.exit(1); + } + } catch (e) { + console.error(`error: ${(e as Error).message}`); + process.exit(1); + } +} + +await main(); diff --git a/.archon/workflows/defaults/archon-create-issue.yaml b/.archon/workflows/defaults/archon-create-issue.yaml index 24d59f8e0c..bff52bd05e 100644 --- a/.archon/workflows/defaults/archon-create-issue.yaml +++ b/.archon/workflows/defaults/archon-create-issue.yaml @@ -134,10 +134,10 @@ nodes: echo "=== Searching for duplicates: $KEYWORDS ===" echo "--- Open Issues ---" - gh issue list --search "$KEYWORDS" --state open --limit 5 --json number,title,url,labels 2>/dev/null || echo "No open matches" + bun "$FORGE_CLI" issue list --search "$KEYWORDS" --state open --limit 5 --json number,title,url,labels 2>/dev/null || echo "No open matches" echo "--- Recently Closed ---" - gh issue list --search "$KEYWORDS" --state closed --limit 3 --json number,title,url,labels 2>/dev/null || echo "No closed matches" + bun "$FORGE_CLI" issue list --search "$KEYWORDS" --state closed --limit 3 --json number,title,url,labels 2>/dev/null || echo "No closed matches" depends_on: [classify] # ═══════════════════════════════════════════════════════════════ diff --git a/.archon/workflows/defaults/archon-fix-github-issue.yaml b/.archon/workflows/defaults/archon-fix-github-issue.yaml index 12ad675de9..e2bbde9dab 100644 --- a/.archon/workflows/defaults/archon-fix-github-issue.yaml +++ b/.archon/workflows/defaults/archon-fix-github-issue.yaml @@ -45,7 +45,7 @@ nodes: echo "Failed to extract issue number from: $extract-issue-number.output" >&2 exit 1 fi - gh issue view "$ISSUE_NUM" --json title,body,labels,comments,state,url,author + bun "$FORGE_CLI" issue view "$ISSUE_NUM" --json title,body,labels,comments,state,url,author depends_on: [extract-issue-number] - id: classify diff --git a/.archon/workflows/defaults/archon-validate-pr.yaml b/.archon/workflows/defaults/archon-validate-pr.yaml index 8a3f12a12e..c66621464f 100644 --- a/.archon/workflows/defaults/archon-validate-pr.yaml +++ b/.archon/workflows/defaults/archon-validate-pr.yaml @@ -28,7 +28,7 @@ nodes: fi if [ -z "$PR_NUMBER" ]; then # Try getting PR from current branch - PR_NUMBER=$(gh pr view --json number -q '.number' 2>/dev/null) + PR_NUMBER=$(bun "$FORGE_CLI" pr view --json number -q '.number' 2>/dev/null) fi if [ -z "$PR_NUMBER" ]; then @@ -39,7 +39,7 @@ nodes: echo "$PR_NUMBER" > "$ARTIFACTS_DIR/.pr-number" # Fetch full PR details - gh pr view "$PR_NUMBER" --json number,title,body,url,headRefName,baseRefName,files,additions,deletions,changedFiles,state,author,labels,isDraft + bun "$FORGE_CLI" pr view "$PR_NUMBER" --json number,title,body,url,headRefName,baseRefName,files,additions,deletions,changedFiles,state,author,labels,isDraft - id: find-ports bash: | @@ -62,8 +62,8 @@ nodes: # Get PR branch info PR_NUMBER=$(cat "$ARTIFACTS_DIR/.pr-number") - PR_HEAD=$(gh pr view "$PR_NUMBER" --json headRefName -q '.headRefName') - PR_BASE=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') + PR_HEAD=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json headRefName -q '.headRefName') + PR_BASE=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') echo "$CANONICAL_REPO" > "$ARTIFACTS_DIR/.canonical-repo" echo "$WORKTREE_PATH" > "$ARTIFACTS_DIR/.worktree-path" diff --git a/eslint.config.mjs b/eslint.config.mjs index 69bf635bd5..525ce2be5b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ export default tseslint.config( 'worktrees/**', '.claude/worktrees/**', '.claude/skills/**', + '.archon/scripts/**', '**/*.js', '*.mjs', '**/*.test.ts', diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 96c0209666..2c44ba1f79 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -19,6 +19,11 @@ import { existsSync } from 'fs'; // cannot leak into AI subprocesses — SUBPROCESS_ENV_ALLOWLIST blocks them. // The env-leak gate provides a second layer by scanning target repos before // spawning. No CWD stripping needed. +// Clear DATABASE_URL that Bun may have auto-loaded from CWD .env (e.g., a +// Prisma project's "file:./dev.db"). If ~/.archon/.env sets its own +// DATABASE_URL, dotenv override:true will re-populate it below. +delete process.env.DATABASE_URL; + const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env'); if (existsSync(globalEnvPath)) { const result = config({ path: globalEnvPath, override: true }); diff --git a/packages/core/src/utils/env-allowlist.ts b/packages/core/src/utils/env-allowlist.ts index d17f30ac55..e287c79554 100644 --- a/packages/core/src/utils/env-allowlist.ts +++ b/packages/core/src/utils/env-allowlist.ts @@ -49,6 +49,15 @@ export const SUBPROCESS_ENV_ALLOWLIST = new Set([ // GitHub CLI (used by Claude Code tools) 'GITHUB_TOKEN', 'GH_TOKEN', + // Gitea (used by Gitea forge adapter and forge-agnostic commands) + 'GITEA_TOKEN', + 'GITEA_URL', + // GitLab (used by GitLab forge adapter and forge-agnostic commands) + 'GITLAB_TOKEN', + 'GITLAB_URL', + // Forge detection (set by workflow executor for AI subprocess use) + 'FORGE_TYPE', + 'FORGE_API_BASE', ]); /** diff --git a/packages/git/src/exec.ts b/packages/git/src/exec.ts index 9380e1e8b8..a085ef9375 100644 --- a/packages/git/src/exec.ts +++ b/packages/git/src/exec.ts @@ -8,7 +8,7 @@ const promisifiedExecFile = promisify(execFile); export async function execFileAsync( cmd: string, args: string[], - options?: { timeout?: number; cwd?: string; maxBuffer?: number } + options?: { timeout?: number; cwd?: string; maxBuffer?: number; env?: NodeJS.ProcessEnv } ): Promise<{ stdout: string; stderr: string }> { const result = await promisifiedExecFile(cmd, args, options); return { diff --git a/packages/git/src/forge.test.ts b/packages/git/src/forge.test.ts new file mode 100644 index 0000000000..8f8828c9bb --- /dev/null +++ b/packages/git/src/forge.test.ts @@ -0,0 +1,110 @@ +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import * as repo from './repo'; +import { detectForge } from './forge'; +import { toRepoPath } from './types'; + +const testRepo = toRepoPath('/tmp/test-repo'); + +describe('detectForge', () => { + let getRemoteUrlSpy: ReturnType; + const savedEnv: Record = {}; + + beforeEach(() => { + getRemoteUrlSpy = spyOn(repo, 'getRemoteUrl'); + // Save env vars we'll mutate + savedEnv.GITEA_URL = process.env.GITEA_URL; + savedEnv.GITLAB_URL = process.env.GITLAB_URL; + delete process.env.GITEA_URL; + delete process.env.GITLAB_URL; + }); + + afterEach(() => { + getRemoteUrlSpy.mockRestore(); + // Restore env + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) delete process.env[key]; + else process.env[key] = val; + } + }); + + test('detects GitHub from HTTPS remote', async () => { + getRemoteUrlSpy.mockResolvedValue('https://github.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('github'); + expect(info.apiBase).toBe('https://api.github.com'); + }); + + test('detects GitHub from SSH remote', async () => { + getRemoteUrlSpy.mockResolvedValue('git@github.com:owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('github'); + expect(info.apiBase).toBe('https://api.github.com'); + }); + + test('detects Gitea when GITEA_URL env matches remote hostname', async () => { + process.env.GITEA_URL = 'https://gitea.example.com'; + getRemoteUrlSpy.mockResolvedValue('https://gitea.example.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitea'); + expect(info.apiBase).toBe('https://gitea.example.com/api/v1'); + }); + + test('detects Gitea with trailing slash in GITEA_URL', async () => { + process.env.GITEA_URL = 'https://gitea.example.com/'; + getRemoteUrlSpy.mockResolvedValue('https://gitea.example.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitea'); + expect(info.apiBase).toBe('https://gitea.example.com/api/v1'); + }); + + test('detects GitLab from gitlab.com remote', async () => { + getRemoteUrlSpy.mockResolvedValue('https://gitlab.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitlab'); + expect(info.apiBase).toBe('https://gitlab.com/api/v4'); + }); + + test('detects GitLab from SSH remote', async () => { + getRemoteUrlSpy.mockResolvedValue('git@gitlab.com:owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitlab'); + expect(info.apiBase).toBe('https://gitlab.com/api/v4'); + }); + + test('detects self-hosted GitLab when GITLAB_URL env matches', async () => { + process.env.GITLAB_URL = 'https://gitlab.corp.com'; + getRemoteUrlSpy.mockResolvedValue('https://gitlab.corp.com/team/project.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitlab'); + expect(info.apiBase).toBe('https://gitlab.corp.com/api/v4'); + }); + + test('returns unknown for unrecognized remote', async () => { + getRemoteUrlSpy.mockResolvedValue('https://bitbucket.org/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('unknown'); + expect(info.apiBase).toBe(''); + }); + + test('defaults to github when no remote exists', async () => { + getRemoteUrlSpy.mockResolvedValue(null); + const info = await detectForge(testRepo); + expect(info.type).toBe('github'); + expect(info.apiBase).toBe('https://api.github.com'); + }); + + test('ignores invalid GITEA_URL env value', async () => { + process.env.GITEA_URL = 'not-a-url'; + getRemoteUrlSpy.mockResolvedValue('https://gitea.example.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('unknown'); + expect(info.apiBase).toBe(''); + }); + + test('Gitea detection is case-insensitive on hostname', async () => { + process.env.GITEA_URL = 'https://Gitea.Example.COM'; + getRemoteUrlSpy.mockResolvedValue('https://gitea.example.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitea'); + }); +}); diff --git a/packages/git/src/forge.ts b/packages/git/src/forge.ts new file mode 100644 index 0000000000..02f345a22f --- /dev/null +++ b/packages/git/src/forge.ts @@ -0,0 +1,104 @@ +import { getRemoteUrl } from './repo'; +import type { RepoPath } from './types'; + +/** Supported forge platforms */ +export type ForgeType = 'github' | 'gitea' | 'gitlab' | 'unknown'; + +/** Result of forge detection — type + API base URL */ +export interface ForgeInfo { + type: ForgeType; + apiBase: string; +} + +/** + * Extract hostname from a git remote URL. + * Handles both HTTPS (https://github.com/owner/repo.git) and + * SSH (git@github.com:owner/repo.git) formats. + */ +function extractHostname(remoteUrl: string): string | null { + // HTTPS: https://github.com/owner/repo.git + try { + const url = new URL(remoteUrl); + return url.hostname.toLowerCase(); + } catch { + // Not a valid URL — try SSH format + } + + // SSH: git@github.com:owner/repo.git + const sshRegex = /^[^@]+@([^:]+):/; + const sshMatch = sshRegex.exec(remoteUrl); + if (sshMatch) { + return sshMatch[1].toLowerCase(); + } + + return null; +} + +/** + * Safely extract hostname from an env-var URL (e.g. GITEA_URL). + * Returns null if the env var is unset or not a valid URL. + */ +function hostnameFromEnv(envVar: string): string | null { + const value = process.env[envVar]; + if (!value) return null; + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return null; + } +} + +/** + * Detect the forge platform from the git remote origin URL. + * + * Detection order: + * 1. github.com hostname → GitHub + * 2. GITEA_URL env hostname match → Gitea + * 3. gitlab.com hostname → GitLab + * 4. GITLAB_URL env hostname match → GitLab + * 5. No match → unknown + * + * Returns { type: 'github', apiBase: 'https://api.github.com' } as the + * backwards-compatible default when no remote exists. + */ +export async function detectForge(repoPath: RepoPath): Promise { + const remoteUrl = await getRemoteUrl(repoPath); + + if (!remoteUrl) { + // No remote — default to github for backwards compatibility + return { type: 'github', apiBase: 'https://api.github.com' }; + } + + const hostname = extractHostname(remoteUrl); + if (!hostname) { + return { type: 'unknown', apiBase: '' }; + } + + // 1. GitHub + if (hostname === 'github.com') { + return { type: 'github', apiBase: 'https://api.github.com' }; + } + + // 2. Gitea — match against GITEA_URL env + const giteaUrl = process.env.GITEA_URL; + const giteaHostname = hostnameFromEnv('GITEA_URL'); + if (giteaUrl && giteaHostname && hostname === giteaHostname) { + const cleanUrl = giteaUrl.replace(/\/+$/, ''); + return { type: 'gitea', apiBase: `${cleanUrl}/api/v1` }; + } + + // 3. GitLab (public) + if (hostname === 'gitlab.com') { + return { type: 'gitlab', apiBase: 'https://gitlab.com/api/v4' }; + } + + // 4. GitLab (self-hosted) — match against GITLAB_URL env + const gitlabUrl = process.env.GITLAB_URL; + const gitlabHostname = hostnameFromEnv('GITLAB_URL'); + if (gitlabUrl && gitlabHostname && hostname === gitlabHostname) { + const cleanUrl = gitlabUrl.replace(/\/+$/, ''); + return { type: 'gitlab', apiBase: `${cleanUrl}/api/v4` }; + } + + return { type: 'unknown', apiBase: '' }; +} diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index 8cfdc865f7..41006fb907 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -47,3 +47,7 @@ export { syncRepository, addSafeDirectory, } from './repo'; + +// Forge detection +export type { ForgeType, ForgeInfo } from './forge'; +export { detectForge } from './forge'; diff --git a/packages/workflows/src/dag-executor.test.ts b/packages/workflows/src/dag-executor.test.ts index 150ea4eeb7..7eb38e0445 100644 --- a/packages/workflows/src/dag-executor.test.ts +++ b/packages/workflows/src/dag-executor.test.ts @@ -791,6 +791,9 @@ describe('executeDagWorkflow -- tool restrictions', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -827,6 +830,9 @@ describe('executeDagWorkflow -- tool restrictions', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', { ...minimalConfig, assistant: 'codex' } ); @@ -854,6 +860,9 @@ describe('executeDagWorkflow -- tool restrictions', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -891,6 +900,9 @@ describe('executeDagWorkflow -- tool restrictions', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -936,6 +948,9 @@ describe('executeDagWorkflow -- tool restrictions', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', { ...minimalConfig, assistant: 'codex' } ); @@ -1002,6 +1017,9 @@ describe('executeDagWorkflow -- bash nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1041,6 +1059,9 @@ describe('executeDagWorkflow -- bash nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1078,6 +1099,9 @@ describe('executeDagWorkflow -- bash nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1116,6 +1140,9 @@ describe('executeDagWorkflow -- bash nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1155,6 +1182,9 @@ describe('executeDagWorkflow -- bash nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1196,6 +1226,9 @@ describe('executeDagWorkflow -- bash nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1291,6 +1324,9 @@ describe('executeDagWorkflow -- output_format structured output', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1335,6 +1371,9 @@ describe('executeDagWorkflow -- output_format structured output', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1381,6 +1420,9 @@ describe('executeDagWorkflow -- output_format structured output', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1449,6 +1491,9 @@ describe('executeDagWorkflow -- output_format structured output', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1501,6 +1546,9 @@ describe('executeDagWorkflow -- output_format structured output', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1576,6 +1624,9 @@ describe('executeDagWorkflow -- when condition parse errors (fail-closed)', () = join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1604,6 +1655,9 @@ describe('executeDagWorkflow -- when condition parse errors (fail-closed)', () = join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1636,6 +1690,9 @@ describe('executeDagWorkflow -- when condition parse errors (fail-closed)', () = join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ) ).resolves.toBeUndefined(); @@ -1707,6 +1764,9 @@ describe('executeDagWorkflow -- node-level retry for transient errors', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1743,6 +1803,9 @@ describe('executeDagWorkflow -- node-level retry for transient errors', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1779,6 +1842,9 @@ describe('executeDagWorkflow -- node-level retry for transient errors', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1819,6 +1885,9 @@ describe('executeDagWorkflow -- node-level retry for transient errors', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1884,6 +1953,9 @@ describe('executeDagWorkflow -- tool_called event persistence', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1925,6 +1997,9 @@ describe('executeDagWorkflow -- tool_called event persistence', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -1989,6 +2064,9 @@ describe('executeDagWorkflow -- tool_completed event emission', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2027,6 +2105,9 @@ describe('executeDagWorkflow -- tool_completed event emission', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2063,6 +2144,9 @@ describe('executeDagWorkflow -- tool_completed event emission', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2251,6 +2335,9 @@ describe('executeDagWorkflow -- skills options', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2297,6 +2384,9 @@ describe('executeDagWorkflow -- skills options', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2337,6 +2427,9 @@ describe('executeDagWorkflow -- skills options', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', { ...minimalConfig, assistant: 'codex' } ); @@ -2504,6 +2597,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig, undefined, undefined, @@ -2548,6 +2644,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig, undefined, undefined, @@ -2585,6 +2684,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig, undefined, undefined, @@ -2625,6 +2727,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig, undefined, undefined, @@ -2656,6 +2761,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2695,6 +2803,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2748,6 +2859,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2807,6 +2921,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2848,6 +2965,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2915,6 +3035,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -2964,6 +3087,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3017,6 +3143,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3062,6 +3191,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3121,6 +3253,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3162,6 +3297,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3209,6 +3347,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3260,6 +3401,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3311,6 +3455,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3371,6 +3518,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3442,6 +3592,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3506,6 +3659,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3554,6 +3710,9 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3631,6 +3790,9 @@ describe('executeDagWorkflow -- break after result (no hang on subprocess exit)' join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ).then(() => 'completed'), new Promise((_, reject) => @@ -3676,6 +3838,9 @@ describe('executeDagWorkflow -- break after result (no hang on subprocess exit)' join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ).then(() => 'completed'), new Promise((_, reject) => @@ -3753,6 +3918,9 @@ describe('executeDagWorkflow -- terminal node output selection', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3782,6 +3950,9 @@ describe('executeDagWorkflow -- terminal node output selection', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3825,6 +3996,9 @@ describe('executeDagWorkflow -- terminal node output selection', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3890,6 +4064,9 @@ describe('executeDagWorkflow -- cancel node', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -3929,6 +4106,9 @@ describe('executeDagWorkflow -- cancel node', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4001,6 +4181,9 @@ describe('executeDagWorkflow -- credit exhaustion', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4075,6 +4258,9 @@ describe('executeDagWorkflow -- approval node', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4123,6 +4309,9 @@ describe('executeDagWorkflow -- approval node', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4190,6 +4379,9 @@ describe('executeDagWorkflow -- approval node', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4250,6 +4442,9 @@ describe('executeDagWorkflow -- approval node', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4310,6 +4505,9 @@ describe('executeDagWorkflow -- approval node', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4369,12 +4567,20 @@ describe('executeDagWorkflow -- env var injection', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', { ...minimalConfig, envVars: { MY_SECRET: 'abc123' } } ); expect(mockSendQueryDag.mock.calls.length).toBeGreaterThan(0); const optionsArg = mockSendQueryDag.mock.calls[0][3] as Record; - expect(optionsArg?.env).toEqual({ MY_SECRET: 'abc123' }); + expect(optionsArg?.env).toEqual({ + MY_SECRET: 'abc123', + FORGE_TYPE: 'github', + FORGE_API_BASE: 'https://api.github.com', + FORGE_CLI: '', + }); }); it('does not set env on claudeOptions when config.envVars is empty', async () => { @@ -4395,12 +4601,20 @@ describe('executeDagWorkflow -- env var injection', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', { ...minimalConfig, envVars: {} } ); expect(mockSendQueryDag.mock.calls.length).toBeGreaterThan(0); const optionsArg = mockSendQueryDag.mock.calls[0]?.[3] as Record | undefined; - expect(optionsArg?.env).toBeUndefined(); + // Forge env vars are always injected even when config.envVars is empty + expect(optionsArg?.env).toEqual({ + FORGE_TYPE: 'github', + FORGE_API_BASE: 'https://api.github.com', + FORGE_CLI: '', + }); }); }); @@ -4469,6 +4683,9 @@ describe('executeDagWorkflow -- Claude SDK advanced options', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4522,6 +4739,9 @@ describe('executeDagWorkflow -- Claude SDK advanced options', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4553,6 +4773,9 @@ describe('executeDagWorkflow -- Claude SDK advanced options', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4583,6 +4806,9 @@ describe('executeDagWorkflow -- Claude SDK advanced options', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4617,6 +4843,9 @@ describe('executeDagWorkflow -- Claude SDK advanced options', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', { ...minimalConfig, assistant: 'codex' } ); @@ -4678,6 +4907,9 @@ describe('executeDagWorkflow -- cost tracking', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4724,6 +4956,9 @@ describe('executeDagWorkflow -- cost tracking', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4759,6 +4994,9 @@ describe('executeDagWorkflow -- cost tracking', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4810,6 +5048,9 @@ describe('executeDagWorkflow -- cost tracking', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4884,6 +5125,9 @@ describe('executeDagWorkflow -- script nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4923,6 +5167,9 @@ describe('executeDagWorkflow -- script nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -4960,6 +5207,9 @@ describe('executeDagWorkflow -- script nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -5000,6 +5250,9 @@ describe('executeDagWorkflow -- script nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -5034,6 +5287,9 @@ describe('executeDagWorkflow -- script nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -5073,6 +5329,9 @@ describe('executeDagWorkflow -- script nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -5112,6 +5371,9 @@ describe('executeDagWorkflow -- script nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -5161,6 +5423,9 @@ describe('executeDagWorkflow -- script nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); @@ -5201,6 +5466,9 @@ describe('executeDagWorkflow -- script nodes', () => { join(testDir, 'logs'), 'main', 'docs/', + 'github', + 'https://api.github.com', + '', minimalConfig ); diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index facfbd1068..8bf19ca0be 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -369,7 +369,10 @@ async function resolveNodeProviderAndModel( conversationId: string, workflowRunId: string, cwd: string, - workflowLevelOptions: WorkflowLevelOptions + workflowLevelOptions: WorkflowLevelOptions, + forgeType: string, + forgeApiBase: string, + forgeCli: string ): Promise<{ provider: 'claude' | 'codex'; model: string | undefined; @@ -595,9 +598,12 @@ async function resolveNodeProviderAndModel( } getLog().info({ nodeId: node.id, skills: node.skills, agentId }, 'dag.skills_agent_created'); } - // Inject per-project env vars (config file + DB) into subprocess env + // Inject per-project env vars (config file + DB) and forge detection into subprocess env + const forgeEnv = { FORGE_TYPE: forgeType, FORGE_API_BASE: forgeApiBase, FORGE_CLI: forgeCli }; if (config.envVars && Object.keys(config.envVars).length > 0) { - claudeOptions.env = config.envVars; + claudeOptions.env = { ...config.envVars, ...forgeEnv }; + } else { + claudeOptions.env = forgeEnv; } // Per-node overrides take precedence over workflow-level defaults; maxBudgetUsd and systemPrompt are per-node only @@ -722,6 +728,9 @@ async function executeNodeInternal( logDir: string, baseBranch: string, docsDir: string, + forgeType: string, + forgeApiBase: string, + forgeCli: string, nodeOutputs: Map, resumeSessionId: string | undefined, configuredCommandFolder?: string, @@ -802,7 +811,10 @@ async function executeNodeInternal( baseBranch, docsDir, issueContext, - `dag node '${node.id}' prompt` + `dag node '${node.id}' prompt`, + forgeType, + forgeApiBase, + forgeCli ); } catch (error) { const err = error as Error; @@ -1313,6 +1325,9 @@ async function executeBashNode( logDir: string, baseBranch: string, docsDir: string, + forgeType: string, + forgeApiBase: string, + forgeCli: string, nodeOutputs: Map, issueContext?: string ): Promise { @@ -1352,7 +1367,12 @@ async function executeBashNode( artifactsDir, baseBranch, docsDir, - issueContext + issueContext, + undefined, // loopUserInput + undefined, // rejectionReason + forgeType, + forgeApiBase, + forgeCli ); const finalScript = substituteNodeOutputRefs(substitutedScript, nodeOutputs, true); @@ -1362,6 +1382,12 @@ async function executeBashNode( const { stdout, stderr } = await execFileAsync('bash', ['-c', finalScript], { cwd, timeout, + env: { + ...process.env, + FORGE_TYPE: forgeType, + FORGE_API_BASE: forgeApiBase, + FORGE_CLI: forgeCli, + }, }); // Trim trailing newline from stdout (common shell behavior) @@ -1463,6 +1489,9 @@ async function executeScriptNode( logDir: string, baseBranch: string, docsDir: string, + forgeType: string, + forgeApiBase: string, + forgeCli: string, nodeOutputs: Map, issueContext?: string ): Promise { @@ -1502,7 +1531,12 @@ async function executeScriptNode( artifactsDir, baseBranch, docsDir, - issueContext + issueContext, + undefined, // loopUserInput + undefined, // rejectionReason + forgeType, + forgeApiBase, + forgeCli ); const finalScript = substituteNodeOutputRefs(substitutedScript, nodeOutputs, false); @@ -1576,6 +1610,12 @@ async function executeScriptNode( const { stdout, stderr } = await execFileAsync(cmd, args, { cwd, timeout, + env: { + ...process.env, + FORGE_TYPE: forgeType, + FORGE_API_BASE: forgeApiBase, + FORGE_CLI: forgeCli, + }, }); // Trim trailing newline from stdout (common shell behavior) @@ -1710,6 +1750,9 @@ async function executeLoopNode( logDir: string, baseBranch: string, docsDir: string, + forgeType: string, + forgeApiBase: string, + forgeCli: string, nodeOutputs: Map, config: WorkflowConfig, issueContext?: string @@ -1813,7 +1856,11 @@ async function executeLoopNode( baseBranch, docsDir, issueContext, - i === startIteration ? loopUserInput : '' + i === startIteration ? loopUserInput : '', + undefined, // rejectionReason + forgeType, + forgeApiBase, + forgeCli ); const finalPrompt = substituteNodeOutputRefs(substitutedPrompt, nodeOutputs); @@ -2011,7 +2058,12 @@ async function executeLoopNode( artifactsDir, baseBranch, docsDir, - issueContext + issueContext, + undefined, // loopUserInput + undefined, // rejectionReason + forgeType, + forgeApiBase, + forgeCli ); const substitutedBash = substituteNodeOutputRefs( bashPrompt, @@ -2201,6 +2253,9 @@ async function executeApprovalNode( logDir: string, baseBranch: string, docsDir: string, + forgeType: string, + forgeApiBase: string, + forgeCli: string, nodeOutputs: Map, config: WorkflowConfig, workflowLevelOptions: WorkflowLevelOptions, @@ -2263,7 +2318,10 @@ async function executeApprovalNode( docsDir, issueContext, undefined, // loopUserInput - rejectionReason + rejectionReason, + forgeType, + forgeApiBase, + forgeCli ); // Build a synthetic PromptNode to reuse executeNodeInternal @@ -2283,7 +2341,10 @@ async function executeApprovalNode( conversationId, workflowRun.id, cwd, - workflowLevelOptions + workflowLevelOptions, + forgeType, + forgeApiBase, + forgeCli ); const output = await executeNodeInternal( @@ -2299,6 +2360,9 @@ async function executeApprovalNode( logDir, baseBranch, docsDir, + forgeType, + forgeApiBase, + forgeCli, nodeOutputs, undefined, // fresh session configuredCommandFolder, @@ -2370,6 +2434,9 @@ export async function executeDagWorkflow( logDir: string, baseBranch: string, docsDir: string, + forgeType: string, + forgeApiBase: string, + forgeCli: string, config: WorkflowConfig, configuredCommandFolder?: string, issueContext?: string, @@ -2592,6 +2659,9 @@ export async function executeDagWorkflow( logDir, baseBranch, docsDir, + forgeType, + forgeApiBase, + forgeCli, nodeOutputs, issueContext ); @@ -2641,6 +2711,9 @@ export async function executeDagWorkflow( logDir, baseBranch, docsDir, + forgeType, + forgeApiBase, + forgeCli, nodeOutputs, config, issueContext @@ -2663,6 +2736,9 @@ export async function executeDagWorkflow( logDir, baseBranch, docsDir, + forgeType, + forgeApiBase, + forgeCli, nodeOutputs, config, workflowLevelOptions, @@ -2717,6 +2793,9 @@ export async function executeDagWorkflow( logDir, baseBranch, docsDir, + forgeType, + forgeApiBase, + forgeCli, nodeOutputs, issueContext ); @@ -2733,7 +2812,10 @@ export async function executeDagWorkflow( conversationId, workflowRun.id, cwd, - workflowLevelOptions + workflowLevelOptions, + forgeType, + forgeApiBase, + forgeCli ); // 5. Determine session — parallel or context:fresh → always fresh @@ -2764,6 +2846,9 @@ export async function executeDagWorkflow( logDir, baseBranch, docsDir, + forgeType, + forgeApiBase, + forgeCli, nodeOutputs, // Always pass the prior session ID — forkSession:true in executeNodeInternal // ensures the source is never mutated, so retries can safely resume from it. diff --git a/packages/workflows/src/executor-shared.ts b/packages/workflows/src/executor-shared.ts index 0537609417..926bccc098 100644 --- a/packages/workflows/src/executor-shared.ts +++ b/packages/workflows/src/executor-shared.ts @@ -275,7 +275,10 @@ export function substituteWorkflowVariables( docsDir: string, issueContext?: string, loopUserInput?: string, - rejectionReason?: string + rejectionReason?: string, + forgeType?: string, + forgeApiBase?: string, + forgeCli?: string ): { prompt: string; contextSubstituted: boolean } { // Fail fast if the prompt references $BASE_BRANCH but no base branch could be resolved if (!baseBranch && prompt.includes('$BASE_BRANCH')) { @@ -297,7 +300,10 @@ export function substituteWorkflowVariables( .replace(/\$BASE_BRANCH/g, baseBranch) .replace(/\$DOCS_DIR/g, resolvedDocsDir) .replace(/\$LOOP_USER_INPUT/g, loopUserInput ?? '') - .replace(/\$REJECTION_REASON/g, rejectionReason ?? ''); + .replace(/\$REJECTION_REASON/g, rejectionReason ?? '') + .replace(/\$FORGE_TYPE/g, forgeType ?? 'github') + .replace(/\$FORGE_API_BASE/g, forgeApiBase ?? 'https://api.github.com') + .replace(/\$FORGE_CLI/g, forgeCli ?? ''); // Check if context variables exist (use fresh regex to avoid lastIndex issues) const hasContextVariables = new RegExp(CONTEXT_VAR_PATTERN_STR).test(result); @@ -343,7 +349,10 @@ export function buildPromptWithContext( baseBranch: string, docsDir: string, issueContext: string | undefined, - logLabel: string + logLabel: string, + forgeType?: string, + forgeApiBase?: string, + forgeCli?: string ): string { const { prompt, contextSubstituted } = substituteWorkflowVariables( template, @@ -352,15 +361,37 @@ export function buildPromptWithContext( artifactsDir, baseBranch, docsDir, - issueContext + issueContext, + undefined, // loopUserInput + undefined, // rejectionReason + forgeType, + forgeApiBase, + forgeCli ); + // Auto-inject forge compatibility preamble for non-GitHub forges. + // This ensures all AI command prompts know to use forge-cli.sh instead of gh. + const resolvedForgeType = forgeType ?? 'github'; + let finalPrompt = prompt; + if (resolvedForgeType !== 'github' && resolvedForgeType !== 'unknown') { + const preamble = + '## Forge Compatibility\n\n' + + `This repository uses **${resolvedForgeType}** (not GitHub).\n\n` + + '**CRITICAL**: Replace ALL `gh` CLI commands with the forge-cli tool:\n' + + '- Instead of `gh pr create ...` use `bun "$FORGE_CLI" pr create ...`\n' + + '- Instead of `gh issue view ...` use `bun "$FORGE_CLI" issue view ...`\n' + + '- Instead of `gh pr comment ...` use `bun "$FORGE_CLI" pr comment ...`\n' + + '- The forge-cli tool handles authentication and API differences automatically.\n' + + '- All git commands (push, branch, log, etc.) are unchanged.\n\n'; + finalPrompt = preamble + finalPrompt; + } + if (issueContext && !contextSubstituted) { getLog().debug({ logLabel }, 'issue_context_appended'); - return prompt + '\n\n---\n\n' + issueContext; + return finalPrompt + '\n\n---\n\n' + issueContext; } - return prompt; + return finalPrompt; } // ─── Completion Signal Detection ──────────────────────────────────────────── diff --git a/packages/workflows/src/executor.test.ts b/packages/workflows/src/executor.test.ts index 0a91ac8299..68d311129d 100644 --- a/packages/workflows/src/executor.test.ts +++ b/packages/workflows/src/executor.test.ts @@ -508,8 +508,9 @@ describe('executeWorkflow', () => { // DB env vars should have been fetched for the codebaseId expect(store.getCodebaseEnvVars).toHaveBeenCalledWith('codebase-1'); - // The config passed to executeDagWorkflow (arg index 12) should have merged envVars - const configArg = mockExecuteDagWorkflow.mock.calls[0]?.[12] as WorkflowConfig | undefined; + // The config passed to executeDagWorkflow (arg index 15) should have merged envVars + // (indices 12-14 are forgeType, forgeApiBase, forgeCli) + const configArg = mockExecuteDagWorkflow.mock.calls[0]?.[15] as WorkflowConfig | undefined; expect(configArg?.envVars).toEqual({ FILE_KEY: 'file_val', DB_KEY: 'db_val' }); }); diff --git a/packages/workflows/src/executor.ts b/packages/workflows/src/executor.ts index e87ea9065b..2d6e42b646 100644 --- a/packages/workflows/src/executor.ts +++ b/packages/workflows/src/executor.ts @@ -7,7 +7,7 @@ import type { IWorkflowPlatform, WorkflowMessageMetadata } from './deps'; import type { WorkflowDeps, WorkflowConfig } from './deps'; import * as archonPaths from '@archon/paths'; import { createLogger } from '@archon/paths'; -import { getDefaultBranch, toRepoPath } from '@archon/git'; +import { getDefaultBranch, detectForge, toRepoPath } from '@archon/git'; import type { WorkflowDefinition, WorkflowRun, WorkflowExecutionResult } from './schemas'; import { executeDagWorkflow } from './dag-executor'; import { logWorkflowStart, logWorkflowError } from './logger'; @@ -275,6 +275,24 @@ export async function executeWorkflow( const docsDir = config.docsPath ?? 'docs/'; + // Auto-detect forge type from git remote URL. + // Defaults to 'github' for backwards compatibility if detection fails. + let forgeType = 'github'; + let forgeApiBase = 'https://api.github.com'; + try { + const forgeInfo = await detectForge(toRepoPath(cwd)); + forgeType = forgeInfo.type; + forgeApiBase = forgeInfo.apiBase; + } catch (error) { + getLog().warn( + { err: error as Error, errorType: (error as Error).constructor.name, cwd }, + 'workflow.forge_detect_failed' + ); + } + + // Resolve path to forge-cli.ts helper script (runs via bun, no external deps) + const forgeCli = join(archonPaths.getAppArchonBasePath(), 'scripts', 'forge-cli.ts'); + // Resolve provider and model once (used by all nodes) // When workflow sets a model but not a provider, infer provider from the model. // e.g. model: sonnet → provider: claude, even if config.assistant is codex. @@ -632,6 +650,9 @@ export async function executeWorkflow( logDir, baseBranch, docsDir, + forgeType, + forgeApiBase, + forgeCli, config, configuredCommandFolder, issueContext, From 96819edb8811ed2095d86ac01688ce982d6a6b3b Mon Sep 17 00:00:00 2001 From: Darin Truckenmiller Date: Sat, 11 Apr 2026 20:17:10 -0700 Subject: [PATCH 2/5] fix(server): skip gh auth check for Gitea/GitLab-only setups When GITEA_URL or GITLAB_URL is set but no GH_TOKEN/GITHUB_TOKEN is configured, the gh CLI auth warning is irrelevant and confusing. Skip the check and log an info message instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/server/src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7152aec8b4..89b11508cb 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -724,6 +724,15 @@ export async function startServer(opts: ServerOptions = {}): Promise { * Helps diagnose expired tokens or missing auth before workflows fail. */ async function checkGhAuth(): Promise { + // Skip gh auth check when user has a non-GitHub forge configured and no GitHub token. + // This avoids a spurious warning for Gitea/GitLab-only setups. + const hasGhToken = !!(process.env.GH_TOKEN || process.env.GITHUB_TOKEN); + const hasOtherForge = !!(process.env.GITEA_URL || process.env.GITLAB_URL); + if (!hasGhToken && hasOtherForge) { + getLog().info('gh_auth.skipped — no GitHub token configured, using alternate forge'); + return; + } + const { execFileAsync } = await import('@archon/git'); try { await execFileAsync('gh', ['auth', 'status'], { timeout: 10_000 }); From 20b0df81843a23d17b118aa1c34c14c90622e881 Mon Sep 17 00:00:00 2001 From: Darin Truckenmiller Date: Sat, 11 Apr 2026 21:20:40 -0700 Subject: [PATCH 3/5] feat: dynamic $FORGE_NAME variable for forge-aware prompts Add $FORGE_NAME variable (GitHub/Gitea/GitLab proper case) and replace ~60 hardcoded "GitHub" references in commands and workflows with it. Prompts now dynamically name the correct forge platform. Also skip gh auth check at server startup for Gitea/GitLab-only setups. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../defaults/archon-auto-fix-review.md | 12 +++---- .archon/commands/defaults/archon-create-pr.md | 4 +-- .../defaults/archon-implement-review-fixes.md | 10 +++--- .archon/commands/defaults/archon-implement.md | 6 ++-- .../defaults/archon-investigate-issue.md | 32 +++++++++---------- .../archon-issue-completion-report.md | 16 +++++----- .../commands/defaults/archon-plan-setup.md | 2 +- .../defaults/archon-post-review-to-pr.md | 4 +-- .../defaults/archon-pr-review-scope.md | 2 +- .../archon-resolve-merge-conflicts.md | 12 +++---- .../commands/defaults/archon-self-fix-all.md | 10 +++--- .../defaults/archon-simplify-changes.md | 2 +- .../defaults/archon-synthesize-review.md | 16 +++++----- .../archon-validate-pr-code-review-main.md | 2 +- .../commands/defaults/archon-web-research.md | 4 +-- .../defaults/archon-workflow-summary.md | 20 ++++++------ .../archon-comprehensive-pr-review.yaml | 2 +- .../defaults/archon-create-issue.yaml | 12 +++---- .../defaults/archon-feature-development.yaml | 2 +- .../defaults/archon-fix-github-issue.yaml | 8 ++--- .../workflows/defaults/archon-idea-to-pr.yaml | 2 +- .../defaults/archon-issue-review-full.yaml | 2 +- .../workflows/defaults/archon-piv-loop.yaml | 4 +-- .../workflows/defaults/archon-plan-to-pr.yaml | 2 +- packages/workflows/src/executor-shared.ts | 17 +++++++++- 25 files changed, 110 insertions(+), 95 deletions(-) diff --git a/.archon/commands/defaults/archon-auto-fix-review.md b/.archon/commands/defaults/archon-auto-fix-review.md index 7e10a5a859..f212c60dc2 100644 --- a/.archon/commands/defaults/archon-auto-fix-review.md +++ b/.archon/commands/defaults/archon-auto-fix-review.md @@ -9,7 +9,7 @@ argument-hint: (none - reads all review artifacts from $ARTIFACTS_DIR/review/) ## IMPORTANT: Output Behavior -**Your output will be posted as a GitHub comment.** Keep working output minimal: +**Your output will be posted as a $FORGE_NAME comment.** Keep working output minimal: - Do NOT narrate each step - Do NOT output verbose progress updates - Only output the final structured report at the end @@ -19,11 +19,11 @@ argument-hint: (none - reads all review artifacts from $ARTIFACTS_DIR/review/) ## Your Mission -Read all review artifacts produced in this workflow run and fix everything surfaced — unless a finding is a clear YAGNI violation or speculative over-engineering beyond the scope of the original fix. Validate, commit, push, write an artifact, and post a GitHub comment explaining what was fixed and why anything was skipped. +Read all review artifacts produced in this workflow run and fix everything surfaced — unless a finding is a clear YAGNI violation or speculative over-engineering beyond the scope of the original fix. Validate, commit, push, write an artifact, and post a $FORGE_NAME comment explaining what was fixed and why anything was skipped. **Output artifact**: `$ARTIFACTS_DIR/review/fix-report.md` **Git action**: Commit AND push fixes to the PR branch -**GitHub action**: Post fix report as a comment on the PR +**$FORGE_NAME action**: Post fix report as a comment on the PR --- @@ -261,7 +261,7 @@ Write to `$ARTIFACTS_DIR/review/fix-report.md`: --- -## Phase 7: POST — GitHub Comment +## Phase 7: POST — $FORGE_NAME Comment Post the fix report as a PR comment: @@ -313,7 +313,7 @@ EOF ``` **PHASE_7_CHECKPOINT:** -- [ ] GitHub comment posted +- [ ] $FORGE_NAME comment posted --- @@ -371,4 +371,4 @@ Cannot proceed without findings. - **ALL_FINDINGS_ADDRESSED**: Every finding is either fixed, skipped (with reason), or blocked (with reason) - **VALIDATION_PASSED**: Type check, lint, and tests all pass - **COMMITTED_AND_PUSHED**: Changes committed and pushed to PR branch -- **REPORTED**: Fix report artifact written and GitHub comment posted +- **REPORTED**: Fix report artifact written and $FORGE_NAME comment posted diff --git a/.archon/commands/defaults/archon-create-pr.md b/.archon/commands/defaults/archon-create-pr.md index b3a1790e87..042c9b5092 100644 --- a/.archon/commands/defaults/archon-create-pr.md +++ b/.archon/commands/defaults/archon-create-pr.md @@ -133,7 +133,7 @@ Look for the project's PR template at `.github/pull_request_template.md`, `.gith --- -[If from a GitHub issue, add: Closes #XXX] +[If from a $FORGE_NAME issue, add: Closes #XXX] ``` ### 3.2 Determine PR Title @@ -222,4 +222,4 @@ Opens the existing PR instead of creating a duplicate. 1. Check if branch exists remotely: `git ls-remote --heads origin [branch]` 2. If conflicts: `git pull --rebase origin $BASE_BRANCH` then retry push -3. If permission issues: Check GitHub access +3. If permission issues: Check $FORGE_NAME access diff --git a/.archon/commands/defaults/archon-implement-review-fixes.md b/.archon/commands/defaults/archon-implement-review-fixes.md index 5194f806f6..75e2f6048c 100644 --- a/.archon/commands/defaults/archon-implement-review-fixes.md +++ b/.archon/commands/defaults/archon-implement-review-fixes.md @@ -9,7 +9,7 @@ argument-hint: (none - reads from consolidated review artifact) ## IMPORTANT: Output Behavior -**Your output will be posted as a GitHub comment.** Keep your working output minimal: +**Your output will be posted as a $FORGE_NAME comment.** Keep your working output minimal: - Do NOT narrate each step ("Now I'll read the file...", "Let me check...") - Do NOT output verbose progress updates - Only output the final structured report at the end @@ -23,7 +23,7 @@ Read the consolidated review artifact and implement all CRITICAL and HIGH priori **Output artifact**: `$ARTIFACTS_DIR/review/fix-report.md` **Git action**: Commit AND push fixes to the PR branch -**GitHub action**: Post fix report comment +**$FORGE_NAME action**: Post fix report comment --- @@ -325,7 +325,7 @@ Write to `$ARTIFACTS_DIR/review/fix-report.md`: --- -## Phase 6: POST - GitHub Comment +## Phase 6: POST - $FORGE_NAME Comment ### 6.1 Post Fix Report @@ -393,7 +393,7 @@ EOF ``` **PHASE_6_CHECKPOINT:** -- [ ] GitHub comment posted +- [ ] $FORGE_NAME comment posted --- @@ -451,4 +451,4 @@ See fix report: `$ARTIFACTS_DIR/review/fix-report.md` - **HIGH_ADDRESSED**: All HIGH issues attempted - **VALIDATION_PASSED**: Type check, lint, tests, build all pass - **COMMITTED_AND_PUSHED**: Changes committed AND pushed to PR branch -- **REPORTED**: Fix report artifact and GitHub comment created +- **REPORTED**: Fix report artifact and $FORGE_NAME comment created diff --git a/.archon/commands/defaults/archon-implement.md b/.archon/commands/defaults/archon-implement.md index 4bcd7bf1c5..31c689144e 100644 --- a/.archon/commands/defaults/archon-implement.md +++ b/.archon/commands/defaults/archon-implement.md @@ -1,6 +1,6 @@ --- description: Execute an implementation plan with rigorous validation loops -argument-hint: +argument-hint: --- # Implement Plan @@ -57,7 +57,7 @@ Check `package.json` (or equivalent) for available scripts: cat $ARGUMENTS ``` -If `$ARGUMENTS` is a GitHub issue URL or number (e.g., `#123`), fetch the issue body which contains the plan. +If `$ARGUMENTS` is a $FORGE_NAME issue URL or number (e.g., `#123`), fetch the issue body which contains the plan. ### 1.2 Extract Key Sections @@ -77,7 +77,7 @@ Locate and understand: ``` Error: Plan not found at $ARGUMENTS -Provide a valid plan path or GitHub issue containing the plan. +Provide a valid plan path or $FORGE_NAME issue containing the plan. ``` **PHASE_1_CHECKPOINT:** diff --git a/.archon/commands/defaults/archon-investigate-issue.md b/.archon/commands/defaults/archon-investigate-issue.md index 09a361acfb..bdfd7bed7d 100644 --- a/.archon/commands/defaults/archon-investigate-issue.md +++ b/.archon/commands/defaults/archon-investigate-issue.md @@ -1,5 +1,5 @@ --- -description: Investigate a GitHub issue or problem - analyze codebase, create plan, post to GitHub +description: Investigate a forge issue or problem - analyze codebase, create plan, post to forge argument-hint: --- @@ -17,7 +17,7 @@ argument-hint: Investigate the issue/problem and produce a comprehensive implementation plan that: 1. Can be executed by `/implement-issue` -2. Is posted as a GitHub comment (if GH issue provided) +2. Is posted as a $FORGE_NAME comment (if $FORGE_NAME issue provided) 3. Captures all context needed for one-pass implementation **Golden Rule**: The artifact you produce IS the specification. The implementing agent should be able to work from it without asking questions. @@ -30,18 +30,18 @@ Investigate the issue/problem and produce a comprehensive implementation plan th **Check the input format:** -- Looks like a number (`123`, `#123`) → GitHub issue number -- Starts with `http` → GitHub URL (extract issue number) +- Looks like a number (`123`, `#123`) → $FORGE_NAME issue number +- Starts with `http` → $FORGE_NAME URL (extract issue number) - Anything else → Free-form description ```bash -# If GitHub issue, fetch it: +# If forge issue, fetch it: gh issue view {number} --json title,body,labels,comments,state,url,author ``` ### 1.2 Extract Context -**If GitHub issue:** +**If $FORGE_NAME issue:** - Title: What's the reported problem? - Body: Details, reproduction steps, expected vs actual - Labels: bug? enhancement? documentation? @@ -50,7 +50,7 @@ gh issue view {number} --json title,body,labels,comments,state,url,author **If free-form:** - Parse as problem description -- Note: No GitHub posting (artifact only) +- Note: No $FORGE_NAME posting (artifact only) ### 1.3 Classify Issue Type @@ -100,13 +100,13 @@ Each assessment requires a **one-sentence reasoning** explaining WHY you chose t | LOW | Uncertain root cause, limited evidence, many unknowns | **PHASE_1_CHECKPOINT:** -- [ ] Input type identified (GH issue or free-form) +- [ ] Input type identified ($FORGE_NAME issue or free-form) - [ ] Issue content extracted - [ ] Type classified - [ ] Severity (bug) or Priority (other) assessed with reasoning - [ ] Complexity assessed with reasoning (after Phase 2) - [ ] Confidence assessed with reasoning (after Phase 3) -- [ ] If GH issue: confirmed it's open and not already has PR +- [ ] If $FORGE_NAME issue: confirmed it's open and not already has PR --- @@ -429,11 +429,11 @@ bun run lint --- -## Phase 5: POST - GitHub Comment +## Phase 5: POST - $FORGE_NAME Comment -**Only if input was a GitHub issue (not free-form):** +**Only if input was a $FORGE_NAME issue (not free-form):** -Format the artifact for GitHub and post: +Format the artifact for $FORGE_NAME and post: ```bash gh issue comment {number} --body "$(cat <<'EOF' @@ -459,7 +459,7 @@ gh issue comment {number} --body "$(cat <<'EOF' ### Root Cause Analysis -{evidence chain, formatted for GitHub} +{evidence chain, formatted for $FORGE_NAME} --- @@ -498,7 +498,7 @@ EOF ``` **PHASE_5_CHECKPOINT:** -- [ ] Comment posted to GitHub (if GH issue) +- [ ] Comment posted to $FORGE_NAME (if $FORGE_NAME issue) - [ ] Formatting renders correctly --- @@ -536,7 +536,7 @@ EOF 📄 `$ARTIFACTS_DIR/investigation.md` -### GitHub +### $FORGE_NAME {✅ Posted to issue | ⏭️ Skipped (free-form input)} @@ -575,5 +575,5 @@ Run `/implement-issue {number}` to execute the plan. - **ARTIFACT_COMPLETE**: All sections filled with specific, actionable content - **EVIDENCE_BASED**: Every claim has file:line reference or proof - **IMPLEMENTABLE**: Another agent can execute without questions -- **GITHUB_POSTED**: Comment visible on issue (if GH issue) +- **FORGE_POSTED**: Comment visible on issue (if $FORGE_NAME issue) - **COMMITTED**: Artifact saved in git diff --git a/.archon/commands/defaults/archon-issue-completion-report.md b/.archon/commands/defaults/archon-issue-completion-report.md index a4938dab7f..bd4318bd26 100644 --- a/.archon/commands/defaults/archon-issue-completion-report.md +++ b/.archon/commands/defaults/archon-issue-completion-report.md @@ -1,5 +1,5 @@ --- -description: Post completion report to GitHub issue with results, unaddressed items, and follow-up suggestions +description: Post completion report to forge issue with results, unaddressed items, and follow-up suggestions argument-hint: (none - reads from workflow artifacts) --- @@ -12,9 +12,9 @@ argument-hint: (none - reads from workflow artifacts) ## Your Mission -Compile all workflow artifacts into a final report and post it to the original GitHub issue. Summarize what was done, what wasn't addressed (and why), and suggest follow-up issues if needed. +Compile all workflow artifacts into a final report and post it to the original $FORGE_NAME issue. Summarize what was done, what wasn't addressed (and why), and suggest follow-up issues if needed. -**GitHub action**: Post completion report as a comment on the original issue +**$FORGE_NAME action**: Post completion report as a comment on the original issue **Output artifact**: `$ARTIFACTS_DIR/completion-report.md` --- @@ -219,9 +219,9 @@ Write to `$ARTIFACTS_DIR/completion-report.md`: --- -## Phase 4: POST — GitHub Issue Comment +## Phase 4: POST — $FORGE_NAME Issue Comment -Post to the original GitHub issue: +Post to the original $FORGE_NAME issue: ```bash ISSUE_NUMBER=$(echo "$ARGUMENTS" | grep -oE '[0-9]+') @@ -292,7 +292,7 @@ EOF **PHASE_4_CHECKPOINT:** -- [ ] GitHub comment posted to issue +- [ ] $FORGE_NAME comment posted to issue --- @@ -318,7 +318,7 @@ EOF ### Artifacts - Completion report: `$ARTIFACTS_DIR/completion-report.md` -- GitHub comment: Posted to issue +- $FORGE_NAME comment: Posted to issue ### Next Steps @@ -333,6 +333,6 @@ EOF - **ALL_ARTIFACTS_READ**: All workflow artifacts loaded and parsed - **REPORT_COMPILED**: Comprehensive completion report written -- **GITHUB_POSTED**: Comment posted to original issue +- **FORGE_POSTED**: Comment posted to original issue - **UNADDRESSED_DOCUMENTED**: Clear reasons for anything not fixed - **FOLLOWUPS_SUGGESTED**: Actionable follow-up issues recommended where appropriate diff --git a/.archon/commands/defaults/archon-plan-setup.md b/.archon/commands/defaults/archon-plan-setup.md index 812d0f8246..49a7211e93 100644 --- a/.archon/commands/defaults/archon-plan-setup.md +++ b/.archon/commands/defaults/archon-plan-setup.md @@ -53,7 +53,7 @@ Read the plan file: cat $PLAN_PATH ``` -If `$ARGUMENTS` is a GitHub issue URL or number (e.g., `#123`), fetch the issue body instead. +If `$ARGUMENTS` is a $FORGE_NAME issue URL or number (e.g., `#123`), fetch the issue body instead. ### 1.3 Extract Key Information diff --git a/.archon/commands/defaults/archon-post-review-to-pr.md b/.archon/commands/defaults/archon-post-review-to-pr.md index 742df07f33..ffd7a0c1e0 100644 --- a/.archon/commands/defaults/archon-post-review-to-pr.md +++ b/.archon/commands/defaults/archon-post-review-to-pr.md @@ -57,7 +57,7 @@ From the review findings, extract: ### 2.2 Build Comment Body -Format the review as a GitHub-friendly comment: +Format the review as a $FORGE_NAME-compatible comment: ```markdown ## 🔍 Code Review @@ -172,7 +172,7 @@ Review comment has been posted to the pull request. - Report error to user ### Comment fails to post -- Check GitHub authentication +- Check $FORGE_NAME authentication - Try with shorter body if too large - Report error with details diff --git a/.archon/commands/defaults/archon-pr-review-scope.md b/.archon/commands/defaults/archon-pr-review-scope.md index 560ce2c050..f435615589 100644 --- a/.archon/commands/defaults/archon-pr-review-scope.md +++ b/.archon/commands/defaults/archon-pr-review-scope.md @@ -81,7 +81,7 @@ gh pr view {number} --json mergeable,mergeStateStatus --jq '.mergeable, .mergeSt |--------|--------| | `MERGEABLE` | Continue | | `CONFLICTING` | **STOP** - Tell user to resolve conflicts first | -| `UNKNOWN` | Warn, continue (GitHub still calculating) | +| `UNKNOWN` | Warn, continue ($FORGE_NAME still calculating) | **If conflicts exist:** ```markdown diff --git a/.archon/commands/defaults/archon-resolve-merge-conflicts.md b/.archon/commands/defaults/archon-resolve-merge-conflicts.md index e92030e851..97a190524d 100644 --- a/.archon/commands/defaults/archon-resolve-merge-conflicts.md +++ b/.archon/commands/defaults/archon-resolve-merge-conflicts.md @@ -20,8 +20,8 @@ Analyze merge conflicts in the PR, automatically resolve simple conflicts where ### 1.1 Parse Input **Check input format:** -- Number (`123`, `#123`) → GitHub PR number -- URL (`https://github.com/...`) → Extract PR number +- Number (`123`, `#123`) → $FORGE_NAME PR number +- URL → Extract PR number - Empty → Check current branch for open PR ```bash @@ -364,7 +364,7 @@ Resolved {N} conflicts in {M} files. - **Timestamp**: {ISO timestamp} ``` -### 6.2 Post GitHub Comment +### 6.2 Post $FORGE_NAME Comment ```bash gh pr comment {number} --body "$(cat <<'EOF' @@ -394,7 +394,7 @@ EOF **PHASE_6_CHECKPOINT:** - [ ] Artifact created -- [ ] GitHub comment posted +- [ ] $FORGE_NAME comment posted --- @@ -476,5 +476,5 @@ If type-check/tests fail after resolution: - **CONFLICTS_RESOLVED**: All conflicts resolved (auto or manual) - **VALIDATION_PASSED**: Type check, tests, lint all pass - **BRANCH_PUSHED**: PR branch updated with resolution -- **PR_MERGEABLE**: GitHub shows PR as mergeable -- **DOCUMENTED**: Resolution artifact and GitHub comment created +- **PR_MERGEABLE**: $FORGE_NAME shows PR as mergeable +- **DOCUMENTED**: Resolution artifact and $FORGE_NAME comment created diff --git a/.archon/commands/defaults/archon-self-fix-all.md b/.archon/commands/defaults/archon-self-fix-all.md index 89966bc6be..1665cde65f 100644 --- a/.archon/commands/defaults/archon-self-fix-all.md +++ b/.archon/commands/defaults/archon-self-fix-all.md @@ -9,7 +9,7 @@ argument-hint: (none - reads all review artifacts from $ARTIFACTS_DIR/review/) ## IMPORTANT: Output Behavior -**Your output will be posted as a GitHub comment.** Keep working output minimal: +**Your output will be posted as a $FORGE_NAME comment.** Keep working output minimal: - Do NOT narrate each step - Do NOT output verbose progress updates - Only output the final structured report at the end @@ -24,7 +24,7 @@ Read all review artifacts and fix EVERYTHING surfaced. Unlike conservative auto- **Output artifact**: `$ARTIFACTS_DIR/review/fix-report.md` **Git action**: Commit AND push fixes to the PR branch -**GitHub action**: Post fix report as a comment on the PR +**$FORGE_NAME action**: Post fix report as a comment on the PR --- @@ -315,7 +315,7 @@ Write to `$ARTIFACTS_DIR/review/fix-report.md`: --- -## Phase 7: POST — GitHub Comment +## Phase 7: POST — $FORGE_NAME Comment Post the fix report as a PR comment: @@ -387,7 +387,7 @@ EOF **PHASE_7_CHECKPOINT:** -- [ ] GitHub comment posted +- [ ] $FORGE_NAME comment posted --- @@ -423,4 +423,4 @@ Fix report: $ARTIFACTS_DIR/review/fix-report.md - **DOCS_UPDATED**: Documentation gaps filled - **VALIDATION_PASSED**: Type check, lint, and tests all pass - **COMMITTED_AND_PUSHED**: Changes committed and pushed to PR branch -- **REPORTED**: Fix report artifact written and GitHub comment posted +- **REPORTED**: Fix report artifact written and $FORGE_NAME comment posted diff --git a/.archon/commands/defaults/archon-simplify-changes.md b/.archon/commands/defaults/archon-simplify-changes.md index f0e834a4a5..0ea4769eba 100644 --- a/.archon/commands/defaults/archon-simplify-changes.md +++ b/.archon/commands/defaults/archon-simplify-changes.md @@ -9,7 +9,7 @@ argument-hint: (none - operates on the current branch diff against $BASE_BRANCH) ## IMPORTANT: Output Behavior -**Your output will be posted as a GitHub comment.** Keep working output minimal: +**Your output will be posted as a $FORGE_NAME comment.** Keep working output minimal: - Do NOT narrate each step - Do NOT output verbose progress updates - Only output the final structured report at the end diff --git a/.archon/commands/defaults/archon-synthesize-review.md b/.archon/commands/defaults/archon-synthesize-review.md index f32563f427..6bcb03cefc 100644 --- a/.archon/commands/defaults/archon-synthesize-review.md +++ b/.archon/commands/defaults/archon-synthesize-review.md @@ -1,5 +1,5 @@ --- -description: Synthesize all review agent findings into consolidated report and post to GitHub +description: Synthesize all review agent findings into consolidated report and post to forge argument-hint: (none - reads from review artifacts) --- @@ -9,10 +9,10 @@ argument-hint: (none - reads from review artifacts) ## Your Mission -Read all parallel review agent artifacts, synthesize findings into a consolidated report, create a master artifact, and post a comprehensive review comment to the GitHub PR. +Read all parallel review agent artifacts, synthesize findings into a consolidated report, create a master artifact, and post a comprehensive review comment to the $FORGE_NAME PR. **Output artifact**: `$ARTIFACTS_DIR/review/consolidated-review.md` -**GitHub action**: Post PR comment with full review +**$FORGE_NAME action**: Post PR comment with full review --- @@ -256,11 +256,11 @@ If not addressing in this PR, create issues for: --- -## Phase 4: POST - GitHub PR Comment +## Phase 4: POST - $FORGE_NAME PR Comment -### 4.1 Format for GitHub +### 4.1 Format for $FORGE_NAME -Create a GitHub-friendly version of the review: +Create a $FORGE_NAME-compatible version of the review: ```bash gh pr comment {number} --body "$(cat <<'EOF' @@ -373,7 +373,7 @@ EOF ``` **PHASE_4_CHECKPOINT:** -- [ ] GitHub comment posted +- [ ] $FORGE_NAME comment posted - [ ] Formatting renders correctly - [ ] All severity levels included @@ -394,4 +394,4 @@ Output only a brief confirmation (this will be posted as a comment): - **ALL_ARTIFACTS_READ**: All 5 agent findings loaded - **FINDINGS_SYNTHESIZED**: Combined, deduplicated, prioritized - **CONSOLIDATED_CREATED**: Master artifact written -- **GITHUB_POSTED**: PR comment visible +- **FORGE_POSTED**: PR comment visible diff --git a/.archon/commands/defaults/archon-validate-pr-code-review-main.md b/.archon/commands/defaults/archon-validate-pr-code-review-main.md index f5c0f665b9..40d0ece977 100644 --- a/.archon/commands/defaults/archon-validate-pr-code-review-main.md +++ b/.archon/commands/defaults/archon-validate-pr-code-review-main.md @@ -37,7 +37,7 @@ From the PR title, body, and linked issue(s): - What is the expected behavior vs actual behavior? - Which files/components are involved? -If the PR body references a GitHub issue, fetch it: +If the PR body references a $FORGE_NAME issue, fetch it: ```bash # Extract issue number from PR body (looks for "Fixes #N", "Closes #N", etc.) diff --git a/.archon/commands/defaults/archon-web-research.md b/.archon/commands/defaults/archon-web-research.md index 8bde9cea92..c8df3de46f 100644 --- a/.archon/commands/defaults/archon-web-research.md +++ b/.archon/commands/defaults/archon-web-research.md @@ -1,5 +1,5 @@ --- -description: Research web sources for context relevant to a GitHub issue or feature +description: Research web sources for context relevant to a forge issue or feature argument-hint: --- @@ -24,7 +24,7 @@ Search the web for information relevant to the issue or feature being worked on. ### 1.1 Get Issue Context -If input looks like a GitHub issue number: +If input looks like a $FORGE_NAME issue number: ```bash gh issue view $ARGUMENTS --json title,body,labels diff --git a/.archon/commands/defaults/archon-workflow-summary.md b/.archon/commands/defaults/archon-workflow-summary.md index 139e2c5263..519ad65027 100644 --- a/.archon/commands/defaults/archon-workflow-summary.md +++ b/.archon/commands/defaults/archon-workflow-summary.md @@ -16,7 +16,7 @@ Create the final summary report for the workflow run: 2. List deviations and their rationale 3. Surface unfixed review findings (MEDIUM/LOW) 4. Create actionable follow-up recommendations -5. Post to GitHub PR as a comment +5. Post to $FORGE_NAME PR as a comment 6. Write artifact for future reference **Output**: Decision matrix the user can act on quickly. @@ -187,7 +187,7 @@ Structure the output for easy decision-making: --- -### 📋 Suggested GitHub Issues +### 📋 Suggested $FORGE_NAME Issues | # | Title | Labels | From | |---|-------|--------|------| @@ -233,9 +233,9 @@ Structure the output for easy decision-making: --- -## Phase 4: POST - GitHub PR Comment +## Phase 4: POST - $FORGE_NAME PR Comment -### 4.1 Format for GitHub +### 4.1 Format for $FORGE_NAME Create a PR comment with the summary: @@ -327,7 +327,7 @@ These were **intentionally excluded** from scope: **Artifacts**: `$ARTIFACTS_DIR/` ``` -### 4.2 Post to GitHub +### 4.2 Post to $FORGE_NAME ```bash gh pr comment {pr-number} --body "{formatted-summary}" @@ -335,7 +335,7 @@ gh pr comment {pr-number} --body "{formatted-summary}" **PHASE_4_CHECKPOINT:** -- [ ] Summary formatted for GitHub +- [ ] Summary formatted for $FORGE_NAME - [ ] Comment posted to PR --- @@ -395,7 +395,7 @@ Write to `$ARTIFACTS_DIR/workflow-summary.md`: ## Follow-Up Recommendations -### GitHub Issues to Create +### $FORGE_NAME Issues to Create {List with draft titles/bodies} @@ -415,7 +415,7 @@ Write to `$ARTIFACTS_DIR/workflow-summary.md`: --- -## GitHub Comment +## $FORGE_NAME Comment Posted to: {PR URL}#comment-{id} ``` @@ -466,7 +466,7 @@ This allows legacy tools to find review artifacts at `$ARTIFACTS_DIR/../reviews/ | Quick wins available | {N} | | Follow-up issues suggested | {N} | -### Posted to GitHub +### Posted to $FORGE_NAME Summary comment added to PR with: - Implementation vs plan comparison @@ -492,6 +492,6 @@ Summary comment added to PR with: - **ARTIFACTS_LOADED**: All workflow artifacts read - **MATRIX_CREATED**: Follow-up items categorized and prioritized -- **GITHUB_POSTED**: Summary comment on PR +- **FORGE_POSTED**: Summary comment on PR - **ARTIFACT_WRITTEN**: workflow-summary.md created - **ACTIONABLE**: User has clear next steps with minimal cognitive load diff --git a/.archon/workflows/defaults/archon-comprehensive-pr-review.yaml b/.archon/workflows/defaults/archon-comprehensive-pr-review.yaml index 81d4cd9764..9e8cd584f0 100644 --- a/.archon/workflows/defaults/archon-comprehensive-pr-review.yaml +++ b/.archon/workflows/defaults/archon-comprehensive-pr-review.yaml @@ -8,7 +8,7 @@ description: | NOT for: Quick questions about a PR, checking CI status, simple "what changed" queries. This workflow produces artifacts in $ARTIFACTS_DIR/../reviews/pr-{number}/ and posts - a comprehensive review comment to the GitHub PR. + a comprehensive review comment to the PR. nodes: - id: scope diff --git a/.archon/workflows/defaults/archon-create-issue.yaml b/.archon/workflows/defaults/archon-create-issue.yaml index bff52bd05e..d5e7a2cb97 100644 --- a/.archon/workflows/defaults/archon-create-issue.yaml +++ b/.archon/workflows/defaults/archon-create-issue.yaml @@ -1,8 +1,8 @@ name: archon-create-issue description: | - Use when: User wants to report a bug or problem as a GitHub issue with automated reproduction. + Use when: User wants to report a bug or problem as a $FORGE_NAME issue with automated reproduction. Triggers: "create issue", "file a bug", "report this bug", "open an issue for", - "create github issue", "report issue", "log this bug". + "create github issue", "create issue", "report issue", "log this bug". Does: Classifies problem area (haiku) -> gathers context in parallel (templates, git state, duplicates) -> investigates relevant code -> reproduces the issue using area-specific tools (agent-browser, CLI, DB queries) -> gates on reproduction success -> creates issue with full evidence OR reports back if cannot reproduce. @@ -452,7 +452,7 @@ nodes: Report to the user clearly: - 1. **State upfront**: "Could not reproduce the reported issue. No GitHub issue was created." + 1. **State upfront**: "Could not reproduce the reported issue. No $FORGE_NAME issue was created." 2. **Summarize what was tried**: List the specific steps the reproduce node took, based on the area playbook. Be concrete — "Started server on port X, navigated to Y, @@ -471,7 +471,7 @@ nodes: 5. **Offer to retry**: "If you can provide more specific steps, run the workflow again with those details." - Do NOT create a GitHub issue. The purpose of this node is to communicate back to the + Do NOT create a $FORGE_NAME issue. The purpose of this node is to communicate back to the user so they can provide better information or investigate manually. depends_on: [check-reproduction] when: "$check-reproduction.output == 'NOT_REPRODUCED'" @@ -479,7 +479,7 @@ nodes: - id: draft-issue prompt: | - You are a technical writer drafting a GitHub issue. Assemble all gathered + You are a technical writer drafting a $FORGE_NAME issue. Assemble all gathered context into a clear, well-structured issue body. ## Classification @@ -558,7 +558,7 @@ nodes: - id: create-issue prompt: | - Create the GitHub issue using the drafted content. + Create the $FORGE_NAME issue using the drafted content. ## Instructions diff --git a/.archon/workflows/defaults/archon-feature-development.yaml b/.archon/workflows/defaults/archon-feature-development.yaml index 6d0747700d..9ce6c4b380 100644 --- a/.archon/workflows/defaults/archon-feature-development.yaml +++ b/.archon/workflows/defaults/archon-feature-development.yaml @@ -1,7 +1,7 @@ name: archon-feature-development description: | Use when: Implementing a feature from an existing plan. - Input: Path to a plan file ($ARTIFACTS_DIR/plan.md) or GitHub issue containing a plan. + Input: Path to a plan file ($ARTIFACTS_DIR/plan.md) or $FORGE_NAME issue containing a plan. Does: Implements the plan with validation loops -> creates pull request. NOT for: Creating plans (plans should be created separately), bug fixes, code reviews. diff --git a/.archon/workflows/defaults/archon-fix-github-issue.yaml b/.archon/workflows/defaults/archon-fix-github-issue.yaml index e2bbde9dab..96ee1975eb 100644 --- a/.archon/workflows/defaults/archon-fix-github-issue.yaml +++ b/.archon/workflows/defaults/archon-fix-github-issue.yaml @@ -1,6 +1,6 @@ name: archon-fix-github-issue description: | - Use when: User wants to FIX, RESOLVE, or IMPLEMENT a solution for a GitHub issue. + Use when: User wants to FIX, RESOLVE, or IMPLEMENT a solution for a $FORGE_NAME issue. Triggers: "fix this issue", "implement issue #123", "resolve this bug", "fix it", "fix issue", "resolve issue", "fix #123". NOT for: Comprehensive multi-agent reviews (use archon-issue-review-full), @@ -15,7 +15,7 @@ description: | 6. Runs smart review (always code review + CLAUDE.md check, conditional additional agents) 7. Aggressively self-fixes all findings (tests, docs, error handling) 8. Simplifies changed code (implements fixes directly, not just reports) - 9. Reports results back to the GitHub issue with follow-up suggestions + 9. Reports results back to the $FORGE_NAME issue with follow-up suggestions provider: claude model: sonnet @@ -27,7 +27,7 @@ nodes: - id: extract-issue-number prompt: | - Find the GitHub issue number for this request. + Find the $FORGE_NAME issue number for this request. Request: $ARGUMENTS @@ -50,7 +50,7 @@ nodes: - id: classify prompt: | - You are an issue classifier. Analyze the GitHub issue below and determine its type. + You are an issue classifier. Analyze the $FORGE_NAME issue below and determine its type. ## Issue Content diff --git a/.archon/workflows/defaults/archon-idea-to-pr.yaml b/.archon/workflows/defaults/archon-idea-to-pr.yaml index 9329c55021..10b538662c 100644 --- a/.archon/workflows/defaults/archon-idea-to-pr.yaml +++ b/.archon/workflows/defaults/archon-idea-to-pr.yaml @@ -13,7 +13,7 @@ description: | 6. Create PR with template, mark ready 7. Comprehensive code review (5 parallel agents with scope limit awareness) 8. Synthesize and fix review findings - 9. Final summary with decision matrix -> GitHub comment + follow-up recommendations + 9. Final summary with decision matrix -> $FORGE_NAME comment + follow-up recommendations NOT for: Executing existing plans (use archon-plan-to-pr), quick fixes, standalone reviews. diff --git a/.archon/workflows/defaults/archon-issue-review-full.yaml b/.archon/workflows/defaults/archon-issue-review-full.yaml index 60f30af2ce..f537ea1067 100644 --- a/.archon/workflows/defaults/archon-issue-review-full.yaml +++ b/.archon/workflows/defaults/archon-issue-review-full.yaml @@ -1,6 +1,6 @@ name: archon-issue-review-full description: | - Use when: User wants a FULL, COMPREHENSIVE fix + review pipeline for a GitHub issue. + Use when: User wants a FULL, COMPREHENSIVE fix + review pipeline for a $FORGE_NAME issue. Triggers: "full review", "comprehensive fix", "fix with full review", "deep review", "issue review full". NOT for: Simple issue fixes (use archon-fix-github-issue instead), questions about issues, CI failures, PR reviews, general exploration. diff --git a/.archon/workflows/defaults/archon-piv-loop.yaml b/.archon/workflows/defaults/archon-piv-loop.yaml index 7227900c2f..2e409110ea 100644 --- a/.archon/workflows/defaults/archon-piv-loop.yaml +++ b/.archon/workflows/defaults/archon-piv-loop.yaml @@ -14,7 +14,7 @@ description: | 4. VALIDATE: Automated code review -> iterative human feedback & fixes (arbitrary rounds) The PIV loop comes AFTER a PRD exists. Each PIV loop focuses on ONE granular feature or bug fix. - Input: A description of what to build, a path to an existing plan, or a GitHub issue number. + Input: A description of what to build, a path to an existing plan, or a $FORGE_NAME issue number. provider: claude interactive: true @@ -50,7 +50,7 @@ nodes: - If it's an existing plan → summarize it and ask if they want to refine or proceed - If it's a PRD → identify the specific phase/feature to focus on - **If it's a GitHub issue** (`#123` format): + **If it's a $FORGE_NAME issue** (`#123` format): - Fetch it: `gh issue view {number} --json title,body,labels,comments` - Summarize the issue context diff --git a/.archon/workflows/defaults/archon-plan-to-pr.yaml b/.archon/workflows/defaults/archon-plan-to-pr.yaml index 067c1a818e..26556d38a0 100644 --- a/.archon/workflows/defaults/archon-plan-to-pr.yaml +++ b/.archon/workflows/defaults/archon-plan-to-pr.yaml @@ -12,7 +12,7 @@ description: | 5. Create PR with template, mark ready 6. Comprehensive code review (5 parallel agents with scope limit awareness) 7. Synthesize and fix review findings - 8. Final summary with decision matrix -> GitHub comment + follow-up recommendations + 8. Final summary with decision matrix -> $FORGE_NAME comment + follow-up recommendations NOT for: Creating plans from scratch (use archon-idea-to-pr), quick fixes, standalone reviews. diff --git a/packages/workflows/src/executor-shared.ts b/packages/workflows/src/executor-shared.ts index 926bccc098..d90ee8b614 100644 --- a/packages/workflows/src/executor-shared.ts +++ b/packages/workflows/src/executor-shared.ts @@ -266,6 +266,20 @@ export const CONTEXT_VAR_PATTERN_STR = '\\$(?:CONTEXT|EXTERNAL_CONTEXT|ISSUE_CON * When issueContext is undefined, context variables are replaced with empty string * to avoid sending literal "$CONTEXT" to the AI. */ + +/** Map forge type to display name for use in prompts */ +function forgeDisplayName(forgeType: string | undefined): string { + switch (forgeType) { + case 'gitea': + return 'Gitea'; + case 'gitlab': + return 'GitLab'; + case 'github': + default: + return 'GitHub'; + } +} + export function substituteWorkflowVariables( prompt: string, workflowId: string, @@ -303,7 +317,8 @@ export function substituteWorkflowVariables( .replace(/\$REJECTION_REASON/g, rejectionReason ?? '') .replace(/\$FORGE_TYPE/g, forgeType ?? 'github') .replace(/\$FORGE_API_BASE/g, forgeApiBase ?? 'https://api.github.com') - .replace(/\$FORGE_CLI/g, forgeCli ?? ''); + .replace(/\$FORGE_CLI/g, forgeCli ?? '') + .replace(/\$FORGE_NAME/g, forgeDisplayName(forgeType)); // Check if context variables exist (use fresh regex to avoid lastIndex issues) const hasContextVariables = new RegExp(CONTEXT_VAR_PATTERN_STR).test(result); From 354f2f9b78daeeb7f08514988b0093f470568cee Mon Sep 17 00:00:00 2001 From: Darin Truckenmiller Date: Mon, 13 Apr 2026 09:29:29 -0700 Subject: [PATCH 4/5] fix: address CodeRabbit review feedback for forge-agnostic support - Replace all hardcoded `gh` CLI calls with `bun "$FORGE_CLI"` across 24 command files and 7 workflow YAMLs - Add JSON field normalization in forge-cli.ts so Gitea/GitLab responses match GitHub's field names (number, title, body, url, headRefName, etc.) - Add pr edit, pr ready, pr checks commands to forge-cli.ts - Guard against 'unknown' forge type overwriting github defaults in executor - Fix Gitea pr diff missing res.ok check - Fix SSH URL parsing for ssh://host:port format - Add GitHub Enterprise detection via GITHUB_URL env var - Clean gh CLI error messages (extract first stderr line) - Add --head flag support to pr create - Light-lint forge-cli.ts under zero-warnings ESLint policy - Fix pr view fallback in archon-validate-pr.yaml to use pr list - Remove unsupported -q flag usage in resolve-paths bash node - Add clarifying comment for un-substituted $FORGE_CLI in preamble Co-Authored-By: Claude Opus 4.6 (1M context) --- .../defaults/archon-auto-fix-review.md | 4 +- .../defaults/archon-code-review-agent.md | 2 +- .../defaults/archon-comment-quality-agent.md | 2 +- .archon/commands/defaults/archon-create-pr.md | 24 +- .../defaults/archon-docs-impact-agent.md | 2 +- .../defaults/archon-error-handling-agent.md | 2 +- .../commands/defaults/archon-finalize-pr.md | 21 +- .../defaults/archon-implement-issue.md | 10 +- .../defaults/archon-implement-review-fixes.md | 4 +- .../defaults/archon-investigate-issue.md | 4 +- .../archon-issue-completion-report.md | 2 +- .../commands/defaults/archon-plan-setup.md | 2 +- .../defaults/archon-post-review-to-pr.md | 4 +- .../defaults/archon-pr-review-scope.md | 20 +- .../archon-resolve-merge-conflicts.md | 12 +- .../commands/defaults/archon-self-fix-all.md | 4 +- .../defaults/archon-synthesize-review.md | 2 +- .../defaults/archon-test-coverage-agent.md | 2 +- .../archon-validate-pr-code-review-feature.md | 6 +- .../archon-validate-pr-code-review-main.md | 8 +- .../defaults/archon-validate-pr-e2e-main.md | 2 +- .../defaults/archon-validate-pr-report.md | 4 +- .../commands/defaults/archon-web-research.md | 2 +- .../defaults/archon-workflow-summary.md | 2 +- .archon/scripts/forge-cli.ts | 315 ++++++++++++++++-- .../workflows/defaults/archon-architect.yaml | 4 +- .../defaults/archon-create-issue.yaml | 6 +- .../defaults/archon-fix-github-issue.yaml | 11 +- .../workflows/defaults/archon-piv-loop.yaml | 6 +- .../workflows/defaults/archon-ralph-dag.yaml | 4 +- .../defaults/archon-refactor-safely.yaml | 4 +- .../defaults/archon-validate-pr.yaml | 17 +- eslint.config.mjs | 19 +- packages/git/src/forge.ts | 21 +- packages/workflows/src/executor-shared.ts | 5 +- packages/workflows/src/executor.ts | 9 +- 36 files changed, 437 insertions(+), 131 deletions(-) diff --git a/.archon/commands/defaults/archon-auto-fix-review.md b/.archon/commands/defaults/archon-auto-fix-review.md index f212c60dc2..9c0456370d 100644 --- a/.archon/commands/defaults/archon-auto-fix-review.md +++ b/.archon/commands/defaults/archon-auto-fix-review.md @@ -33,7 +33,7 @@ Read all review artifacts produced in this workflow run and fix everything surfa ```bash PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number) -HEAD_BRANCH=$(gh pr view $PR_NUMBER --json headRefName --jq '.headRefName') +HEAD_BRANCH=$(bun "$FORGE_CLI" pr view $PR_NUMBER --json headRefName --jq '.headRefName') echo "PR: $PR_NUMBER, Branch: $HEAD_BRANCH" ``` @@ -266,7 +266,7 @@ Write to `$ARTIFACTS_DIR/review/fix-report.md`: Post the fix report as a PR comment: ```bash -gh pr comment $PR_NUMBER --body "$(cat <<'EOF' +bun "$FORGE_CLI" pr comment $PR_NUMBER --body "$(cat <<'EOF' ## ⚡ Auto-Fix Report **Status**: {COMPLETE | PARTIAL} diff --git a/.archon/commands/defaults/archon-code-review-agent.md b/.archon/commands/defaults/archon-code-review-agent.md index 3557c7a410..981e62df86 100644 --- a/.archon/commands/defaults/archon-code-review-agent.md +++ b/.archon/commands/defaults/archon-code-review-agent.md @@ -39,7 +39,7 @@ Note: ### 1.3 Get PR Diff ```bash -gh pr diff {number} +bun "$FORGE_CLI" pr diff {number} ``` ### 1.4 Read CLAUDE.md diff --git a/.archon/commands/defaults/archon-comment-quality-agent.md b/.archon/commands/defaults/archon-comment-quality-agent.md index e40e1b2ed7..215feb2f91 100644 --- a/.archon/commands/defaults/archon-comment-quality-agent.md +++ b/.archon/commands/defaults/archon-comment-quality-agent.md @@ -34,7 +34,7 @@ cat $ARTIFACTS_DIR/review/scope.md ### 1.3 Get PR Diff ```bash -gh pr diff {number} +bun "$FORGE_CLI" pr diff {number} ``` Focus on: diff --git a/.archon/commands/defaults/archon-create-pr.md b/.archon/commands/defaults/archon-create-pr.md index 042c9b5092..c92dd80e55 100644 --- a/.archon/commands/defaults/archon-create-pr.md +++ b/.archon/commands/defaults/archon-create-pr.md @@ -27,7 +27,7 @@ ISSUE_NUM=$(echo "$BRANCH" | grep -oE '[0-9]+' | tail -1) If an issue number was found, search for open PRs that already reference it: ```bash -gh pr list \ +bun "$FORGE_CLI" pr list \ --search "Fixes #${ISSUE_NUM} OR Closes #${ISSUE_NUM}" \ --state open \ --json number,url,headRefName @@ -150,16 +150,16 @@ cat > $ARTIFACTS_DIR/pr-body.md <<'EOF' [body from above] EOF -gh pr create \ +bun "$FORGE_CLI" pr create \ --title "[title]" \ - --body-file $ARTIFACTS_DIR/pr-body.md \ + --body "$(<$ARTIFACTS_DIR/pr-body.md)" \ --base $BASE_BRANCH ``` Or if the content is simple: ```bash -gh pr create --fill --base $BASE_BRANCH +bun "$FORGE_CLI" pr create --fill --base $BASE_BRANCH ``` After creating the PR, capture its identifiers for downstream steps. Only write artifacts if PR creation succeeded — never persist stale data from a pre-existing PR: @@ -167,14 +167,12 @@ After creating the PR, capture its identifiers for downstream steps. Only write ```bash # After creating the PR, capture and persist the PR number for downstream steps # IMPORTANT: Only write artifacts after confirmed successful PR creation -if gh pr view --json number,url -q '.number,.url' > /dev/null 2>&1; then - PR_NUMBER=$(gh pr view --json number -q '.number') - PR_URL=$(gh pr view --json url -q '.url') - echo "$PR_NUMBER" > "$ARTIFACTS_DIR/.pr-number" - echo "$PR_URL" > "$ARTIFACTS_DIR/.pr-url" -else - echo "WARNING: Could not confirm PR creation; skipping .pr-number/.pr-url artifacts" -fi +## After creating the PR, extract the PR number from the create output above, +## then persist it for downstream steps: +PR_NUMBER={number from create output} +PR_URL=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json url -q '.url') +echo "$PR_NUMBER" > "$ARTIFACTS_DIR/.pr-number" +echo "$PR_URL" > "$ARTIFACTS_DIR/.pr-url" ``` --- @@ -213,7 +211,7 @@ Nothing to create a PR for. ### Branch Already Has PR ```bash -gh pr view --web +bun "$FORGE_CLI" pr view {pr-number} --json url ``` Opens the existing PR instead of creating a duplicate. diff --git a/.archon/commands/defaults/archon-docs-impact-agent.md b/.archon/commands/defaults/archon-docs-impact-agent.md index 73b34081d3..8a689c10a2 100644 --- a/.archon/commands/defaults/archon-docs-impact-agent.md +++ b/.archon/commands/defaults/archon-docs-impact-agent.md @@ -34,7 +34,7 @@ cat $ARTIFACTS_DIR/review/scope.md ### 1.3 Get PR Diff ```bash -gh pr diff {number} +bun "$FORGE_CLI" pr diff {number} ``` ### 1.4 Read Current Documentation diff --git a/.archon/commands/defaults/archon-error-handling-agent.md b/.archon/commands/defaults/archon-error-handling-agent.md index e9e05f1d0e..5e6d093b56 100644 --- a/.archon/commands/defaults/archon-error-handling-agent.md +++ b/.archon/commands/defaults/archon-error-handling-agent.md @@ -34,7 +34,7 @@ cat $ARTIFACTS_DIR/review/scope.md ### 1.3 Get PR Diff ```bash -gh pr diff {number} +bun "$FORGE_CLI" pr diff {number} ``` ### 1.4 Read CLAUDE.md Error Handling Rules diff --git a/.archon/commands/defaults/archon-finalize-pr.md b/.archon/commands/defaults/archon-finalize-pr.md index 4a9a616bae..96e524667b 100644 --- a/.archon/commands/defaults/archon-finalize-pr.md +++ b/.archon/commands/defaults/archon-finalize-pr.md @@ -50,7 +50,7 @@ Extract: ### 1.3 Check for Existing PR ```bash -gh pr list --head $(git branch --show-current) --json number,url,state +bun "$FORGE_CLI" pr list --head $(git branch --show-current) --json number,url,state ``` **If PR already exists**: Will update it instead of creating new one. @@ -186,16 +186,16 @@ cat > $ARTIFACTS_DIR/pr-body.md <<'EOF' {prepared-body} EOF -gh pr create \ +bun "$FORGE_CLI" pr create \ --title "{plan-title}" \ - --body-file $ARTIFACTS_DIR/pr-body.md \ + --body "$(<$ARTIFACTS_DIR/pr-body.md)" \ --base $BASE_BRANCH ``` **If PR already exists**, update it: ```bash -gh pr edit {pr-number} --body-file $ARTIFACTS_DIR/pr-body.md +bun "$FORGE_CLI" pr edit {pr-number} --body "$(<$ARTIFACTS_DIR/pr-body.md)" ``` ### 3.3 Ensure Ready for Review @@ -203,13 +203,17 @@ gh pr edit {pr-number} --body-file $ARTIFACTS_DIR/pr-body.md If PR was created as draft, mark ready: ```bash -gh pr ready {pr-number} 2>/dev/null || true +bun "$FORGE_CLI" pr ready {pr-number} 2>/dev/null || true ``` ### 3.4 Capture PR Info +After creating the PR, capture and persist its number and URL for downstream steps. +Use the PR number from the create/list output above: + ```bash -gh pr view --json number,url,headRefName,baseRefName +PR_NUMBER={pr-number from create or list output} +bun "$FORGE_CLI" pr view "$PR_NUMBER" --json number,url,headRefName,baseRefName ``` ### 3.5 Write PR Number Registry @@ -217,9 +221,8 @@ gh pr view --json number,url,headRefName,baseRefName Write PR number for downstream review steps: ```bash -PR_NUMBER=$(gh pr view --json number -q '.number') -PR_URL=$(gh pr view --json url -q '.url') echo "$PR_NUMBER" > $ARTIFACTS_DIR/.pr-number +PR_URL=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json url -q '.url') echo "$PR_URL" > $ARTIFACTS_DIR/.pr-url ``` @@ -384,7 +387,7 @@ Check: ❌ PR not found: #{number} The draft PR may have been closed or deleted. Create a new one: -`gh pr create --title "..." --body "..."` +`bun "$FORGE_CLI" pr create --title "..." --body "..."` ``` ### Template Parsing diff --git a/.archon/commands/defaults/archon-implement-issue.md b/.archon/commands/defaults/archon-implement-issue.md index 66f7411b10..a5127fea22 100644 --- a/.archon/commands/defaults/archon-implement-issue.md +++ b/.archon/commands/defaults/archon-implement-issue.md @@ -338,7 +338,7 @@ EOF ## Phase 8: PR - Create Pull Request -**Before creating a PR**, check if one already exists for this issue or branch using `gh pr list`. If a PR already exists, skip creation and use the existing one. +**Before creating a PR**, check if one already exists for this issue or branch using `bun "$FORGE_CLI" pr list`. If a PR already exists, skip creation and use the existing one. ### 8.1 Push to Remote @@ -364,15 +364,15 @@ Look for the project's PR template at `.github/pull_request_template.md`, `.gith Write the prepared body to `$ARTIFACTS_DIR/pr-body.md`, then: ```bash -gh pr create --title "Fix: {title} (#{number})" \ - --body-file $ARTIFACTS_DIR/pr-body.md +bun "$FORGE_CLI" pr create --title "Fix: {title} (#{number})" \ + --body "$(<$ARTIFACTS_DIR/pr-body.md)" ``` ### 8.3 Get PR Number ```bash -PR_URL=$(gh pr view --json url -q '.url') -PR_NUMBER=$(gh pr view --json number -q '.number') +PR_URL=$(bun "$FORGE_CLI" pr view --json url -q '.url') +PR_NUMBER=$(bun "$FORGE_CLI" pr view --json number -q '.number') ``` **PHASE_8_CHECKPOINT:** diff --git a/.archon/commands/defaults/archon-implement-review-fixes.md b/.archon/commands/defaults/archon-implement-review-fixes.md index 75e2f6048c..883e9a27d7 100644 --- a/.archon/commands/defaults/archon-implement-review-fixes.md +++ b/.archon/commands/defaults/archon-implement-review-fixes.md @@ -35,7 +35,7 @@ Read the consolidated review artifact and implement all CRITICAL and HIGH priori PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number) # Get the PR's head branch name -HEAD_BRANCH=$(gh pr view $PR_NUMBER --json headRefName --jq '.headRefName') +HEAD_BRANCH=$(bun "$FORGE_CLI" pr view $PR_NUMBER --json headRefName --jq '.headRefName') echo "PR: $PR_NUMBER, Branch: $HEAD_BRANCH" ``` @@ -330,7 +330,7 @@ Write to `$ARTIFACTS_DIR/review/fix-report.md`: ### 6.1 Post Fix Report ```bash -gh pr comment {number} --body "$(cat <<'EOF' +bun "$FORGE_CLI" pr comment {number} --body "$(cat <<'EOF' # ⚡ Auto-Fix Report **Status**: {COMPLETE | PARTIAL} diff --git a/.archon/commands/defaults/archon-investigate-issue.md b/.archon/commands/defaults/archon-investigate-issue.md index bdfd7bed7d..afa5af588e 100644 --- a/.archon/commands/defaults/archon-investigate-issue.md +++ b/.archon/commands/defaults/archon-investigate-issue.md @@ -36,7 +36,7 @@ Investigate the issue/problem and produce a comprehensive implementation plan th ```bash # If forge issue, fetch it: -gh issue view {number} --json title,body,labels,comments,state,url,author +bun "$FORGE_CLI" issue view {number} --json title,body,labels,comments,state,url,author ``` ### 1.2 Extract Context @@ -436,7 +436,7 @@ bun run lint Format the artifact for $FORGE_NAME and post: ```bash -gh issue comment {number} --body "$(cat <<'EOF' +bun "$FORGE_CLI" issue comment {number} --body "$(cat <<'EOF' ## 🔍 Investigation: {Title} **Type**: `{TYPE}` diff --git a/.archon/commands/defaults/archon-issue-completion-report.md b/.archon/commands/defaults/archon-issue-completion-report.md index bd4318bd26..9ef16d0d9f 100644 --- a/.archon/commands/defaults/archon-issue-completion-report.md +++ b/.archon/commands/defaults/archon-issue-completion-report.md @@ -226,7 +226,7 @@ Post to the original $FORGE_NAME issue: ```bash ISSUE_NUMBER=$(echo "$ARGUMENTS" | grep -oE '[0-9]+') -gh issue comment $ISSUE_NUMBER --body "$(cat <<'EOF' +bun "$FORGE_CLI" issue comment $ISSUE_NUMBER --body "$(cat <<'EOF' ## ✅ Issue Resolution Report **PR**: #{pr-number} ({pr-url}) diff --git a/.archon/commands/defaults/archon-plan-setup.md b/.archon/commands/defaults/archon-plan-setup.md index 49a7211e93..08f48573d5 100644 --- a/.archon/commands/defaults/archon-plan-setup.md +++ b/.archon/commands/defaults/archon-plan-setup.md @@ -107,7 +107,7 @@ git remote get-url origin Extract owner/repo from the remote URL for PR creation: ```bash -gh repo view --json nameWithOwner -q .nameWithOwner +bun "$FORGE_CLI" repo view --json nameWithOwner -q .nameWithOwner ``` ### 2.3 Branch Decision diff --git a/.archon/commands/defaults/archon-post-review-to-pr.md b/.archon/commands/defaults/archon-post-review-to-pr.md index ffd7a0c1e0..6ddfff22f1 100644 --- a/.archon/commands/defaults/archon-post-review-to-pr.md +++ b/.archon/commands/defaults/archon-post-review-to-pr.md @@ -131,7 +131,7 @@ Format the review as a $FORGE_NAME-compatible comment: ### 3.1 Post the Comment ```bash -gh pr comment {PR_NUMBER} --body "$(cat <<'EOF' +bun "$FORGE_CLI" pr comment {PR_NUMBER} --body "$(cat <<'EOF' {formatted comment body} EOF )" @@ -141,7 +141,7 @@ EOF ```bash # Check the comment was posted -gh pr view {PR_NUMBER} --comments --json comments --jq '.comments | length' +bun "$FORGE_CLI" pr view {PR_NUMBER} --comments --json comments --jq '.comments | length' ``` **PHASE_3_CHECKPOINT:** diff --git a/.archon/commands/defaults/archon-pr-review-scope.md b/.archon/commands/defaults/archon-pr-review-scope.md index f435615589..5c86743a23 100644 --- a/.archon/commands/defaults/archon-pr-review-scope.md +++ b/.archon/commands/defaults/archon-pr-review-scope.md @@ -34,7 +34,7 @@ fi # From current branch if [ -z "$PR_NUMBER" ]; then - PR_NUMBER=$(gh pr view --json number -q '.number' 2>/dev/null) + PR_NUMBER=$(bun "$FORGE_CLI" pr view --json number -q '.number' 2>/dev/null) fi if [ -z "$PR_NUMBER" ]; then @@ -49,7 +49,7 @@ echo "$PR_NUMBER" > $ARTIFACTS_DIR/.pr-number ### 1.2 Fetch PR Details ```bash -gh pr view {number} --json number,title,body,url,headRefName,baseRefName,files,additions,deletions,changedFiles,state,author,isDraft,mergeable,mergeStateStatus +bun "$FORGE_CLI" pr view {number} --json number,title,body,url,headRefName,baseRefName,files,additions,deletions,changedFiles,state,author,isDraft,mergeable,mergeStateStatus ``` **Extract:** @@ -74,7 +74,7 @@ gh pr view {number} --json number,title,body,url,headRefName,baseRefName,files,a ### 2.1 Check for Merge Conflicts ```bash -gh pr view {number} --json mergeable,mergeStateStatus --jq '.mergeable, .mergeStateStatus' +bun "$FORGE_CLI" pr view {number} --json mergeable,mergeStateStatus --jq '.mergeable, .mergeStateStatus' ``` | Status | Action | @@ -102,7 +102,7 @@ Then re-request the review. ### 2.2 Check CI Status ```bash -gh pr checks {number} --json name,state,conclusion --jq '.[] | "\(.name): \(.state) (\(.conclusion // "pending"))"' +bun "$FORGE_CLI" pr checks {number} --json name,state,conclusion --jq '.[] | "\(.name): \(.state) (\(.conclusion // "pending"))"' ``` | Status | Action | @@ -118,8 +118,8 @@ gh pr checks {number} --json name,state,conclusion --jq '.[] | "\(.name): \(.sta ```bash # Get branch names -PR_BASE=$(gh pr view {number} --json baseRefName --jq '.baseRefName') -PR_HEAD=$(gh pr view {number} --json headRefName --jq '.headRefName') +PR_BASE=$(bun "$FORGE_CLI" pr view {number} --json baseRefName --jq '.baseRefName') +PR_HEAD=$(bun "$FORGE_CLI" pr view {number} --json headRefName --jq '.headRefName') # Fetch and count git fetch origin $PR_BASE --quiet @@ -150,7 +150,7 @@ git push --force-with-lease ### 2.4 Check Draft Status ```bash -gh pr view {number} --json isDraft --jq '.isDraft' +bun "$FORGE_CLI" pr view {number} --json isDraft --jq '.isDraft' ``` | Status | Action | @@ -200,7 +200,7 @@ Large PRs are harder to review thoroughly. Consider splitting into smaller PRs f ### 3.1 Get Full Diff ```bash -gh pr diff {number} +bun "$FORGE_CLI" pr diff {number} ``` Store this for reference - parallel agents will re-fetch as needed. @@ -208,7 +208,7 @@ Store this for reference - parallel agents will re-fetch as needed. ### 3.2 List Changed Files by Type ```bash -gh pr view {number} --json files --jq '.files[].path' +bun "$FORGE_CLI" pr view {number} --json files --jq '.files[].path' ``` **Categorize files:** @@ -238,7 +238,7 @@ For each new abstraction found, note it in the scope manifest under "Review Focu ```bash # Quick scan for new abstractions in diff -gh pr diff {number} | grep "^+" | sed 's/^+//' | grep -E "(^interface |^export interface |^type |^abstract class |^export class )" | head -20 +bun "$FORGE_CLI" pr diff {number} | grep "^+" | sed 's/^+//' | grep -E "(^interface |^export interface |^type |^abstract class |^export class )" | head -20 ``` **PHASE_3_CHECKPOINT:** diff --git a/.archon/commands/defaults/archon-resolve-merge-conflicts.md b/.archon/commands/defaults/archon-resolve-merge-conflicts.md index 97a190524d..7e973117d2 100644 --- a/.archon/commands/defaults/archon-resolve-merge-conflicts.md +++ b/.archon/commands/defaults/archon-resolve-merge-conflicts.md @@ -25,13 +25,13 @@ Analyze merge conflicts in the PR, automatically resolve simple conflicts where - Empty → Check current branch for open PR ```bash -gh pr view {number} --json number,title,headRefName,baseRefName,mergeable,mergeStateStatus +bun "$FORGE_CLI" pr view {number} --json number,title,headRefName,baseRefName,mergeable,mergeStateStatus ``` ### 1.2 Verify Conflicts Exist ```bash -gh pr view {number} --json mergeable,mergeStateStatus --jq '.mergeable, .mergeStateStatus' +bun "$FORGE_CLI" pr view {number} --json mergeable,mergeStateStatus --jq '.mergeable, .mergeStateStatus' ``` | Status | Action | @@ -52,8 +52,8 @@ PR #{number} has no merge conflicts. It's ready for review/merge. ```bash # Get branch info -PR_HEAD=$(gh pr view {number} --json headRefName --jq '.headRefName') -PR_BASE=$(gh pr view {number} --json baseRefName --jq '.baseRefName') +PR_HEAD=$(bun "$FORGE_CLI" pr view {number} --json headRefName --jq '.headRefName') +PR_BASE=$(bun "$FORGE_CLI" pr view {number} --json baseRefName --jq '.baseRefName') # Fetch latest git fetch origin $PR_BASE @@ -277,7 +277,7 @@ git push --force-with-lease origin $PR_HEAD ### 5.2 Verify PR is Now Mergeable ```bash -gh pr view {number} --json mergeable,mergeStateStatus +bun "$FORGE_CLI" pr view {number} --json mergeable,mergeStateStatus ``` Should show `MERGEABLE`. @@ -367,7 +367,7 @@ Resolved {N} conflicts in {M} files. ### 6.2 Post $FORGE_NAME Comment ```bash -gh pr comment {number} --body "$(cat <<'EOF' +bun "$FORGE_CLI" pr comment {number} --body "$(cat <<'EOF' ## ✅ Conflicts Resolved **Rebased onto**: `{base}` diff --git a/.archon/commands/defaults/archon-self-fix-all.md b/.archon/commands/defaults/archon-self-fix-all.md index 1665cde65f..6dc20cde8f 100644 --- a/.archon/commands/defaults/archon-self-fix-all.md +++ b/.archon/commands/defaults/archon-self-fix-all.md @@ -34,7 +34,7 @@ Read all review artifacts and fix EVERYTHING surfaced. Unlike conservative auto- ```bash PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number) -HEAD_BRANCH=$(gh pr view $PR_NUMBER --json headRefName --jq '.headRefName') +HEAD_BRANCH=$(bun "$FORGE_CLI" pr view $PR_NUMBER --json headRefName --jq '.headRefName') echo "PR: $PR_NUMBER, Branch: $HEAD_BRANCH" ``` @@ -320,7 +320,7 @@ Write to `$ARTIFACTS_DIR/review/fix-report.md`: Post the fix report as a PR comment: ```bash -gh pr comment $PR_NUMBER --body "$(cat <<'EOF' +bun "$FORGE_CLI" pr comment $PR_NUMBER --body "$(cat <<'EOF' ## ⚡ Self-Fix Report (Aggressive) **Status**: {COMPLETE | PARTIAL} diff --git a/.archon/commands/defaults/archon-synthesize-review.md b/.archon/commands/defaults/archon-synthesize-review.md index 6bcb03cefc..11914c9a5b 100644 --- a/.archon/commands/defaults/archon-synthesize-review.md +++ b/.archon/commands/defaults/archon-synthesize-review.md @@ -263,7 +263,7 @@ If not addressing in this PR, create issues for: Create a $FORGE_NAME-compatible version of the review: ```bash -gh pr comment {number} --body "$(cat <<'EOF' +bun "$FORGE_CLI" pr comment {number} --body "$(cat <<'EOF' # 🔍 Comprehensive PR Review **PR**: #{number} diff --git a/.archon/commands/defaults/archon-test-coverage-agent.md b/.archon/commands/defaults/archon-test-coverage-agent.md index d2c96f9f15..c5cf3b394c 100644 --- a/.archon/commands/defaults/archon-test-coverage-agent.md +++ b/.archon/commands/defaults/archon-test-coverage-agent.md @@ -36,7 +36,7 @@ Note which files are source vs test files. ### 1.3 Get PR Diff ```bash -gh pr diff {number} +bun "$FORGE_CLI" pr diff {number} ``` ### 1.4 Read Existing Tests diff --git a/.archon/commands/defaults/archon-validate-pr-code-review-feature.md b/.archon/commands/defaults/archon-validate-pr-code-review-feature.md index 65d0ac63ea..2e6ac14f29 100644 --- a/.archon/commands/defaults/archon-validate-pr-code-review-feature.md +++ b/.archon/commands/defaults/archon-validate-pr-code-review-feature.md @@ -15,7 +15,7 @@ Analyze the code changes in the PR to verify the fix is correct, complete, and i ```bash PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number | tr -d '\n') -gh pr view "$PR_NUMBER" --json title,body,headRefName,baseRefName,labels +bun "$FORGE_CLI" pr view "$PR_NUMBER" --json title,body,headRefName,baseRefName,labels ``` ```bash @@ -38,7 +38,7 @@ cat $ARTIFACTS_DIR/.feature-branch ```bash PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number | tr -d '\n') -gh pr diff "$PR_NUMBER" +bun "$FORGE_CLI" pr diff "$PR_NUMBER" ``` ### 2.2 Read Changed Files on Feature Branch @@ -48,7 +48,7 @@ The current working directory IS the feature branch (worktree). Read each change ```bash PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number | tr -d '\n') # List changed files -gh pr view "$PR_NUMBER" --json files -q '.files[].path' +bun "$FORGE_CLI" pr view "$PR_NUMBER" --json files -q '.files[].path' ``` For each file, read the full file in the current working directory to understand the complete context, not just the diff hunks. diff --git a/.archon/commands/defaults/archon-validate-pr-code-review-main.md b/.archon/commands/defaults/archon-validate-pr-code-review-main.md index 40d0ece977..cb11b09cb0 100644 --- a/.archon/commands/defaults/archon-validate-pr-code-review-main.md +++ b/.archon/commands/defaults/archon-validate-pr-code-review-main.md @@ -19,7 +19,7 @@ cat $ARTIFACTS_DIR/.pr-number ```bash PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number | tr -d '\n') -gh pr view "$PR_NUMBER" --json title,body,headRefName,baseRefName,labels +bun "$FORGE_CLI" pr view "$PR_NUMBER" --json title,body,headRefName,baseRefName,labels ``` ### 1.2 Read Path Information @@ -42,9 +42,9 @@ If the PR body references a $FORGE_NAME issue, fetch it: ```bash # Extract issue number from PR body (looks for "Fixes #N", "Closes #N", etc.) PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number | tr -d '\n') -ISSUE_NUMBER=$(gh pr view "$PR_NUMBER" --json body -q '.body' | grep -oE '(Fixes|Closes|Resolves)\s*#[0-9]+' | grep -oE '[0-9]+' | head -1) +ISSUE_NUMBER=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json body -q '.body' | grep -oE '(Fixes|Closes|Resolves)\s*#[0-9]+' | grep -oE '[0-9]+' | head -1) if [ -n "$ISSUE_NUMBER" ]; then - gh issue view "$ISSUE_NUMBER" --json title,body,labels,comments + bun "$FORGE_CLI" issue view "$ISSUE_NUMBER" --json title,body,labels,comments fi ``` @@ -58,7 +58,7 @@ Get the list of changed files from the PR diff, then read those **same files on ```bash PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number | tr -d '\n') -gh pr view "$PR_NUMBER" --json files -q '.files[].path' +bun "$FORGE_CLI" pr view "$PR_NUMBER" --json files -q '.files[].path' ``` **CRITICAL**: Read the files from the **canonical repo** (main branch), NOT from the current worktree (feature branch). The canonical repo path is in `$ARTIFACTS_DIR/.canonical-repo`. diff --git a/.archon/commands/defaults/archon-validate-pr-e2e-main.md b/.archon/commands/defaults/archon-validate-pr-e2e-main.md index 2ba3a623e0..7611fdfa79 100644 --- a/.archon/commands/defaults/archon-validate-pr-e2e-main.md +++ b/.archon/commands/defaults/archon-validate-pr-e2e-main.md @@ -45,7 +45,7 @@ echo "Main repo: $CANONICAL_REPO" ```bash PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number | tr -d '\n') -gh pr view "$PR_NUMBER" --json title,body +bun "$FORGE_CLI" pr view "$PR_NUMBER" --json title,body ``` ```bash diff --git a/.archon/commands/defaults/archon-validate-pr-report.md b/.archon/commands/defaults/archon-validate-pr-report.md index 71ea256064..a3b08e3bf1 100644 --- a/.archon/commands/defaults/archon-validate-pr-report.md +++ b/.archon/commands/defaults/archon-validate-pr-report.md @@ -34,7 +34,7 @@ Also read the PR details: ```bash PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number | tr -d '\n') -gh pr view "$PR_NUMBER" --json title,body,url,headRefName,baseRefName,additions,deletions,changedFiles +bun "$FORGE_CLI" pr view "$PR_NUMBER" --json title,body,url,headRefName,baseRefName,additions,deletions,changedFiles ``` List all screenshots taken: @@ -187,7 +187,7 @@ If the verdict is clear, post a condensed summary to the PR as a comment: PR_NUMBER=$(cat $ARTIFACTS_DIR/.pr-number | tr -d '\n') # Create a concise PR comment -gh pr comment "$PR_NUMBER" --body "$(cat <<'COMMENT' +bun "$FORGE_CLI" pr comment "$PR_NUMBER" --body "$(cat <<'COMMENT' ## Archon PR Validation Report **Verdict**: {APPROVE / REQUEST_CHANGES} diff --git a/.archon/commands/defaults/archon-web-research.md b/.archon/commands/defaults/archon-web-research.md index c8df3de46f..b3cacb8bb2 100644 --- a/.archon/commands/defaults/archon-web-research.md +++ b/.archon/commands/defaults/archon-web-research.md @@ -27,7 +27,7 @@ Search the web for information relevant to the issue or feature being worked on. If input looks like a $FORGE_NAME issue number: ```bash -gh issue view $ARGUMENTS --json title,body,labels +bun "$FORGE_CLI" issue view $ARGUMENTS --json title,body,labels ``` ### 1.2 Identify Research Targets diff --git a/.archon/commands/defaults/archon-workflow-summary.md b/.archon/commands/defaults/archon-workflow-summary.md index 519ad65027..c31f49e3a3 100644 --- a/.archon/commands/defaults/archon-workflow-summary.md +++ b/.archon/commands/defaults/archon-workflow-summary.md @@ -330,7 +330,7 @@ These were **intentionally excluded** from scope: ### 4.2 Post to $FORGE_NAME ```bash -gh pr comment {pr-number} --body "{formatted-summary}" +bun "$FORGE_CLI" pr comment {pr-number} --body "{formatted-summary}" ``` **PHASE_4_CHECKPOINT:** diff --git a/.archon/scripts/forge-cli.ts b/.archon/scripts/forge-cli.ts index 6b72d20b3a..6a37bdde00 100644 --- a/.archon/scripts/forge-cli.ts +++ b/.archon/scripts/forge-cli.ts @@ -27,11 +27,15 @@ function getOwnerRepo(): string { const url = execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' }).trim(); const cleaned = url.replace(/\.git$/, ''); + // SSH protocol URL: ssh://git@host:port/owner/repo or ssh://git@host/owner/repo + const sshProtoMatch = cleaned.match(/ssh:\/\/[^@]+@[^/]+(\/.*)/); + if (sshProtoMatch) return sshProtoMatch[1].replace(/^\//, ''); + // HTTPS: https://host/owner/repo or https://token@host/owner/repo const httpsMatch = cleaned.match(/https?:\/\/(?:[^@]+@)?[^/]+\/(.+)/); if (httpsMatch) return httpsMatch[1]; - // SSH: git@host:owner/repo + // SSH shorthand: git@host:owner/repo const sshMatch = cleaned.match(/@[^:]+:(.+)/); if (sshMatch) return sshMatch[1]; @@ -93,7 +97,15 @@ async function apiPatch(url: string, body: unknown): Promise { /** Run gh CLI and return stdout */ function gh(args: string[]): string { - return execFileSync('gh', args, { encoding: 'utf8' }); + try { + return execFileSync('gh', args, { encoding: 'utf8' }); + } catch (e) { + const err = e as { stderr?: string; message?: string }; + // execFileSync includes verbose stderr; extract the useful line + const stderr = (err.stderr ?? '').trim(); + const firstLine = stderr.split('\n')[0] || err.message || 'gh command failed'; + throw new Error(`gh ${args.join(' ')}: ${firstLine}`); + } } /** Get GitLab project ID from owner/repo path */ @@ -127,6 +139,119 @@ function parseArgs(args: string[]): { positional: string[]; flags: Record; + +/** Normalize a Gitea PR to GitHub-style fields */ +function normalizeGiteaPr(raw: AnyRecord): AnyRecord { + const user = raw.user as AnyRecord | undefined; + const head = raw.head as AnyRecord | undefined; + const base = raw.base as AnyRecord | undefined; + const labels = raw.labels as AnyRecord[] | undefined; + return { + number: raw.number, + title: raw.title, + body: raw.body, + url: raw.html_url ?? raw.url, + headRefName: head?.ref ?? raw.head_branch, + baseRefName: base?.ref ?? raw.base_branch, + state: raw.state, + isDraft: raw.draft ?? false, + author: { login: user?.login ?? '' }, + labels: (labels ?? []).map((l) => ({ name: l.name })), + additions: raw.additions, + deletions: raw.deletions, + changedFiles: raw.changed_files, + files: raw.changed_files_list, // Gitea may not provide per-file details in PR view + comments: raw.comments, + mergeable: raw.mergeable, + }; +} + +/** Normalize a GitLab MR to GitHub-style fields */ +function normalizeGitlabMr(raw: AnyRecord): AnyRecord { + const author = raw.author as AnyRecord | undefined; + const labels = raw.labels as string[] | undefined; + return { + number: raw.iid, + title: raw.title, + body: raw.description, + url: raw.web_url, + headRefName: raw.source_branch, + baseRefName: raw.target_branch, + state: raw.state === 'opened' ? 'open' : raw.state, + isDraft: raw.draft ?? (raw.title as string)?.startsWith('Draft:') ?? false, + author: { login: author?.username ?? '' }, + labels: (labels ?? []).map((name) => ({ name })), + additions: raw.additions, + deletions: raw.deletions, + changedFiles: raw.changes_count, + comments: raw.user_notes_count, + mergeable: raw.merge_status === 'can_be_merged', + }; +} + +/** Normalize a Gitea issue to GitHub-style fields */ +function normalizeGiteaIssue(raw: AnyRecord): AnyRecord { + const user = raw.user as AnyRecord | undefined; + const labels = raw.labels as AnyRecord[] | undefined; + return { + number: raw.number, + title: raw.title, + body: raw.body, + url: raw.html_url ?? raw.url, + state: raw.state, + author: { login: user?.login ?? '' }, + labels: (labels ?? []).map((l) => ({ name: l.name })), + comments: raw.comments, + }; +} + +/** Normalize a GitLab issue to GitHub-style fields */ +function normalizeGitlabIssue(raw: AnyRecord): AnyRecord { + const author = raw.author as AnyRecord | undefined; + const labels = raw.labels as string[] | undefined; + return { + number: raw.iid, + title: raw.title, + body: raw.description, + url: raw.web_url, + state: raw.state === 'opened' ? 'open' : raw.state, + author: { login: author?.username ?? '' }, + labels: (labels ?? []).map((name) => ({ name })), + comments: raw.user_notes_count, + }; +} + +/** Filter an object to only include the requested JSON fields */ +function filterFields(obj: AnyRecord, jsonFields: string | undefined): AnyRecord { + if (!jsonFields) return obj; + const fields = jsonFields.split(',').map((f) => f.trim()); + const result: AnyRecord = {}; + for (const field of fields) { + if (field in obj) result[field] = obj[field]; + } + return result; +} + +/** Normalize + filter, then output */ +function outputNormalized( + raw: unknown, + normalize: (_r: AnyRecord) => AnyRecord, + jsonFields: string | undefined +): void { + if (Array.isArray(raw)) { + const items = raw.map((item) => filterFields(normalize(item as AnyRecord), jsonFields)); + console.log(JSON.stringify(items, null, 2)); + } else { + const normalized = filterFields(normalize(raw as AnyRecord), jsonFields); + console.log(JSON.stringify(normalized, null, 2)); + } +} + // ─── PR / Merge Request ──────────────────────────────────────────────────── async function prView(args: string[]): Promise { @@ -134,23 +259,24 @@ async function prView(args: string[]): Promise { const number = positional[0]; if (!number) { console.error('error: pr view requires a number'); process.exit(1); } const ownerRepo = getOwnerRepo(); + const jsonFields = flags['--json'] as string | undefined; switch (FORGE_TYPE) { case 'github': { const ghArgs = ['pr', 'view', number]; - if (flags['--json']) ghArgs.push('--json', flags['--json'] as string); + if (jsonFields) ghArgs.push('--json', jsonFields); console.log(gh(ghArgs)); break; } case 'gitea': { const data = await apiGet(`${FORGE_API_BASE}/repos/${ownerRepo}/pulls/${number}`); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGiteaPr, jsonFields); break; } case 'gitlab': { const pid = await gitlabProjectId(ownerRepo); const data = await apiGet(`${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}`); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGitlabMr, jsonFields); break; } } @@ -163,12 +289,14 @@ async function prCreate(args: string[]): Promise { const base = flags['--base'] as string ?? ''; const draft = flags['--draft'] === true; const ownerRepo = getOwnerRepo(); - const head = execFileSync('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim(); + const head = (flags['--head'] as string | undefined) + ?? execFileSync('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim(); switch (FORGE_TYPE) { case 'github': { const ghArgs = ['pr', 'create', '--title', title, '--body', body]; if (base) ghArgs.push('--base', base); + if (flags['--head']) ghArgs.push('--head', head); if (draft) ghArgs.push('--draft'); console.log(gh(ghArgs)); break; @@ -177,7 +305,7 @@ async function prCreate(args: string[]): Promise { const data = await apiPost(`${FORGE_API_BASE}/repos/${ownerRepo}/pulls`, { title, body, head, base, }); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGiteaPr, undefined); break; } case 'gitlab': { @@ -185,7 +313,7 @@ async function prCreate(args: string[]): Promise { const data = await apiPost(`${FORGE_API_BASE}/projects/${pid}/merge_requests`, { title, description: body, source_branch: head, target_branch: base, draft, }); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGitlabMr, undefined); break; } } @@ -227,12 +355,13 @@ async function prList(args: string[]): Promise { const { flags } = parseArgs(args); const head = flags['--head'] as string | undefined; const ownerRepo = getOwnerRepo(); + const jsonFields = flags['--json'] as string | undefined; switch (FORGE_TYPE) { case 'github': { const ghArgs = ['pr', 'list']; if (head) ghArgs.push('--head', head); - if (flags['--json']) ghArgs.push('--json', 'number,url,headRefName,state'); + if (jsonFields) ghArgs.push('--json', jsonFields); console.log(gh(ghArgs)); break; } @@ -240,7 +369,7 @@ async function prList(args: string[]): Promise { let url = `${FORGE_API_BASE}/repos/${ownerRepo}/pulls?state=open`; if (head) url += `&head=${ownerRepo.split('/')[0]}:${head}`; const data = await apiGet(url); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGiteaPr, jsonFields); break; } case 'gitlab': { @@ -248,7 +377,7 @@ async function prList(args: string[]): Promise { let url = `${FORGE_API_BASE}/projects/${pid}/merge_requests?state=opened`; if (head) url += `&source_branch=${head}`; const data = await apiGet(url); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGitlabMr, jsonFields); break; } } @@ -267,6 +396,9 @@ async function prDiff(args: string[]): Promise { const res = await fetch(`${FORGE_API_BASE}/repos/${ownerRepo}/pulls/${number}.diff`, { headers: { ...authHeaders(), Accept: 'text/plain' }, }); + if (!res.ok) { + throw new Error(`API ${res.status}: ${await res.text()}`); + } console.log(await res.text()); break; } @@ -277,7 +409,7 @@ async function prDiff(args: string[]): Promise { )) as { source_branch: string; target_branch: string }; const compare = (await apiGet( `${FORGE_API_BASE}/projects/${pid}/repository/compare?from=${mr.target_branch}&to=${mr.source_branch}`, - )) as { diffs: Array<{ old_path: string; new_path: string; diff: string }> }; + )) as { diffs: { old_path: string; new_path: string; diff: string }[] }; for (const d of compare.diffs) { console.log(`diff --git a/${d.old_path} b/${d.new_path}\n${d.diff}`); } @@ -286,6 +418,125 @@ async function prDiff(args: string[]): Promise { } } +async function prEdit(args: string[]): Promise { + const { positional, flags } = parseArgs(args); + const number = positional[0]; + if (!number) { console.error('error: pr edit requires a number'); process.exit(1); } + const ownerRepo = getOwnerRepo(); + const body = flags['--body'] as string | undefined; + const title = flags['--title'] as string | undefined; + + switch (FORGE_TYPE) { + case 'github': { + const ghArgs = ['pr', 'edit', number]; + if (body) ghArgs.push('--body', body); + if (title) ghArgs.push('--title', title); + console.log(gh(ghArgs)); + break; + } + case 'gitea': { + const patch: Record = {}; + if (body) patch.body = body; + if (title) patch.title = title; + const data = await apiPatch(`${FORGE_API_BASE}/repos/${ownerRepo}/pulls/${number}`, patch); + outputNormalized(data, normalizeGiteaPr, undefined); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const patch: Record = {}; + if (body) patch.description = body; + if (title) patch.title = title; + const res = await fetch(`${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}`, { + method: 'PUT', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`API ${res.status}: ${text}`); + } + outputNormalized(await res.json(), normalizeGitlabMr, undefined); + break; + } + } +} + +async function prReady(args: string[]): Promise { + const number = args[0]; + if (!number) { console.error('error: pr ready requires a number'); process.exit(1); } + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': + console.log(gh(['pr', 'ready', number])); + break; + case 'gitea': { + // Gitea doesn't have a native draft concept — no-op + console.log('Gitea does not support draft PRs — no action needed.'); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + // Remove "Draft: " prefix from title to mark as ready + const mr = (await apiGet( + `${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}`, + )) as { title: string }; + const newTitle = mr.title.replace(/^Draft:\s*/i, ''); + const res = await fetch(`${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}`, { + method: 'PUT', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: newTitle }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`API ${res.status}: ${text}`); + } + console.log(JSON.stringify(await res.json(), null, 2)); + break; + } + } +} + +async function prChecks(args: string[]): Promise { + const { positional, flags } = parseArgs(args); + const number = positional[0]; + if (!number) { console.error('error: pr checks requires a number'); process.exit(1); } + const ownerRepo = getOwnerRepo(); + + switch (FORGE_TYPE) { + case 'github': { + const ghArgs = ['pr', 'checks', number]; + if (flags['--json']) ghArgs.push('--json', flags['--json'] as string); + if (flags['--jq']) ghArgs.push('--jq', flags['--jq'] as string); + console.log(gh(ghArgs)); + break; + } + case 'gitea': { + // Gitea: get commit statuses for the PR's head SHA + const pr = (await apiGet( + `${FORGE_API_BASE}/repos/${ownerRepo}/pulls/${number}`, + )) as { head: { sha: string } }; + const statuses = await apiGet( + `${FORGE_API_BASE}/repos/${ownerRepo}/statuses/${pr.head.sha}`, + ); + console.log(JSON.stringify(statuses, null, 2)); + break; + } + case 'gitlab': { + const pid = await gitlabProjectId(ownerRepo); + const mr = (await apiGet( + `${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}`, + )) as { sha: string }; + const pipelines = await apiGet( + `${FORGE_API_BASE}/projects/${pid}/repository/commits/${mr.sha}/statuses`, + ); + console.log(JSON.stringify(pipelines, null, 2)); + break; + } + } +} + // ─── Issue ────────────────────────────────────────────────────────────────── async function issueView(args: string[]): Promise { @@ -293,23 +544,24 @@ async function issueView(args: string[]): Promise { const number = positional[0]; if (!number) { console.error('error: issue view requires a number'); process.exit(1); } const ownerRepo = getOwnerRepo(); + const jsonFields = flags['--json'] as string | undefined; switch (FORGE_TYPE) { case 'github': { const ghArgs = ['issue', 'view', number]; - if (flags['--json']) ghArgs.push('--json', flags['--json'] as string); + if (jsonFields) ghArgs.push('--json', jsonFields); console.log(gh(ghArgs)); break; } case 'gitea': { const data = await apiGet(`${FORGE_API_BASE}/repos/${ownerRepo}/issues/${number}`); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGiteaIssue, jsonFields); break; } case 'gitlab': { const pid = await gitlabProjectId(ownerRepo); const data = await apiGet(`${FORGE_API_BASE}/projects/${pid}/issues/${number}`); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGitlabIssue, jsonFields); break; } } @@ -340,7 +592,7 @@ async function issueCreate(args: string[]): Promise { if (labels.length > 0) { const allLabels = (await apiGet( `${FORGE_API_BASE}/repos/${ownerRepo}/labels`, - )) as Array<{ id: number; name: string }>; + )) as { id: number; name: string }[]; labelIds = labels .map((name) => allLabels.find((l) => l.name === name)?.id) .filter((id): id is number => id !== undefined); @@ -348,7 +600,7 @@ async function issueCreate(args: string[]): Promise { const data = await apiPost(`${FORGE_API_BASE}/repos/${ownerRepo}/issues`, { title, body, labels: labelIds, }); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGiteaIssue, undefined); break; } case 'gitlab': { @@ -356,7 +608,7 @@ async function issueCreate(args: string[]): Promise { const data = await apiPost(`${FORGE_API_BASE}/projects/${pid}/issues`, { title, description: body, labels: labels.join(','), }); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGitlabIssue, undefined); break; } } @@ -398,12 +650,13 @@ async function issueList(args: string[]): Promise { const search = flags['--search'] as string | undefined; const state = (flags['--state'] as string) ?? 'open'; const ownerRepo = getOwnerRepo(); + const jsonFields = flags['--json'] as string | undefined; switch (FORGE_TYPE) { case 'github': { const ghArgs = ['issue', 'list', '--state', state]; if (search) ghArgs.push('--search', search); - if (flags['--json']) ghArgs.push('--json', 'number,title,url,labels,state'); + if (jsonFields) ghArgs.push('--json', jsonFields); console.log(gh(ghArgs)); break; } @@ -411,7 +664,7 @@ async function issueList(args: string[]): Promise { let url = `${FORGE_API_BASE}/repos/${ownerRepo}/issues?state=${state}&type=issues`; if (search) url += `&q=${encodeURIComponent(search)}`; const data = await apiGet(url); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGiteaIssue, jsonFields); break; } case 'gitlab': { @@ -420,7 +673,7 @@ async function issueList(args: string[]): Promise { let url = `${FORGE_API_BASE}/projects/${pid}/issues?state=${glState}`; if (search) url += `&search=${encodeURIComponent(search)}`; const data = await apiGet(url); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeGitlabIssue, jsonFields); break; } } @@ -431,23 +684,30 @@ async function issueList(args: string[]): Promise { async function labelList(args: string[]): Promise { const { flags } = parseArgs(args); const ownerRepo = getOwnerRepo(); + const jsonFields = flags['--json'] as string | undefined; + + const normalizeLabel = (raw: AnyRecord): AnyRecord => ({ + name: raw.name, + color: raw.color, + description: raw.description, + }); switch (FORGE_TYPE) { case 'github': { const ghArgs = ['label', 'list']; - if (flags['--json']) ghArgs.push('--json', 'name'); + if (jsonFields) ghArgs.push('--json', jsonFields); console.log(gh(ghArgs)); break; } case 'gitea': { const data = await apiGet(`${FORGE_API_BASE}/repos/${ownerRepo}/labels`); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeLabel, jsonFields); break; } case 'gitlab': { const pid = await gitlabProjectId(ownerRepo); const data = await apiGet(`${FORGE_API_BASE}/projects/${pid}/labels`); - console.log(JSON.stringify(data, null, 2)); + outputNormalized(data, normalizeLabel, jsonFields); break; } } @@ -502,9 +762,12 @@ async function main(): Promise { case 'comment': await prComment(rest); break; case 'list': await prList(rest); break; case 'diff': await prDiff(rest); break; + case 'edit': await prEdit(rest); break; + case 'ready': await prReady(rest); break; + case 'checks': await prChecks(rest); break; default: console.error(`error: unknown pr action: ${action}`); - console.error('Usage: forge-cli.ts pr {view|create|comment|list|diff} [args...]'); + console.error('Usage: forge-cli.ts pr {view|create|comment|list|diff|edit|ready|checks} [args...]'); process.exit(1); } break; @@ -534,7 +797,7 @@ async function main(): Promise { console.error('Usage: forge-cli.ts [args...]'); console.error(''); console.error('Resources:'); - console.error(' pr {view|create|comment|list|diff}'); + console.error(' pr {view|create|comment|list|diff|edit|ready|checks}'); console.error(' issue {view|create|comment|list}'); console.error(' label {list}'); console.error(' repo {info}'); diff --git a/.archon/workflows/defaults/archon-architect.yaml b/.archon/workflows/defaults/archon-architect.yaml index a41a75cd33..b64826969f 100644 --- a/.archon/workflows/defaults/archon-architect.yaml +++ b/.archon/workflows/defaults/archon-architect.yaml @@ -311,7 +311,7 @@ nodes: 1. Stage all changes and create a single commit (or verify existing commits) 2. Push the branch: `git push -u origin HEAD` - 3. Check if a PR already exists: `gh pr list --head $(git branch --show-current)` + 3. Check if a PR already exists: `bun "$FORGE_CLI" pr list --head $(git branch --show-current)` 4. Create the PR with: - Title: concise description of what was simplified (under 70 chars) - Body: use the format below @@ -355,5 +355,5 @@ nodes: hookSpecificOutput: hookEventName: PostToolUse additionalContext: > - Verify this command succeeded. If git push or gh pr create failed, + Verify this command succeeded. If git push or bun "$FORGE_CLI" pr create failed, read the error message carefully before retrying. diff --git a/.archon/workflows/defaults/archon-create-issue.yaml b/.archon/workflows/defaults/archon-create-issue.yaml index d5e7a2cb97..a3c6af2178 100644 --- a/.archon/workflows/defaults/archon-create-issue.yaml +++ b/.archon/workflows/defaults/archon-create-issue.yaml @@ -568,13 +568,13 @@ nodes: 4. Check which labels actually exist in the repo: ```bash - gh label list --json name -q '.[].name' | head -50 + bun "$FORGE_CLI" label list --json name -q '.[].name' | head -50 ``` Only use labels that exist. Skip any suggested label that doesn't match. 5. Create the issue: ```bash - gh issue create \ + bun "$FORGE_CLI" issue create \ --title "$(cat "$ARTIFACTS_DIR/.issue-title")" \ --body-file "$ARTIFACTS_DIR/issue-draft.md" \ --label "label1,label2" @@ -582,7 +582,7 @@ nodes: 6. Capture the result: ```bash - ISSUE_URL=$(gh issue list --limit 1 --json url -q '.[0].url') + ISSUE_URL=$(bun "$FORGE_CLI" issue list --limit 1 --json url -q '.[0].url') echo "$ISSUE_URL" > "$ARTIFACTS_DIR/.issue-url" ``` diff --git a/.archon/workflows/defaults/archon-fix-github-issue.yaml b/.archon/workflows/defaults/archon-fix-github-issue.yaml index 96ee1975eb..3e91aba408 100644 --- a/.archon/workflows/defaults/archon-fix-github-issue.yaml +++ b/.archon/workflows/defaults/archon-fix-github-issue.yaml @@ -33,7 +33,7 @@ nodes: Rules: - If the message contains an explicit issue number (e.g., "#709", "issue 709", "709"), extract that number. - - If the message is ambiguous (e.g., "fix the SQLite timestamp bug"), use `gh issue list` to search for matching issues and pick the best match. + - If the message is ambiguous (e.g., "fix the SQLite timestamp bug"), use `bun "$FORGE_CLI" issue list` to search for matching issues and pick the best match. CRITICAL: Your final output must be ONLY the bare number with no quotes, no markdown, no explanation. Example correct output: 709 @@ -157,6 +157,7 @@ nodes: - **Issue**: $ARGUMENTS - **Classification**: $classify.output - **Issue title**: $classify.output.title + - **Forge**: $FORGE_TYPE ## Instructions @@ -166,18 +167,18 @@ nodes: - `$ARTIFACTS_DIR/investigation.md` or `$ARTIFACTS_DIR/plan.md` - `$ARTIFACTS_DIR/implementation.md` - `$ARTIFACTS_DIR/validation.md` - 4. Check if a PR already exists for this branch: `gh pr list --head $(git branch --show-current)` + 4. Check if a PR already exists for this branch: `bun "$FORGE_CLI" pr list --head $(git branch --show-current)` - If PR exists, skip creation and capture its number 5. Look for the project's PR template at `.github/pull_request_template.md`, `.github/PULL_REQUEST_TEMPLATE.md`, or `docs/PULL_REQUEST_TEMPLATE.md`. Read whichever one exists. - 6. Create a DRAFT PR: `gh pr create --draft --base $BASE_BRANCH` + 6. Create a DRAFT PR: `bun "$FORGE_CLI" pr create --draft --base $BASE_BRANCH --title "TITLE" --body "BODY"` - Title: concise, imperative mood, under 70 chars - Body: if a PR template was found, fill in **every section** with details from the artifacts. Don't skip sections or leave placeholders. If no template, write a body with summary, changes, validation evidence, and `Fixes #...`. - Link to issue: include `Fixes #...` or `Closes #...` 7. Capture PR identifiers: ```bash - PR_NUMBER=$(gh pr view --json number -q '.number') + PR_NUMBER=$(bun "$FORGE_CLI" pr view $(git branch --show-current) --json number -q '.number') echo "$PR_NUMBER" > "$ARTIFACTS_DIR/.pr-number" - PR_URL=$(gh pr view --json url -q '.url') + PR_URL=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json url -q '.url') echo "$PR_URL" > "$ARTIFACTS_DIR/.pr-url" ``` depends_on: [validate] diff --git a/.archon/workflows/defaults/archon-piv-loop.yaml b/.archon/workflows/defaults/archon-piv-loop.yaml index 2e409110ea..2e22b45d2c 100644 --- a/.archon/workflows/defaults/archon-piv-loop.yaml +++ b/.archon/workflows/defaults/archon-piv-loop.yaml @@ -51,7 +51,7 @@ nodes: - If it's a PRD → identify the specific phase/feature to focus on **If it's a $FORGE_NAME issue** (`#123` format): - - Fetch it: `gh issue view {number} --json title,body,labels,comments` + - Fetch it: `bun "$FORGE_CLI" issue view {number} --json title,body,labels,comments` - Summarize the issue context **If it's free text**: @@ -725,7 +725,7 @@ nodes: ## Step 3: Create PR (if not already created) ```bash - gh pr view HEAD --json url 2>/dev/null || echo "NO_PR" + bun "$FORGE_CLI" pr view HEAD --json url 2>/dev/null || echo "NO_PR" ``` If no PR exists: @@ -734,7 +734,7 @@ nodes: cat .github/pull_request_template.md 2>/dev/null || echo "NO_TEMPLATE" ``` - Create with `gh pr create --draft --base $BASE_BRANCH`: + Create with `bun "$FORGE_CLI" pr create --draft --base $BASE_BRANCH`: - Title from the plan's feature name - Body summarizing the implementation - Use a HEREDOC for the body diff --git a/.archon/workflows/defaults/archon-ralph-dag.yaml b/.archon/workflows/defaults/archon-ralph-dag.yaml index 5c0d7c9099..b180f9587b 100644 --- a/.archon/workflows/defaults/archon-ralph-dag.yaml +++ b/.archon/workflows/defaults/archon-ralph-dag.yaml @@ -528,7 +528,7 @@ nodes: If no template was found, write a summary with: problem, what changed, stories table, and validation evidence. - 3. **Create a draft PR** using `gh pr create --draft --base $BASE_BRANCH --title "feat: {PRD feature name}"` with the filled-in template as the body. Use a HEREDOC for the body. + 3. **Create a draft PR** using `bun "$FORGE_CLI" pr create --draft --base $BASE_BRANCH --title "feat: {PRD feature name}"` with the filled-in template as the body. Use a HEREDOC for the body. 4. **Output completion signal:** ``` @@ -694,7 +694,7 @@ nodes: ### 3. Check PR Status ```bash - gh pr view HEAD --json url,number,state 2>/dev/null || echo "No PR found" + bun "$FORGE_CLI" pr view HEAD --json url,number,state 2>/dev/null || echo "No PR found" ``` ### 4. Generate Report diff --git a/.archon/workflows/defaults/archon-refactor-safely.yaml b/.archon/workflows/defaults/archon-refactor-safely.yaml index 56bc96ac36..4de98c7b4b 100644 --- a/.archon/workflows/defaults/archon-refactor-safely.yaml +++ b/.archon/workflows/defaults/archon-refactor-safely.yaml @@ -445,7 +445,7 @@ nodes: 1. Stage all changes and create a final commit if there are uncommitted changes 2. Push the branch: `git push -u origin HEAD` - 3. Check if a PR already exists: `gh pr list --head $(git branch --show-current)` + 3. Check if a PR already exists: `bun "$FORGE_CLI" pr list --head $(git branch --show-current)` 4. Create the PR with the format below 5. Save the PR URL to `$ARTIFACTS_DIR/.pr-url` @@ -507,5 +507,5 @@ nodes: hookSpecificOutput: hookEventName: PostToolUse additionalContext: > - Verify this command succeeded. If git push or gh pr create failed, + Verify this command succeeded. If git push or bun "$FORGE_CLI" pr create failed, read the error message carefully before retrying. diff --git a/.archon/workflows/defaults/archon-validate-pr.yaml b/.archon/workflows/defaults/archon-validate-pr.yaml index c66621464f..e7b3568d49 100644 --- a/.archon/workflows/defaults/archon-validate-pr.yaml +++ b/.archon/workflows/defaults/archon-validate-pr.yaml @@ -27,8 +27,9 @@ nodes: PR_NUMBER=$(echo "$ARGUMENTS" | grep -oE '[0-9]+' | head -1) fi if [ -z "$PR_NUMBER" ]; then - # Try getting PR from current branch - PR_NUMBER=$(bun "$FORGE_CLI" pr view --json number -q '.number' 2>/dev/null) + # Try finding PR for current branch via pr list + CURRENT_BRANCH=$(git branch --show-current) + PR_NUMBER=$(bun "$FORGE_CLI" pr list --head "$CURRENT_BRANCH" --json number 2>/dev/null | bun -e "const d=await Bun.stdin.json();console.log(Array.isArray(d)?d[0]?.number??'':d.number??'')") fi if [ -z "$PR_NUMBER" ]; then @@ -60,10 +61,16 @@ nodes: WORKTREE_PATH=$(pwd) FEATURE_BRANCH=$(git branch --show-current) - # Get PR branch info + # Extract PR branch info from fetch-pr output (already fetched, avoid redundant API calls) PR_NUMBER=$(cat "$ARTIFACTS_DIR/.pr-number") - PR_HEAD=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json headRefName -q '.headRefName') - PR_BASE=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') + PR_HEAD=$(echo '$fetch-pr.output' | bun -e "const d=await Bun.stdin.json();console.log(d.headRefName??'')") + PR_BASE=$(echo '$fetch-pr.output' | bun -e "const d=await Bun.stdin.json();console.log(d.baseRefName??'')") + + # Fallback: if parsing from output failed, fetch explicitly + if [ -z "$PR_HEAD" ] || [ -z "$PR_BASE" ]; then + PR_HEAD=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json headRefName | bun -e "const d=await Bun.stdin.json();console.log(d.headRefName??'')") + PR_BASE=$(bun "$FORGE_CLI" pr view "$PR_NUMBER" --json baseRefName | bun -e "const d=await Bun.stdin.json();console.log(d.baseRefName??'')") + fi echo "$CANONICAL_REPO" > "$ARTIFACTS_DIR/.canonical-repo" echo "$WORKTREE_PATH" > "$ARTIFACTS_DIR/.worktree-path" diff --git a/eslint.config.mjs b/eslint.config.mjs index 525ce2be5b..95b8f8ff73 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,7 +17,7 @@ export default tseslint.config( 'worktrees/**', '.claude/worktrees/**', '.claude/skills/**', - '.archon/scripts/**', + '.archon/scripts/**/!(forge-cli).ts', '**/*.js', '*.mjs', '**/*.test.ts', @@ -39,6 +39,23 @@ export default tseslint.config( // Prettier integration prettierConfig, + // Disable type-checked rules for project-maintained scripts + { + files: ['.archon/scripts/forge-cli.ts'], + extends: [tseslint.configs.disableTypeChecked], + languageOptions: { + parser: tseslint.parser, + }, + rules: { + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-undef': 'off', // Bun globals, process, etc. + semi: ['error', 'always'], + quotes: ['error', 'single', { avoidEscape: true }], + eqeqeq: 'error', + 'no-console': 'off', + }, + }, + // Project-specific settings { files: ['packages/*/src/**/*.{ts,tsx}'], diff --git a/packages/git/src/forge.ts b/packages/git/src/forge.ts index 02f345a22f..985496b4dd 100644 --- a/packages/git/src/forge.ts +++ b/packages/git/src/forge.ts @@ -53,10 +53,11 @@ function hostnameFromEnv(envVar: string): string | null { * * Detection order: * 1. github.com hostname → GitHub - * 2. GITEA_URL env hostname match → Gitea - * 3. gitlab.com hostname → GitLab - * 4. GITLAB_URL env hostname match → GitLab - * 5. No match → unknown + * 2. GITHUB_URL env hostname match → GitHub Enterprise + * 3. GITEA_URL env hostname match → Gitea + * 4. gitlab.com hostname → GitLab + * 5. GITLAB_URL env hostname match → GitLab (self-hosted) + * 6. No match → unknown * * Returns { type: 'github', apiBase: 'https://api.github.com' } as the * backwards-compatible default when no remote exists. @@ -74,12 +75,20 @@ export async function detectForge(repoPath: RepoPath): Promise { return { type: 'unknown', apiBase: '' }; } - // 1. GitHub + // 1. GitHub (public) if (hostname === 'github.com') { return { type: 'github', apiBase: 'https://api.github.com' }; } - // 2. Gitea — match against GITEA_URL env + // 2. GitHub Enterprise — match against GITHUB_URL env + const githubUrl = process.env.GITHUB_URL; + const githubHostname = hostnameFromEnv('GITHUB_URL'); + if (githubUrl && githubHostname && hostname === githubHostname) { + const cleanUrl = githubUrl.replace(/\/+$/, ''); + return { type: 'github', apiBase: `${cleanUrl}/api/v3` }; + } + + // 3. Gitea — match against GITEA_URL env const giteaUrl = process.env.GITEA_URL; const giteaHostname = hostnameFromEnv('GITEA_URL'); if (giteaUrl && giteaHostname && hostname === giteaHostname) { diff --git a/packages/workflows/src/executor-shared.ts b/packages/workflows/src/executor-shared.ts index d90ee8b614..a2bbff473b 100644 --- a/packages/workflows/src/executor-shared.ts +++ b/packages/workflows/src/executor-shared.ts @@ -385,7 +385,10 @@ export function buildPromptWithContext( ); // Auto-inject forge compatibility preamble for non-GitHub forges. - // This ensures all AI command prompts know to use forge-cli.sh instead of gh. + // This ensures all AI command prompts know to use forge-cli instead of gh. + // NOTE: The `$FORGE_CLI` references in the preamble text below are intentionally + // un-substituted — they use shell variable syntax to show the AI how to invoke + // the forge-cli tool at runtime (the env var is set by the executor). const resolvedForgeType = forgeType ?? 'github'; let finalPrompt = prompt; if (resolvedForgeType !== 'github' && resolvedForgeType !== 'unknown') { diff --git a/packages/workflows/src/executor.ts b/packages/workflows/src/executor.ts index 2d6e42b646..64649d72e9 100644 --- a/packages/workflows/src/executor.ts +++ b/packages/workflows/src/executor.ts @@ -281,8 +281,13 @@ export async function executeWorkflow( let forgeApiBase = 'https://api.github.com'; try { const forgeInfo = await detectForge(toRepoPath(cwd)); - forgeType = forgeInfo.type; - forgeApiBase = forgeInfo.apiBase; + // Only override defaults if detection returned a known forge type. + // 'unknown' means the hostname didn't match any known forge, so keep + // the github defaults for backwards compatibility. + if (forgeInfo.type !== 'unknown') { + forgeType = forgeInfo.type; + forgeApiBase = forgeInfo.apiBase; + } } catch (error) { getLog().warn( { err: error as Error, errorType: (error as Error).constructor.name, cwd }, From 78f243271ba59ce6df3e59acb4e1f580ff3b4d22 Mon Sep 17 00:00:00 2001 From: Darin Truckenmiller Date: Wed, 15 Apr 2026 05:49:31 -0700 Subject: [PATCH 5/5] fix: GitLab draft MR title prefix and raw_diffs endpoint - GitLab API doesn't accept 'draft' param on MR creation; prefix title with "Draft: " instead - Use /merge_requests/:iid/raw_diffs endpoint for GitLab pr diff instead of repository/compare, which drifts as branches move Co-Authored-By: Claude Opus 4.6 (1M context) --- .archon/scripts/forge-cli.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.archon/scripts/forge-cli.ts b/.archon/scripts/forge-cli.ts index 6a37bdde00..4a098872c7 100644 --- a/.archon/scripts/forge-cli.ts +++ b/.archon/scripts/forge-cli.ts @@ -310,8 +310,10 @@ async function prCreate(args: string[]): Promise { } case 'gitlab': { const pid = await gitlabProjectId(ownerRepo); + // GitLab doesn't accept 'draft' as an input param — use title prefix instead + const gitlabTitle = draft ? `Draft: ${title}` : title; const data = await apiPost(`${FORGE_API_BASE}/projects/${pid}/merge_requests`, { - title, description: body, source_branch: head, target_branch: base, draft, + title: gitlabTitle, description: body, source_branch: head, target_branch: base, }); outputNormalized(data, normalizeGitlabMr, undefined); break; @@ -404,15 +406,16 @@ async function prDiff(args: string[]): Promise { } case 'gitlab': { const pid = await gitlabProjectId(ownerRepo); - const mr = (await apiGet( - `${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}`, - )) as { source_branch: string; target_branch: string }; - const compare = (await apiGet( - `${FORGE_API_BASE}/projects/${pid}/repository/compare?from=${mr.target_branch}&to=${mr.source_branch}`, - )) as { diffs: { old_path: string; new_path: string; diff: string }[] }; - for (const d of compare.diffs) { - console.log(`diff --git a/${d.old_path} b/${d.new_path}\n${d.diff}`); + // Use raw_diffs endpoint (GitLab 17+) for the actual MR diff, + // rather than repository/compare which drifts as branches move + const res = await fetch( + `${FORGE_API_BASE}/projects/${pid}/merge_requests/${number}/raw_diffs`, + { headers: { ...authHeaders(), Accept: 'text/plain' } }, + ); + if (!res.ok) { + throw new Error(`API ${res.status}: ${await res.text()}`); } + console.log(await res.text()); break; } }