diff --git a/AGENTS.md b/AGENTS.md index 49fc7b1..6494268 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.2.0 +- Package: `@four-bytes/four-opencode-git` v0.3.0 - Build: `bun run build` → `dist/four-opencode-git.js` - Test: `bun test` -## Tool Stack (22 tools) +## Tool Stack (25 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) @@ -46,14 +46,17 @@ returns `No issue backend for .` as a plain string. - `gitlab_mr_status` — check MR state/mergeability/pipelines - `gitlab_issue_list` — list issues (state/label/assignee/search), one line per issue -### Forgejo (4 tools) +### Forgejo (7 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_issue_create` — create issue with labels in one call; unknown label → nothing created +- `forgejo_issue_comment` — comment on issue/PR (API body argument, no shell) +- `forgejo_issue_comments` — comments one line each, newest N +- `forgejo_issue_close` — close issue with optional API-posted comment + linked-PR (zombie) check - `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 22 tools +- Entry: `src/four-opencode-git.ts` — registers all 25 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, forgejo-utils.ts, secrets.ts, plugin-log.ts, debug-logger.ts, diff-parse.ts - Tests: `tests/` — bun-native diff --git a/README.md b/README.md index 1cc0409..a8cb9c8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Git analysis + GitHub/GitLab ops tools for opencode agents. "plugin": ["file:///home/robby/four-opencode-git/dist/four-opencode-git.js"] ``` -## Tools (22) +## Tools (25) ### Git core (3) @@ -68,7 +68,7 @@ the `remote` line is omitted. Detached HEAD reports the 7-char short OID. Pass issue; `repo`/`project` optionally override the origin remote. GitLab's `opened` API state is mapped from the user-facing `open` -### Forgejo (4) +### Forgejo (7) 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 @@ -76,7 +76,12 @@ 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_issue_create` — create an issue **with labels in one call**; label names are resolved + to ids first (repo + org labels) and an unknown label aborts before anything is created +- `forgejo_issue_comment` — comment on an issue or PR; text is a tool argument, no shell +- `forgejo_issue_comments` — read comments, one line each (date, author, first line), newest N +- `forgejo_issue_close` — close an issue with an optional comment; reports whether a merged PR + references it (`Closes #N` in the body or `#N` in the title) - `forgejo_pr_status` — PR state, resolving `open` / `merged` / `closed + merged_via git-squash` / `closed` (abandoned) by checking git for the head commit diff --git a/package.json b/package.json index 8605523..083278f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-git", - "version": "0.2.0", + "version": "0.3.0", "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 3c48c8b..7b4b0f2 100644 --- a/src/four-opencode-git.ts +++ b/src/four-opencode-git.ts @@ -24,6 +24,9 @@ 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'; +import { forgejoIssueCreateTool } from './tools/forgejo-issue-create'; +import { forgejoIssueCommentTool } from './tools/forgejo-issue-comment'; +import { forgejoIssueCommentsTool } from './tools/forgejo-issue-comments'; import { setPluginClient } from './lib/plugin-log'; const FourOpencodeGit: Plugin = async (ctx) => { @@ -51,6 +54,9 @@ const FourOpencodeGit: Plugin = async (ctx) => { forgejo_issue_list: forgejoIssueListTool, forgejo_issue_view: forgejoIssueViewTool, forgejo_issue_close: forgejoIssueCloseTool, + forgejo_issue_create: forgejoIssueCreateTool, + forgejo_issue_comment: forgejoIssueCommentTool, + forgejo_issue_comments: forgejoIssueCommentsTool, forgejo_pr_status: forgejoPrStatusTool, }, }; diff --git a/src/lib/forgejo-utils.ts b/src/lib/forgejo-utils.ts index dc844fc..311203b 100644 --- a/src/lib/forgejo-utils.ts +++ b/src/lib/forgejo-utils.ts @@ -228,18 +228,38 @@ export function getForgejoConfig( return result; } -/** Get `owner/repo` from current repo's remote origin (Forgejo uses raw paths). */ +/** + * `owner/repo` from a remote URL — the last two path segments, `.git` stripped, + * so subpath installs (`https://host/forgejo/owner/repo`) work too. Handles + * `https://host/o/r.git`, `ssh://git@host:2222/o/r.git` and `git@host:o/r.git`. + * Exported for testing. + */ +export function repoFromRemoteUrl(url: string): string | null { + const trimmed = url.trim(); + let path: string; + if (trimmed.includes('://')) { + try { + path = new URL(trimmed).pathname; + } catch { + return null; + } + } else { + const scp = trimmed.match(/^[^@]+@[^:]+:(.+)$/); + if (!scp) return null; + path = scp[1]!; + } + const parts = path.replace(/\.git$/, '').split('/').filter((p) => p !== ''); + if (parts.length < 2) return null; + // Forgejo API paths take raw owner/repo — do NOT encodeURIComponent. + return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`; +} + +/** Get `owner/repo` from the current repo's `origin` remote. */ 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; + return url ? repoFromRemoteUrl(url) : null; } catch { return null; } @@ -296,3 +316,97 @@ export async function forgejoApi( }; } } + +/** + * Fetch every page of a list endpoint (`limit=50`, at most `maxPages`). `path` + * may already carry a query string. Any failed page fails the whole call — a + * partial list would look complete to the caller. + */ +export async function forgejoApiAll( + path: string, + config: ForgejoConfig, + maxPages = 10 +): Promise { + const sep = path.includes('?') ? '&' : '?'; + const items: unknown[] = []; + for (let page = 1; page <= maxPages; page++) { + const result = await forgejoApi(`${path}${sep}limit=50&page=${page}`, config); + if (!result.ok) { + return page === 1 ? result : { ...result, error: `page ${page}: ${result.error}` }; + } + const batch = Array.isArray(result.data) ? result.data : []; + items.push(...batch); + if (batch.length < 50) break; + } + return { ok: true, status: 200, data: items }; +} + +export interface LabelResolution { + ids: number[]; + unknown: string[]; + error?: string; +} + +/** + * Pure: map label names to ids, case-insensitively. Exported for testing. + * Repo labels win over org labels of the same name. + */ +export function matchLabelIds( + names: string[], + available: Array<{ id: number; name: string }> +): { ids: number[]; unknown: string[] } { + const byName = new Map(); + for (const label of [...available].reverse()) byName.set(label.name.toLowerCase(), label.id); + const ids: number[] = []; + const unknown: string[] = []; + for (const name of names) { + const id = byName.get(name.trim().toLowerCase()); + if (id === undefined) unknown.push(name.trim()); + else if (!ids.includes(id)) ids.push(id); + } + return { ids, unknown }; +} + +/** Resolve label names against the repo's labels plus its org's labels (if any). */ +export async function resolveLabelIds( + repo: string, + names: string[], + config: ForgejoConfig +): Promise { + if (names.length === 0) return { ids: [], unknown: [] }; + + const repoLabels = await forgejoApiAll(`/repos/${repo}/labels`, config); + if (!repoLabels.ok) return { ids: [], unknown: [], error: repoLabels.error }; + + // A user-owned repo has no org — only a 404 means "no org labels". Any other + // failure must surface, or an org label would be reported as unknown. + const owner = repo.split('/')[0]!; + const orgLabels = await forgejoApiAll(`/orgs/${owner}/labels`, config); + if (!orgLabels.ok && orgLabels.status !== 404) { + return { ids: [], unknown: [], error: `org labels: ${orgLabels.error}` }; + } + const available = [ + ...(repoLabels.data as Array<{ id: number; name: string }>), + ...(orgLabels.ok ? (orgLabels.data as Array<{ id: number; name: string }>) : []), + ]; + + return matchLabelIds(names, available); +} + +/** Split a comma-separated label argument into trimmed, non-empty names. */ +export function splitLabels(value: string | undefined): string[] { + if (!value) return []; + return value + .split(',') + .map((l) => l.trim()) + .filter((l) => l !== ''); +} + +/** First line of a comment body, cut to `max` chars, plus a `(+N lines)` hint. */ +export function summarizeBody(body: string, max = 100): string { + const lines = body.replace(/\r\n/g, '\n').trim().split('\n'); + let first = (lines[0] ?? '').trim(); + if (first.length > max) first = `${first.slice(0, max - 1)}…`; + const rest = lines.length - 1; + return rest > 0 ? `${first} (+${rest} lines)` : first; +} diff --git a/src/tools/forgejo-issue-close.ts b/src/tools/forgejo-issue-close.ts index 35a1237..16861f9 100644 --- a/src/tools/forgejo-issue-close.ts +++ b/src/tools/forgejo-issue-close.ts @@ -4,6 +4,7 @@ import { tool } from '@opencode-ai/plugin'; import { forgejoApi, + forgejoApiAll, getForgejoConfig, getForgejoRepo, } from '../lib/forgejo-utils'; @@ -21,6 +22,52 @@ export interface ForgejoCloseResult { closed: boolean; commentError?: string; closeError?: string; + /** PRs whose title/body reference the issue; undefined when the lookup failed. */ + linkedPulls?: LinkedPull[]; +} + +export interface LinkedPull { + number: number; + state: string; + merged: boolean; +} + +// ──────────────────────────────────────────────────────────────── +// Zombie check (pure) +// ──────────────────────────────────────────────────────────────── + +/** + * PRs that reference `#issue` — a closing keyword in the body (`Closes #12`) or + * `#12` in the title (`feat: x (#12)`). Exported for testing. + */ +export function findLinkedPulls(pulls: unknown, issue: number): LinkedPull[] { + if (!Array.isArray(pulls)) return []; + const inBody = new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issue}\\b`, 'i'); + const inTitle = new RegExp(`#${issue}\\b`); + return pulls + .filter( + (p: any) => + inBody.test(typeof p?.body === 'string' ? p.body : '') || + inTitle.test(typeof p?.title === 'string' ? p.title : '') + ) + .map((p: any) => ({ number: p.number, state: p.state, merged: p.merged === true })); +} + +/** PRs scanned by the zombie check — the most recently updated ones. */ +export const LINKED_PULL_SCAN = 500; + +/** One line on how the issue relates to its PRs. Exported for testing. */ +export function formatLinkedPulls(issue: number, linked: LinkedPull[] | undefined): string { + if (linked === undefined) return `⚠ Could not check PRs referencing #${issue}.`; + const merged = linked.filter((p) => p.merged); + if (merged.length > 0) { + return `✓ Referenced by merged PR ${merged.map((p) => `!${p.number}`).join(', ')}.`; + } + if (linked.length > 0) { + const list = linked.map((p) => `!${p.number} (${p.state})`).join(', '); + return `⚠ No merged PR for #${issue} — referenced by ${list}. Closing as requested.`; + } + return `⚠ No PR references #${issue} (checked the ${LINKED_PULL_SCAN} most recently updated PRs) — closing without a linked merge.`; } // ──────────────────────────────────────────────────────────────── @@ -36,7 +83,10 @@ function formatForgejoIssueClose(params: ForgejoCloseResult): string { 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.closed) { + lines.push(`✓ Issue #${params.issue} "${params.title}" closed.`); + lines.push(formatLinkedPulls(params.issue, params.linkedPulls)); + } if (params.closeError) lines.push(`✗ Failed to close issue: ${params.closeError}`); return lines.join('\n'); } @@ -47,11 +97,11 @@ function formatForgejoIssueClose(params: ForgejoCloseResult): string { 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.', + 'Close a Forgejo issue, with an optional comment. Reports whether a merged PR references the issue (zombie check). The comment is posted via the REST API as a tool argument — it never passes through a shell.', args: { issue: tool.schema.number().describe('Issue number (index) to close'), - comment: tool.schema.string().describe('Optional comment to post before closing'), + comment: tool.schema.string().optional().describe('Optional comment to post before closing'), }, async execute(args, ctx) { @@ -113,9 +163,21 @@ export const forgejoIssueCloseTool = tool({ body: { state: 'closed' }, }); + // ── Step 4: Zombie check — which PRs reference this issue ── + let linkedPulls: LinkedPull[] | undefined; + if (closeResult.ok) { + const pulls = await forgejoApiAll( + `/repos/${repo}/pulls?state=all&sort=recentupdate`, + config, + LINKED_PULL_SCAN / 50 + ); + linkedPulls = pulls.ok ? findLinkedPulls(pulls.data, issueNum) : undefined; + } + const output = formatForgejoIssueClose({ issue: issueNum, title, + linkedPulls, alreadyClosed: false, commentPosted, closed: closeResult.ok, diff --git a/src/tools/forgejo-issue-comment.ts b/src/tools/forgejo-issue-comment.ts new file mode 100644 index 0000000..f7e737b --- /dev/null +++ b/src/tools/forgejo-issue-comment.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { forgejoApi, getForgejoConfig, getForgejoRepo } from '../lib/forgejo-utils'; +import { logDebugEvent } from '../lib/debug-logger'; + +export const forgejoIssueCommentTool = tool({ + description: + 'Post a comment on a Forgejo issue or pull request (they share one number space). The text is a tool argument sent as JSON — backticks and $() arrive verbatim, no shell involved.', + + args: { + issue: tool.schema.number().describe('Issue or PR number'), + body: tool.schema.string().describe('Comment text (markdown)'), + }, + + async execute(args, ctx) { + const issueNum = args.issue as number; + const body = (args.body as string) ?? ''; + const cwd = ctx.directory; + + logDebugEvent('forgejo_issue_comment.start', { issue: issueNum }); + + try { + if (!body.trim()) return 'Error: comment body must not be empty.'; + + const cfg = getForgejoConfig(cwd); + if (!cfg.ok) return cfg.message; + + const repo = await getForgejoRepo(cwd); + if (!repo) return 'Could not determine Forgejo repository from origin remote.'; + + const result = await forgejoApi(`/repos/${repo}/issues/${issueNum}/comments`, cfg.config, { + method: 'POST', + body: { body }, + }); + if (!result.ok) return `Error commenting on #${issueNum}: ${result.error}`; + + logDebugEvent('forgejo_issue_comment.done', { issue: issueNum }); + return `✓ Comment posted on #${issueNum}\n ${result.data?.html_url ?? ''}`.trimEnd(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logDebugEvent('forgejo_issue_comment.error', { error: msg }); + return `Error commenting: ${msg}`; + } + }, +}); diff --git a/src/tools/forgejo-issue-comments.ts b/src/tools/forgejo-issue-comments.ts new file mode 100644 index 0000000..ea7cdc6 --- /dev/null +++ b/src/tools/forgejo-issue-comments.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { + forgejoApi, + getForgejoConfig, + getForgejoRepo, + summarizeBody, +} from '../lib/forgejo-utils'; +import { logDebugEvent } from '../lib/debug-logger'; + +// ──────────────────────────────────────────────────────────────── +// Output formatting +// ──────────────────────────────────────────────────────────────── + +/** + * One line per comment, newest `limit` in chronological order: + * ` 2026-06-10 alice: first line… (+3 lines)`. Pure — exported for testing. + */ +export function formatForgejoIssueComments(raw: unknown, issue: number, limit: number): string { + const all = Array.isArray(raw) ? raw : []; + if (all.length === 0) return `FORGEJO COMMENTS — #${issue} — none`; + + const shown = all.slice(-limit); + const header = + shown.length < all.length + ? `FORGEJO COMMENTS — #${issue} — last ${shown.length} of ${all.length}` + : `FORGEJO COMMENTS — #${issue} — ${all.length}`; + + const lines = shown.map((c: any) => { + const date = typeof c.created_at === 'string' ? c.created_at.slice(0, 10) : '????-??-??'; + const author = c.user?.login ?? 'unknown'; + return ` ${date} ${author}: ${summarizeBody(typeof c.body === 'string' ? c.body : '')}`; + }); + + return [header, '', ...lines].join('\n'); +} + +// ──────────────────────────────────────────────────────────────── +// Tool definition +// ──────────────────────────────────────────────────────────────── + +export const forgejoIssueCommentsTool = tool({ + description: + 'Read the comments on a Forgejo issue or PR: one line per comment (date, author, first line of the body), newest N. Use forgejo_issue_view for the issue itself.', + + args: { + issue: tool.schema.number().describe('Issue or PR number'), + limit: tool.schema + .number() + .optional() + .describe('How many of the most recent comments to show (default: 10)'), + }, + + async execute(args, ctx) { + const issueNum = args.issue as number; + const limit = Math.max(1, (args.limit as number | undefined) ?? 10); + const cwd = ctx.directory; + + logDebugEvent('forgejo_issue_comments.start', { issue: issueNum, limit }); + + try { + const cfg = getForgejoConfig(cwd); + if (!cfg.ok) return cfg.message; + + const repo = await getForgejoRepo(cwd); + if (!repo) return 'Could not determine Forgejo repository from origin remote.'; + + // This endpoint is not paginated — it returns every comment in one response. + const result = await forgejoApi(`/repos/${repo}/issues/${issueNum}/comments`, cfg.config); + if (!result.ok) return `Error reading comments on #${issueNum}: ${result.error}`; + + logDebugEvent('forgejo_issue_comments.done', { issue: issueNum }); + return formatForgejoIssueComments(result.data, issueNum, limit); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logDebugEvent('forgejo_issue_comments.error', { error: msg }); + return `Error reading comments: ${msg}`; + } + }, +}); diff --git a/src/tools/forgejo-issue-create.ts b/src/tools/forgejo-issue-create.ts new file mode 100644 index 0000000..7f4abe7 --- /dev/null +++ b/src/tools/forgejo-issue-create.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { + forgejoApi, + getForgejoConfig, + getForgejoRepo, + resolveLabelIds, + splitLabels, +} from '../lib/forgejo-utils'; +import { logDebugEvent } from '../lib/debug-logger'; + +// ──────────────────────────────────────────────────────────────── +// Output formatting +// ──────────────────────────────────────────────────────────────── + +/** Refusal line for unknown labels. Pure — exported for testing. */ +export function formatUnknownLabels(unknown: string[], repo: string): string { + return `✗ Issue not created — unknown label(s) in ${repo}: ${unknown.join(', ')}. Create them first (e.g. rollout-defekt-labels --apply), then retry.`; +} + +/** Success line. Pure — exported for testing. */ +export function formatForgejoIssueCreated(raw: any, labels: string[]): string { + const labelPart = labels.length > 0 ? ` [${labels.join(', ')}]` : ''; + return `✓ Created #${raw?.number}${labelPart} ${raw?.title ?? ''}\n ${raw?.html_url ?? ''}`.trimEnd(); +} + +// ──────────────────────────────────────────────────────────────── +// Tool definition +// ──────────────────────────────────────────────────────────────── + +export const forgejoIssueCreateTool = tool({ + description: + 'Create a Forgejo issue with labels in one call. Label names are resolved to ids first; any unknown label aborts and nothing is created. Title/body are tool arguments — never passed through a shell. Replaces `fj issue create` + `fj issue edit labels`.', + + args: { + title: tool.schema.string().describe('Issue title, e.g. "[FEAT] one-sentence change"'), + body: tool.schema.string().optional().describe('Issue body (markdown)'), + labels: tool.schema + .string() + .optional() + .describe('Comma-separated label names, e.g. "bug,agent-code,caught-dev"'), + }, + + async execute(args, ctx) { + const title = (args.title as string)?.trim(); + const body = (args.body as string | undefined) ?? ''; + const labelNames = splitLabels(args.labels as string | undefined); + const cwd = ctx.directory; + + logDebugEvent('forgejo_issue_create.start', { labels: labelNames }); + + try { + if (!title) return 'Error: title must not be empty.'; + + const cfg = getForgejoConfig(cwd); + if (!cfg.ok) return cfg.message; + const config = cfg.config; + + const repo = await getForgejoRepo(cwd); + if (!repo) return 'Could not determine Forgejo repository from origin remote.'; + + const resolved = await resolveLabelIds(repo, labelNames, config); + if (resolved.error) return `Error reading labels: ${resolved.error}`; + if (resolved.unknown.length > 0) return formatUnknownLabels(resolved.unknown, repo); + + const payload: Record = { title, body }; + if (resolved.ids.length > 0) payload.labels = resolved.ids; + + const result = await forgejoApi(`/repos/${repo}/issues`, config, { + method: 'POST', + body: payload, + }); + if (!result.ok) return `Error creating issue: ${result.error}`; + + logDebugEvent('forgejo_issue_create.done', { issue: result.data?.number }); + return formatForgejoIssueCreated(result.data, labelNames); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logDebugEvent('forgejo_issue_create.error', { error: msg }); + return `Error creating issue: ${msg}`; + } + }, +}); diff --git a/tests/forgejo-helpers.ts b/tests/forgejo-helpers.ts new file mode 100644 index 0000000..b19d105 --- /dev/null +++ b/tests/forgejo-helpers.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes +// +// Shared fixtures for tool-level Forgejo tests: a throwaway repo whose origin +// points at an example host, a configured token for it, and a fetch mock that +// records every request and answers from a route table. + +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export const HOST = 'https://forgejo.example.com'; +export const API = `${HOST}/api/v1/repos/acme/widgets`; + +export interface Recorded { + method: string; + url: string; + body?: any; +} + +type Route = (req: Recorded) => { status?: number; json?: unknown } | undefined; + +/** A git repo with `origin` at the example host. */ +export function exampleRepo(): string { + const dir = mkdtempSync(join(tmpdir(), 'git-forgejo-')); + Bun.spawnSync(['git', 'init', '-q'], { cwd: dir }); + Bun.spawnSync(['git', 'remote', 'add', 'origin', `${HOST}/acme/widgets.git`], { cwd: dir }); + return dir; +} + +/** + * Configure the example host, install a fetch mock, run `fn`, restore all. + * Unrouted requests answer 404 so a missing route shows up as a failed call. + */ +export async function withForgejo( + route: Route, + fn: (calls: Recorded[]) => Promise +): Promise { + const calls: Recorded[] = []; + const realFetch = globalThis.fetch; + process.env.FORGEJO_HOST_EX = 'forgejo.example.com'; + process.env.FORGEJO_TOKEN_EX = 'test-token'; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + const req: Recorded = { + method: init?.method ?? 'GET', + url: String(url), + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + }; + calls.push(req); + const res = route(req) ?? { status: 404, json: { message: 'not found' } }; + return new Response(JSON.stringify(res.json ?? null), { status: res.status ?? 200 }); + }) as unknown as typeof fetch; + try { + await fn(calls); + } finally { + globalThis.fetch = realFetch; + delete process.env.FORGEJO_HOST_EX; + delete process.env.FORGEJO_TOKEN_EX; + } +} + +/** Minimal tool context — the tools only read `directory`. */ +export function ctx(directory: string): any { + return { directory, worktree: directory, sessionID: 't', messageID: 't', agent: 't' }; +} diff --git a/tests/forgejo-issue-write.test.ts b/tests/forgejo-issue-write.test.ts new file mode 100644 index 0000000..7a1fa8a --- /dev/null +++ b/tests/forgejo-issue-write.test.ts @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { describe, it, expect } from 'bun:test'; +import { forgejoIssueCreateTool } from '../src/tools/forgejo-issue-create'; +import { forgejoIssueCommentTool } from '../src/tools/forgejo-issue-comment'; +import { + forgejoIssueCommentsTool, + formatForgejoIssueComments, +} from '../src/tools/forgejo-issue-comments'; +import { + findLinkedPulls, + forgejoIssueCloseTool, + formatLinkedPulls, +} from '../src/tools/forgejo-issue-close'; +import { + forgejoApiAll, + matchLabelIds, + repoFromRemoteUrl, + resolveLabelIds, + splitLabels, + summarizeBody, +} from '../src/lib/forgejo-utils'; +import { API, ctx, exampleRepo, withForgejo } from './forgejo-helpers'; + +const REPO = exampleRepo(); + +const LABELS = [ + { id: 1, name: 'bug' }, + { id: 2, name: 'agent-code' }, + { id: 3, name: 'caught-dev' }, +]; + +const labelRoutes = (req: { method: string; url: string }) => { + if (req.method === 'GET' && req.url.startsWith(`${API}/labels`)) return { json: LABELS }; + if (req.method === 'GET' && req.url.includes('/orgs/acme/labels')) { + return { json: [{ id: 9, name: 'spec-change' }] }; + } + return undefined; +}; + +// ──────────────────────────────────────────────────────────────── +// Pure helpers +// ──────────────────────────────────────────────────────────────── + +describe('matchLabelIds', () => { + it('matches case-insensitively and reports unknown names', () => { + expect(matchLabelIds(['Bug', 'nope', 'caught-dev'], LABELS)).toEqual({ + ids: [1, 3], + unknown: ['nope'], + }); + }); + + it('prefers a repo label over an org label of the same name', () => { + expect(matchLabelIds(['bug'], [{ id: 1, name: 'bug' }, { id: 50, name: 'bug' }]).ids).toEqual([1]); + }); +}); + +describe('splitLabels / summarizeBody', () => { + it('splits and trims comma lists', () => { + expect(splitLabels(' bug, agent-code ,,')).toEqual(['bug', 'agent-code']); + expect(splitLabels(undefined)).toEqual([]); + }); + + it('keeps the first line and counts the rest', () => { + expect(summarizeBody('one\ntwo\nthree')).toBe('one (+2 lines)'); + expect(summarizeBody('x'.repeat(120), 10)).toBe(`${'x'.repeat(9)}…`); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// forgejo_issue_create +// ──────────────────────────────────────────────────────────────── + +describe('forgejo_issue_create', () => { + it('creates with resolved label ids in one POST', async () => { + await withForgejo( + (req) => + labelRoutes(req) ?? + (req.method === 'POST' && req.url === `${API}/issues` + ? { status: 201, json: { number: 42, title: req.body.title, html_url: `${API}/issues/42` } } + : undefined), + async (calls) => { + const out = await forgejoIssueCreateTool.execute( + { title: '[FIX] thing', body: 'b', labels: 'bug,agent-code,spec-change' }, + ctx(REPO) + ); + expect(out).toContain('✓ Created #42 [bug, agent-code, spec-change] [FIX] thing'); + const posts = calls.filter((c) => c.method === 'POST'); + expect(posts).toHaveLength(1); + expect(posts[0]!.body).toEqual({ title: '[FIX] thing', body: 'b', labels: [1, 2, 9] }); + } + ); + }); + + it('creates nothing when a label is unknown', async () => { + await withForgejo(labelRoutes, async (calls) => { + const out = await forgejoIssueCreateTool.execute( + { title: '[FIX] thing', labels: 'bug,hit-prod' }, + ctx(REPO) + ); + expect(out).toContain('unknown label(s) in acme/widgets: hit-prod'); + expect(calls.some((c) => c.method === 'POST')).toBe(false); + }); + }); + + it('skips the label lookup without labels', async () => { + await withForgejo( + (req) => (req.method === 'POST' ? { status: 201, json: { number: 1, title: 't' } } : undefined), + async (calls) => { + await forgejoIssueCreateTool.execute({ title: 't' }, ctx(REPO)); + expect(calls.map((c) => c.method)).toEqual(['POST']); + expect(calls[0]!.body.labels).toBeUndefined(); + } + ); + }); + + it('returns the not-configured line without a token', async () => { + const out = await forgejoIssueCreateTool.execute({ title: 't' }, ctx(REPO)); + expect(out).toContain('No Forgejo token for forgejo.example.com'); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// forgejo_issue_comment / forgejo_issue_comments +// ──────────────────────────────────────────────────────────────── + +describe('forgejo_issue_comment', () => { + it('sends shell metacharacters verbatim', async () => { + const text = 'Fixed in `abc123` — $(rm -rf /) "quoted"'; + await withForgejo( + (req) => + req.method === 'POST' && req.url === `${API}/issues/7/comments` + ? { status: 201, json: { html_url: `${API}/issues/7#c1` } } + : undefined, + async (calls) => { + const out = await forgejoIssueCommentTool.execute({ issue: 7, body: text }, ctx(REPO)); + expect(out).toContain('✓ Comment posted on #7'); + expect(calls[0]!.body).toEqual({ body: text }); + } + ); + }); + + it('refuses an empty comment without a request', async () => { + await withForgejo(() => undefined, async (calls) => { + expect(await forgejoIssueCommentTool.execute({ issue: 7, body: ' ' }, ctx(REPO))).toContain( + 'must not be empty' + ); + expect(calls).toHaveLength(0); + }); + }); +}); + +describe('forgejo_issue_comments', () => { + const COMMENTS = [ + { user: { login: 'alice' }, created_at: '2026-06-01T10:00:00Z', body: 'first\nmore' }, + { user: { login: 'bob' }, created_at: '2026-06-02T10:00:00Z', body: 'second' }, + { user: { login: 'carol' }, created_at: '2026-06-03T10:00:00Z', body: 'third' }, + ]; + + it('formats one line per comment, newest N', () => { + expect(formatForgejoIssueComments(COMMENTS, 5, 2)).toBe( + [ + 'FORGEJO COMMENTS — #5 — last 2 of 3', + '', + ' 2026-06-02 bob: second', + ' 2026-06-03 carol: third', + ].join('\n') + ); + expect(formatForgejoIssueComments(COMMENTS, 5, 10)).toContain('alice: first (+1 lines)'); + expect(formatForgejoIssueComments([], 5, 10)).toBe('FORGEJO COMMENTS — #5 — none'); + }); + + it('reads the comment list in one request', async () => { + await withForgejo( + (req) => (req.url === `${API}/issues/5/comments` ? { json: COMMENTS } : undefined), + async (calls) => { + const out = await forgejoIssueCommentsTool.execute({ issue: 5 }, ctx(REPO)); + expect(out).toContain('FORGEJO COMMENTS — #5 — 3'); + expect(calls).toHaveLength(1); + } + ); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// forgejo_issue_close — zombie check +// ──────────────────────────────────────────────────────────────── + +describe('findLinkedPulls / formatLinkedPulls', () => { + const PULLS = [ + { number: 10, title: 'feat: a (#12)', body: '', state: 'closed', merged: true }, + { number: 11, title: 'other', body: 'Closes #12', state: 'open', merged: false }, + { number: 13, title: 'feat: b (#123)', body: 'Fixes #120', state: 'closed', merged: true }, + ]; + + it('matches title refs and closing keywords, not longer numbers', () => { + expect(findLinkedPulls(PULLS, 12).map((p) => p.number)).toEqual([10, 11]); + }); + + it('describes merged, unmerged and missing links', () => { + expect(formatLinkedPulls(12, findLinkedPulls(PULLS, 12))).toBe('✓ Referenced by merged PR !10.'); + expect(formatLinkedPulls(12, [{ number: 11, state: 'open', merged: false }])).toContain( + 'No merged PR for #12 — referenced by !11 (open)' + ); + expect(formatLinkedPulls(99, [])).toContain('No PR references #99'); + expect(formatLinkedPulls(99, undefined)).toContain('Could not check'); + }); +}); + +describe('forgejo_issue_close', () => { + it('closes without a comment and reports the linked PR', async () => { + await withForgejo( + (req) => { + if (req.method === 'GET' && req.url === `${API}/issues/12`) { + return { json: { title: 'T', state: 'open' } }; + } + if (req.method === 'PATCH') return { json: { state: 'closed' } }; + if (req.method === 'GET' && req.url.startsWith(`${API}/pulls`)) { + return { json: [{ number: 10, title: 'feat (#12)', body: '', state: 'closed', merged: true }] }; + } + return undefined; + }, + async (calls) => { + const out = await forgejoIssueCloseTool.execute({ issue: 12 }, ctx(REPO)); + expect(out).toContain('✓ Issue #12 "T" closed.'); + expect(out).toContain('✓ Referenced by merged PR !10.'); + expect(calls.some((c) => c.url.endsWith('/comments'))).toBe(false); + } + ); + }); +}); + +describe('repoFromRemoteUrl', () => { + it('takes owner/repo from https, ssh and scp-style remotes', () => { + expect(repoFromRemoteUrl('https://forgejo.example.com/acme/widgets.git')).toBe('acme/widgets'); + expect(repoFromRemoteUrl('https://user:pw@forgejo.example.com/acme/widgets')).toBe('acme/widgets'); + expect(repoFromRemoteUrl('ssh://git@forgejo.example.com:2222/acme/widgets.git')).toBe('acme/widgets'); + expect(repoFromRemoteUrl('git@forgejo.example.com:acme/widgets.git')).toBe('acme/widgets'); + expect(repoFromRemoteUrl('https://example.com/forgejo/acme/my.repo.git')).toBe('acme/my.repo'); + }); + + it('returns null without owner and repo', () => { + expect(repoFromRemoteUrl('https://forgejo.example.com/')).toBeNull(); + expect(repoFromRemoteUrl('not-a-url')).toBeNull(); + }); +}); + +// ──────────────────────────────────────────────────────────────── +// Error paths, pagination, not-configured +// ──────────────────────────────────────────────────────────────── + +describe('forgejoApiAll', () => { + const item = (i: number) => ({ id: i, name: `l${i}` }); + + it('walks pages until a short page', async () => { + await withForgejo( + (req) => { + const page = Number(new URL(req.url).searchParams.get('page')); + const size = page === 1 ? 50 : 3; + return { json: Array.from({ length: size }, (_, i) => item(page * 100 + i)) }; + }, + async (calls) => { + const result = await forgejoApiAll(`${'/repos/acme/widgets/labels'}`, { + token: 't', + host: 'https://forgejo.example.com', + }); + expect(result.ok).toBe(true); + expect(result.data).toHaveLength(53); + expect(calls).toHaveLength(2); + expect(calls[0]!.url).toContain('limit=50&page=1'); + } + ); + }); + + it('fails the whole call when a later page fails', async () => { + await withForgejo( + (req) => + req.url.includes('page=1') + ? { json: Array.from({ length: 50 }, (_, i) => item(i)) } + : { status: 500, json: { message: 'boom' } }, + async () => { + const result = await forgejoApiAll('/repos/acme/widgets/labels', { + token: 't', + host: 'https://forgejo.example.com', + }); + expect(result.ok).toBe(false); + expect(result.error).toBe('page 2: boom'); + } + ); + }); +}); + +describe('resolveLabelIds — org labels', () => { + const config = { token: 't', host: 'https://forgejo.example.com' }; + + it('treats a 404 on org labels as "no org"', async () => { + await withForgejo( + (req) => (req.url.includes('/repos/acme/widgets/labels') ? { json: LABELS } : undefined), + async () => { + expect(await resolveLabelIds('acme/widgets', ['bug'], config)).toEqual({ ids: [1], unknown: [] }); + } + ); + }); + + it('surfaces any other org-label failure instead of calling labels unknown', async () => { + await withForgejo( + (req) => + req.url.includes('/repos/acme/widgets/labels') + ? { json: LABELS } + : { status: 403, json: { message: 'forbidden' } }, + async () => { + const res = await resolveLabelIds('acme/widgets', ['spec-change'], config); + expect(res.error).toBe('org labels: forbidden'); + } + ); + }); +}); + +describe('API error paths', () => { + const fail500 = () => ({ status: 500, json: { message: 'server error' } }); + + it('create reports a failed POST', async () => { + await withForgejo(fail500, async () => { + expect(await forgejoIssueCreateTool.execute({ title: 't' }, ctx(REPO))).toBe( + 'Error creating issue: server error' + ); + }); + }); + + it('comment and comments report failures', async () => { + await withForgejo(fail500, async () => { + expect(await forgejoIssueCommentTool.execute({ issue: 3, body: 'x' }, ctx(REPO))).toBe( + 'Error commenting on #3: server error' + ); + expect(await forgejoIssueCommentsTool.execute({ issue: 3 }, ctx(REPO))).toBe( + 'Error reading comments on #3: server error' + ); + }); + }); + + it('close posts the comment first, then closes', async () => { + await withForgejo( + (req) => { + if (req.method === 'GET' && req.url === `${API}/issues/12`) return { json: { title: 'T', state: 'open' } }; + if (req.method === 'POST') return { status: 201, json: {} }; + if (req.method === 'PATCH') return { json: { state: 'closed' } }; + if (req.url.startsWith(`${API}/pulls`)) return { json: [] }; + return undefined; + }, + async (calls) => { + const out = await forgejoIssueCloseTool.execute({ issue: 12, comment: 'done `x`' }, ctx(REPO)); + expect(out).toContain('✓ Comment posted on #12'); + expect(out).toContain('No PR references #12'); + const writes = calls.filter((c) => c.method !== 'GET'); + expect(writes.map((c) => c.method)).toEqual(['POST', 'PATCH']); + expect(writes[0]!.body).toEqual({ body: 'done `x`' }); + } + ); + }); +}); + +describe('not configured', () => { + it('every new tool returns the one-line message and makes no request', async () => { + const realFetch = globalThis.fetch; + let called = false; + globalThis.fetch = (async () => { + called = true; + return new Response('null'); + }) as unknown as typeof fetch; + try { + const outs = [ + await forgejoIssueCreateTool.execute({ title: 't' }, ctx(REPO)), + await forgejoIssueCommentTool.execute({ issue: 1, body: 'x' }, ctx(REPO)), + await forgejoIssueCommentsTool.execute({ issue: 1 }, ctx(REPO)), + await forgejoIssueCloseTool.execute({ issue: 1 }, ctx(REPO)), + ]; + for (const out of outs) expect(out).toContain('No Forgejo token for forgejo.example.com'); + expect(called).toBe(false); + } finally { + globalThis.fetch = realFetch; + } + }); +});