Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -46,14 +46,17 @@ returns `No issue backend for <host>.` 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
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -68,15 +68,20 @@ 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
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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/four-opencode-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
},
};
Expand Down
130 changes: 122 additions & 8 deletions src/lib/forgejo-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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;
}
Expand Down Expand Up @@ -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<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;
}
68 changes: 65 additions & 3 deletions src/tools/forgejo-issue-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { tool } from '@opencode-ai/plugin';
import {
forgejoApi,
forgejoApiAll,
getForgejoConfig,
getForgejoRepo,
} from '../lib/forgejo-utils';
Expand All @@ -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.`;
}

// ────────────────────────────────────────────────────────────────
Expand All @@ -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');
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions src/tools/forgejo-issue-comment.ts
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, {
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}`;
}
},
});
Loading
Loading