pi-project-gate 1.0.0 → 1.1.0
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/package.json +10 -3
- package/src/__tests__/project-gate.test.ts +186 -0
- package/src/config.ts +48 -0
- package/src/helpers.ts +28 -0
- package/src/index.ts +9 -680
- package/src/tools/project.ts +113 -0
- package/src/validate.ts +31 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-project-gate",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Project orchestration gate for AI agents
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Project orchestration gate for AI agents \u2014 structured issues, WIP limits, dependency blocking, and auto-generated release notes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension",
|
|
@@ -39,5 +39,12 @@
|
|
|
39
39
|
"extensions": [
|
|
40
40
|
"./src/index.ts"
|
|
41
41
|
]
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"vitest": "^2.1.9"
|
|
42
49
|
}
|
|
43
|
-
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { loadConfig, DEFAULT_CONFIG } from "../config";
|
|
3
|
+
import { validateIssueTemplate, parseDependencies, parseConventionalCommits } 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("ProjectConfig", () => {
|
|
13
|
+
it("returns defaults when no config", () => {
|
|
14
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "project-test-"));
|
|
15
|
+
const config = loadConfig(tmp);
|
|
16
|
+
expect(config.maxWip).toBe(3);
|
|
17
|
+
expect(config.requiredSections).toContain("## Problem");
|
|
18
|
+
expect(config.requiredSections).toContain("## Proposed Solution");
|
|
19
|
+
expect(config.requiredSections).toContain("## Acceptance Criteria");
|
|
20
|
+
expect(config.complexityLevels).toContain("medium");
|
|
21
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("parses projectrc.yml", () => {
|
|
25
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "project-test-"));
|
|
26
|
+
fs.writeFileSync(path.join(tmp, ".projectrc.yml"), [
|
|
27
|
+
"maxWip: 5",
|
|
28
|
+
'requiredSections: "## Problem,## Solution"',
|
|
29
|
+
'areas: "backend,frontend,infra"',
|
|
30
|
+
"releaseNoteIncludeHashes: true",
|
|
31
|
+
].join("\n"));
|
|
32
|
+
const config = loadConfig(tmp);
|
|
33
|
+
expect(config.maxWip).toBe(5);
|
|
34
|
+
expect(config.requiredSections).toEqual(["## Problem", "## Solution"]);
|
|
35
|
+
expect(config.areas).toEqual(["backend", "frontend", "infra"]);
|
|
36
|
+
expect(config.releaseNoteIncludeHashes).toBe(true);
|
|
37
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ═══════════════════════════════════════
|
|
42
|
+
// Issue template validation
|
|
43
|
+
// ═══════════════════════════════════════
|
|
44
|
+
describe("validateIssueTemplate", () => {
|
|
45
|
+
const config = {
|
|
46
|
+
...DEFAULT_CONFIG,
|
|
47
|
+
requiredSections: ["## Problem", "## Proposed Solution", "## Acceptance Criteria"],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
it("passes a complete template", () => {
|
|
51
|
+
const body = "## Problem\nSomething is broken\n\n## Proposed Solution\nFix it\n\n## Acceptance Criteria\n- [ ] Tests pass";
|
|
52
|
+
expect(validateIssueTemplate(body, config).ok).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("fails when missing a section", () => {
|
|
56
|
+
const body = "## Problem\nSomething is broken\n\n## Proposed Solution\nFix it";
|
|
57
|
+
const result = validateIssueTemplate(body, config);
|
|
58
|
+
expect(result.ok).toBe(false);
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
expect(result.missingSections).toContain("## Acceptance Criteria");
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("fails when all sections missing", () => {
|
|
65
|
+
const body = "Just a description with no sections";
|
|
66
|
+
const result = validateIssueTemplate(body, config);
|
|
67
|
+
expect(result.ok).toBe(false);
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
expect(result.missingSections.length).toBe(3);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("passes with custom required sections", () => {
|
|
74
|
+
const customConfig = { ...config, requiredSections: ["## Description"] };
|
|
75
|
+
expect(validateIssueTemplate("## Description\nSome text", customConfig).ok).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ═══════════════════════════════════════
|
|
80
|
+
// Dependency parsing
|
|
81
|
+
// ═══════════════════════════════════════
|
|
82
|
+
describe("parseDependencies", () => {
|
|
83
|
+
const config = {
|
|
84
|
+
...DEFAULT_CONFIG,
|
|
85
|
+
dependencyPattern: "(?:Depends on|Blocked by|Requires)\\s+#(\\d+)",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
it("parses Depends on", () => {
|
|
89
|
+
const deps = parseDependencies("Depends on #42", config);
|
|
90
|
+
expect(deps).toEqual(["42"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("parses Blocked by", () => {
|
|
94
|
+
const deps = parseDependencies("Blocked by #123", config);
|
|
95
|
+
expect(deps).toEqual(["123"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("parses Requires", () => {
|
|
99
|
+
const deps = parseDependencies("Requires #789", config);
|
|
100
|
+
expect(deps).toEqual(["789"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("parses multiple dependencies", () => {
|
|
104
|
+
const deps = parseDependencies("Depends on #42 and Blocked by #123", config);
|
|
105
|
+
expect(deps).toContain("42");
|
|
106
|
+
expect(deps).toContain("123");
|
|
107
|
+
expect(deps.length).toBe(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns empty for no dependencies", () => {
|
|
111
|
+
expect(parseDependencies("No dependencies here", config)).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("is case insensitive", () => {
|
|
115
|
+
const deps = parseDependencies("depends on #42 and blocked by #99", config);
|
|
116
|
+
expect(deps).toContain("42");
|
|
117
|
+
expect(deps).toContain("99");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("deduplicates", () => {
|
|
121
|
+
const deps = parseDependencies("Depends on #42 and Depends on #42", config);
|
|
122
|
+
expect(deps).toEqual(["42"]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ═══════════════════════════════════════
|
|
127
|
+
// Conventional commit parsing
|
|
128
|
+
// ═══════════════════════════════════════
|
|
129
|
+
describe("parseConventionalCommits", () => {
|
|
130
|
+
it("parses feat commits", () => {
|
|
131
|
+
const log = "commit abc12345\nfeat(api): add new endpoint\n---";
|
|
132
|
+
const commits = parseConventionalCommits(log);
|
|
133
|
+
expect(commits.length).toBe(1);
|
|
134
|
+
expect(commits[0].type).toBe("feat");
|
|
135
|
+
expect(commits[0].scope).toBe("api");
|
|
136
|
+
expect(commits[0].subject).toBe("add new endpoint");
|
|
137
|
+
expect(commits[0].hash).toBe("abc12345");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("parses fix commits without scope", () => {
|
|
141
|
+
const log = "commit def67890\nfix: resolve null pointer\n---";
|
|
142
|
+
const commits = parseConventionalCommits(log);
|
|
143
|
+
expect(commits.length).toBe(1);
|
|
144
|
+
expect(commits[0].type).toBe("fix");
|
|
145
|
+
expect(commits[0].scope).toBe("");
|
|
146
|
+
expect(commits[0].subject).toBe("resolve null pointer");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("skips non-conventional commits", () => {
|
|
150
|
+
const log = "commit ghi11111\nUpdated some stuff\n---";
|
|
151
|
+
const commits = parseConventionalCommits(log);
|
|
152
|
+
expect(commits.length).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("parses multiple commits", () => {
|
|
156
|
+
const log = "commit aaa11111\nfeat: first feature\n---\n\ncommit bbb22222\nfix: bug fix\n---";
|
|
157
|
+
const commits = parseConventionalCommits(log);
|
|
158
|
+
expect(commits.length).toBe(2);
|
|
159
|
+
expect(commits[0].type).toBe("feat");
|
|
160
|
+
expect(commits[1].type).toBe("fix");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("handles chore and refactor types", () => {
|
|
164
|
+
const log = "commit ccc33333\nchore(deps): update packages\n---\n\ncommit ddd44444\nrefactor: clean up\n---";
|
|
165
|
+
const commits = parseConventionalCommits(log);
|
|
166
|
+
expect(commits.length).toBe(2);
|
|
167
|
+
expect(commits[0].type).toBe("chore");
|
|
168
|
+
expect(commits[1].type).toBe("refactor");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ═══════════════════════════════════════
|
|
173
|
+
// Helpers
|
|
174
|
+
// ═══════════════════════════════════════
|
|
175
|
+
describe("exec helper", () => {
|
|
176
|
+
it("returns ok for valid command", () => {
|
|
177
|
+
const r = exec("echo project-test");
|
|
178
|
+
expect(r.ok).toBe(true);
|
|
179
|
+
expect(r.stdout).toBe("project-test");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("returns not ok for invalid command", () => {
|
|
183
|
+
const r = exec("nonexistent-cmd-xyz-999 2>/dev/null");
|
|
184
|
+
expect(r.ok).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface ProjectConfig {
|
|
5
|
+
maxWip: number;
|
|
6
|
+
requiredSections: string[];
|
|
7
|
+
complexityLevels: string[];
|
|
8
|
+
areas: string[];
|
|
9
|
+
releaseNoteGroups: string[];
|
|
10
|
+
releaseNoteIncludeHashes: boolean;
|
|
11
|
+
dependencyPattern: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_CONFIG: ProjectConfig = {
|
|
15
|
+
maxWip: 3,
|
|
16
|
+
requiredSections: ["## Problem", "## Proposed Solution", "## Acceptance Criteria"],
|
|
17
|
+
complexityLevels: ["trivial", "small", "medium", "large", "epic"],
|
|
18
|
+
areas: [],
|
|
19
|
+
releaseNoteGroups: ["feat", "fix", "perf", "refactor", "chore", "docs", "test", "ci", "build"],
|
|
20
|
+
releaseNoteIncludeHashes: false,
|
|
21
|
+
dependencyPattern: "(?:Depends on|Blocked by|Requires)\\s+#(\\d+)",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function loadConfig(cwd: string): ProjectConfig {
|
|
25
|
+
const configPath = path.join(cwd, ".projectrc.yml");
|
|
26
|
+
if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
|
|
27
|
+
try {
|
|
28
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
29
|
+
const result: Record<string, unknown> = {};
|
|
30
|
+
for (const line of content.split("\n")) {
|
|
31
|
+
const m = line.match(/^\s*([\w][\w.]*):\s*(.+)$/);
|
|
32
|
+
if (m) {
|
|
33
|
+
let val = m[2].trim();
|
|
34
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
|
|
35
|
+
result[m[1]] = val;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
maxWip: parseInt(result["maxWip"] as string) || DEFAULT_CONFIG.maxWip,
|
|
40
|
+
requiredSections: (result["requiredSections"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.requiredSections,
|
|
41
|
+
complexityLevels: (result["complexityLevels"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.complexityLevels,
|
|
42
|
+
areas: (result["areas"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || [],
|
|
43
|
+
releaseNoteGroups: (result["releaseNoteGroups"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.releaseNoteGroups,
|
|
44
|
+
releaseNoteIncludeHashes: result["releaseNoteIncludeHashes"] === "true",
|
|
45
|
+
dependencyPattern: (result["dependencyPattern"] as string) || DEFAULT_CONFIG.dependencyPattern,
|
|
46
|
+
};
|
|
47
|
+
} catch { return { ...DEFAULT_CONFIG }; }
|
|
48
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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 { const r = cp.execSync(cmd, { cwd, encoding: "utf-8", timeout: 30000 }); return { ok: true, stdout: r.trim(), stderr: "" }; }
|
|
5
|
+
catch (e: any) { return { ok: false, stdout: e.stdout?.trim() || "", stderr: e.stderr?.trim() || e.message }; }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function currentBranch(cwd: string): string { return exec("git branch --show-current", cwd).stdout; }
|
|
9
|
+
|
|
10
|
+
export function resolveGitea(cwd: string): { repo: string; token: string } {
|
|
11
|
+
const remote = exec("git remote get-url gitea 2>/dev/null || git remote get-url origin", cwd);
|
|
12
|
+
const url = remote.stdout || "";
|
|
13
|
+
const match = url.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
14
|
+
const repo = match ? `${match[1]}/${match[2]}` : "factory/wrok.in";
|
|
15
|
+
const credMatch = url.match(/:\/\/([^:]+):([^@]+)@/);
|
|
16
|
+
return { repo, token: credMatch ? credMatch[2] : "" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function giteaApi(path: string, method: string, body: Record<string, unknown> | null, opts: { repo: string; token?: string }, cwd: string): { ok: boolean; data: unknown; error?: string } {
|
|
20
|
+
const base = `http://127.0.0.1:3001/api/v1/repos/${opts.repo}`;
|
|
21
|
+
const headers = [opts.token ? `-H "Authorization: token ${opts.token}"` : "", `-H "Content-Type: application/json"`].filter(Boolean).join(" ");
|
|
22
|
+
const dataFlag = body ? `-d '${JSON.stringify(body).replace(/'/g, "'\\''")}'` : "";
|
|
23
|
+
const cmd = `curl -sf -w "\\n%{http_code}" -X ${method} "${base}${path}" ${headers} ${dataFlag}`;
|
|
24
|
+
const r = exec(cmd, cwd);
|
|
25
|
+
if (!r.ok) { const lines = r.stdout.split("\n"); return { ok: false, data: null, error: r.stderr || lines.slice(0, -1).join("\n") || "API error" }; }
|
|
26
|
+
const lines = r.stdout.split("\n"); const bodyText = lines.slice(0, -1).join("\n");
|
|
27
|
+
try { return { ok: true, data: JSON.parse(bodyText) }; } catch { return { ok: true, data: bodyText }; }
|
|
28
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,687 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-project-gate — Project Orchestration Gate
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* and auto-generates release notes from conventional commits.
|
|
7
|
-
*
|
|
8
|
-
* Tools:
|
|
9
|
-
* project_check(issue_id) → validate issue readiness
|
|
10
|
-
* project_start(issue_id) → start work (WIP + dependency checks)
|
|
11
|
-
* project_status() → project board — active work, blockers
|
|
12
|
-
* project_release_notes(from, to) → generate release notes from commits
|
|
13
|
-
*
|
|
14
|
-
* Config: .projectrc.yml (WIP limits, required template sections, release note format)
|
|
4
|
+
* Tools: project_check, project_start, project_status, project_release_notes
|
|
5
|
+
* Config: .projectrc.yml
|
|
15
6
|
*/
|
|
16
|
-
|
|
17
|
-
import
|
|
18
|
-
import { Type } from "typebox";
|
|
19
|
-
import * as cp from "node:child_process";
|
|
20
|
-
import * as fs from "node:fs";
|
|
21
|
-
import * as path from "node:path";
|
|
22
|
-
|
|
23
|
-
// ── Types ──
|
|
24
|
-
|
|
25
|
-
interface ProjectConfig {
|
|
26
|
-
/** Maximum concurrent open PRs per agent */
|
|
27
|
-
maxWip: number;
|
|
28
|
-
/** Required sections in issue body */
|
|
29
|
-
requiredSections: string[];
|
|
30
|
-
/** Complexity levels and their labels */
|
|
31
|
-
complexityLevels: string[];
|
|
32
|
-
/** Area labels for categorization */
|
|
33
|
-
areas: string[];
|
|
34
|
-
/** Release note grouping order */
|
|
35
|
-
releaseNoteGroups: string[];
|
|
36
|
-
/** Whether to include commit hashes in release notes */
|
|
37
|
-
releaseNoteIncludeHashes: boolean;
|
|
38
|
-
/** Issue dependency marker pattern (e.g., "Depends on #123") */
|
|
39
|
-
dependencyPattern: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const DEFAULT_CONFIG: ProjectConfig = {
|
|
43
|
-
maxWip: 3,
|
|
44
|
-
requiredSections: ["## Problem", "## Proposed Solution", "## Acceptance Criteria"],
|
|
45
|
-
complexityLevels: ["trivial", "small", "medium", "large", "epic"],
|
|
46
|
-
areas: [],
|
|
47
|
-
releaseNoteGroups: ["feat", "fix", "perf", "refactor", "chore", "docs", "test", "ci", "build"],
|
|
48
|
-
releaseNoteIncludeHashes: false,
|
|
49
|
-
dependencyPattern: "(?:Depends on|Blocked by|Requires)\\s+#(\\d+)",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// ── Session state ──
|
|
53
|
-
|
|
54
|
-
let activeIssueId: string | null = null;
|
|
55
|
-
|
|
56
|
-
// ── Config ──
|
|
57
|
-
|
|
58
|
-
function loadConfig(cwd: string): ProjectConfig {
|
|
59
|
-
const configPath = path.join(cwd, ".projectrc.yml");
|
|
60
|
-
if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
|
|
61
|
-
try {
|
|
62
|
-
const content = fs.readFileSync(configPath, "utf-8");
|
|
63
|
-
const result: Record<string, unknown> = {};
|
|
64
|
-
for (const line of content.split("\n")) {
|
|
65
|
-
const m = line.match(/^\s*([\w][\w.]*):\s*(.+)$/);
|
|
66
|
-
if (m) result[m[1]] = m[2].trim();
|
|
67
|
-
}
|
|
68
|
-
return {
|
|
69
|
-
maxWip: parseInt(result["maxWip"] as string) || DEFAULT_CONFIG.maxWip,
|
|
70
|
-
requiredSections: (result["requiredSections"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.requiredSections,
|
|
71
|
-
complexityLevels: (result["complexityLevels"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.complexityLevels,
|
|
72
|
-
areas: (result["areas"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || [],
|
|
73
|
-
releaseNoteGroups: (result["releaseNoteGroups"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.releaseNoteGroups,
|
|
74
|
-
releaseNoteIncludeHashes: result["releaseNoteIncludeHashes"] === "true",
|
|
75
|
-
dependencyPattern: (result["dependencyPattern"] as string) || DEFAULT_CONFIG.dependencyPattern,
|
|
76
|
-
};
|
|
77
|
-
} catch {
|
|
78
|
-
return { ...DEFAULT_CONFIG };
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ── Git helpers ──
|
|
83
|
-
|
|
84
|
-
function exec(cmd: string, cwd?: string): { ok: boolean; stdout: string; stderr: string } {
|
|
85
|
-
try {
|
|
86
|
-
const r = cp.execSync(cmd, { cwd, encoding: "utf-8", timeout: 30000 });
|
|
87
|
-
return { ok: true, stdout: r.trim(), stderr: "" };
|
|
88
|
-
} catch (e: any) {
|
|
89
|
-
return { ok: false, stdout: e.stdout?.trim() || "", stderr: e.stderr?.trim() || e.message };
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function currentBranch(cwd: string): string {
|
|
94
|
-
return exec("git branch --show-current", cwd).stdout;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ── Gitea API ──
|
|
98
|
-
|
|
99
|
-
interface GiteaApiOpts {
|
|
100
|
-
repo: string;
|
|
101
|
-
token?: string;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function resolveGitea(cwd: string): GiteaApiOpts {
|
|
105
|
-
const remote = exec("git remote get-url gitea 2>/dev/null || git remote get-url origin", cwd);
|
|
106
|
-
const url = remote.stdout || "";
|
|
107
|
-
const match = url.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
108
|
-
const repo = match ? `${match[1]}/${match[2]}` : "factory/wrok.in";
|
|
109
|
-
const credMatch = url.match(/:\/\/([^:]+):([^@]+)@/);
|
|
110
|
-
const token = credMatch ? credMatch[2] : "";
|
|
111
|
-
return { repo, token };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function giteaApi(
|
|
115
|
-
path: string,
|
|
116
|
-
method: string,
|
|
117
|
-
body: Record<string, unknown> | null,
|
|
118
|
-
opts: GiteaApiOpts,
|
|
119
|
-
cwd: string,
|
|
120
|
-
): { ok: boolean; data: unknown; error?: string } {
|
|
121
|
-
const base = `http://127.0.0.1:3001/api/v1/repos/${opts.repo}`;
|
|
122
|
-
const headers = [
|
|
123
|
-
opts.token ? `-H "Authorization: token ${opts.token}"` : "",
|
|
124
|
-
`-H "Content-Type: application/json"`,
|
|
125
|
-
`-H "Accept: application/json"`,
|
|
126
|
-
].filter(Boolean).join(" ");
|
|
127
|
-
|
|
128
|
-
const dataFlag = body ? `-d '${JSON.stringify(body).replace(/'/g, "'\\''")}'` : "";
|
|
129
|
-
const cmd = `curl -sf -w "\\n%{http_code}" -X ${method} "${base}${path}" ${headers} ${dataFlag}`;
|
|
130
|
-
const r = exec(cmd, cwd);
|
|
131
|
-
|
|
132
|
-
if (!r.ok) {
|
|
133
|
-
const lines = r.stdout.split("\n");
|
|
134
|
-
const bodyText = lines.slice(0, -1).join("\n");
|
|
135
|
-
return { ok: false, data: null, error: r.stderr || bodyText || "API error" };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const lines = r.stdout.split("\n");
|
|
139
|
-
const bodyText = lines.slice(0, -1).join("\n");
|
|
140
|
-
try {
|
|
141
|
-
return { ok: true, data: JSON.parse(bodyText) };
|
|
142
|
-
} catch {
|
|
143
|
-
return { ok: true, data: bodyText };
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ── Issue template validation ──
|
|
148
|
-
|
|
149
|
-
function validateIssueTemplate(
|
|
150
|
-
issueBody: string,
|
|
151
|
-
config: ProjectConfig,
|
|
152
|
-
): { ok: true } | { ok: false; missingSections: string[] } {
|
|
153
|
-
const missing = config.requiredSections.filter(section =>
|
|
154
|
-
!issueBody.includes(section)
|
|
155
|
-
);
|
|
156
|
-
if (missing.length > 0) {
|
|
157
|
-
return { ok: false, missingSections: missing };
|
|
158
|
-
}
|
|
159
|
-
return { ok: true };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// ── Dependency parsing ──
|
|
163
|
-
|
|
164
|
-
function parseDependencies(body: string, config: ProjectConfig): string[] {
|
|
165
|
-
const pattern = new RegExp(config.dependencyPattern, "gi");
|
|
166
|
-
const deps = new Set<string>();
|
|
167
|
-
let match;
|
|
168
|
-
while ((match = pattern.exec(body)) !== null) {
|
|
169
|
-
deps.add(match[1]);
|
|
170
|
-
}
|
|
171
|
-
return [...deps];
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ── WIP counting ──
|
|
175
|
-
|
|
176
|
-
function countOpenPRs(opts: GiteaApiOpts, cwd: string): { total: number; byAuthor: Record<string, number> } {
|
|
177
|
-
const r = giteaApi("/pulls?state=open&limit=100", "GET", null, opts, cwd);
|
|
178
|
-
if (!r.ok || !r.data) return { total: 0, byAuthor: {} };
|
|
179
|
-
|
|
180
|
-
const prs = Array.isArray(r.data) ? r.data : [];
|
|
181
|
-
const byAuthor: Record<string, number> = {};
|
|
182
|
-
for (const pr of prs) {
|
|
183
|
-
const author = (pr as any).user?.login || "unknown";
|
|
184
|
-
byAuthor[author] = (byAuthor[author] || 0) + 1;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return { total: prs.length, byAuthor };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ── Release notes generation ──
|
|
191
|
-
|
|
192
|
-
interface CommitEntry {
|
|
193
|
-
hash: string;
|
|
194
|
-
type: string;
|
|
195
|
-
scope: string;
|
|
196
|
-
subject: string;
|
|
197
|
-
body: string;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function parseConventionalCommits(log: string): CommitEntry[] {
|
|
201
|
-
const entries: CommitEntry[] = [];
|
|
202
|
-
// Parse each commit from `git log --format` output
|
|
203
|
-
const commits = log.split(/\n(?=commit )/);
|
|
204
|
-
for (const block of commits) {
|
|
205
|
-
const hashMatch = block.match(/^commit (\S+)/m);
|
|
206
|
-
if (!hashMatch) continue;
|
|
207
|
-
const hash = hashMatch[1].slice(0, 8);
|
|
208
|
-
|
|
209
|
-
// Extract subject line
|
|
210
|
-
const subjectLine = block.split("\n").find(l => l.trim() && !l.startsWith("commit ") && !l.startsWith("Author:") && !l.startsWith("Date:"));
|
|
211
|
-
if (!subjectLine) continue;
|
|
212
|
-
|
|
213
|
-
const convMatch = subjectLine.trim().match(/^(feat|fix|perf|refactor|chore|docs|style|test|ci|build|revert)(?:\(([^)]+)\))?:\s(.+)$/);
|
|
214
|
-
if (!convMatch) continue;
|
|
215
|
-
|
|
216
|
-
entries.push({
|
|
217
|
-
hash,
|
|
218
|
-
type: convMatch[1],
|
|
219
|
-
scope: convMatch[2] || "",
|
|
220
|
-
subject: convMatch[3].trim(),
|
|
221
|
-
body: "",
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
return entries;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function generateReleaseNotes(
|
|
228
|
-
from: string,
|
|
229
|
-
to: string,
|
|
230
|
-
config: ProjectConfig,
|
|
231
|
-
cwd: string,
|
|
232
|
-
): { version: string; date: string; sections: Record<string, string[]> } {
|
|
233
|
-
const range = from ? `${from}..${to}` : to;
|
|
234
|
-
const log = exec(`git log ${range} --format="commit %H%n%B%n---" --no-merges`, cwd);
|
|
235
|
-
const commits = parseConventionalCommits(log.stdout || "");
|
|
236
|
-
|
|
237
|
-
const sections: Record<string, string[]> = {};
|
|
238
|
-
for (const group of config.releaseNoteGroups) {
|
|
239
|
-
sections[group] = [];
|
|
240
|
-
}
|
|
241
|
-
sections["other"] = [];
|
|
242
|
-
|
|
243
|
-
for (const commit of commits) {
|
|
244
|
-
const prefix = config.releaseNoteIncludeHashes ? `- ${commit.hash} ` : "- ";
|
|
245
|
-
const scope = commit.scope ? `**${commit.scope}**: ` : "";
|
|
246
|
-
const line = `${prefix}${scope}${commit.subject}`;
|
|
247
|
-
|
|
248
|
-
if (sections[commit.type]) {
|
|
249
|
-
sections[commit.type].push(line);
|
|
250
|
-
} else {
|
|
251
|
-
sections["other"].push(line);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return {
|
|
256
|
-
version: to || "HEAD",
|
|
257
|
-
date: new Date().toISOString().split("T")[0],
|
|
258
|
-
sections,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function formatReleaseNotes(release: { version: string; date: string; sections: Record<string, string[]> }): string {
|
|
263
|
-
const lines: string[] = [];
|
|
264
|
-
lines.push(`# Release ${release.version} (${release.date})`);
|
|
265
|
-
lines.push("");
|
|
266
|
-
|
|
267
|
-
const labels: Record<string, string> = {
|
|
268
|
-
feat: "🚀 Features",
|
|
269
|
-
fix: "🐛 Bug Fixes",
|
|
270
|
-
perf: "⚡ Performance",
|
|
271
|
-
refactor: "♻️ Refactoring",
|
|
272
|
-
chore: "🔧 Chores",
|
|
273
|
-
docs: "📝 Documentation",
|
|
274
|
-
test: "✅ Tests",
|
|
275
|
-
ci: "👷 CI/CD",
|
|
276
|
-
build: "📦 Build",
|
|
277
|
-
other: "📌 Other",
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
for (const [group, entries] of Object.entries(release.sections)) {
|
|
281
|
-
if (entries.length === 0) continue;
|
|
282
|
-
const label = labels[group] || group;
|
|
283
|
-
lines.push(`### ${label}`);
|
|
284
|
-
lines.push("");
|
|
285
|
-
for (const entry of entries) {
|
|
286
|
-
lines.push(entry);
|
|
287
|
-
}
|
|
288
|
-
lines.push("");
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return lines.join("\n");
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ── Extension ──
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { checkTool, startTool, statusTool, releaseTool } from "./tools/project";
|
|
295
9
|
|
|
296
10
|
export default function (pi: ExtensionAPI) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
pi.registerTool(
|
|
301
|
-
|
|
302
|
-
label: "Check Issue Readiness",
|
|
303
|
-
description: "Validate that an issue is ready to be worked on — has required sections, no blockers, not already taken.",
|
|
304
|
-
parameters: Type.Object({
|
|
305
|
-
issue_id: Type.String({ description: "Issue number to check" }),
|
|
306
|
-
}),
|
|
307
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
308
|
-
const config = loadConfig(ctx.cwd);
|
|
309
|
-
const opts = resolveGitea(ctx.cwd);
|
|
310
|
-
const issueId = params.issue_id.replace(/^#/, "");
|
|
311
|
-
|
|
312
|
-
// Fetch issue from Gitea
|
|
313
|
-
const r = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
|
|
314
|
-
if (!r.ok || !r.data) {
|
|
315
|
-
return {
|
|
316
|
-
content: [{ type: "text", text: `Issue #${issueId} not found: ${r.error || "API error"}` }],
|
|
317
|
-
isError: true,
|
|
318
|
-
details: {},
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const issue = r.data as Record<string, unknown>;
|
|
323
|
-
const lines: string[] = [];
|
|
324
|
-
const issues: string[] = [];
|
|
325
|
-
|
|
326
|
-
lines.push(`📋 Issue #${issueId}: ${issue.title || "untitled"}`);
|
|
327
|
-
lines.push(` State: ${issue.state}`);
|
|
328
|
-
lines.push(` Assignee: ${(issue.assignee as any)?.login || "unassigned"}`);
|
|
329
|
-
|
|
330
|
-
// 1. Template validation
|
|
331
|
-
const body = (issue.body as string) || "";
|
|
332
|
-
const templateCheck = validateIssueTemplate(body, config);
|
|
333
|
-
if (!templateCheck.ok) {
|
|
334
|
-
issues.push(`❌ Missing required sections: ${templateCheck.missingSections.join(", ")}`);
|
|
335
|
-
issues.push(` Add to issue: ${config.requiredSections.join(", ")}`);
|
|
336
|
-
} else {
|
|
337
|
-
lines.push(` Template: ✅ complete`);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// 2. Complexity check
|
|
341
|
-
const complexity = config.complexityLevels.find(l => body.toLowerCase().includes(l.toLowerCase()));
|
|
342
|
-
if (complexity) {
|
|
343
|
-
lines.push(` Complexity: ${complexity}`);
|
|
344
|
-
} else {
|
|
345
|
-
issues.push(`⚠️ No complexity label found. Consider adding: ${config.complexityLevels.join(", ")}`);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// 3. Area check
|
|
349
|
-
const area = config.areas.find(a => body.includes(a));
|
|
350
|
-
if (area) {
|
|
351
|
-
lines.push(` Area: ${area}`);
|
|
352
|
-
} else if (config.areas.length > 0) {
|
|
353
|
-
issues.push(`⚠️ No area tag found. Available: ${config.areas.join(", ")}`);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// 4. Dependency check
|
|
357
|
-
const dependencies = parseDependencies(body, config);
|
|
358
|
-
if (dependencies.length > 0) {
|
|
359
|
-
lines.push(` Dependencies: #${dependencies.join(", #")}`);
|
|
360
|
-
|
|
361
|
-
// Check if dependencies are resolved
|
|
362
|
-
const blocked: string[] = [];
|
|
363
|
-
for (const dep of dependencies) {
|
|
364
|
-
const dr = giteaApi(`/issues/${dep}`, "GET", null, opts, ctx.cwd);
|
|
365
|
-
if (dr.ok && dr.data) {
|
|
366
|
-
const depIssue = dr.data as Record<string, unknown>;
|
|
367
|
-
if (depIssue.state === "open") {
|
|
368
|
-
blocked.push(dep);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
if (blocked.length > 0) {
|
|
373
|
-
issues.push(`🔒 Blocked by unresolved dependencies: #${blocked.join(", #")}`);
|
|
374
|
-
} else {
|
|
375
|
-
lines.push(` Dependencies resolved: ✅`);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// 5. Already assigned?
|
|
380
|
-
if (issue.assignee) {
|
|
381
|
-
issues.push(`⚠️ Already assigned to ${(issue.assignee as any)?.login}`);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Summary
|
|
385
|
-
const hasBlockers = issues.some(i => i.startsWith("❌") || i.startsWith("🔒"));
|
|
386
|
-
if (issues.length > 0) {
|
|
387
|
-
lines.push("");
|
|
388
|
-
for (const i of issues) lines.push(` ${i}`);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (hasBlockers) {
|
|
392
|
-
lines.push("", "❌ Issue is not ready to start. Resolve blockers first.");
|
|
393
|
-
} else if (issues.length === 0) {
|
|
394
|
-
lines.push("", "✅ Issue is ready! Use project_start() to begin work.");
|
|
395
|
-
} else {
|
|
396
|
-
lines.push("", "⚠️ Issue has warnings but can be started.");
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
return {
|
|
400
|
-
content: [{ type: "text", text: lines.join("\n") }],
|
|
401
|
-
details: {
|
|
402
|
-
issueId,
|
|
403
|
-
title: issue.title,
|
|
404
|
-
state: issue.state,
|
|
405
|
-
dependencies,
|
|
406
|
-
ready: !hasBlockers,
|
|
407
|
-
},
|
|
408
|
-
};
|
|
409
|
-
},
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
// ═══════════════════════════════════════
|
|
413
|
-
// Tool: project_start
|
|
414
|
-
// ═══════════════════════════════════════
|
|
415
|
-
pi.registerTool({
|
|
416
|
-
name: "project_start",
|
|
417
|
-
label: "Start Work on Issue",
|
|
418
|
-
description: "Mark an issue as in-progress. Checks WIP limits, dependency blocking, and template completeness.",
|
|
419
|
-
parameters: Type.Object({
|
|
420
|
-
issue_id: Type.String({ description: "Issue number to start working on" }),
|
|
421
|
-
}),
|
|
422
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
423
|
-
const config = loadConfig(ctx.cwd);
|
|
424
|
-
const opts = resolveGitea(ctx.cwd);
|
|
425
|
-
const issueId = params.issue_id.replace(/^#/, "");
|
|
426
|
-
|
|
427
|
-
// Fetch issue
|
|
428
|
-
const r = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
|
|
429
|
-
if (!r.ok || !r.data) {
|
|
430
|
-
return {
|
|
431
|
-
content: [{ type: "text", text: `Issue #${issueId} not found.` }],
|
|
432
|
-
isError: true,
|
|
433
|
-
details: {},
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const issue = r.data as Record<string, unknown>;
|
|
438
|
-
|
|
439
|
-
// 1. Template validation
|
|
440
|
-
const body = (issue.body as string) || "";
|
|
441
|
-
const templateCheck = validateIssueTemplate(body, config);
|
|
442
|
-
if (!templateCheck.ok) {
|
|
443
|
-
return {
|
|
444
|
-
content: [{
|
|
445
|
-
type: "text",
|
|
446
|
-
text: [
|
|
447
|
-
`❌ Issue #${issueId} is missing required sections:`,
|
|
448
|
-
...templateCheck.missingSections.map(s => ` - ${s}`),
|
|
449
|
-
"",
|
|
450
|
-
`Required: ${config.requiredSections.join(", ")}`,
|
|
451
|
-
"Add the missing sections to the issue body before starting work.",
|
|
452
|
-
].join("\n"),
|
|
453
|
-
}],
|
|
454
|
-
isError: true,
|
|
455
|
-
details: { missingSections: templateCheck.missingSections },
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// 2. Dependency check
|
|
460
|
-
const dependencies = parseDependencies(body, config);
|
|
461
|
-
if (dependencies.length > 0) {
|
|
462
|
-
const blocked: string[] = [];
|
|
463
|
-
for (const dep of dependencies) {
|
|
464
|
-
const dr = giteaApi(`/issues/${dep}`, "GET", null, opts, ctx.cwd);
|
|
465
|
-
if (dr.ok && dr.data) {
|
|
466
|
-
const depIssue = dr.data as Record<string, unknown>;
|
|
467
|
-
if (depIssue.state === "open") {
|
|
468
|
-
blocked.push(dep);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
if (blocked.length > 0) {
|
|
473
|
-
return {
|
|
474
|
-
content: [{
|
|
475
|
-
type: "text",
|
|
476
|
-
text: [
|
|
477
|
-
`🔒 Cannot start — blocked by unresolved dependencies:`,
|
|
478
|
-
...blocked.map(b => ` - #${b} (still open)`),
|
|
479
|
-
"",
|
|
480
|
-
"Close or merge the blocking issues first.",
|
|
481
|
-
].join("\n"),
|
|
482
|
-
}],
|
|
483
|
-
isError: true,
|
|
484
|
-
details: { blockedBy: blocked },
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// 3. WIP limit check
|
|
490
|
-
const wip = countOpenPRs(opts, ctx.cwd);
|
|
491
|
-
const author = issue.user?.login || "factory";
|
|
492
|
-
const currentWip = wip.byAuthor[author as string] || 0;
|
|
493
|
-
|
|
494
|
-
if (currentWip >= config.maxWip) {
|
|
495
|
-
return {
|
|
496
|
-
content: [{
|
|
497
|
-
type: "text",
|
|
498
|
-
text: [
|
|
499
|
-
`⚠️ WIP limit reached (${currentWip}/${config.maxWip} open PRs).`,
|
|
500
|
-
"",
|
|
501
|
-
`Your open PRs:`,
|
|
502
|
-
` (check with project_status())`,
|
|
503
|
-
"",
|
|
504
|
-
`Complete or close existing PRs before starting new work.`,
|
|
505
|
-
`WIP limit: ${config.maxWip} — configured in .projectrc.yml`,
|
|
506
|
-
].join("\n"),
|
|
507
|
-
}],
|
|
508
|
-
isError: true,
|
|
509
|
-
details: { currentWip, maxWip: config.maxWip },
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// 4. All checks passed — mark as started
|
|
514
|
-
activeIssueId = issueId;
|
|
515
|
-
|
|
516
|
-
return {
|
|
517
|
-
content: [{
|
|
518
|
-
type: "text",
|
|
519
|
-
text: [
|
|
520
|
-
`✅ Work started on #${issueId}: "${issue.title || "untitled"}"`,
|
|
521
|
-
` WIP: ${currentWip + 1}/${config.maxWip}`,
|
|
522
|
-
"",
|
|
523
|
-
`Next: Use contrib_start_work(#${issueId}) to create your branch,`,
|
|
524
|
-
`then contrib_propose() → contrib_submit() to ship.`,
|
|
525
|
-
].join("\n"),
|
|
526
|
-
}],
|
|
527
|
-
details: { issueId, title: issue.title, wip: currentWip + 1, maxWip: config.maxWip },
|
|
528
|
-
};
|
|
529
|
-
},
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
// ═══════════════════════════════════════
|
|
533
|
-
// Tool: project_status
|
|
534
|
-
// ═══════════════════════════════════════
|
|
535
|
-
pi.registerTool({
|
|
536
|
-
name: "project_status",
|
|
537
|
-
label: "Project Status",
|
|
538
|
-
description: "Show project board — active issues, WIP counts, blockers, and open PRs.",
|
|
539
|
-
parameters: Type.Object({}),
|
|
540
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
541
|
-
const config = loadConfig(ctx.cwd);
|
|
542
|
-
const opts = resolveGitea(ctx.cwd);
|
|
543
|
-
|
|
544
|
-
const lines: string[] = [];
|
|
545
|
-
lines.push("📊 Project Status");
|
|
546
|
-
lines.push("");
|
|
547
|
-
|
|
548
|
-
// WIP summary
|
|
549
|
-
const wip = countOpenPRs(opts, ctx.cwd);
|
|
550
|
-
lines.push(`🏗 WIP: ${wip.total} open PRs (limit: ${config.maxWip} per agent)`);
|
|
551
|
-
if (Object.keys(wip.byAuthor).length > 0) {
|
|
552
|
-
lines.push("");
|
|
553
|
-
for (const [author, count] of Object.entries(wip.byAuthor).sort(([, a], [, b]) => b - a)) {
|
|
554
|
-
const status = count >= config.maxWip ? "⚠️ AT LIMIT" : "✅";
|
|
555
|
-
lines.push(` ${author}: ${count}/${config.maxWip} ${status}`);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Open issues (recent)
|
|
560
|
-
const issues = giteaApi("/issues?state=open&limit=10", "GET", null, opts, ctx.cwd);
|
|
561
|
-
if (issues.ok && Array.isArray(issues.data)) {
|
|
562
|
-
const openIssues = issues.data as Record<string, unknown>[];
|
|
563
|
-
const assigned = openIssues.filter(i => i.assignee);
|
|
564
|
-
const unassigned = openIssues.filter(i => !i.assignee);
|
|
565
|
-
|
|
566
|
-
lines.push("");
|
|
567
|
-
lines.push(`📝 ${assigned.length} assigned, ${unassigned.length} unassigned open issues`);
|
|
568
|
-
|
|
569
|
-
if (assigned.length > 0) {
|
|
570
|
-
lines.push("");
|
|
571
|
-
lines.push(" In Progress:");
|
|
572
|
-
for (const i of assigned.slice(0, 10)) {
|
|
573
|
-
const labels = (i.labels as any[])?.map((l: any) => l.name).join(", ") || "";
|
|
574
|
-
const assignee = (i.assignee as any)?.login || "?";
|
|
575
|
-
lines.push(` - #${i.number} [${assignee}] ${i.title}${labels ? ` (${labels})` : ""}`);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// Flag blocked issues
|
|
580
|
-
const blocked = openIssues.filter(i => {
|
|
581
|
-
const b = (i.body as string) || "";
|
|
582
|
-
const deps = parseDependencies(b, config);
|
|
583
|
-
return deps.length > 0;
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
if (blocked.length > 0) {
|
|
587
|
-
lines.push("");
|
|
588
|
-
lines.push(`🔒 ${blocked.length} blocked issues (unresolved dependencies):`);
|
|
589
|
-
for (const i of blocked.slice(0, 5)) {
|
|
590
|
-
const deps = parseDependencies((i.body as string) || "", config);
|
|
591
|
-
lines.push(` - #${i.number} ${i.title} → depends on #${deps.join(", #")}`);
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Active issue
|
|
597
|
-
if (activeIssueId) {
|
|
598
|
-
lines.push("");
|
|
599
|
-
lines.push(`🎯 Currently working on: #${activeIssueId}`);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// Release info
|
|
603
|
-
const tags = exec("git tag --sort=-creatordate | head -3", ctx.cwd);
|
|
604
|
-
if (tags.ok && tags.stdout) {
|
|
605
|
-
lines.push("");
|
|
606
|
-
lines.push("🏷 Recent tags:");
|
|
607
|
-
for (const tag of tags.stdout.split("\n").filter(Boolean)) {
|
|
608
|
-
lines.push(` ${tag}`);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
return {
|
|
613
|
-
content: [{ type: "text", text: lines.join("\n") }],
|
|
614
|
-
details: { wip, activeIssueId },
|
|
615
|
-
};
|
|
616
|
-
},
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// ═══════════════════════════════════════
|
|
620
|
-
// Tool: project_release_notes
|
|
621
|
-
// ═══════════════════════════════════════
|
|
622
|
-
pi.registerTool({
|
|
623
|
-
name: "project_release_notes",
|
|
624
|
-
label: "Generate Release Notes",
|
|
625
|
-
description: "Generate release notes from conventional commits between two tags or from the latest tag to HEAD.",
|
|
626
|
-
parameters: Type.Object({
|
|
627
|
-
from: Type.Optional(Type.String({ description: "Starting tag/ref (default: latest tag)" })),
|
|
628
|
-
to: Type.Optional(Type.String({ description: "Ending tag/ref (default: HEAD)" })),
|
|
629
|
-
}),
|
|
630
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
631
|
-
const config = loadConfig(ctx.cwd);
|
|
632
|
-
|
|
633
|
-
// Determine range
|
|
634
|
-
let from = params.from || "";
|
|
635
|
-
const to = params.to || "HEAD";
|
|
636
|
-
|
|
637
|
-
if (!from) {
|
|
638
|
-
// Use latest tag
|
|
639
|
-
const latestTag = exec("git describe --tags --abbrev=0 2>/dev/null || echo ''", ctx.cwd);
|
|
640
|
-
from = latestTag.stdout || "";
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
if (!from) {
|
|
644
|
-
// No tags found — use all commits
|
|
645
|
-
const firstCommit = exec("git rev-list --max-parents=0 HEAD", ctx.cwd);
|
|
646
|
-
from = firstCommit.stdout || "";
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
if (!from && !to) {
|
|
650
|
-
return {
|
|
651
|
-
content: [{ type: "text", text: "No commits or tags found to generate release notes from." }],
|
|
652
|
-
isError: true,
|
|
653
|
-
details: {},
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
const release = generateReleaseNotes(from, to, config, ctx.cwd);
|
|
658
|
-
|
|
659
|
-
// Check if there's anything
|
|
660
|
-
const totalEntries = Object.values(release.sections).reduce((sum, arr) => sum + arr.length, 0);
|
|
661
|
-
if (totalEntries === 0) {
|
|
662
|
-
return {
|
|
663
|
-
content: [{
|
|
664
|
-
type: "text",
|
|
665
|
-
text: `No conventional commits found between ${from} and ${to}.`,
|
|
666
|
-
}],
|
|
667
|
-
isError: true,
|
|
668
|
-
details: {},
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const notes = formatReleaseNotes(release);
|
|
673
|
-
|
|
674
|
-
return {
|
|
675
|
-
content: [{ type: "text", text: notes }],
|
|
676
|
-
details: { from, to, totalEntries, sections: release.sections },
|
|
677
|
-
};
|
|
678
|
-
},
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
// ═══════════════════════════════════════
|
|
682
|
-
// Session cleanup
|
|
683
|
-
// ═══════════════════════════════════════
|
|
684
|
-
pi.on("session_shutdown", async () => {
|
|
685
|
-
activeIssueId = null;
|
|
686
|
-
});
|
|
11
|
+
pi.registerTool(checkTool);
|
|
12
|
+
pi.registerTool(startTool);
|
|
13
|
+
pi.registerTool(statusTool);
|
|
14
|
+
pi.registerTool(releaseTool);
|
|
15
|
+
pi.on("session_shutdown", () => { delete (globalThis as any).__project_issueId; });
|
|
687
16
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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 { validateIssueTemplate, parseDependencies, parseConventionalCommits } from "../validate";
|
|
6
|
+
|
|
7
|
+
const LABELS: Record<string, string> = { feat: "🚀 Features", fix: "🐛 Bug Fixes", perf: "⚡ Performance", refactor: "♻️ Refactoring", chore: "🔧 Chores", docs: "📝 Documentation", test: "✅ Tests", ci: "👷 CI/CD", build: "📦 Build", other: "📌 Other" };
|
|
8
|
+
|
|
9
|
+
export const checkTool = {
|
|
10
|
+
name: "project_check" as const, label: "Check Issue Readiness",
|
|
11
|
+
description: "Validate that an issue is ready to be worked on.",
|
|
12
|
+
parameters: Type.Object({ issue_id: Type.String({}) }),
|
|
13
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
14
|
+
const config = loadConfig(ctx.cwd); const opts = resolveGitea(ctx.cwd);
|
|
15
|
+
const issueId = params.issue_id.replace(/^#/, "");
|
|
16
|
+
const r = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
|
|
17
|
+
if (!r.ok || !r.data) return { content: [{ type: "text", text: `Issue #${issueId} not found.` }], isError: true, details: {} };
|
|
18
|
+
const issue = r.data as Record<string, unknown>;
|
|
19
|
+
const lines: string[] = []; const issues: string[] = [];
|
|
20
|
+
lines.push(`📋 Issue #${issueId}: ${issue.title}`);
|
|
21
|
+
const body = (issue.body as string) || "";
|
|
22
|
+
const tpl = validateIssueTemplate(body, config);
|
|
23
|
+
if (!tpl.ok) issues.push(`❌ Missing sections: ${tpl.missingSections.join(", ")}`);
|
|
24
|
+
else lines.push(" Template: ✅");
|
|
25
|
+
const complexity = config.complexityLevels.find(l => body.toLowerCase().includes(l.toLowerCase()));
|
|
26
|
+
lines.push(` Complexity: ${complexity || "?"}`);
|
|
27
|
+
const deps = parseDependencies(body, config);
|
|
28
|
+
if (deps.length > 0) {
|
|
29
|
+
const blocked: string[] = [];
|
|
30
|
+
for (const dep of deps) { const dr = giteaApi(`/issues/${dep}`, "GET", null, opts, ctx.cwd); if (dr.ok && (dr.data as any)?.state === "open") blocked.push(dep); }
|
|
31
|
+
if (blocked.length > 0) issues.push(`🔒 Blocked by: #${blocked.join(", #")}`);
|
|
32
|
+
else lines.push(" Dependencies: ✅");
|
|
33
|
+
}
|
|
34
|
+
if (issue.assignee) issues.push(`⚠️ Assigned to ${(issue.assignee as any)?.login}`);
|
|
35
|
+
if (issues.length > 0) { lines.push(""); for (const i of issues) lines.push(` ${i}`); }
|
|
36
|
+
const blocked = issues.some(i => i.startsWith("❌") || i.startsWith("🔒"));
|
|
37
|
+
lines.push("", blocked ? "❌ Not ready." : issues.length === 0 ? "✅ Ready!" : "⚠️ Warnings but can start.");
|
|
38
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { ready: !blocked } };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const startTool = {
|
|
43
|
+
name: "project_start" as const, label: "Start Work",
|
|
44
|
+
description: "Mark an issue as in-progress. Checks WIP limits, dependencies, template.",
|
|
45
|
+
parameters: Type.Object({ issue_id: Type.String({}) }),
|
|
46
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
47
|
+
const config = loadConfig(ctx.cwd); const opts = resolveGitea(ctx.cwd);
|
|
48
|
+
const issueId = params.issue_id.replace(/^#/, "");
|
|
49
|
+
const r = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
|
|
50
|
+
if (!r.ok || !r.data) return { content: [{ type: "text", text: `Issue #${issueId} not found.` }], isError: true, details: {} };
|
|
51
|
+
const issue = r.data as Record<string, unknown>;
|
|
52
|
+
const body = (issue.body as string) || "";
|
|
53
|
+
const tpl = validateIssueTemplate(body, config);
|
|
54
|
+
if (!tpl.ok) return { content: [{ type: "text", text: `❌ Missing sections: ${tpl.missingSections.join(", ")}` }], isError: true, details: {} };
|
|
55
|
+
const deps = parseDependencies(body, config);
|
|
56
|
+
if (deps.length > 0) {
|
|
57
|
+
const blocked: string[] = [];
|
|
58
|
+
for (const dep of deps) { const dr = giteaApi(`/issues/${dep}`, "GET", null, opts, ctx.cwd); if (dr.ok && (dr.data as any)?.state === "open") blocked.push(dep); }
|
|
59
|
+
if (blocked.length > 0) return { content: [{ type: "text", text: `🔒 Blocked: #${blocked.join(", #")}` }], isError: true, details: {} };
|
|
60
|
+
}
|
|
61
|
+
const wipR = giteaApi("/pulls?state=open&limit=100", "GET", null, opts, ctx.cwd);
|
|
62
|
+
const prs = Array.isArray(wipR.data) ? wipR.data : [];
|
|
63
|
+
const author = (issue.user as any)?.login || "factory";
|
|
64
|
+
const currentWip = prs.filter((p: any) => p.user?.login === author).length;
|
|
65
|
+
if (currentWip >= config.maxWip) return { content: [{ type: "text", text: `⚠️ WIP limit reached (${currentWip}/${config.maxWip}).` }], isError: true, details: {} };
|
|
66
|
+
(globalThis as any).__project_issueId = issueId;
|
|
67
|
+
return { content: [{ type: "text", text: `✅ Work started on #${issueId}: "${issue.title}" (WIP ${currentWip + 1}/${config.maxWip})` }], details: { issueId } };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const statusTool = {
|
|
72
|
+
name: "project_status" as const, label: "Project Status",
|
|
73
|
+
description: "Show project board — active issues, WIP, blockers.",
|
|
74
|
+
parameters: Type.Object({}),
|
|
75
|
+
async execute(_id: string, _p: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
76
|
+
const config = loadConfig(ctx.cwd); const opts = resolveGitea(ctx.cwd);
|
|
77
|
+
const lines = ["📊 Project Status", ""];
|
|
78
|
+
const wipR = giteaApi("/pulls?state=open&limit=100", "GET", null, opts, ctx.cwd);
|
|
79
|
+
const prs = Array.isArray(wipR.data) ? wipR.data : [];
|
|
80
|
+
const byAuthor: Record<string, number> = {};
|
|
81
|
+
for (const pr of prs) { const a = (pr as any).user?.login || "?"; byAuthor[a] = (byAuthor[a] || 0) + 1; }
|
|
82
|
+
lines.push(`🏗 WIP: ${prs.length} open PRs (limit: ${config.maxWip})`);
|
|
83
|
+
for (const [a, c] of Object.entries(byAuthor).sort(([, a], [, b]) => b - a)) lines.push(` ${a}: ${c}/${config.maxWip} ${c >= config.maxWip ? "⚠️" : "✅"}`);
|
|
84
|
+
const issuesR = giteaApi("/issues?state=open&limit=10", "GET", null, opts, ctx.cwd);
|
|
85
|
+
if (issuesR.ok && Array.isArray(issuesR.data)) {
|
|
86
|
+
const assigned = (issuesR.data as any[]).filter((i: any) => i.assignee).slice(0, 5);
|
|
87
|
+
if (assigned.length > 0) { lines.push("", "In Progress:"); for (const i of assigned) lines.push(` - #${i.number} [${i.assignee?.login}] ${i.title}`); }
|
|
88
|
+
}
|
|
89
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const releaseTool = {
|
|
94
|
+
name: "project_release_notes" as const, label: "Generate Release Notes",
|
|
95
|
+
description: "Generate release notes from conventional commits.",
|
|
96
|
+
parameters: Type.Object({ from: Type.Optional(Type.String({})), to: Type.Optional(Type.String({})) }),
|
|
97
|
+
async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
|
|
98
|
+
const config = loadConfig(ctx.cwd);
|
|
99
|
+
let from = params.from || exec("git describe --tags --abbrev=0 2>/dev/null || echo ''", ctx.cwd).stdout;
|
|
100
|
+
const to = params.to || "HEAD";
|
|
101
|
+
if (!from) from = exec("git rev-list --max-parents=0 HEAD", ctx.cwd).stdout;
|
|
102
|
+
const range = from ? `${from}..${to}` : to;
|
|
103
|
+
const log = exec(`git log ${range} --format="commit %H%n%B%n---" --no-merges`, ctx.cwd);
|
|
104
|
+
const commits = parseConventionalCommits(log.stdout || "");
|
|
105
|
+
const sections: Record<string, string[]> = {};
|
|
106
|
+
for (const g of config.releaseNoteGroups) sections[g] = [];
|
|
107
|
+
sections["other"] = [];
|
|
108
|
+
for (const c of commits) { const prefix = config.releaseNoteIncludeHashes ? `- ${c.hash} ` : "- "; const line = `${prefix}${c.scope ? `**${c.scope}**: ` : ""}${c.subject}`; (sections[c.type] || sections["other"]).push(line); }
|
|
109
|
+
const lines = [`# Release ${to} (${new Date().toISOString().split("T")[0]})`, ""];
|
|
110
|
+
for (const [group, entries] of Object.entries(sections)) { if (entries.length === 0) continue; lines.push(`### ${LABELS[group] || group}`, ""); for (const e of entries) lines.push(e); lines.push(""); }
|
|
111
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
|
|
112
|
+
},
|
|
113
|
+
};
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ProjectConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
export function validateIssueTemplate(body: string, config: ProjectConfig): { ok: true } | { ok: false; missingSections: string[] } {
|
|
4
|
+
const missing = config.requiredSections.filter(s => !body.includes(s));
|
|
5
|
+
return missing.length > 0 ? { ok: false, missingSections: missing } : { ok: true };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseDependencies(body: string, config: ProjectConfig): string[] {
|
|
9
|
+
const pattern = new RegExp(config.dependencyPattern, "gi");
|
|
10
|
+
const deps = new Set<string>();
|
|
11
|
+
let match;
|
|
12
|
+
while ((match = pattern.exec(body)) !== null) deps.add(match[1]);
|
|
13
|
+
return [...deps];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CommitEntry { hash: string; type: string; scope: string; subject: string; }
|
|
17
|
+
|
|
18
|
+
export function parseConventionalCommits(log: string): CommitEntry[] {
|
|
19
|
+
const entries: CommitEntry[] = [];
|
|
20
|
+
const commits = log.split(/\n(?=commit )/);
|
|
21
|
+
for (const block of commits) {
|
|
22
|
+
const hashMatch = block.match(/^commit (\S+)/m);
|
|
23
|
+
if (!hashMatch) continue;
|
|
24
|
+
const subjectLine = block.split("\n").find(l => l.trim() && !l.startsWith("commit ") && !l.startsWith("Author:") && !l.startsWith("Date:"));
|
|
25
|
+
if (!subjectLine) continue;
|
|
26
|
+
const convMatch = subjectLine.trim().match(/^(feat|fix|perf|refactor|chore|docs|style|test|ci|build|revert)(?:\(([^)]+)\))?:\s(.+)$/);
|
|
27
|
+
if (!convMatch) continue;
|
|
28
|
+
entries.push({ hash: hashMatch[1].slice(0, 8), type: convMatch[1], scope: convMatch[2] || "", subject: convMatch[3].trim() });
|
|
29
|
+
}
|
|
30
|
+
return entries;
|
|
31
|
+
}
|