@bytesbrains/pi-contrib-gate 1.6.1
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 +125 -0
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/package.json +51 -0
- package/src/__tests__/contrib-gate.test.ts +375 -0
- package/src/config.ts +80 -0
- package/src/helpers.ts +331 -0
- package/src/index.ts +51 -0
- package/src/intercepts.ts +153 -0
- package/src/state.ts +9 -0
- package/src/tools/propose.ts +120 -0
- package/src/tools/start_work.ts +242 -0
- package/src/tools/status.ts +67 -0
- package/src/tools/submit.ts +125 -0
- package/src/types.ts +64 -0
- package/src/validate.ts +53 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ContribConfig, BestPracticesConfig } from "./types";
|
|
4
|
+
import { DEFAULT_CONFIG, BEST_PRACTICES_DEFAULTS } from "./types";
|
|
5
|
+
|
|
6
|
+
function parseBool(val: string | undefined, def: boolean): boolean {
|
|
7
|
+
if (val === undefined) return def;
|
|
8
|
+
return val !== "false" && val !== "no" && val !== "0";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function loadBestPractices(result: Record<string, unknown>): BestPracticesConfig {
|
|
12
|
+
const rawGuidance = result["commits.bestPractices.guidanceText"] as string | undefined;
|
|
13
|
+
let guidanceText: string[];
|
|
14
|
+
if (rawGuidance) {
|
|
15
|
+
// Split by | for multi-line YAML block scalars or newline-separated
|
|
16
|
+
guidanceText = rawGuidance
|
|
17
|
+
.split(/\||\n/)
|
|
18
|
+
.map(s => s.trim())
|
|
19
|
+
.filter(s => s.length > 0);
|
|
20
|
+
} else {
|
|
21
|
+
guidanceText = [...BEST_PRACTICES_DEFAULTS.guidanceText];
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
shortFrequentCommits: parseBool(
|
|
25
|
+
result["commits.bestPractices.shortFrequentCommits"] as string | undefined,
|
|
26
|
+
BEST_PRACTICES_DEFAULTS.shortFrequentCommits,
|
|
27
|
+
),
|
|
28
|
+
maxLinesPerCommit:
|
|
29
|
+
parseInt(result["commits.bestPractices.maxLinesPerCommit"] as string) || BEST_PRACTICES_DEFAULTS.maxLinesPerCommit,
|
|
30
|
+
requireAtomic: parseBool(
|
|
31
|
+
result["commits.bestPractices.requireAtomic"] as string | undefined,
|
|
32
|
+
BEST_PRACTICES_DEFAULTS.requireAtomic,
|
|
33
|
+
),
|
|
34
|
+
maxUnrelatedDirs:
|
|
35
|
+
parseInt(result["commits.bestPractices.maxUnrelatedDirs"] as string) || BEST_PRACTICES_DEFAULTS.maxUnrelatedDirs,
|
|
36
|
+
guidanceText,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadConfig(cwd: string): ContribConfig {
|
|
41
|
+
const configPath = path.join(cwd, ".contribrc.yml");
|
|
42
|
+
if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
|
|
43
|
+
try {
|
|
44
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
45
|
+
const result: Record<string, unknown> = {};
|
|
46
|
+
for (const line of content.split("\n")) {
|
|
47
|
+
const m = line.match(/^\s*(\w[\w.]*):\s*(.+)$/);
|
|
48
|
+
if (m) {
|
|
49
|
+
let val = m[2].trim();
|
|
50
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
51
|
+
val = val.slice(1, -1);
|
|
52
|
+
}
|
|
53
|
+
result[m[1]] = val;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
branches: {
|
|
58
|
+
featPattern: (result["branches.featPattern"] as string) || DEFAULT_CONFIG.branches.featPattern,
|
|
59
|
+
fixPattern: (result["branches.fixPattern"] as string) || DEFAULT_CONFIG.branches.fixPattern,
|
|
60
|
+
chorePattern: (result["branches.chorePattern"] as string) || DEFAULT_CONFIG.branches.chorePattern,
|
|
61
|
+
},
|
|
62
|
+
commits: {
|
|
63
|
+
convention: ((result["commits.convention"] as string) || DEFAULT_CONFIG.commits.convention) as "conventional" | "simple",
|
|
64
|
+
maxSubjectLength: parseInt(result["commits.maxSubjectLength"] as string) || DEFAULT_CONFIG.commits.maxSubjectLength,
|
|
65
|
+
scopes: (result["commits.scopes"] as string)?.split(",").map(s => s.trim()) || [],
|
|
66
|
+
bestPractices: loadBestPractices(result),
|
|
67
|
+
},
|
|
68
|
+
quality: {
|
|
69
|
+
lint: result["quality.lint"] !== "false",
|
|
70
|
+
typeCheck: result["quality.typeCheck"] !== "false",
|
|
71
|
+
doctorAudit: result["quality.doctorAudit"] !== "false",
|
|
72
|
+
maxFilesChanged: parseInt(result["quality.maxFilesChanged"] as string) || DEFAULT_CONFIG.quality.maxFilesChanged,
|
|
73
|
+
maxLinesAdded: parseInt(result["quality.maxLinesAdded"] as string) || DEFAULT_CONFIG.quality.maxLinesAdded,
|
|
74
|
+
},
|
|
75
|
+
requireIssueValidation: parseBool(result["requireIssueValidation"] as string, DEFAULT_CONFIG.requireIssueValidation),
|
|
76
|
+
};
|
|
77
|
+
} catch {
|
|
78
|
+
return { ...DEFAULT_CONFIG };
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import * as cp from "node:child_process";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export function exec(cmd: string, cwd?: string): { ok: boolean; stdout: string; stderr: string } {
|
|
5
|
+
try {
|
|
6
|
+
const r = cp.execSync(cmd, { cwd, encoding: "utf-8", timeout: 30000 });
|
|
7
|
+
return { ok: true, stdout: r.trim(), stderr: "" };
|
|
8
|
+
} catch (e: any) {
|
|
9
|
+
return { ok: false, stdout: e.stdout?.trim() || "", stderr: e.stderr?.trim() || e.message };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function currentBranch(cwd: string): string {
|
|
14
|
+
return exec("git branch --show-current", cwd).stdout;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract a Gitea issue ID from a branch name.
|
|
19
|
+
* Supports: feat/issue-42, fix/issue-7, chore/issue-99, or bare issue-42.
|
|
20
|
+
*/
|
|
21
|
+
export function extractIssueFromBranch(branch: string): string | null {
|
|
22
|
+
const match = branch.match(/^(?:(?:feat|fix|chore)\/)?issue-(\d+)$/);
|
|
23
|
+
return match ? match[1] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the current linked issue ID from session state or branch name.
|
|
28
|
+
*/
|
|
29
|
+
export function getLinkedIssueId(cwd: string): string | null {
|
|
30
|
+
return (globalThis as any).__contrib_issueId || extractIssueFromBranch(currentBranch(cwd)) || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isClean(cwd: string): boolean {
|
|
34
|
+
const r = exec("git status --porcelain", cwd);
|
|
35
|
+
return r.ok && r.stdout === "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True if git merge is in progress (MERGE_HEAD exists). */
|
|
39
|
+
export function isMergeInProgress(cwd: string): boolean {
|
|
40
|
+
const r = exec("git rev-parse --verify MERGE_HEAD 2>/dev/null", cwd);
|
|
41
|
+
return r.ok;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True if git rebase is in progress. */
|
|
45
|
+
export function isRebaseInProgress(cwd: string): boolean {
|
|
46
|
+
const merge = exec("test -d .git/rebase-merge && echo yes || echo no", cwd);
|
|
47
|
+
const apply = exec("test -d .git/rebase-apply && echo yes || echo no", cwd);
|
|
48
|
+
return merge.stdout === "yes" || apply.stdout === "yes";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True if any conflict is in progress (merge or rebase). */
|
|
52
|
+
export function isConflictInProgress(cwd: string): boolean {
|
|
53
|
+
return isMergeInProgress(cwd) || isRebaseInProgress(cwd);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Scan staged changes for unresolved conflict markers.
|
|
58
|
+
* Returns list of files containing <<<<<<< or >>>>>>> markers.
|
|
59
|
+
*/
|
|
60
|
+
export function scanForConflictMarkers(cwd: string): string[] {
|
|
61
|
+
const diff = exec(
|
|
62
|
+
"git diff --cached -U0 2>/dev/null | grep -nE '^\\+<<<<<<< |^\\+>>>>>>> ' || true",
|
|
63
|
+
cwd,
|
|
64
|
+
);
|
|
65
|
+
if (!diff.stdout) return [];
|
|
66
|
+
|
|
67
|
+
// Also check the working tree for any unstaged conflict markers (safety net)
|
|
68
|
+
const staged = exec("git diff --cached --name-only 2>/dev/null", cwd);
|
|
69
|
+
const stagedFiles = staged.ok ? staged.stdout.split("\n").filter(Boolean) : [];
|
|
70
|
+
|
|
71
|
+
const conflicts: string[] = [];
|
|
72
|
+
for (const file of stagedFiles) {
|
|
73
|
+
const r = exec(
|
|
74
|
+
`git show :0:${file} 2>/dev/null | grep -nE '<<<<<<< |>>>>>>> ' || true`,
|
|
75
|
+
cwd,
|
|
76
|
+
);
|
|
77
|
+
if (r.stdout) conflicts.push(file);
|
|
78
|
+
}
|
|
79
|
+
return conflicts;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function hasUnpushed(cwd: string): boolean {
|
|
83
|
+
const branch = currentBranch(cwd);
|
|
84
|
+
const r = exec(`git log origin/${branch}..HEAD --oneline 2>/dev/null || git log gitea/${branch}..HEAD --oneline 2>/dev/null || echo ""`, cwd);
|
|
85
|
+
return r.ok && r.stdout.length > 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the list of staged files and their change counts.
|
|
90
|
+
* Returns { files, linesAdded }.
|
|
91
|
+
*/
|
|
92
|
+
export function getStagedStats(cwd: string): { files: string[]; linesAdded: number } {
|
|
93
|
+
const diff = exec("git diff --cached --name-only", cwd);
|
|
94
|
+
const files = diff.ok ? diff.stdout.split("\n").filter(Boolean) : [];
|
|
95
|
+
const loc = exec("git diff --cached --numstat | awk '{s+=$1} END {print s}'", cwd);
|
|
96
|
+
const linesAdded = loc.ok ? parseInt(loc.stdout) || 0 : 0;
|
|
97
|
+
return { files, linesAdded };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Escape a string for safe interpolation into a shell command.
|
|
102
|
+
* Wraps value in single quotes after escaping embedded single quotes.
|
|
103
|
+
*/
|
|
104
|
+
export function shellEscape(value: string): string {
|
|
105
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if the remote branch still exists (not deleted after PR merge).
|
|
110
|
+
*/
|
|
111
|
+
export function remoteBranchExists(
|
|
112
|
+
cwd: string,
|
|
113
|
+
branch?: string,
|
|
114
|
+
): { exists: boolean; remoteName: string } {
|
|
115
|
+
const br = branch || currentBranch(cwd);
|
|
116
|
+
if (!br) return { exists: false, remoteName: "" };
|
|
117
|
+
|
|
118
|
+
for (const remote of ["origin", "gitea"]) {
|
|
119
|
+
const check = exec(`git ls-remote --heads ${remote} ${br}`, cwd);
|
|
120
|
+
if (check.ok && check.stdout.length > 0) {
|
|
121
|
+
return { exists: true, remoteName: remote };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { exists: false, remoteName: "origin" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check PR state for the current branch. Uses gh CLI (GitHub) or Gitea API.
|
|
129
|
+
*/
|
|
130
|
+
export async function checkBranchPRState(
|
|
131
|
+
cwd: string,
|
|
132
|
+
branch?: string,
|
|
133
|
+
): Promise<{ state: "open" | "merged" | "closed"; url: string } | null> {
|
|
134
|
+
const br = branch || currentBranch(cwd);
|
|
135
|
+
if (!br) return null;
|
|
136
|
+
|
|
137
|
+
// Try gh CLI first (GitHub)
|
|
138
|
+
const ghCheck = exec(
|
|
139
|
+
`gh pr list --head ${shellEscape(br)} --state all --json state,url --jq '.[0]'`,
|
|
140
|
+
cwd,
|
|
141
|
+
);
|
|
142
|
+
if (ghCheck.ok && ghCheck.stdout && ghCheck.stdout !== "null") {
|
|
143
|
+
try {
|
|
144
|
+
const data = JSON.parse(ghCheck.stdout);
|
|
145
|
+
const state = data.state as string;
|
|
146
|
+
return {
|
|
147
|
+
state: state === "MERGED" ? "merged" : state === "OPEN" ? "open" : "closed",
|
|
148
|
+
url: data.url || "",
|
|
149
|
+
};
|
|
150
|
+
} catch { /* fall through */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Try Gitea API
|
|
154
|
+
const remote = exec("git remote get-url origin 2>/dev/null || git remote get-url gitea 2>/dev/null", cwd);
|
|
155
|
+
if (!remote.ok) return null;
|
|
156
|
+
const url = remote.stdout;
|
|
157
|
+
|
|
158
|
+
if (url.includes("gitea") || url.includes("127.0.0.1:3001")) {
|
|
159
|
+
const match = url.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
160
|
+
if (!match) return null;
|
|
161
|
+
const apiUrl = `http://127.0.0.1:3001/api/v1/repos/${match[1]}/${match[2]}`;
|
|
162
|
+
const credMatch = url.match(/:\/\/([^:]+):([^@]+)@/);
|
|
163
|
+
const token = credMatch ? credMatch[2] : "";
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
167
|
+
if (token) headers["Authorization"] = `token ${token}`;
|
|
168
|
+
const res = await fetch(`${apiUrl}/pulls?head=${encodeURIComponent(br)}&state=all&limit=1`, { headers });
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
171
|
+
const pr = data[0];
|
|
172
|
+
return {
|
|
173
|
+
state: pr.merged ? "merged" : pr.state === "open" ? "open" : "closed",
|
|
174
|
+
url: pr.html_url || `${apiUrl}/pulls/${pr.number}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
} catch { /* ignore */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Escape a string for safe interpolation into a double-quoted shell context.
|
|
185
|
+
* Escapes: $, `, \, ", !, newlines
|
|
186
|
+
*/
|
|
187
|
+
export function shellEscapeDoubleQuoted(value: string): string {
|
|
188
|
+
return value
|
|
189
|
+
.replace(/\\/g, "\\\\")
|
|
190
|
+
.replace(/\$/g, "\\$")
|
|
191
|
+
.replace(/`/g, "\\`")
|
|
192
|
+
.replace(/"/g, '\\"')
|
|
193
|
+
.replace(/!/g, "\\!")
|
|
194
|
+
.replace(/\n/g, " ");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Count the number of distinct top-level directories touched by a list of files.
|
|
199
|
+
* Used as a heuristic for non-atomic commits (touching many unrelated areas).
|
|
200
|
+
*/
|
|
201
|
+
export function countUnrelatedDirs(files: string[]): number {
|
|
202
|
+
if (files.length === 0) return 0;
|
|
203
|
+
|
|
204
|
+
const dirs = new Set<string>();
|
|
205
|
+
const hasNesting = files.some(f => f.split("/").length >= 3);
|
|
206
|
+
|
|
207
|
+
for (const file of files) {
|
|
208
|
+
const parts = file.split("/");
|
|
209
|
+
if (hasNesting) {
|
|
210
|
+
// For deeply nested paths, use first two segments as directory grouping
|
|
211
|
+
const dir = parts.length >= 2 ? parts.slice(0, 2).join("/") : parts[0];
|
|
212
|
+
dirs.add(dir);
|
|
213
|
+
} else {
|
|
214
|
+
// For flat paths (dir/file.ext), first segment is the directory
|
|
215
|
+
const topDir = parts[0];
|
|
216
|
+
if (topDir && topDir !== ".") {
|
|
217
|
+
dirs.add(topDir);
|
|
218
|
+
} else {
|
|
219
|
+
dirs.add("(root)");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return dirs.size;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function createPR(
|
|
227
|
+
branch: string,
|
|
228
|
+
base: string,
|
|
229
|
+
title: string,
|
|
230
|
+
body: string,
|
|
231
|
+
ctx: ExtensionContext,
|
|
232
|
+
remoteName: string,
|
|
233
|
+
): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
|
|
234
|
+
const remote = exec(`git remote get-url ${remoteName || "origin"}`, ctx.cwd);
|
|
235
|
+
if (!remote.ok) return { ok: false, error: "No git remote found" };
|
|
236
|
+
|
|
237
|
+
const url = remote.stdout;
|
|
238
|
+
let apiUrl = "";
|
|
239
|
+
let token = "";
|
|
240
|
+
|
|
241
|
+
if (url.includes("gitea") || url.includes("127.0.0.1:3001")) {
|
|
242
|
+
const match = url.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
243
|
+
if (!match) return { ok: false, error: `Cannot parse Gitea repo from: ${url}` };
|
|
244
|
+
apiUrl = `http://127.0.0.1:3001/api/v1/repos/${match[1]}/${match[2]}`;
|
|
245
|
+
const credMatch = url.match(/:\/\/([^:]+):([^@]+)@/);
|
|
246
|
+
token = credMatch ? credMatch[2] : "";
|
|
247
|
+
} else if (url.includes("github.com")) {
|
|
248
|
+
// gh CLI handles its own argument quoting — only escape double-quotes and strip newlines
|
|
249
|
+
const safeTitle = title.replace(/"/g, '\\"').replace(/\n/g, " ");
|
|
250
|
+
const safeBody = body.replace(/"/g, '\\"').replace(/\n/g, " ");
|
|
251
|
+
const r = exec(`gh pr create --base ${shellEscape(base)} --head ${shellEscape(branch)} --title "${safeTitle}" --body "${safeBody}"`, ctx.cwd);
|
|
252
|
+
if (r.ok) {
|
|
253
|
+
const lines = r.stdout.split("\n");
|
|
254
|
+
const prUrl = lines.find(l => l.includes("github.com")) || r.stdout;
|
|
255
|
+
return { ok: true, url: prUrl.trim() };
|
|
256
|
+
}
|
|
257
|
+
return { ok: false, error: r.stderr || "gh pr create failed" };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!apiUrl) return { ok: false, error: "Unsupported remote. Use Gitea or GitHub." };
|
|
261
|
+
|
|
262
|
+
// Use fetch() instead of shell-executed curl — avoids token exposure in process lists
|
|
263
|
+
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
|
|
264
|
+
if (token) fetchHeaders["Authorization"] = `token ${token}`;
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const res = await fetch(`${apiUrl}/pulls`, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: fetchHeaders,
|
|
270
|
+
body: JSON.stringify({ title, head: branch, base, body }),
|
|
271
|
+
});
|
|
272
|
+
const text = await res.text();
|
|
273
|
+
if (!res.ok) return { ok: false, error: text || `HTTP ${res.status}` };
|
|
274
|
+
try {
|
|
275
|
+
const data = JSON.parse(text);
|
|
276
|
+
return { ok: true, url: data.html_url || `${apiUrl}/pulls/${data.number}` };
|
|
277
|
+
} catch {
|
|
278
|
+
return { ok: true, url: text };
|
|
279
|
+
}
|
|
280
|
+
} catch (e: any) {
|
|
281
|
+
return { ok: false, error: e.message || "Network error" };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Gitea API Helpers ──────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolve Gitea repo path and token from git remote.
|
|
289
|
+
*/
|
|
290
|
+
export function resolveGitea(cwd: string): { repo: string; token: string } {
|
|
291
|
+
const remote = exec("git remote get-url gitea 2>/dev/null || git remote get-url origin", cwd);
|
|
292
|
+
const url = remote.stdout || "";
|
|
293
|
+
const match = url.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
294
|
+
const repo = match ? `${match[1]}/${match[2]}` : "";
|
|
295
|
+
const credMatch = url.match(/:\/\/([^:]+):([^@]+)@/);
|
|
296
|
+
return { repo, token: credMatch ? credMatch[2] : "" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Call Gitea API.
|
|
301
|
+
*/
|
|
302
|
+
export async function giteaApi(
|
|
303
|
+
path: string,
|
|
304
|
+
method: string,
|
|
305
|
+
body: Record<string, unknown> | null,
|
|
306
|
+
opts: { repo: string; token: string },
|
|
307
|
+
): Promise<{ ok: boolean; data: unknown; error?: string }> {
|
|
308
|
+
const base = `http://127.0.0.1:3001/api/v1/repos/${opts.repo}`;
|
|
309
|
+
const url = `${base}${path}`;
|
|
310
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
311
|
+
if (opts.token) headers["Authorization"] = `token ${opts.token}`;
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const res = await fetch(url, {
|
|
315
|
+
method,
|
|
316
|
+
headers,
|
|
317
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
318
|
+
});
|
|
319
|
+
const text = await res.text();
|
|
320
|
+
if (!res.ok) {
|
|
321
|
+
return { ok: false, data: null, error: text || `HTTP ${res.status}` };
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
return { ok: true, data: JSON.parse(text) };
|
|
325
|
+
} catch {
|
|
326
|
+
return { ok: true, data: text };
|
|
327
|
+
}
|
|
328
|
+
} catch (e: any) {
|
|
329
|
+
return { ok: false, data: null, error: e.message || "Network error" };
|
|
330
|
+
}
|
|
331
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-contrib-gate — AI Agent Contribution Gateway
|
|
3
|
+
*
|
|
4
|
+
* Tools: contrib_start_work, contrib_propose, contrib_submit, contrib_status
|
|
5
|
+
* Config: .contribrc.yml
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { interceptToolCall } from "./intercepts";
|
|
9
|
+
import { startWorkTool } from "./tools/start_work";
|
|
10
|
+
import { proposeTool } from "./tools/propose";
|
|
11
|
+
import { submitTool } from "./tools/submit";
|
|
12
|
+
import { statusTool } from "./tools/status";
|
|
13
|
+
import { getLinkedIssueId, remoteBranchExists, checkBranchPRState, currentBranch } from "./helpers";
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI) {
|
|
16
|
+
pi.on("tool_call", interceptToolCall);
|
|
17
|
+
pi.registerTool(startWorkTool);
|
|
18
|
+
pi.registerTool(proposeTool);
|
|
19
|
+
pi.registerTool(submitTool);
|
|
20
|
+
pi.registerTool(statusTool);
|
|
21
|
+
|
|
22
|
+
// Auto-detect linked issue from branch name on session start/resume
|
|
23
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
24
|
+
const issueId = getLinkedIssueId(ctx.cwd);
|
|
25
|
+
if (issueId && !(globalThis as any).__contrib_issueId) {
|
|
26
|
+
(globalThis as any).__contrib_issueId = issueId;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Detect orphaned branch (remote deleted = PR likely merged)
|
|
30
|
+
const branch = currentBranch(ctx.cwd);
|
|
31
|
+
const isFeatureBranch = /^(feat|fix|chore)\//.test(branch);
|
|
32
|
+
if (isFeatureBranch) {
|
|
33
|
+
const remote = remoteBranchExists(ctx.cwd);
|
|
34
|
+
if (!remote.exists) {
|
|
35
|
+
const pr = await checkBranchPRState(ctx.cwd);
|
|
36
|
+
const prNote = pr
|
|
37
|
+
? `\nPR ${pr.url} was ${pr.state}.`
|
|
38
|
+
: "";
|
|
39
|
+
ctx.ui.notify(
|
|
40
|
+
"⚠️ Orphaned branch detected",
|
|
41
|
+
`Remote branch "${branch}" no longer exists — the PR was likely merged.${prNote}\n\nDo NOT continue work on this branch. Use contrib_start_work(issue_id) to start fresh.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
pi.on("session_shutdown", () => {
|
|
48
|
+
delete (globalThis as any).__contrib_issueId;
|
|
49
|
+
delete (globalThis as any).__contrib_lastHash;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig } from "./config";
|
|
3
|
+
import { exec, isMergeInProgress, isRebaseInProgress, isConflictInProgress, isClean, getLinkedIssueId } from "./helpers";
|
|
4
|
+
import { validateConventionalCommit } from "./validate";
|
|
5
|
+
|
|
6
|
+
const PROTECTED_BRANCHES = ["dev", "main", "master", "production"];
|
|
7
|
+
|
|
8
|
+
function onProtectedBranch(cwd: string): boolean {
|
|
9
|
+
const branch = exec("git branch --show-current", cwd).stdout;
|
|
10
|
+
return PROTECTED_BRANCHES.includes(branch);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function interceptToolCall(event: any, ctx: ExtensionContext) {
|
|
14
|
+
const config = loadConfig(ctx.cwd);
|
|
15
|
+
|
|
16
|
+
// ═══════════════════════════════════════════
|
|
17
|
+
// Block file modifications without a linked issue
|
|
18
|
+
// ═══════════════════════════════════════════
|
|
19
|
+
if (["write", "edit"].includes(event.toolName)) {
|
|
20
|
+
const branch = exec("git branch --show-current", ctx.cwd).stdout;
|
|
21
|
+
|
|
22
|
+
// Protected branches: strict block
|
|
23
|
+
if (PROTECTED_BRANCHES.includes(branch)) {
|
|
24
|
+
return {
|
|
25
|
+
block: true,
|
|
26
|
+
reason: `Cannot modify files directly on "${branch}". Use contrib_start_work(issue_id) to create a feature branch first.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Non-feature, non-protected branch with no linked issue: block early
|
|
31
|
+
const isFeatureBranch = /^(feat|fix|chore)\//.test(branch);
|
|
32
|
+
const issueId = getLinkedIssueId(ctx.cwd);
|
|
33
|
+
|
|
34
|
+
if (!issueId && !isFeatureBranch) {
|
|
35
|
+
return {
|
|
36
|
+
block: true,
|
|
37
|
+
reason: `No Gitea issue linked. Before making changes, link your work to an issue with contrib_start_work(issue_id).\n\nThis creates a properly named branch and links it to an issue so commits and PRs are traceable.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!issueId && isFeatureBranch) {
|
|
42
|
+
// Feature branch but no issue found in branch name or session state.
|
|
43
|
+
// Warn but allow — the agent may be resuming work from a branch created
|
|
44
|
+
// outside the current session.
|
|
45
|
+
ctx.ui.notify(
|
|
46
|
+
"No issue linked",
|
|
47
|
+
`You're on feature branch "${branch}" but no Gitea issue is linked.\nRun contrib_start_work(issue_id) to link an issue before committing.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (event.toolName !== "bash" || typeof event.input.command !== "string") return;
|
|
53
|
+
|
|
54
|
+
const cmd = event.input.command;
|
|
55
|
+
|
|
56
|
+
// Block direct branch creation bypassing contrib_start_work
|
|
57
|
+
if (/\bgit\s+checkout\s+-b\s+/.test(cmd) || /\bgit\s+switch\s+-c\s+/.test(cmd)) {
|
|
58
|
+
const branchMatch = cmd.match(/(?:checkout\s+-b|switch\s+-c)\s+(\S+)/);
|
|
59
|
+
const branchName = branchMatch?.[1] || "";
|
|
60
|
+
const isIssueBranch = /^(feat|fix|chore)\/issue-\d+$/.test(branchName) || /^issue-\d+$/.test(branchName);
|
|
61
|
+
if (isIssueBranch) {
|
|
62
|
+
return {
|
|
63
|
+
block: true,
|
|
64
|
+
reason: `Cannot create issue branch directly. Use contrib_start_work(issue_id) instead.\n\nThis ensures the issue exists on Gitea and is in the correct state before work begins.\n\nBlocked: ${cmd}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (/\bgit\s+push\b/.test(cmd)) {
|
|
70
|
+
const isProtected = /\b(main|master|dev|production)\b/.test(cmd);
|
|
71
|
+
if (isProtected) {
|
|
72
|
+
const ok = await ctx.ui.confirm("Protected branch push blocked", `Push to protected branch detected. Use contrib_submit() instead.\n\nCommand: ${cmd}\n\nAllow anyway?`);
|
|
73
|
+
if (!ok) return { block: true, reason: "Use contrib_submit() to create a PR instead of pushing directly." };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (/\bgit\s+push\s+.*--force/.test(cmd)) {
|
|
78
|
+
const ok = await ctx.ui.confirm("Force push detected", `Force push can overwrite remote history.\n\nCommand: ${cmd}\n\nContinue?`);
|
|
79
|
+
if (!ok) return { block: true, reason: "Force push blocked by user." };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (/\bgit\s+commit\b/.test(cmd)) {
|
|
83
|
+
// Block direct git commit on feature branches — force through contrib_propose
|
|
84
|
+
const branch = exec("git branch --show-current", ctx.cwd).stdout;
|
|
85
|
+
const isFeatureBranch = /^(feat|fix|chore)\//.test(branch);
|
|
86
|
+
if (isFeatureBranch) {
|
|
87
|
+
return {
|
|
88
|
+
block: true,
|
|
89
|
+
reason: `Cannot commit directly on feature branch "${branch}". Use contrib_propose(message) to stage, lint, typecheck, and commit with quality gates.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (config.commits.convention === "conventional") {
|
|
94
|
+
const msgMatch = cmd.match(/-m\s+"([^"]+)"/);
|
|
95
|
+
if (msgMatch) {
|
|
96
|
+
const validation = validateConventionalCommit(msgMatch[1], config);
|
|
97
|
+
if (!validation.ok) ctx.ui.notify(`Conventional commit: ${validation.error}`, "warning");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ═══════════════════════════════════════════
|
|
103
|
+
// Merge conflict safety gates
|
|
104
|
+
// ═══════════════════════════════════════════
|
|
105
|
+
|
|
106
|
+
// Gate: Require clean working tree before rebase/merge
|
|
107
|
+
if (/\bgit\s+(rebase|merge|pull)\b/.test(cmd) && !/\bgit\s+merge\s+--(abort|continue)\b/.test(cmd)) {
|
|
108
|
+
const branch = exec("git branch --show-current", ctx.cwd).stdout;
|
|
109
|
+
const isFeatureBranch = /^(feat|fix|chore)\//.test(branch);
|
|
110
|
+
if (isFeatureBranch && !isClean(ctx.cwd)) {
|
|
111
|
+
return {
|
|
112
|
+
block: true,
|
|
113
|
+
reason: `Working tree is dirty. Stash or commit changes before running:\n ${cmd}\n\nUse contrib_propose(message) to commit first, or git stash to save work temporarily.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Gate: Block destructive checkout in conflict state
|
|
119
|
+
if (/\bgit\s+checkout\s+(--theirs|--ours)\b/.test(cmd)) {
|
|
120
|
+
if (isConflictInProgress(ctx.cwd)) {
|
|
121
|
+
const pattern = /\bgit\s+checkout\s+(--theirs|--ours)\s+(\S+)/.exec(cmd);
|
|
122
|
+
const target = pattern?.[2] || "";
|
|
123
|
+
const isBroad = target === "." || target === "*" || target.includes("*") || target === "";
|
|
124
|
+
if (isBroad) {
|
|
125
|
+
const ok = await ctx.ui.confirm(
|
|
126
|
+
"Destructive conflict resolution blocked",
|
|
127
|
+
`Running \`git checkout ${pattern?.[1] || "--theirs"} ${target || "(all)"}\` will overwrite ALL conflicted files with one side.\n\nThis usually destroys work. Resolve conflicts file-by-file instead.\n\nAllow anyway?`,
|
|
128
|
+
);
|
|
129
|
+
if (!ok) return { block: true, reason: "Resolve conflicts file-by-file instead of blindly accepting one side for all files. Read each conflicted file, understand both sides, then write the correct resolution." };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Gate: Block reset --hard during conflict (too destructive with no undo)
|
|
135
|
+
if (/\bgit\s+reset\s+--hard\b/.test(cmd)) {
|
|
136
|
+
if (isConflictInProgress(ctx.cwd)) {
|
|
137
|
+
const ok = await ctx.ui.confirm(
|
|
138
|
+
"Hard reset during conflict blocked",
|
|
139
|
+
`\`git reset --hard\` during a merge/rebase will destroy ALL in-progress conflict resolution work.\n\nPrefer:\n git merge --abort\n git rebase --abort\n...to cleanly exit without losing in-progress resolution.\n\nContinue with reset --hard?`,
|
|
140
|
+
);
|
|
141
|
+
if (!ok) return { block: true, reason: "Use git merge --abort or git rebase --abort instead. They cleanly exit without destroying partially-resolved work." };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Gate: Prevent committing when conflict is still in progress
|
|
146
|
+
if (/\bgit\s+commit\b/.test(cmd) && isConflictInProgress(ctx.cwd)) {
|
|
147
|
+
const state = isMergeInProgress(ctx.cwd) ? "merge" : "rebase";
|
|
148
|
+
return {
|
|
149
|
+
block: true,
|
|
150
|
+
reason: `Cannot commit while a ${state} is in progress. Git will interpret this as completing the ${state} with unresolved conflicts.\n\nResolve ALL conflicts first (remove <<<<<<< markers from every file), stage resolved files with git add, then use contrib_propose() to commit.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/state.ts
ADDED