diff --git a/AGENTS.md b/AGENTS.md index 9f6b08c..02a901f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,11 +3,11 @@ ## Pointer - Meta-repo: `~/four-opencode-plugins/` - Repo: `four-bytes/four-opencode-git` -- Package: `@four-bytes/four-opencode-git` v0.1.0 +- Package: `@four-bytes/four-opencode-git` v0.1.1 - Build: `bun run build` → `dist/four-opencode-git.js` - Test: `bun test` -## Tool Stack (15 tools) +## Tool Stack (19 tools) ### git_analyze — Unified Analysis Dispatcher Collapses 7 metrics into 1 tool schema. Pass `metric` arg to route: - `curse_score` — rank files by risk (changes × recency × churn) @@ -38,16 +38,23 @@ Collapses 7 metrics into 1 tool schema. Pass `metric` arg to route: - `gitlab_mr_comment` — add comment to MR - `gitlab_mr_status` — check MR state/mergeability/pipelines +### Forgejo (4 tools) +- `forgejo_issue_list` — list issues (state/label/assignee), one line per issue +- `forgejo_issue_view` — issue detail, body truncated to ~20 lines, comment count +- `forgejo_issue_close` — close issue with optional API-posted comment (no shell) +- `forgejo_pr_status` — PR state; resolves `closed` + not-merged into `merged_via git-squash` via git + ## Architecture -- Entry: `src/four-opencode-git.ts` — registers all 15 tools +- Entry: `src/four-opencode-git.ts` — registers all 19 tools - Tools: `src/tools/` — one file per tool; analysis tools export execute fns used by git_analyze dispatcher -- Lib: `src/lib/` — git-utils.ts, gh-utils.ts, gitlab-utils.ts, debug-logger.ts, diff-parse.ts -- Tests: `tests/` — 146 tests, bun-native +- Lib: `src/lib/` — git-utils.ts, gh-utils.ts, gitlab-utils.ts, forgejo-utils.ts, debug-logger.ts, diff-parse.ts +- Tests: `tests/` — bun-native ## Dependencies - `@opencode-ai/plugin` 1.15.13 (exact pin) - Bun runtime, ESM modules - `gh` CLI for GitHub tools, `glab` for GitLab tools +- Forgejo REST API (`FORGEJO_TOKEN`, `FORGEJO_HOST`) — no `fj` CLI dependency ## Loading ```jsonc diff --git a/README.md b/README.md index e87cb85..38b302c 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,55 @@ Git analysis + GitHub/GitLab ops tools for opencode agents. "plugin": ["file:///home/robby/four-opencode-git/dist/four-opencode-git.js"] ``` -## Tools (coming in follow-up PRs) +## Tools (19) -- git_diff, git_log_structured — git history and diffs -- git_analyze — unified analysis dispatcher (curse_score, bus_factor, implicit_coupling, ownership, blast_radius, trend, pr_risk) -- gh_pr_*, gh_issue_*, gh_branch_cleanup, gh_release_info, gh_bot_review — GitHub operations -- gitlab_mr_* — GitLab merge request operations +### Git core (2) + +- `git_diff` — structured diff output (staged, file, between refs) +- `git_log_structured` — parsed log with author/date/file filters + +### Analysis dispatcher (1) + +- `git_analyze` — routes `metric` to curse_score, bus_factor, implicit_coupling, + ownership, blast_radius, trend, pr_risk + +### GitHub (9) + +- `gh_pr_create`, `gh_pr_comment`, `gh_pr_review`, `gh_pr_status` +- `gh_issue_list`, `gh_issue_close` (zombie detection) +- `gh_branch_cleanup` (dry_run first), `gh_release_info`, `gh_bot_review` + +### GitLab (3) + +- `gitlab_mr_create`, `gitlab_mr_comment`, `gitlab_mr_status` + +### Forgejo (4) + +Forgejo's `fj` CLI has no `--json` and no `--format`, and reports an out-of-forge +squash-merge as `Closed`. These tools talk to the Forgejo REST API so state can be +read back reliably. + +- `forgejo_issue_list` — list issues (state/label/assignee filters), one line per issue +- `forgejo_issue_view` — issue detail: state, labels, body (~20 lines), comment count +- `forgejo_issue_close` — close an issue with an optional comment (API body argument, no shell) +- `forgejo_pr_status` — PR state, resolving `open` / `merged` / `closed + merged_via git-squash` + / `closed` (abandoned) by checking git for the head commit + +## Configuration + +### Forgejo + +| Env var | Required | Purpose | +|---|---|---| +| `FORGEJO_TOKEN` | yes | Forgejo API token (`Authorization: token …`). Tools return a one-line "not configured" string when absent. | +| `FORGEJO_HOST` | no | Base host, e.g. `https://git.4serv.de`. When unset, derived from the `origin` remote. If neither is available the config is treated as absent and the tools return `Forgejo host not configured (set FORGEJO_HOST)`. | + +The repository (`owner/repo`) is derived from `git remote get-url origin`; `ctx.directory` +is used as the working directory, never `process.cwd()`. + +### GitLab + +`GITLAB_TOKEN` (required) and `GITLAB_HOST` (defaults to `https://gitlab.com`). ## License diff --git a/package.json b/package.json index 5425600..ac8d1cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-git", - "version": "0.1.0", + "version": "0.1.1", "description": "Git analysis + GitHub/GitLab ops tools for opencode agents", "type": "module", "license": "Apache-2.0", diff --git a/src/four-opencode-git.ts b/src/four-opencode-git.ts index 0d0185f..a475f5b 100644 --- a/src/four-opencode-git.ts +++ b/src/four-opencode-git.ts @@ -17,6 +17,10 @@ import { ghBotReviewTool } from './tools/gh-bot-review'; import { gitlabMrCreateTool } from './tools/gitlab-mr-create'; import { gitlabMrCommentTool } from './tools/gitlab-mr-comment'; import { gitlabMrStatusTool } from './tools/gitlab-mr-status'; +import { forgejoIssueListTool } from './tools/forgejo-issue-list'; +import { forgejoIssueViewTool } from './tools/forgejo-issue-view'; +import { forgejoIssueCloseTool } from './tools/forgejo-issue-close'; +import { forgejoPrStatusTool } from './tools/forgejo-pr-status'; const FourOpencodeGit: Plugin = async (_ctx) => { return { @@ -36,6 +40,10 @@ const FourOpencodeGit: Plugin = async (_ctx) => { gitlab_mr_create: gitlabMrCreateTool, gitlab_mr_comment: gitlabMrCommentTool, gitlab_mr_status: gitlabMrStatusTool, + forgejo_issue_list: forgejoIssueListTool, + forgejo_issue_view: forgejoIssueViewTool, + forgejo_issue_close: forgejoIssueCloseTool, + forgejo_pr_status: forgejoPrStatusTool, }, }; }; diff --git a/src/lib/forgejo-utils.ts b/src/lib/forgejo-utils.ts new file mode 100644 index 0000000..0f01469 --- /dev/null +++ b/src/lib/forgejo-utils.ts @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { logDebugEvent } from './debug-logger'; + +export interface ForgejoConfig { + token: string; + host: string; +} + +/** + * Result of resolving Forgejo config. + * `ok: false` carries which input was missing so tools can emit a precise message. + */ +export type ForgejoConfigResult = + | { ok: true; config: ForgejoConfig } + | { ok: false; reason: 'token' | 'host' }; + +export interface ForgejoApiResult { + ok: boolean; + status: number; + data: any; + error?: string; +} + +export interface ForgejoApiInit { + method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + body?: object; +} + +let missingTokenLogged = false; +let missingHostLogged = false; + +/** + * Parse a git remote URL into a Forgejo base host (scheme + host, no path). + * `https://git.4serv.de/scr/dev-playground.git` → `https://git.4serv.de` + * `git@git.4serv.de:scr/dev-playground.git` → `https://git.4serv.de` + * Returns null when the URL carries no derivable host. Exported for testing. + */ +export function deriveHostFromRemoteUrl(url: string): string | null { + const httpsMatch = url.match(/^(https?:\/\/[^/]+)/); + if (httpsMatch) return httpsMatch[1]!; + + const sshMatch = url.match(/^[^@]+@([^:]+):/); + if (sshMatch) return `https://${sshMatch[1]!}`; + + return null; +} + +/** Derive the Forgejo host from the current repo's `origin` remote. */ +function deriveHostFromRemote(cwd: string): string | null { + try { + const result = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + }); + if (result.exitCode !== 0) return null; + + return deriveHostFromRemoteUrl(result.stdout.toString().trim()); + } catch { + return null; + } +} + +/** + * Read Forgejo config from environment. + * `FORGEJO_TOKEN` is required. Host comes from `FORGEJO_HOST`, else is derived from + * the repo's origin remote. When the token is present but no host can be derived the + * config is treated as absent (`reason: 'host'`) — no public host is invented. + */ +export function getForgejoConfig(cwd?: string): ForgejoConfigResult { + const token = process.env.FORGEJO_TOKEN; + if (!token) { + if (!missingTokenLogged) { + missingTokenLogged = true; + logDebugEvent('forgejo.config.missing', { reason: 'FORGEJO_TOKEN not set' }); + } + return { ok: false, reason: 'token' }; + } + + const host = process.env.FORGEJO_HOST || (cwd ? deriveHostFromRemote(cwd) : null); + if (!host) { + if (!missingHostLogged) { + missingHostLogged = true; + logDebugEvent('forgejo.config.missing', { + reason: 'FORGEJO_HOST not set and origin host underivable', + }); + } + return { ok: false, reason: 'host' }; + } + + return { ok: true, config: { token, host } }; +} + +/** One-line not-configured message for a failed config resolution. */ +export function forgejoConfigMessage(reason: 'token' | 'host'): string { + return reason === 'token' + ? 'Forgejo not configured (FORGEJO_TOKEN not set)' + : 'Forgejo host not configured (set FORGEJO_HOST)'; +} + +/** Get `owner/repo` from current repo's remote origin (Forgejo uses raw paths). */ +export async function getForgejoRepo(cwd: string): Promise { + try { + const proc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], { cwd, stdout: 'pipe' }); + const url = (await new Response(proc.stdout).text()).trim(); + // Extract: :group/project.git → group/project + const match = url.match(/[/:]([^/]+\/[^.]+?)(?:\.git)?$/); + if (match) { + // Forgejo API paths take raw owner/repo — do NOT encodeURIComponent. + return match[1]!; + } + return null; + } catch { + return null; + } +} + +/** Call Forgejo v1 API. `path` starts with `/repos/...`. */ +export async function forgejoApi( + path: string, + config: ForgejoConfig, + init?: ForgejoApiInit +): Promise { + const url = `${config.host}/api/v1${path}`; + + try { + const headers: Record = { + Authorization: `token ${config.token}`, + Accept: 'application/json', + }; + + const opts: RequestInit = { + method: init?.method ?? 'GET', + headers, + }; + if (init?.body !== undefined) { + headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(init.body); + } + + const response = await fetch(url, opts); + const data = await response.json().catch(() => null); + + return { + ok: response.ok, + status: response.status, + data, + error: response.ok ? undefined : (data as any)?.message || `HTTP ${response.status}`, + }; + } catch (err) { + return { + ok: false, + status: 0, + data: null, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/tools/forgejo-issue-close.ts b/src/tools/forgejo-issue-close.ts new file mode 100644 index 0000000..2fdf582 --- /dev/null +++ b/src/tools/forgejo-issue-close.ts @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { + forgejoApi, + forgejoConfigMessage, + getForgejoConfig, + getForgejoRepo, +} from '../lib/forgejo-utils'; +import { logDebugEvent } from '../lib/debug-logger'; + +// ──────────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────────── + +export interface ForgejoCloseResult { + issue: number; + title: string; + alreadyClosed: boolean; + commentPosted: boolean; + closed: boolean; + commentError?: string; + closeError?: string; +} + +// ──────────────────────────────────────────────────────────────── +// Output formatting +// ──────────────────────────────────────────────────────────────── + +/** Pure formatter — exported for testing without a repo or API. */ +function formatForgejoIssueClose(params: ForgejoCloseResult): string { + if (params.alreadyClosed) { + return `Issue #${params.issue} "${params.title}" is already closed. Nothing to do.`; + } + + const lines: string[] = []; + if (params.commentPosted) lines.push(`✓ Comment posted on #${params.issue}`); + if (params.commentError) lines.push(`⚠ Failed to post comment: ${params.commentError}`); + if (params.closed) lines.push(`✓ Issue #${params.issue} "${params.title}" closed.`); + if (params.closeError) lines.push(`✗ Failed to close issue: ${params.closeError}`); + return lines.join('\n'); +} + +// ──────────────────────────────────────────────────────────────── +// Tool definition +// ──────────────────────────────────────────────────────────────── + +export const forgejoIssueCloseTool = tool({ + description: + 'Close a Forgejo issue, with an optional comment. The comment is posted via the REST API as a tool argument — it never passes through a shell. Forgejo state queries go through this tool — fj has no --json.', + + args: { + issue: tool.schema.number().describe('Issue number (index) to close'), + comment: tool.schema.string().describe('Optional comment to post before closing'), + }, + + async execute(args, ctx) { + const issueNum = args.issue as number; + const comment = args.comment as string | undefined; + const cwd = ctx.directory; + + logDebugEvent('forgejo_issue_close.start', { issue: issueNum, hasComment: !!comment }); + + try { + const cfg = getForgejoConfig(cwd); + if (!cfg.ok) return forgejoConfigMessage(cfg.reason); + const config = cfg.config; + + const repo = await getForgejoRepo(cwd); + if (!repo) return 'Could not determine Forgejo repository from origin remote.'; + + // ── Step 1: Check if issue is already closed ── + const viewResult = await forgejoApi(`/repos/${repo}/issues/${issueNum}`, config); + if (!viewResult.ok) { + return `Error viewing issue #${issueNum}: ${viewResult.error}`; + } + + const issue = viewResult.data as { title?: string; state?: string }; + const title = issue.title ?? ''; + const state = issue.state ?? ''; + + if (state === 'closed') { + const output = formatForgejoIssueClose({ + issue: issueNum, + title, + alreadyClosed: true, + commentPosted: false, + closed: false, + }); + logDebugEvent('forgejo_issue_close.done', { issue: issueNum, alreadyClosed: true }); + return output; + } + + // ── Step 2: Post optional comment ── + let commentPosted = false; + let commentError: string | undefined; + if (comment) { + const commentResult = await forgejoApi( + `/repos/${repo}/issues/${issueNum}/comments`, + config, + { method: 'POST', body: { body: comment } } + ); + if (commentResult.ok) { + commentPosted = true; + } else { + commentError = commentResult.error; + } + } + + // ── Step 3: Close the issue ── + const closeResult = await forgejoApi(`/repos/${repo}/issues/${issueNum}`, config, { + method: 'PATCH', + body: { state: 'closed' }, + }); + + const output = formatForgejoIssueClose({ + issue: issueNum, + title, + alreadyClosed: false, + commentPosted, + closed: closeResult.ok, + commentError, + closeError: closeResult.ok ? undefined : closeResult.error, + }); + + logDebugEvent('forgejo_issue_close.done', { issue: issueNum, closed: closeResult.ok }); + return output; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logDebugEvent('forgejo_issue_close.error', { error: msg }); + return `Error closing issue: ${msg}`; + } + }, +}); + +export { formatForgejoIssueClose }; diff --git a/src/tools/forgejo-issue-list.ts b/src/tools/forgejo-issue-list.ts new file mode 100644 index 0000000..e45c0ca --- /dev/null +++ b/src/tools/forgejo-issue-list.ts @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { + forgejoApi, + forgejoConfigMessage, + getForgejoConfig, + getForgejoRepo, +} from '../lib/forgejo-utils'; +import { logDebugEvent } from '../lib/debug-logger'; + +// ──────────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────────── + +export interface ForgejoIssueListItem { + number: number; + title: string; + state: string; + labels: string[]; + assignees: string[]; + html_url?: string; + updated_at?: string; +} + +// ──────────────────────────────────────────────────────────────── +// Normalization +// ──────────────────────────────────────────────────────────────── + +/** + * Map raw Forgejo API issue objects to the normalized shape. + * Labels arrive as `{name, color, ...}` and assignees as `{login, ...}`. + * Exported for testing with fixture JSON. + */ +export function normalizeForgejoIssueListItems(raw: unknown): ForgejoIssueListItem[] { + if (!Array.isArray(raw)) return []; + + return raw.map((issue: any) => ({ + number: issue.number, + title: issue.title, + state: issue.state, + labels: (issue.labels || []).map((l: unknown) => + typeof l === 'string' ? l : ((l as { name?: string })?.name ?? String(l)) + ), + assignees: (issue.assignees || []).map((a: unknown) => + typeof a === 'string' ? a : ((a as { login?: string })?.login ?? String(a)) + ), + html_url: issue.html_url, + updated_at: issue.updated_at, + })); +} + +// ──────────────────────────────────────────────────────────────── +// Output formatting +// ──────────────────────────────────────────────────────────────── + +/** + * Pure formatter — one line per issue, `#N [labels] title`. + * Exported for testing without a repo or API. + */ +function formatForgejoIssueList( + issues: ForgejoIssueListItem[], + repo: string, + state: string +): string { + if (issues.length === 0) { + return `FORGEJO ISSUE LIST — ${repo} — no ${state} issues found.`; + } + + const lines: string[] = []; + lines.push( + `FORGEJO ISSUE LIST — ${repo} — ${issues.length} ${state} issue${issues.length !== 1 ? 's' : ''}` + ); + lines.push(''); + + for (const issue of issues) { + const labelStr = issue.labels.length > 0 ? ` [${issue.labels.join(', ')}]` : ''; + lines.push(` #${issue.number}${labelStr} ${issue.title}`); + } + + return lines.join('\n'); +} + +// ──────────────────────────────────────────────────────────────── +// Tool definition +// ──────────────────────────────────────────────────────────────── + +export const forgejoIssueListTool = tool({ + description: + 'List Forgejo issues via the REST API with state/label/assignee filters, one line per issue. Forgejo state queries go through this tool — fj has no --json.', + + args: { + state: tool.schema + .string() + .describe("Issue state filter: 'open', 'closed', or 'all' (default: 'open')"), + label: tool.schema + .string() + .describe('Filter by label (comma-separated for multiple, e.g. "bug,help wanted")'), + assignee: tool.schema.string().describe('Filter by assignee username'), + limit: tool.schema.number().describe('Maximum number of issues to return (default: 20)'), + }, + + async execute(args, ctx) { + const state = ((args.state as string) ?? 'open').toLowerCase(); + const label = args.label as string | undefined; + const assignee = args.assignee as string | undefined; + const limit = (args.limit as number) ?? 20; + const cwd = ctx.directory; + + logDebugEvent('forgejo_issue_list.start', { state, label, assignee, limit }); + + try { + const cfg = getForgejoConfig(cwd); + if (!cfg.ok) return forgejoConfigMessage(cfg.reason); + const config = cfg.config; + + const repo = await getForgejoRepo(cwd); + if (!repo) return 'Could not determine Forgejo repository from origin remote.'; + + if (!['open', 'closed', 'all'].includes(state)) { + return `Error: Invalid state "${state}". Must be "open", "closed", or "all".`; + } + + const params = new URLSearchParams(); + params.set('state', state); + params.set('limit', String(limit)); + if (label) params.set('labels', label); + if (assignee) params.set('assignee', assignee); + + const result = await forgejoApi(`/repos/${repo}/issues?${params.toString()}`, config); + if (!result.ok) return `Failed to list issues: ${result.error}`; + + const normalized = normalizeForgejoIssueListItems(result.data); + + logDebugEvent('forgejo_issue_list.done', { count: normalized.length }); + return formatForgejoIssueList(normalized, repo, state); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logDebugEvent('forgejo_issue_list.error', { error: msg }); + return `Error listing issues: ${msg}`; + } + }, +}); + +export { formatForgejoIssueList }; diff --git a/src/tools/forgejo-issue-view.ts b/src/tools/forgejo-issue-view.ts new file mode 100644 index 0000000..c030c17 --- /dev/null +++ b/src/tools/forgejo-issue-view.ts @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { + forgejoApi, + forgejoConfigMessage, + getForgejoConfig, + getForgejoRepo, +} from '../lib/forgejo-utils'; +import { logDebugEvent } from '../lib/debug-logger'; + +// ──────────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────────── + +export interface ForgejoIssueView { + number: number; + title: string; + state: string; + labels: string[]; + assignees: string[]; + body: string; + comments: number; + html_url?: string; +} + +// ──────────────────────────────────────────────────────────────── +// Normalization +// ──────────────────────────────────────────────────────────────── + +/** + * Map a raw Forgejo API issue object to the normalized shape. + * Labels arrive as `{name, color, ...}`, assignees as `{login, ...}`, and + * `comments` as a number. `commentCountOverride` is used when the caller had to + * fetch the comment list separately. Exported for testing with fixture JSON. + */ +export function normalizeForgejoIssueView( + raw: any, + commentCountOverride?: number +): ForgejoIssueView { + const comments = + commentCountOverride ?? (typeof raw.comments === 'number' ? raw.comments : 0); + + return { + number: raw.number, + title: raw.title, + state: raw.state, + labels: (raw.labels || []).map((l: unknown) => + typeof l === 'string' ? l : ((l as { name?: string })?.name ?? String(l)) + ), + assignees: (raw.assignees || []).map((a: unknown) => + typeof a === 'string' ? a : ((a as { login?: string })?.login ?? String(a)) + ), + body: typeof raw.body === 'string' ? raw.body : '', + comments, + html_url: raw.html_url, + }; +} + +// ──────────────────────────────────────────────────────────────── +// Output formatting +// ──────────────────────────────────────────────────────────────── + +/** Truncate a body to at most `maxLines` lines, appending a marker. Exported for testing. */ +function truncateBody(body: string, maxLines = 20): string { + if (!body) return ''; + const lines = body.replace(/\r\n/g, '\n').split('\n'); + if (lines.length <= maxLines) return lines.join('\n'); + return [...lines.slice(0, maxLines), `… (${lines.length - maxLines} more lines)`].join('\n'); +} + +/** Pure formatter — exported for testing without a repo or API. */ +function formatForgejoIssueView(issue: ForgejoIssueView, repo: string): string { + const lines: string[] = []; + + lines.push(`FORGEJO ISSUE #${issue.number} — ${issue.title}`); + lines.push(` repo ${repo}`); + lines.push(` state ${issue.state}`); + lines.push(` labels ${issue.labels.length > 0 ? issue.labels.join(', ') : '—'}`); + lines.push(` comments ${issue.comments}`); + if (issue.html_url) lines.push(` url ${issue.html_url}`); + + const body = truncateBody(issue.body); + if (body) { + lines.push(''); + lines.push(body); + } + + return lines.join('\n'); +} + +// ──────────────────────────────────────────────────────────────── +// Tool definition +// ──────────────────────────────────────────────────────────────── + +export const forgejoIssueViewTool = tool({ + description: + 'Read a Forgejo issue — number, title, state, labels, body truncated to ~20 lines, comment count. Forgejo state queries go through this tool — fj has no --json.', + + args: { + issue: tool.schema.number().describe('Issue number (index) to view'), + }, + + async execute(args, ctx) { + const issueNum = args.issue as number; + const cwd = ctx.directory; + + logDebugEvent('forgejo_issue_view.start', { issue: issueNum }); + + try { + const cfg = getForgejoConfig(cwd); + if (!cfg.ok) return forgejoConfigMessage(cfg.reason); + const config = cfg.config; + + const repo = await getForgejoRepo(cwd); + if (!repo) return 'Could not determine Forgejo repository from origin remote.'; + + const result = await forgejoApi(`/repos/${repo}/issues/${issueNum}`, config); + if (!result.ok) return `Failed to get issue #${issueNum}: ${result.error}`; + + const issue = result.data as any; + + // Comment count: prefer the `comments` field, fall back to a listing call. + let commentCount: number | undefined = + typeof issue.comments === 'number' ? issue.comments : undefined; + if (commentCount === undefined) { + const commentsResult = await forgejoApi( + `/repos/${repo}/issues/${issueNum}/comments`, + config + ); + commentCount = + commentsResult.ok && Array.isArray(commentsResult.data) + ? commentsResult.data.length + : 0; + } + + const normalized = normalizeForgejoIssueView(issue, commentCount); + + logDebugEvent('forgejo_issue_view.done', { issue: issueNum, comments: normalized.comments }); + return formatForgejoIssueView(normalized, repo); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logDebugEvent('forgejo_issue_view.error', { error: msg }); + return `Error viewing issue: ${msg}`; + } + }, +}); + +export { formatForgejoIssueView, truncateBody }; diff --git a/src/tools/forgejo-pr-status.ts b/src/tools/forgejo-pr-status.ts new file mode 100644 index 0000000..4eb22ad --- /dev/null +++ b/src/tools/forgejo-pr-status.ts @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { + forgejoApi, + forgejoConfigMessage, + getForgejoConfig, + getForgejoRepo, +} from '../lib/forgejo-utils'; +import { runGit } from '../lib/git-utils'; +import { logDebugEvent } from '../lib/debug-logger'; + +// ──────────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────────── + +export interface ForgejoPull { + number: number; + title: string; + state: string; // 'open' | 'closed' + merged: boolean; + merge_commit_sha?: string | null; + head: { ref: string; sha: string }; + base: { ref: string }; + additions?: number; + deletions?: number; + comments?: number; + html_url?: string; +} + +export interface MergedVia { + sha: string; + branch: string; +} + +// ──────────────────────────────────────────────────────────────── +// Pure helpers +// ──────────────────────────────────────────────────────────────── + +function shortSha(sha: string): string { + return sha.slice(0, 8); +} + +/** + * Return the remote DEFAULT branch carrying a commit — `origin/` — or null. + * Only the PR's base (remote default) branch counts: a commit reachable from some + * unrelated `origin/*` branch was NOT merged into the default branch and must be + * reported as abandoned. `git branch -r --contains` lines may include + * `origin/HEAD -> origin/main`, which is ignored. Exported for testing. + */ +export function pickContainingBranch(branches: string[], baseRef: string): string | null { + const cleaned = branches + .map((b) => b.trim()) + .filter((b) => b !== '' && !b.includes('->')); + + const defaultBranch = `origin/${baseRef}`; + return cleaned.includes(defaultBranch) ? defaultBranch : null; +} + +/** Resolve the single-line state descriptor. Exported for testing. */ +export function resolveStateLine(pr: ForgejoPull, mergedVia: MergedVia | null): string { + if (pr.state === 'open') return 'open'; + + if (pr.state === 'closed' && pr.merged) { + const sha = pr.merge_commit_sha || pr.head.sha; + return `merged (${shortSha(sha)})`; + } + + if (pr.state === 'closed' && !pr.merged && mergedVia) { + return `closed · merged_via git-squash (${shortSha(mergedVia.sha)} on ${mergedVia.branch})`; + } + + return 'closed'; +} + +/** Pure 4-line formatter — exported for testing without a repo or API. */ +export function formatForgejoPrStatus(pr: ForgejoPull, mergedVia: MergedVia | null): string { + const lines: string[] = [ + `PR #${pr.number} — ${pr.title}`, + ` state ${resolveStateLine(pr, mergedVia)}`, + ` branch ${pr.head.ref} → ${pr.base.ref}`, + ` diff +${pr.additions ?? 0} -${pr.deletions ?? 0} · ${pr.comments ?? 0} comments`, + ]; + return lines.join('\n'); +} + +// ──────────────────────────────────────────────────────────────── +// Tool definition +// ──────────────────────────────────────────────────────────────── + +export const forgejoPrStatusTool = tool({ + description: + 'Check a Forgejo PR status and resolve merged-vs-abandoned. Forgejo reports an out-of-forge squash-merge as closed + not merged, so this tool checks git for the head commit when needed. Use before branch cleanup.', + + args: { + pr: tool.schema.number().describe('PR number (index) to check'), + }, + + async execute(args, ctx) { + const prNum = args.pr as number; + const cwd = ctx.directory; + + logDebugEvent('forgejo_pr_status.start', { pr: prNum }); + + try { + const cfg = getForgejoConfig(cwd); + if (!cfg.ok) return forgejoConfigMessage(cfg.reason); + const config = cfg.config; + + const repo = await getForgejoRepo(cwd); + if (!repo) return 'Could not determine Forgejo repository from origin remote.'; + + const result = await forgejoApi(`/repos/${repo}/pulls/${prNum}`, config); + if (!result.ok) return `Failed to get PR #${prNum}: ${result.error}`; + + const pr = result.data as ForgejoPull; + + // The whole point: `fj pr view` shows a squash-merged PR as closed. + // When the forge says closed + not merged, ask git whether the head + // commit landed on a remote branch anyway. + let mergedVia: MergedVia | null = null; + if (pr.state === 'closed' && pr.merged === false && pr.head?.sha) { + try { + const output = await runGit(['branch', '-r', '--contains', pr.head.sha], cwd); + const branch = pickContainingBranch(output.split('\n'), pr.base.ref); + if (branch) mergedVia = { sha: pr.head.sha, branch }; + } catch { + // No git repo / git error — fall back to reporting plain `closed`. + mergedVia = null; + } + } + + logDebugEvent('forgejo_pr_status.done', { + pr: prNum, + state: pr.state, + merged: pr.merged, + mergedVia: mergedVia?.branch ?? null, + }); + return formatForgejoPrStatus(pr, mergedVia); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logDebugEvent('forgejo_pr_status.error', { error: msg }); + return `Error checking PR status: ${msg}`; + } + }, +}); diff --git a/tests/forgejo-tools.test.ts b/tests/forgejo-tools.test.ts new file mode 100644 index 0000000..df37e79 --- /dev/null +++ b/tests/forgejo-tools.test.ts @@ -0,0 +1,498 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { describe, it, expect } from 'bun:test'; +import { + formatForgejoIssueList, + normalizeForgejoIssueListItems, +} from '../src/tools/forgejo-issue-list'; +import { + formatForgejoIssueView, + normalizeForgejoIssueView, + truncateBody, +} from '../src/tools/forgejo-issue-view'; +import { formatForgejoIssueClose } from '../src/tools/forgejo-issue-close'; +import { + formatForgejoPrStatus, + pickContainingBranch, + resolveStateLine, + type ForgejoPull, +} from '../src/tools/forgejo-pr-status'; +import { deriveHostFromRemoteUrl, getForgejoConfig } from '../src/lib/forgejo-utils'; + +/** Run `fn` with a temporary env overlay, restoring the previous values afterwards. */ +function withEnv(vars: Record, fn: () => void): void { + const saved: Record = {}; + for (const key of Object.keys(vars)) saved[key] = process.env[key]; + try { + for (const [key, value] of Object.entries(vars)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fn(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +// ──────────────────────────────────────────────────────────────── +// Unit tests — formatForgejoIssueList +// ──────────────────────────────────────────────────────────────── + +describe('formatForgejoIssueList', () => { + it('formats one line per issue with labels', () => { + // Fixture: GET /repos/scr/dev-playground/issues + const issues = [ + { + number: 1303, + title: 'Add updated today/yesterday invoice filters', + state: 'open', + labels: ['spec-change'], + assignees: ['robby'], + html_url: 'https://git.4serv.de/scr/dev-playground/issues/1303', + updated_at: '2026-06-10T12:00:00Z', + }, + { + number: 1302, + title: 'Fix router null pointer', + state: 'open', + labels: ['bug', 'backend'], + assignees: [], + html_url: 'https://git.4serv.de/scr/dev-playground/issues/1302', + updated_at: '2026-06-09T08:00:00Z', + }, + ]; + + const output = formatForgejoIssueList(issues, 'scr/dev-playground', 'open'); + expect(output).toContain( + 'FORGEJO ISSUE LIST — scr/dev-playground — 2 open issues' + ); + expect(output).toContain(' #1303 [spec-change] Add updated today/yesterday invoice filters'); + expect(output).toContain(' #1302 [bug, backend] Fix router null pointer'); + // One line per issue — the header + blank + 2 issue lines. + expect(output.split('\n')).toHaveLength(4); + }); + + it('handles an empty issue list', () => { + const output = formatForgejoIssueList([], 'scr/dev-playground', 'open'); + expect(output).toContain('FORGEJO ISSUE LIST — scr/dev-playground'); + expect(output).toContain('no open issues found'); + }); + + it('uses singular "issue" for a single result', () => { + const issues = [ + { + number: 1, + title: 'Initial setup', + state: 'closed', + labels: [], + assignees: [], + }, + ]; + const output = formatForgejoIssueList(issues, 'scr/dev-playground', 'closed'); + expect(output).toMatch(/1 closed issue\b/); + }); + + it('omits label brackets when there are no labels', () => { + const issues = [ + { number: 7, title: 'No labels here', state: 'open', labels: [], assignees: [] }, + ]; + const output = formatForgejoIssueList(issues, 'scr/dev-playground', 'open'); + expect(output).toContain('#7 No labels here'); + expect(output).not.toContain('[]'); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// Unit tests — truncateBody / formatForgejoIssueView +// ──────────────────────────────────────────────────────────────── + +describe('truncateBody', () => { + it('returns short bodies unchanged', () => { + expect(truncateBody('line1\nline2')).toBe('line1\nline2'); + }); + + it('truncates to 20 lines with a marker', () => { + const body = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join('\n'); + const output = truncateBody(body); + const lines = output.split('\n'); + expect(lines).toHaveLength(21); + expect(lines[20]).toBe('… (5 more lines)'); + }); + + it('handles empty body', () => { + expect(truncateBody('')).toBe(''); + }); +}); + +describe('formatForgejoIssueView', () => { + it('formats full issue detail with a body', () => { + // Fixture: GET /repos/scr/dev-playground/issues/1303 + const issue = { + number: 1303, + title: 'Add updated today/yesterday invoice filters', + state: 'open', + labels: ['spec-change'], + assignees: ['robby'], + body: '## Goal\n\nAdd filters.', + comments: 3, + html_url: 'https://git.4serv.de/scr/dev-playground/issues/1303', + }; + + const output = formatForgejoIssueView(issue, 'scr/dev-playground'); + expect(output).toContain('FORGEJO ISSUE #1303 — Add updated today/yesterday invoice filters'); + expect(output).toContain('repo scr/dev-playground'); + expect(output).toContain('state open'); + expect(output).toContain('labels spec-change'); + expect(output).toContain('comments 3'); + expect(output).toContain('url https://git.4serv.de/scr/dev-playground/issues/1303'); + expect(output).toContain('Add filters.'); + }); + + it('shows a dash when there are no labels and omits an empty body', () => { + const issue = { + number: 9, + title: 'Bare issue', + state: 'closed', + labels: [], + assignees: [], + body: '', + comments: 0, + }; + const output = formatForgejoIssueView(issue, 'scr/dev-playground'); + expect(output).toContain('labels —'); + expect(output).toContain('comments 0'); + // No trailing blank line + body block when body is empty. + expect(output.endsWith('comments 0')).toBe(true); + }); + + it('truncates a long body in the rendered output', () => { + const issue = { + number: 10, + title: 'Long body', + state: 'open', + labels: ['x'], + assignees: [], + body: Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join('\n'), + comments: 0, + }; + const output = formatForgejoIssueView(issue, 'scr/dev-playground'); + expect(output).toContain('… (10 more lines)'); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// Unit tests — formatForgejoIssueClose +// ──────────────────────────────────────────────────────────────── + +describe('formatForgejoIssueClose', () => { + it('reports an already-closed issue without doing anything', () => { + const output = formatForgejoIssueClose({ + issue: 1303, + title: 'Add filters', + alreadyClosed: true, + commentPosted: false, + closed: false, + }); + expect(output).toBe('Issue #1303 "Add filters" is already closed. Nothing to do.'); + }); + + it('reports comment + close success', () => { + const output = formatForgejoIssueClose({ + issue: 1303, + title: 'Add filters', + alreadyClosed: false, + commentPosted: true, + closed: true, + }); + expect(output).toContain('✓ Comment posted on #1303'); + expect(output).toContain('✓ Issue #1303 "Add filters" closed.'); + }); + + it('reports comment + close without a comment', () => { + const output = formatForgejoIssueClose({ + issue: 55, + title: 'No comment', + alreadyClosed: false, + commentPosted: false, + closed: true, + }); + expect(output).not.toContain('Comment posted'); + expect(output).toContain('✓ Issue #55 "No comment" closed.'); + }); + + it('surfaces a comment failure but still closes', () => { + const output = formatForgejoIssueClose({ + issue: 55, + title: 'Comment fails', + alreadyClosed: false, + commentPosted: false, + commentError: 'HTTP 403', + closed: true, + }); + expect(output).toContain('⚠ Failed to post comment: HTTP 403'); + expect(output).toContain('✓ Issue #55 "Comment fails" closed.'); + }); + + it('surfaces a close failure', () => { + const output = formatForgejoIssueClose({ + issue: 55, + title: 'Close fails', + alreadyClosed: false, + commentPosted: true, + closed: false, + closeError: 'HTTP 500', + }); + expect(output).toContain('✓ Comment posted on #55'); + expect(output).toContain('✗ Failed to close issue: HTTP 500'); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// Unit tests — pickContainingBranch +// ──────────────────────────────────────────────────────────────── + +describe('pickContainingBranch', () => { + it('returns origin/ when the remote default branch contains the commit', () => { + const branches = [' origin/feat/x', ' origin/main', ' origin/other']; + expect(pickContainingBranch(branches, 'main')).toBe('origin/main'); + }); + + it('returns null when only a non-default origin branch contains the commit', () => { + // A commit reachable from some other branch was NOT merged into the default. + const branches = [' origin/feat/x', ' origin/release']; + expect(pickContainingBranch(branches, 'main')).toBeNull(); + }); + + it('returns null when baseRef is not among the remote branches', () => { + const branches = [' origin/main', ' origin/develop']; + expect(pickContainingBranch(branches, 'release')).toBeNull(); + }); + + it('ignores origin/HEAD symbolic-ref lines', () => { + const branches = [' origin/HEAD -> origin/main', ' origin/main']; + expect(pickContainingBranch(branches, 'main')).toBe('origin/main'); + }); + + it('returns null when nothing contains the commit', () => { + expect(pickContainingBranch([''], 'main')).toBeNull(); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// Unit tests — formatForgejoPrStatus / resolveStateLine +// ──────────────────────────────────────────────────────────────── + +describe('formatForgejoPrStatus', () => { + const basePr: ForgejoPull = { + number: 1304, + title: 'feat: add updated today/yesterday invoice filters (#1303)', + state: 'open', + merged: false, + merge_commit_sha: null, + head: { ref: 'feat/1303-updated-at-filters', sha: 'b22fbc09aabbccddeeff00112233445566778899' }, + base: { ref: 'main' }, + additions: 14, + deletions: 0, + comments: 0, + html_url: 'https://git.4serv.de/scr/dev-playground/pulls/1304', + }; + + it('formats an open PR', () => { + const output = formatForgejoPrStatus(basePr, null); + expect(output).toContain('PR #1304 — feat: add updated today/yesterday invoice filters (#1303)'); + expect(output).toContain('state open'); + expect(output).toContain('branch feat/1303-updated-at-filters → main'); + expect(output).toContain('diff +14 -0 · 0 comments'); + expect(output.split('\n')).toHaveLength(4); + }); + + it('formats a forge-side merged PR', () => { + const pr: ForgejoPull = { + ...basePr, + state: 'closed', + merged: true, + merge_commit_sha: 'abc1234def567890', + }; + const output = formatForgejoPrStatus(pr, null); + expect(output).toContain('state merged (abc1234d)'); + }); + + it('formats a squash-merge detected via git as merged_via git-squash', () => { + const pr: ForgejoPull = { ...basePr, state: 'closed', merged: false }; + const output = formatForgejoPrStatus(pr, { + sha: 'b22fbc09aabbccddeeff00112233445566778899', + branch: 'origin/main', + }); + expect(output).toContain('state closed · merged_via git-squash (b22fbc09 on origin/main)'); + }); + + it('formats an abandoned closed PR when git finds no containing branch', () => { + const pr: ForgejoPull = { ...basePr, state: 'closed', merged: false }; + const output = formatForgejoPrStatus(pr, null); + expect(output).toContain('state closed'); + expect(output).not.toContain('merged_via'); + }); + + it('defaults missing diff counters to zero', () => { + const pr: ForgejoPull = { + ...basePr, + additions: undefined, + deletions: undefined, + comments: undefined, + }; + const output = formatForgejoPrStatus(pr, null); + expect(output).toContain('diff +0 -0 · 0 comments'); + }); +}); + +describe('resolveStateLine', () => { + const pr: ForgejoPull = { + number: 1, + title: 'x', + state: 'closed', + merged: false, + head: { ref: 'a', sha: 'deadbeefdeadbeef' }, + base: { ref: 'main' }, + }; + + it('distinguishes open, merged, squash-merged and abandoned', () => { + expect(resolveStateLine({ ...pr, state: 'open' }, null)).toBe('open'); + expect(resolveStateLine({ ...pr, merged: true, merge_commit_sha: 'cafe1234' }, null)).toBe( + 'merged (cafe1234)' + ); + expect( + resolveStateLine(pr, { sha: 'deadbeefdeadbeef', branch: 'origin/main' }) + ).toBe('closed · merged_via git-squash (deadbeef on origin/main)'); + expect(resolveStateLine(pr, null)).toBe('closed'); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// Unit tests — raw API → normalized mapping +// ──────────────────────────────────────────────────────────────── + +describe('normalizeForgejoIssueListItems', () => { + it('maps raw label/assignee objects to names', () => { + // Fixture: raw GET /repos/{owner}/{repo}/issues response. + const raw = [ + { + number: 12, + title: 'Raw shape', + state: 'open', + labels: [ + { id: 1, name: 'bug', color: '#ff0000' }, + { id: 2, name: 'backend', color: '#00ff00' }, + ], + assignees: [{ id: 9, login: 'robby', full_name: 'Robby Beyer' }], + html_url: 'https://git.4serv.de/scr/dev-playground/issues/12', + updated_at: '2026-06-10T12:00:00Z', + }, + ]; + + const normalized = normalizeForgejoIssueListItems(raw); + expect(normalized).toHaveLength(1); + expect(normalized[0]!.labels).toEqual(['bug', 'backend']); + expect(normalized[0]!.assignees).toEqual(['robby']); + expect(normalized[0]!.updated_at).toBe('2026-06-10T12:00:00Z'); + }); + + it('accepts plain string labels/assignees', () => { + const raw = [{ number: 1, title: 't', state: 'open', labels: ['x'], assignees: ['y'] }]; + const normalized = normalizeForgejoIssueListItems(raw); + expect(normalized[0]!.labels).toEqual(['x']); + expect(normalized[0]!.assignees).toEqual(['y']); + }); + + it('returns [] for a non-array payload', () => { + expect(normalizeForgejoIssueListItems(null)).toEqual([]); + expect(normalizeForgejoIssueListItems({ message: 'Not Found' })).toEqual([]); + }); +}); + +describe('normalizeForgejoIssueView', () => { + it('maps raw API shape including numeric comments', () => { + // Fixture: raw GET /repos/{owner}/{repo}/issues/{index} response. + const raw = { + number: 1303, + title: 'Raw view', + state: 'closed', + labels: [{ id: 1, name: 'spec-change', color: '#abcdef' }], + assignees: [{ id: 9, login: 'robby' }], + body: 'hello world', + comments: 7, + html_url: 'https://git.4serv.de/scr/dev-playground/issues/1303', + }; + + const view = normalizeForgejoIssueView(raw); + expect(view.labels).toEqual(['spec-change']); + expect(view.assignees).toEqual(['robby']); + expect(view.comments).toBe(7); + expect(view.body).toBe('hello world'); + expect(view.state).toBe('closed'); + }); + + it('honours a comment-count override when the list was fetched separately', () => { + const view = normalizeForgejoIssueView( + { number: 5, title: 't', state: 'open', labels: [], assignees: [] }, + 3 + ); + expect(view.comments).toBe(3); + }); + + it('defaults comments to 0 and body to empty when absent', () => { + const view = normalizeForgejoIssueView({ number: 5, labels: [], assignees: [] }); + expect(view.comments).toBe(0); + expect(view.body).toBe(''); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// Unit tests — host derivation / config resolution (no live git) +// ──────────────────────────────────────────────────────────────── + +describe('deriveHostFromRemoteUrl', () => { + it('extracts scheme+host from an https remote URL', () => { + expect(deriveHostFromRemoteUrl('https://git.4serv.de/scr/dev-playground.git')).toBe( + 'https://git.4serv.de' + ); + }); + + it('extracts host from an ssh remote URL', () => { + expect(deriveHostFromRemoteUrl('git@git.4serv.de:scr/dev-playground.git')).toBe( + 'https://git.4serv.de' + ); + }); + + it('returns null for an underivable URL', () => { + expect(deriveHostFromRemoteUrl('not-a-url')).toBeNull(); + expect(deriveHostFromRemoteUrl('')).toBeNull(); + }); +}); + +describe('getForgejoConfig', () => { + it('uses FORGEJO_HOST when set', () => { + withEnv({ FORGEJO_TOKEN: 'tok', FORGEJO_HOST: 'https://forge.example' }, () => { + expect(getForgejoConfig()).toEqual({ + ok: true, + config: { token: 'tok', host: 'https://forge.example' }, + }); + }); + }); + + it('returns reason "host" when the token is present but no host is derivable', () => { + // No cwd → remote is not consulted, and FORGEJO_HOST is unset → host absent. + withEnv({ FORGEJO_TOKEN: 'tok', FORGEJO_HOST: undefined }, () => { + expect(getForgejoConfig()).toEqual({ ok: false, reason: 'host' }); + }); + }); + + it('returns reason "token" when FORGEJO_TOKEN is unset', () => { + withEnv({ FORGEJO_TOKEN: undefined, FORGEJO_HOST: 'https://forge.example' }, () => { + expect(getForgejoConfig()).toEqual({ ok: false, reason: 'token' }); + }); + }); +});