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
17 changes: 12 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.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)
Expand Down Expand Up @@ -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
Expand Down
53 changes: 48 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.1.0",
"version": "0.1.1",
"description": "Git analysis + GitHub/GitLab ops tools for opencode agents",
"type": "module",
"license": "Apache-2.0",
Expand Down
8 changes: 8 additions & 0 deletions src/four-opencode-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
},
};
};
Expand Down
160 changes: 160 additions & 0 deletions src/lib/forgejo-utils.ts
Original file line number Diff line number Diff line change
@@ -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<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;
} catch {
return null;
}
}

/** Call Forgejo v1 API. `path` starts with `/repos/...`. */
export async function forgejoApi(
path: string,
config: ForgejoConfig,
init?: ForgejoApiInit
): Promise<ForgejoApiResult> {
const url = `${config.host}/api/v1${path}`;

try {
const headers: Record<string, string> = {
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),
};
}
}
Loading
Loading