@bytesbrains/pi-review-gate 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,80 @@
1
+ # Review Gate — Agent Usage Guide
2
+
3
+ > You are an AI agent. Use review-gate tools for all PR review and merge operations. Never call `git merge` directly.
4
+
5
+ ## Golden Rule
6
+
7
+ > **⚠️ DO NOT call `git merge`, `gh pr merge`, or close PRs directly.**
8
+ > Use `review_check()` → `review_approve()` and let CI handle the merge.
9
+ > CI enforces all gates: required reviewers, breaking change detection, and conventional commit squash.
10
+
11
+ ## Workflow for Reviewing a PR
12
+
13
+ ```
14
+ review_check(pr_number="51") ← check merge readiness
15
+
16
+
17
+ [CI green? Reviews in? No breaking changes? Not stale?]
18
+
19
+
20
+ review_approve(pr_number="51", ← submit your approval
21
+ comment="LGTM — all gates pass")
22
+
23
+
24
+ [CI detects approval + gates pass → auto-merge]
25
+ ```
26
+
27
+ ## Workflow for Closing Stale PRs
28
+
29
+ ```
30
+ review_status(pr_number="23") ← check PR age
31
+
32
+
33
+ review_close_stale(pr_number="23", ← close with reason
34
+ reason="14+ days inactive")
35
+ ```
36
+
37
+ ## Requesting Reviews
38
+
39
+ ```
40
+ review_request(pr_number="51",
41
+ reviewers="alice,bob")
42
+ ```
43
+
44
+ ## Understanding review_check Output
45
+
46
+ ```
47
+ 📋 PR #51 Merge Readiness
48
+ Title: feat(backup): add Firebase volume backup
49
+ State: open
50
+ CI Checks: ✅ all green
51
+ Reviews: 1 approved, 0 changes requested
52
+ Required reviewers: alice ✅
53
+ Breaking changes: ✅ none
54
+ Age: ✅ fresh (2 days)
55
+ Protected paths: ✅ none
56
+
57
+ ✅ PR is ready to merge! All gates passed.
58
+ ```
59
+
60
+ ## When Things Go Wrong
61
+
62
+ | Problem | Solution |
63
+ |---|---|
64
+ | CI failed | Check CI logs, fix issues, push update |
65
+ | Changes requested | Address reviewer feedback, push fixes, re-request review |
66
+ | Breaking changes detected | Add BREAKING CHANGE to commit body, flag for human review |
67
+ | Missing required reviewers | Use `review_request()` to assign path-required reviewers |
68
+ | PR is stale | Either close with `review_close_stale()` or push an update to refresh |
69
+
70
+ ## Configuration Reference
71
+
72
+ `.reviewrc.yml` controls review behavior:
73
+
74
+ ```yaml
75
+ staleDays: 14 # days before PR is considered stale
76
+ minDiverseReviews: 1 # minimum different model families that must review
77
+ requiredReviewers.agents/**: agent-owner # path → reviewer mapping
78
+ protectedPaths: .gitea/workflows/,agents/ # paths needing human review
79
+ breakingChangePatterns: export interface,export type,BREAKING CHANGE:
80
+ ```
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nandal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # Review Gate for Pi
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-review-gate)](https://www.npmjs.com/package/pi-review-gate)
4
+ [![license](https://img.shields.io/npm/l/pi-review-gate)](./LICENSE)
5
+
6
+ > CI-level merge guardian for AI agents — required reviewers, breaking change detection, stale PR cleanup, and review automation. **Agents don't `git merge` — CI merges only after all gates pass.**
7
+
8
+ ## Philosophy
9
+
10
+ `pi-contrib-gate` handles the *contribution* side (branch → commit → PR).
11
+ `pi-review-gate` handles the *review* side (check → approve → merge).
12
+
13
+ Core enforcement runs in Gitea Actions — agents **cannot bypass** these gates.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pi install npm:pi-review-gate
19
+ ```
20
+
21
+ ## Tools
22
+
23
+ | Tool | What it does |
24
+ |---|---|
25
+ | `review_check(pr_number)` | Check PR merge readiness — CI, reviewers, breaking changes, staleness |
26
+ | `review_approve(pr_number, comment)` | Submit an approval review |
27
+ | `review_request(pr_number, reviewers)` | Request reviewers by username |
28
+ | `review_status(pr_number)` | Show detailed review + CI status |
29
+ | `review_close_stale(pr_number, reason)` | Close stale PR with comment |
30
+
31
+ ## Safety Intercepts
32
+
33
+ The gate **passively monitors** all `bash` tool calls and:
34
+
35
+ - ⛔ Blocks `git merge` — directs agents to use review tools instead
36
+ - ⚠️ Warns on `gh pr close` or branch deletion
37
+
38
+ ## Review Gates (CI-enforced)
39
+
40
+ These run in Gitea Actions — agents cannot skip them:
41
+
42
+ | Gate | Config | Description |
43
+ |---|---|---|
44
+ | CI green | `quality.*` in .contribrc.yml | lint, test, build, doctor audit |
45
+ | Required reviewers | `requiredReviewers.*` in .reviewrc.yml | Path-based required reviewer mapping |
46
+ | Breaking change detection | `breakingChangePatterns` | API surface changes flagged for human review |
47
+ | Stale PR closure | `staleDays` | Auto-close PRs inactive past threshold |
48
+ | Protected paths | `protectedPaths` | Modifications to CI, agents, Docker → human review |
49
+ | Model diversity | `minDiverseReviews` | Different model families must review (from #42) |
50
+
51
+ ## Configuration
52
+
53
+ Create `.reviewrc.yml` in your project root:
54
+
55
+ ```yaml
56
+ # Required reviewers by path pattern
57
+ requiredReviewers.factory/**: factory-admin
58
+ requiredReviewers.agents/**: agent-owner
59
+ requiredReviewers..gitea/**: ci-admin
60
+
61
+ # Stale PR threshold (days)
62
+ staleDays: 14
63
+
64
+ # Minimum distinct model families that must review (0 = disabled)
65
+ minDiverseReviews: 1
66
+
67
+ # Paths that always require human review
68
+ protectedPaths: .gitea/workflows/,docker-compose.yml,Dockerfile,agents/,factory/package.json
69
+
70
+ # Patterns that signal API-breaking changes
71
+ breakingChangePatterns: export interface,export type,export function,export class,BREAKING CHANGE:
72
+ ```
73
+
74
+ > Tip: Start with `staleDays: 14` and `minDiverseReviews: 0` during adoption, then tighten.
75
+
76
+ ## Workflow
77
+
78
+ ```
79
+ contrib_submit() from pi-contrib-gate
80
+
81
+
82
+ PR opened on Gitea
83
+
84
+
85
+ CI runs (lint, test, build, doctor audit)
86
+
87
+
88
+ review_check(pr_number) ← agent checks readiness
89
+
90
+
91
+ review_request(pr_number, ...) ← request specific reviewers
92
+
93
+
94
+ [reviewer approves]
95
+
96
+
97
+ review_check(pr_number) ← re-check: all green? required reviewers satisfied?
98
+
99
+
100
+ CI auto-merges (squash) ← only CI can merge
101
+
102
+
103
+ PR closed, branch deleted
104
+ ```
105
+
106
+ ## Stale PR Cleanup
107
+
108
+ PRs older than `staleDays` (default 14) are flagged. A CI scheduled job can auto-close them:
109
+
110
+ ```bash
111
+ # Runs nightly in Gitea Actions
112
+ review-gate-stale.sh --repo factory/wrok.in --days 14
113
+ ```
114
+
115
+ ## Breaking Change Detection
116
+
117
+ Every CI run diffs against the base branch and scans for:
118
+ - `export interface` / `export type` / `export function` / `export class` additions
119
+ - `BREAKING CHANGE:` in commit messages
120
+
121
+ If detected, CI adds a `⚠️ breaking-change` label and requires human review.
122
+
123
+ ## Integration with pi-contrib-gate
124
+
125
+ The two gates work together:
126
+
127
+ ```
128
+ pi-contrib-gate pi-review-gate
129
+ │ │
130
+ │ contrib_start_work │
131
+ │ contrib_propose │
132
+ │ contrib_submit ────→ PR created
133
+ │ │
134
+ │ │ review_check
135
+ │ │ review_request
136
+ │ │ review_approve
137
+ │ │ [CI enforcement]
138
+ │ │ auto-merge (squash)
139
+ ```
140
+
141
+ Install both for full agent governance:
142
+
143
+ ```bash
144
+ pi install npm:pi-contrib-gate
145
+ pi install npm:pi-review-gate
146
+ ```
147
+
148
+ ## License
149
+
150
+ MIT © [nandal](https://github.com/nandal)
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@bytesbrains/pi-review-gate",
3
+ "version": "1.1.2",
4
+ "description": "CI-level merge guardian for AI agents \u2014 required reviewers, breaking change detection, stale PR cleanup, and review automation.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "governance",
9
+ "ci",
10
+ "code-review",
11
+ "merge-gate",
12
+ "breaking-changes"
13
+ ],
14
+ "author": "nandal <nandal@users.noreply.github.com>",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/nandal/pi-ext",
18
+ "directory": "review-gate"
19
+ },
20
+ "homepage": "https://github.com/nandal/pi-ext/tree/main/review-gate",
21
+ "bugs": {
22
+ "url": "https://github.com/nandal/pi-ext/issues"
23
+ },
24
+ "license": "MIT",
25
+ "main": "./src/index.ts",
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "files": [
30
+ "src/",
31
+ "README.md",
32
+ "AGENTS.md",
33
+ "LICENSE"
34
+ ],
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "typebox": "*"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./src/index.ts"
42
+ ]
43
+ },
44
+ "scripts": {
45
+ "test": "vitest run",
46
+ "test:watch": "vitest"
47
+ },
48
+ "devDependencies": {
49
+ "vitest": "^2.1.9"
50
+ }
51
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { loadConfig, DEFAULT_CONFIG } from "../config";
3
+ import { detectBreakingChanges, isStale, matchRequiredReviewers } from "../validate";
4
+ import { exec } from "../helpers";
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import * as os from "node:os";
8
+
9
+ // ═══════════════════════════════════════
10
+ // Config
11
+ // ═══════════════════════════════════════
12
+ describe("ReviewConfig", () => {
13
+ it("returns defaults when no config", () => {
14
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "review-test-"));
15
+ const config = loadConfig(tmp);
16
+ expect(config.staleDays).toBe(14);
17
+ expect(config.minDiverseReviews).toBe(1);
18
+ expect(config.protectedPaths.length).toBeGreaterThan(0);
19
+ expect(config.breakingChangePatterns.length).toBeGreaterThan(0);
20
+ fs.rmSync(tmp, { recursive: true, force: true });
21
+ });
22
+
23
+ it("parses reviewrc.yml", () => {
24
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "review-test-"));
25
+ fs.writeFileSync(path.join(tmp, ".reviewrc.yml"), [
26
+ "staleDays: 7",
27
+ "minDiverseReviews: 2",
28
+ "protectedPaths: .gitea/,agents/",
29
+ 'requiredReviewers.factory/**: factory-admin',
30
+ 'requiredReviewers.agents/**: agent-owner,reviewer',
31
+ ].join("\n"));
32
+ const config = loadConfig(tmp);
33
+ expect(config.staleDays).toBe(7);
34
+ expect(config.minDiverseReviews).toBe(2);
35
+ expect(config.protectedPaths).toContain(".gitea/");
36
+ expect(config.protectedPaths).toContain("agents/");
37
+ expect(config.requiredReviewers["factory/**"]).toEqual(["factory-admin"]);
38
+ expect(config.requiredReviewers["agents/**"]).toEqual(["agent-owner", "reviewer"]);
39
+ fs.rmSync(tmp, { recursive: true, force: true });
40
+ });
41
+ });
42
+
43
+ // ═══════════════════════════════════════
44
+ // Stale detection
45
+ // ═══════════════════════════════════════
46
+ describe("isStale", () => {
47
+ it("detects stale PR", () => {
48
+ const oldDate = new Date(Date.now() - 20 * 86400000).toISOString();
49
+ expect(isStale(oldDate, 14)).toBe(true);
50
+ });
51
+
52
+ it("detects fresh PR", () => {
53
+ const recentDate = new Date(Date.now() - 5 * 86400000).toISOString();
54
+ expect(isStale(recentDate, 14)).toBe(false);
55
+ });
56
+
57
+ it("exactly at boundary is fresh", () => {
58
+ const boundaryDate = new Date(Date.now() - 14 * 86400000 + 1000).toISOString();
59
+ expect(isStale(boundaryDate, 14)).toBe(false);
60
+ });
61
+ });
62
+
63
+ // ═══════════════════════════════════════
64
+ // Required reviewers
65
+ // ═══════════════════════════════════════
66
+ describe("matchRequiredReviewers", () => {
67
+ const config = {
68
+ ...DEFAULT_CONFIG,
69
+ requiredReviewers: {
70
+ "factory/": ["backend-dev"],
71
+ "agents/": ["agent-owner"],
72
+ ".gitea/": ["ci-admin"],
73
+ "Dockerfile": ["devops"],
74
+ },
75
+ };
76
+
77
+ it("matches by directory prefix", () => {
78
+ expect(matchRequiredReviewers(["factory/orchestrator.ts"], config)).toContain("backend-dev");
79
+ });
80
+
81
+ it("matches multiple patterns", () => {
82
+ const reviewers = matchRequiredReviewers(["factory/orchestrator.ts", "agents/developer.md"], config);
83
+ expect(reviewers).toContain("backend-dev");
84
+ expect(reviewers).toContain("agent-owner");
85
+ });
86
+
87
+ it("matches exact file", () => {
88
+ expect(matchRequiredReviewers(["Dockerfile"], config)).toContain("devops");
89
+ });
90
+
91
+ it("returns empty for unmatched paths", () => {
92
+ expect(matchRequiredReviewers(["README.md", "src/app.ts"], config)).toEqual([]);
93
+ });
94
+ });
95
+
96
+ // ═══════════════════════════════════════
97
+ // Breaking change detection
98
+ // ═══════════════════════════════════════
99
+ describe("detectBreakingChanges", () => {
100
+ const config = {
101
+ ...DEFAULT_CONFIG,
102
+ breakingChangePatterns: ["export interface", "export type", "BREAKING CHANGE:"],
103
+ };
104
+
105
+ it("returns no breaking changes for empty diff", () => {
106
+ // In a test env without a real diff, should return false
107
+ const result = detectBreakingChanges("HEAD", config, process.cwd());
108
+ expect(typeof result.hasBreaking).toBe("boolean");
109
+ });
110
+ });
111
+
112
+ // ═══════════════════════════════════════
113
+ // Helpers
114
+ // ═══════════════════════════════════════
115
+ describe("exec helper", () => {
116
+ it("returns ok for valid command", () => {
117
+ const r = exec("echo test");
118
+ expect(r.ok).toBe(true);
119
+ expect(r.stdout).toBe("test");
120
+ });
121
+
122
+ it("returns not ok for invalid command", () => {
123
+ const r = exec("nonexistent-cmd-xyz 2>/dev/null");
124
+ expect(r.ok).toBe(false);
125
+ });
126
+ });
package/src/config.ts ADDED
@@ -0,0 +1,50 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface ReviewConfig {
5
+ minDiverseReviews: number;
6
+ requiredReviewers: Record<string, string[]>;
7
+ staleDays: number;
8
+ protectedPaths: string[];
9
+ breakingChangePatterns: string[];
10
+ }
11
+
12
+ export const DEFAULT_CONFIG: ReviewConfig = {
13
+ minDiverseReviews: 1,
14
+ requiredReviewers: {},
15
+ staleDays: 14,
16
+ protectedPaths: [".gitea/workflows/", "docker-compose.yml", "Dockerfile", ".env.example", "agents/", "factory/package.json"],
17
+ breakingChangePatterns: ["export interface", "export type", "export function", "export class", "BREAKING CHANGE:"],
18
+ };
19
+
20
+ export function loadConfig(cwd: string): ReviewConfig {
21
+ const configPath = path.join(cwd, ".reviewrc.yml");
22
+ if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
23
+ try {
24
+ const content = fs.readFileSync(configPath, "utf-8");
25
+ const result: Record<string, unknown> = {};
26
+ for (const line of content.split("\n")) {
27
+ const m = line.match(/^\s*([\w][\w.*\/-]+):\s*(.+)$/);
28
+ if (m) {
29
+ let val = m[2].trim();
30
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
31
+ result[m[1]] = val;
32
+ }
33
+ }
34
+ const reviewers: Record<string, string[]> = {};
35
+ for (const [key, val] of Object.entries(result)) {
36
+ if (key.startsWith("requiredReviewers.")) {
37
+ reviewers[key.replace("requiredReviewers.", "")] = (val as string).split(",").map(s => s.trim()).filter(Boolean);
38
+ }
39
+ }
40
+ return {
41
+ minDiverseReviews: parseInt(result["minDiverseReviews"] as string) || DEFAULT_CONFIG.minDiverseReviews,
42
+ requiredReviewers: reviewers,
43
+ staleDays: parseInt(result["staleDays"] as string) || DEFAULT_CONFIG.staleDays,
44
+ protectedPaths: (result["protectedPaths"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.protectedPaths,
45
+ breakingChangePatterns: (result["breakingChangePatterns"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.breakingChangePatterns,
46
+ };
47
+ } catch {
48
+ return { ...DEFAULT_CONFIG };
49
+ }
50
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,46 @@
1
+ import * as cp from "node:child_process";
2
+
3
+ export function exec(cmd: string, cwd?: string): { ok: boolean; stdout: string; stderr: string } {
4
+ try {
5
+ const r = cp.execSync(cmd, { cwd, encoding: "utf-8", timeout: 30000 });
6
+ return { ok: true, stdout: r.trim(), stderr: "" };
7
+ } catch (e: any) {
8
+ return { ok: false, stdout: e.stdout?.trim() || "", stderr: e.stderr?.trim() || e.message };
9
+ }
10
+ }
11
+
12
+ export function currentBranch(cwd: string): string {
13
+ return exec("git branch --show-current", cwd).stdout;
14
+ }
15
+
16
+ export function resolveGitea(cwd: string): { repo: string; token: string } {
17
+ const remote = exec("git remote get-url gitea 2>/dev/null || git remote get-url origin", cwd);
18
+ const url = remote.stdout || "";
19
+ const match = url.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
20
+ const repo = match ? `${match[1]}/${match[2]}` : "factory/wrok.in";
21
+ const credMatch = url.match(/:\/\/([^:]+):([^@]+)@/);
22
+ const token = credMatch ? credMatch[2] : "";
23
+ return { repo, token };
24
+ }
25
+
26
+ export async function giteaApi(path: string, method: string, body: Record<string, unknown> | null, opts: { repo: string; token?: string }, _cwd: string): Promise<{ ok: boolean; data: unknown; error?: string }> {
27
+ const base = `http://127.0.0.1:3001/api/v1/repos/${opts.repo}`;
28
+ const url = `${base}${path}`;
29
+ const headers: Record<string, string> = { "Content-Type": "application/json", "Accept": "application/json" };
30
+ if (opts.token) headers["Authorization"] = `token ${opts.token}`;
31
+
32
+ try {
33
+ const res = await fetch(url, {
34
+ method,
35
+ headers,
36
+ body: body ? JSON.stringify(body) : undefined,
37
+ });
38
+ const text = await res.text();
39
+ if (!res.ok) return { ok: false, data: null, error: text || `HTTP ${res.status}` };
40
+ try { return { ok: true, data: JSON.parse(text) }; } catch { return { ok: true, data: text }; }
41
+ } catch (e: any) {
42
+ return { ok: false, data: null, error: e.message || "Network error" };
43
+ }
44
+ }
45
+
46
+ export interface CommitEntry { hash: string; type: string; scope: string; subject: string; body: string; }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * pi-review-gate — CI-Level Merge Guardian
3
+ *
4
+ * Tools: review_check, review_approve, review_request, review_status, review_close_stale
5
+ * Config: .reviewrc.yml
6
+ */
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { interceptToolCall } from "./intercepts";
9
+ import { checkTool } from "./tools/check";
10
+ import { approveTool, requestTool, statusTool, closeStaleTool } from "./tools/review";
11
+
12
+ export default function (pi: ExtensionAPI) {
13
+ pi.on("tool_call", interceptToolCall);
14
+ pi.registerTool(checkTool);
15
+ pi.registerTool(approveTool);
16
+ pi.registerTool(requestTool);
17
+ pi.registerTool(statusTool);
18
+ pi.registerTool(closeStaleTool);
19
+ }
@@ -0,0 +1,14 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export async function interceptToolCall(event: any, ctx: ExtensionContext) {
4
+ if (event.toolName !== "bash" || typeof event.input.command !== "string") return;
5
+ const cmd = event.input.command;
6
+
7
+ if (/\bgit\s+merge\b/.test(cmd)) {
8
+ const ok = await ctx.ui.confirm("Manual merge blocked", `Direct git merge bypasses review gates.\n\nCommand: ${cmd}\n\nUse review tools instead.\n\nAllow anyway?`);
9
+ if (!ok) return { block: true, reason: "Use review tools. CI performs the merge after all gates pass." };
10
+ }
11
+ if (/\bgh\s+pr\s+close\b/.test(cmd) || /\bgit\s+push\b.*--delete/.test(cmd)) {
12
+ ctx.ui.notify("Consider using review_close_stale() for PR lifecycle management.", "warning");
13
+ }
14
+ }
@@ -0,0 +1,81 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "../config";
4
+ import { exec, currentBranch, resolveGitea, giteaApi } from "../helpers";
5
+ import { detectBreakingChanges, isStale, matchRequiredReviewers } from "../validate";
6
+
7
+ export const checkTool = {
8
+ name: "review_check" as const,
9
+ label: "Check PR Merge Readiness",
10
+ description: "Check if a PR meets all merge requirements: CI green, reviewers, no breaking changes, not stale.",
11
+ parameters: Type.Object({
12
+ pr_number: Type.Optional(Type.String({})),
13
+ base: Type.Optional(Type.String({})),
14
+ }),
15
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
16
+ const config = loadConfig(ctx.cwd);
17
+ const opts = resolveGitea(ctx.cwd);
18
+ const base = params.base || "dev";
19
+ let prNumber = params.pr_number;
20
+ let pr: Record<string, unknown> | null = null;
21
+
22
+ if (prNumber) {
23
+ const r = await giteaApi(`/pulls/${prNumber}`, "GET", null, opts, ctx.cwd);
24
+ if (r.ok) pr = r.data as Record<string, unknown>;
25
+ } else {
26
+ const branch = currentBranch(ctx.cwd);
27
+ const r = await giteaApi(`/pulls?state=open&head=${branch}`, "GET", null, opts, ctx.cwd);
28
+ if (r.ok && Array.isArray(r.data) && r.data.length > 0) {
29
+ pr = r.data[0] as Record<string, unknown>;
30
+ prNumber = String(pr.number);
31
+ }
32
+ }
33
+ if (!pr) return { content: [{ type: "text", text: "No open PR found." }], isError: true, details: {} };
34
+
35
+ const lines: string[] = [];
36
+ const issues: string[] = [];
37
+ let ready = true;
38
+
39
+ lines.push(`📋 PR #${prNumber} Merge Readiness`);
40
+ lines.push(` Title: ${pr.title || "?"}`);
41
+ lines.push(` State: ${pr.state}`);
42
+
43
+ const reviewsR = await giteaApi(`/pulls/${prNumber}/reviews`, "GET", null, opts, ctx.cwd);
44
+ const reviews = Array.isArray(reviewsR.data) ? reviewsR.data : [];
45
+ const changeRequests = reviews.filter((r: any) => r.state === "REQUEST_CHANGES");
46
+ if (changeRequests.length > 0) { issues.push(`❌ ${changeRequests.length} reviewer(s) requested changes`); ready = false; }
47
+ lines.push(` Reviews: ${reviews.filter((r: any) => r.state === "APPROVED").length} approved, ${changeRequests.length} changes requested`);
48
+
49
+ const diff = exec(`git diff ${base}...HEAD --name-only 2>/dev/null`, ctx.cwd);
50
+ const changedFiles = diff.ok ? diff.stdout.split("\n").filter(Boolean) : [];
51
+ const required = matchRequiredReviewers(changedFiles, config);
52
+ if (required.length > 0) {
53
+ const reviewerLogins = new Set(reviews.map((r: any) => r.user?.login));
54
+ const missing = required.filter(r => !reviewerLogins.has(r));
55
+ if (missing.length > 0) issues.push(`⚠️ Missing required reviewers: ${missing.join(", ")}`);
56
+ lines.push(` Required reviewers: ${required.join(", ")}`);
57
+ }
58
+
59
+ const breaking = detectBreakingChanges(base, config, ctx.cwd);
60
+ if (breaking.hasBreaking) { issues.push("⚠️ Potential breaking changes detected"); for (const c of breaking.changes) issues.push(` - ${c}`); }
61
+ lines.push(` Breaking changes: ${breaking.hasBreaking ? "⚠️ detected" : "✅ none"}`);
62
+
63
+ if (pr.created_at) {
64
+ const stale = isStale(pr.created_at as string, config.staleDays);
65
+ if (stale) issues.push(`⚠️ PR is ${config.staleDays}+ days old`);
66
+ lines.push(` Age: ${stale ? "⚠️ stale" : "✅ fresh"}`);
67
+ }
68
+
69
+ const hasProtected = changedFiles.some(f => config.protectedPaths.some(p => f.startsWith(p)));
70
+ if (hasProtected) issues.push("🔒 Protected paths modified — human review required");
71
+ lines.push(` Protected paths: ${hasProtected ? "🔒 yes" : "✅ none"}`);
72
+
73
+ const hasBlockers = issues.some(i => i.startsWith("❌") || i.startsWith("🔒"));
74
+ if (issues.length > 0) { lines.push(""); for (const i of issues) lines.push(` ${i}`); }
75
+ if (hasBlockers) lines.push("", "❌ PR is NOT ready.");
76
+ else if (issues.length === 0) lines.push("", "✅ PR is ready to merge!");
77
+ else lines.push("", "⚠️ PR has warnings but can be merged.");
78
+
79
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { prNumber, ready: !hasBlockers } };
80
+ },
81
+ };
@@ -0,0 +1,101 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { currentBranch, resolveGitea, giteaApi } from "../helpers";
4
+
5
+ export const approveTool = {
6
+ name: "review_approve" as const,
7
+ label: "Approve PR",
8
+ description: "Submit an approval review on a pull request.",
9
+ parameters: Type.Object({
10
+ pr_number: Type.Optional(Type.String({})),
11
+ comment: Type.Optional(Type.String({})),
12
+ }),
13
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
14
+ const opts = resolveGitea(ctx.cwd);
15
+ let prNumber = params.pr_number;
16
+ if (!prNumber) {
17
+ const branch = currentBranch(ctx.cwd);
18
+ const r = await giteaApi(`/pulls?state=open&head=${branch}`, "GET", null, opts, ctx.cwd);
19
+ if (r.ok && Array.isArray(r.data) && r.data.length > 0) prNumber = String((r.data[0] as any).number);
20
+ }
21
+ if (!prNumber) return { content: [{ type: "text", text: "No open PR found." }], isError: true, details: {} };
22
+ const body: Record<string, unknown> = { event: "APPROVE" };
23
+ if (params.comment) body.body = params.comment;
24
+ const r = await giteaApi(`/pulls/${prNumber}/reviews`, "POST", body, opts, ctx.cwd);
25
+ if (!r.ok) return { content: [{ type: "text", text: `Approval failed: ${r.error}` }], isError: true, details: {} };
26
+ return { content: [{ type: "text", text: `✅ PR #${prNumber} approved. CI will auto-merge once all checks pass.` }], details: { prNumber } };
27
+ },
28
+ };
29
+
30
+ export const requestTool = {
31
+ name: "review_request" as const,
32
+ label: "Request Reviewers",
33
+ description: "Request specific reviewers on a pull request.",
34
+ parameters: Type.Object({
35
+ pr_number: Type.Optional(Type.String({})),
36
+ reviewers: Type.String({}),
37
+ }),
38
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
39
+ const opts = resolveGitea(ctx.cwd);
40
+ let prNumber = params.pr_number;
41
+ if (!prNumber) {
42
+ const branch = currentBranch(ctx.cwd);
43
+ const r = await giteaApi(`/pulls?state=open&head=${branch}`, "GET", null, opts, ctx.cwd);
44
+ if (r.ok && Array.isArray(r.data) && r.data.length > 0) prNumber = String((r.data[0] as any).number);
45
+ }
46
+ if (!prNumber) return { content: [{ type: "text", text: "No open PR found." }], isError: true, details: {} };
47
+ const reviewerList = params.reviewers.split(",").map((s: string) => s.trim()).filter(Boolean);
48
+ if (reviewerList.length === 0) return { content: [{ type: "text", text: "No valid reviewers." }], isError: true, details: {} };
49
+ const r = await giteaApi(`/pulls/${prNumber}/requested_reviewers`, "POST", { reviewers: reviewerList }, opts, ctx.cwd);
50
+ if (!r.ok) return { content: [{ type: "text", text: `Failed: ${r.error}` }], isError: true, details: {} };
51
+ return { content: [{ type: "text", text: `✅ Reviewers requested on PR #${prNumber}: ${reviewerList.join(", ")}` }], details: { prNumber } };
52
+ },
53
+ };
54
+
55
+ export const statusTool = {
56
+ name: "review_status" as const,
57
+ label: "Review Status",
58
+ description: "Show detailed review and CI status for a PR.",
59
+ parameters: Type.Object({ pr_number: Type.Optional(Type.String({})) }),
60
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
61
+ const opts = resolveGitea(ctx.cwd);
62
+ let prNumber = params.pr_number;
63
+ if (!prNumber) {
64
+ const branch = currentBranch(ctx.cwd);
65
+ const r = await giteaApi(`/pulls?state=open&head=${branch}`, "GET", null, opts, ctx.cwd);
66
+ if (r.ok && Array.isArray(r.data) && r.data.length > 0) prNumber = String((r.data[0] as any).number);
67
+ }
68
+ if (!prNumber) return { content: [{ type: "text", text: "No open PR found." }], isError: true, details: {} };
69
+ const prR = await giteaApi(`/pulls/${prNumber}`, "GET", null, opts, ctx.cwd);
70
+ if (!prR.ok) return { content: [{ type: "text", text: `PR #${prNumber} not found.` }], isError: true, details: {} };
71
+ const pr = prR.data as Record<string, unknown>;
72
+ const reviewsR = await giteaApi(`/pulls/${prNumber}/reviews`, "GET", null, opts, ctx.cwd);
73
+ const reviews = Array.isArray(reviewsR.data) ? reviewsR.data : [];
74
+ const lines = [`📋 PR #${prNumber} — ${pr.title}`, ` State: ${pr.state} | Draft: ${pr.draft ? "yes" : "no"}`, ` Author: ${(pr.user as any)?.login}`, ` Branch: ${(pr as any).head?.label || "?"} → ${(pr as any).base?.label || "?"}`, ` URL: ${pr.html_url}`, "", `👥 Reviews (${reviews.length}):`];
75
+ for (const r of reviews as any[]) {
76
+ const icon = r.state === "APPROVED" ? "✅" : r.state === "REQUEST_CHANGES" ? "❌" : "💬";
77
+ lines.push(` ${icon} ${r.user?.login} — ${r.state}`);
78
+ }
79
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { prNumber } };
80
+ },
81
+ };
82
+
83
+ export const closeStaleTool = {
84
+ name: "review_close_stale" as const,
85
+ label: "Close Stale PR",
86
+ description: "Close a stale pull request with a comment.",
87
+ parameters: Type.Object({
88
+ pr_number: Type.String({}),
89
+ reason: Type.Optional(Type.String({})),
90
+ }),
91
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
92
+ const opts = resolveGitea(ctx.cwd);
93
+ const reason = params.reason || "stale";
94
+ const prR = await giteaApi(`/pulls/${params.pr_number}`, "GET", null, opts, ctx.cwd);
95
+ if (!prR.ok) return { content: [{ type: "text", text: `PR #${params.pr_number} not found.` }], isError: true, details: {} };
96
+ await giteaApi(`/issues/${params.pr_number}/comments`, "POST", { body: `🔒 Auto-closed: ${reason}.` }, opts, ctx.cwd);
97
+ const r = await giteaApi(`/pulls/${params.pr_number}`, "PATCH", { state: "closed" }, opts, ctx.cwd);
98
+ if (!r.ok) return { content: [{ type: "text", text: `Failed: ${r.error}` }], isError: true, details: {} };
99
+ return { content: [{ type: "text", text: `🔒 PR #${params.pr_number} closed.` }], details: { prNumber: params.pr_number } };
100
+ },
101
+ };
@@ -0,0 +1,37 @@
1
+ import type { ReviewConfig } from "./config";
2
+ import { exec } from "./helpers";
3
+
4
+ export function detectBreakingChanges(baseBranch: string, config: ReviewConfig, cwd: string): { hasBreaking: boolean; changes: string[] } {
5
+ const changes: string[] = [];
6
+ const diff = exec(`git diff ${baseBranch}...HEAD --diff-filter=M -- "*.ts" "*.tsx" 2>/dev/null`, cwd);
7
+ if (!diff.ok || !diff.stdout) return { hasBreaking: false, changes: [] };
8
+ for (const pattern of config.breakingChangePatterns) {
9
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10
+ const matches = diff.stdout.split("\n").filter(l => l.startsWith("+") && new RegExp(escaped).test(l));
11
+ if (matches.length > 0) changes.push(`Pattern "${pattern}" matched in ${matches.length} added line(s)`);
12
+ }
13
+ const log = exec(`git log ${baseBranch}..HEAD --oneline`, cwd);
14
+ if (log.ok && log.stdout.includes("BREAKING CHANGE")) changes.push("Commit message includes BREAKING CHANGE marker");
15
+ return { hasBreaking: changes.length > 0, changes };
16
+ }
17
+
18
+ export function isStale(prCreatedAt: string, staleDays: number): boolean {
19
+ return (Date.now() - new Date(prCreatedAt).getTime()) / 86400000 > staleDays;
20
+ }
21
+
22
+ export function matchRequiredReviewers(changedFiles: string[], config: ReviewConfig): string[] {
23
+ const matched = new Set<string>();
24
+ for (const [pattern, reviewers] of Object.entries(config.requiredReviewers)) {
25
+ if (changedFiles.some(f => pattern.endsWith("*") ? f.startsWith(pattern.slice(0, -1)) : pattern.endsWith("/") ? f.startsWith(pattern) : f === pattern)) {
26
+ for (const r of reviewers) matched.add(r);
27
+ }
28
+ }
29
+ return [...matched];
30
+ }
31
+
32
+ export function parseDependencies(body: string, pattern: string): string[] {
33
+ const deps = new Set<string>();
34
+ let m; const re = new RegExp(pattern, "gi");
35
+ while ((m = re.exec(body)) !== null) deps.add(m[1]);
36
+ return [...deps];
37
+ }