Repository navigation
feat: forgejo issue write ops — create with labels, comment, comments, close zombie check (#16) #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 !== ''); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents
Suggested change
|
||||||
| 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<string | null> { | ||||||
| try { | ||||||
| const proc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], { cwd, stdout: 'pipe' }); | ||||||
| const url = (await new Response(proc.stdout).text()).trim(); | ||||||
| // Extract: <EMAIL_1>: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 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The default 10-page cap silently truncates label lookup at 500 labels. Remove the cap for label resolution or return an explicit truncation error instead of aborting creation as an unknown-label failure. Prompt for AI agents
Suggested change
|
||||||
| ): Promise<ForgejoApiResult> { | ||||||
| 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<string, number>(); | ||||||
| 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<LabelResolution> { | ||||||
| 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; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The scan window of the 500 most-recently-updated PRs systematically misses the classic zombie case: a PR merged long ago whose issue stayed open has an old Prompt for AI agents |
||
| } | ||
|
|
||
| // ──────────────────────────────────────────────────────────────── | ||
|
|
@@ -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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This new tool duplicates the inline comment POST already in forgejo-issue-close (step 2), and the two have already diverged: the new tool rejects empty/whitespace bodies, while the close tool's Prompt for AI agents |
||
| 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}`; | ||
| } | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: This rejects valid scp-style remotes without an explicit username, such as
forgejo.example.com:acme/widgets.git. Allow the optionaluser@prefix so those repositories do not fail repository detection.Prompt for AI agents