@bytesbrains/pi-project-gate 1.2.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 ADDED
@@ -0,0 +1,159 @@
1
+ # Project Gate — Agent Usage Guide
2
+
3
+ > You are an AI agent. Use project-gate tools to manage issues, validate work readiness, and track progress.
4
+
5
+ ## Golden Rules
6
+
7
+ > **⚠️ DO NOT start work on an issue without calling `project_check()` first.**
8
+ > This ensures the issue has required sections, no blockers, and you haven't exceeded WIP limits.
9
+
10
+ > **📝 Use `project_create_issue()` to create issues — never use raw `curl` or the browser.**
11
+ > This ensures issue templates are validated and governance gates are enforced.
12
+
13
+ ## Workflow
14
+
15
+ ```
16
+ project_check(issue_id="42") ← validate: template? deps resolved? not taken?
17
+
18
+
19
+ project_start(issue_id="42") ← WIP check, dep check, mark started
20
+
21
+
22
+ contrib_start_work(issue_id="42") ← create branch
23
+
24
+
25
+ [implement + test]
26
+
27
+
28
+ contrib_propose(message="...") ← validate + commit
29
+
30
+
31
+ contrib_submit(title="...") ← push + PR
32
+
33
+
34
+ [CI + review gates pass → merged]
35
+
36
+
37
+ project_status() ← verify WIP freed, blockers clear
38
+ ```
39
+
40
+ ## Issue Template Requirements
41
+
42
+ Issues MUST have these sections (configurable in `.projectrc.yml`):
43
+
44
+ ```markdown
45
+ ## Problem
46
+ What is the problem we're solving?
47
+
48
+ ## Proposed Solution
49
+ How should we solve it?
50
+
51
+ ## Acceptance Criteria
52
+ - [ ] Criterion 1
53
+ - [ ] Criterion 2
54
+
55
+ Complexity: medium
56
+ Area: backend
57
+ Depends on #41
58
+ ```
59
+
60
+ The `project_check()` tool validates these sections exist. If missing, it blocks work start.
61
+
62
+ ## WIP Limits
63
+
64
+ Default: max 3 open PRs per agent. Configured via `maxWip` in `.projectrc.yml`.
65
+
66
+ If you have 3 open PRs, `project_start()` will refuse to start new work.
67
+
68
+ ## Dependency Blocking
69
+
70
+ Issues can declare dependencies using:
71
+
72
+ ```
73
+ Depends on #123
74
+ Blocked by #456
75
+ Requires #789
76
+ ```
77
+
78
+ If any dependency is still open, `project_start()` blocks work.
79
+
80
+ ## Managing Issues (CRUD)
81
+
82
+ ### Create an Issue
83
+
84
+ Always use `project_create_issue()` instead of raw curl or browser:
85
+
86
+ ```
87
+ project_create_issue(
88
+ title="Add dark mode toggle",
89
+ body="## Problem\nNo dark mode support.\n\n## Proposed Solution\nAdd a toggle in settings.\n\n## Acceptance Criteria\n- [ ] Toggle switches themes\n- [ ] Persists preference",
90
+ labels=["enhancement", "frontend"],
91
+ milestone="v2.0"
92
+ )
93
+ ```
94
+
95
+ The body is validated against required sections (configurable in `.projectrc.yml`).
96
+
97
+ ### Update an Issue
98
+
99
+ Modify any aspect of an existing issue:
100
+
101
+ ```
102
+ project_update_issue(
103
+ issue_id="42",
104
+ state="closed"
105
+ )
106
+
107
+ project_update_issue(
108
+ issue_id="42",
109
+ title="Updated title",
110
+ labels=["bug", "high-priority"],
111
+ assignee="nandal"
112
+ )
113
+ ```
114
+
115
+ Only the fields you provide are changed. Omitted fields are left as-is.
116
+
117
+ ### List/Search Issues
118
+
119
+ Find issues with flexible filters:
120
+
121
+ ```
122
+ project_list_issues() ← all open issues
123
+ project_list_issues(state="closed") ← closed issues
124
+ project_list_issues(labels="bug,high-priority") ← by labels
125
+ project_list_issues(milestone="v2.0") ← by milestone
126
+ project_list_issues(assignee="nandal") ← by assignee
127
+ project_list_issues(q="dark mode") ← text search
128
+ project_list_issues(state="open", limit=50) ← pagination
129
+ ```
130
+
131
+ ### Get Issue Details
132
+
133
+ Fetch full issue info including comments:
134
+
135
+ ```
136
+ project_get_issue(issue_id="42") ← full details + comments
137
+ project_get_issue(issue_id="42", include_comments=false) ← body only
138
+ ```
139
+
140
+ ## Release Notes
141
+
142
+ Generate release notes from conventional commits:
143
+
144
+ ```
145
+ project_release_notes() ← latest tag to HEAD
146
+ project_release_notes(from="v1.0.0") ← specific tag to HEAD
147
+ project_release_notes(from="v1.0.0", to="v1.1.0") ← between two tags
148
+ ```
149
+
150
+ ## When Things Go Wrong
151
+
152
+ | Problem | Solution |
153
+ |---|---|
154
+ | Issue missing sections | Add required sections to the issue body, or use `project_update_issue()` |
155
+ | WIP limit reached | Close or merge an existing PR first |
156
+ | Blocked by dependency | Help resolve the blocking issue or wait for it to be merged |
157
+ | No complexity label | Add `Complexity: small/medium/large/epic` to issue body |
158
+ | Cannot find an issue | Use `project_list_issues(q="keyword")` to search |
159
+ | Need to close an issue | Use `project_update_issue(issue_id="42", state="closed")` |
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,132 @@
1
+ # Project Gate for Pi
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-project-gate)](https://www.npmjs.com/package/pi-project-gate)
4
+ [![license](https://img.shields.io/npm/l/pi-project-gate)](./LICENSE)
5
+
6
+ > Project orchestration gate for AI agents — structured issues, WIP limits, dependency blocking, and auto-generated release notes. **Agents work from issues, not vibes.**
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
+ `pi-project-gate` handles the *project* side (issue → plan → release).
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pi install npm:pi-project-gate
18
+ ```
19
+
20
+ ## Tools
21
+
22
+ | Tool | What it does |
23
+ |---|---|
24
+ | `project_check(issue_id)` | Validate issue readiness — template, complexity, dependencies |
25
+ | `project_start(issue_id)` | Start work on an issue — checks WIP, dependencies, template |
26
+ | `project_status()` | Project board — active issues, WIP counts, blockers |
27
+ | `project_release_notes(from, to)` | Generate release notes from conventional commits |
28
+
29
+ ## Gates
30
+
31
+ | Gate | Config | Description |
32
+ |---|---|---|
33
+ | Issue template | `requiredSections` | Block work start if issue is missing required sections |
34
+ | WIP limit | `maxWip` | Max concurrent open PRs per agent (default: 3) |
35
+ | Dependency blocking | `dependencyPattern` | Prevent starting issues with unresolved dependencies |
36
+ | Complexity tracking | `complexityLevels` | Ensure issues are scoped with complexity labels |
37
+ | Release notes | `releaseNoteGroups` | Auto-generate grouped release notes from conventional commits |
38
+
39
+ ## Configuration
40
+
41
+ Create `.projectrc.yml`:
42
+
43
+ ```yaml
44
+ # Max open PRs per agent
45
+ maxWip: 3
46
+
47
+ # Required sections in issue body (comma-separated)
48
+ requiredSections: "## Problem,## Proposed Solution,## Acceptance Criteria"
49
+
50
+ # Complexity levels
51
+ complexityLevels: trivial,small,medium,large,epic
52
+
53
+ # Area labels for categorization
54
+ areas: backend,frontend,infra,docs,tooling
55
+
56
+ # Grouping order for release notes
57
+ releaseNoteGroups: feat,fix,perf,refactor,chore,docs,test,ci,build
58
+
59
+ # Include commit hashes in release notes
60
+ releaseNoteIncludeHashes: false
61
+
62
+ # Regex pattern for issue dependencies
63
+ dependencyPattern: "(?:Depends on|Blocked by|Requires)\\s+#(\\d+)"
64
+ ```
65
+
66
+ ## Workflow
67
+
68
+ ```
69
+ project_check(#42) ← validate issue readiness
70
+
71
+
72
+ project_start(#42) ← start work (WIP + deps checked)
73
+
74
+
75
+ contrib_start_work(#42) ← create branch
76
+
77
+
78
+ [implement]
79
+
80
+
81
+ contrib_propose(...) ← validate + commit
82
+
83
+
84
+ contrib_submit(...) ← push + create PR
85
+
86
+
87
+ [CI + review gates pass]
88
+
89
+
90
+ MERGED → project_status() ← WIP freed, blockers resolved
91
+ ```
92
+
93
+ ## Release Notes
94
+
95
+ ```bash
96
+ # Generate notes from last tag to HEAD
97
+ project_release_notes()
98
+
99
+ # Generate notes between two tags
100
+ project_release_notes(from="v1.0.0", to="v1.1.0")
101
+ ```
102
+
103
+ Example output:
104
+
105
+ ```
106
+ # Release v1.1.0 (2026-05-14)
107
+
108
+ ### 🚀 Features
109
+ - **auth**: add OAuth2 support
110
+ - **api**: new user endpoint
111
+
112
+ ### 🐛 Bug Fixes
113
+ - fix null pointer in login flow
114
+ - fix race condition in task queue
115
+
116
+ ### 🔧 Chores
117
+ - update dependencies
118
+ ```
119
+
120
+ ## Integration
121
+
122
+ Install all three gates for full agent governance:
123
+
124
+ ```bash
125
+ pi install npm:pi-contrib-gate
126
+ pi install npm:pi-review-gate
127
+ pi install npm:pi-project-gate
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT © [nandal](https://github.com/nandal)
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@bytesbrains/pi-project-gate",
3
+ "version": "1.2.1",
4
+ "description": "Project orchestration gate for AI agents \u2014 structured issues, WIP limits, dependency blocking, and auto-generated release notes.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "governance",
9
+ "project-management",
10
+ "release-notes",
11
+ "issue-tracking"
12
+ ],
13
+ "author": "nandal <nandal@users.noreply.github.com>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/nandal/pi-ext",
17
+ "directory": "project-gate"
18
+ },
19
+ "homepage": "https://github.com/nandal/pi-ext/tree/main/project-gate",
20
+ "bugs": {
21
+ "url": "https://github.com/nandal/pi-ext/issues"
22
+ },
23
+ "license": "MIT",
24
+ "main": "./src/index.ts",
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "src/",
30
+ "README.md",
31
+ "AGENTS.md",
32
+ "LICENSE"
33
+ ],
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "typebox": "*"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./src/index.ts"
41
+ ]
42
+ },
43
+ "scripts": {
44
+ "test": "vitest run",
45
+ "test:watch": "vitest"
46
+ },
47
+ "devDependencies": {
48
+ "vitest": "^2.1.9"
49
+ }
50
+ }
@@ -0,0 +1,300 @@
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 { createTool, updateTool, listTool, getTool } from "../tools/issues";
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import * as os from "node:os";
9
+
10
+ // ═══════════════════════════════════════
11
+ // Config
12
+ // ═══════════════════════════════════════
13
+ describe("ProjectConfig", () => {
14
+ it("returns defaults when no config", () => {
15
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "project-test-"));
16
+ const config = loadConfig(tmp);
17
+ expect(config.maxWip).toBe(3);
18
+ expect(config.requiredSections).toContain("## Problem");
19
+ expect(config.requiredSections).toContain("## Proposed Solution");
20
+ expect(config.requiredSections).toContain("## Acceptance Criteria");
21
+ expect(config.complexityLevels).toContain("medium");
22
+ fs.rmSync(tmp, { recursive: true, force: true });
23
+ });
24
+
25
+ it("parses projectrc.yml", () => {
26
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "project-test-"));
27
+ fs.writeFileSync(path.join(tmp, ".projectrc.yml"), [
28
+ "maxWip: 5",
29
+ 'requiredSections: "## Problem,## Solution"',
30
+ 'areas: "backend,frontend,infra"',
31
+ "releaseNoteIncludeHashes: true",
32
+ ].join("\n"));
33
+ const config = loadConfig(tmp);
34
+ expect(config.maxWip).toBe(5);
35
+ expect(config.requiredSections).toEqual(["## Problem", "## Solution"]);
36
+ expect(config.areas).toEqual(["backend", "frontend", "infra"]);
37
+ expect(config.releaseNoteIncludeHashes).toBe(true);
38
+ fs.rmSync(tmp, { recursive: true, force: true });
39
+ });
40
+ });
41
+
42
+ // ═══════════════════════════════════════
43
+ // Issue template validation
44
+ // ═══════════════════════════════════════
45
+ describe("validateIssueTemplate", () => {
46
+ const config = {
47
+ ...DEFAULT_CONFIG,
48
+ requiredSections: ["## Problem", "## Proposed Solution", "## Acceptance Criteria"],
49
+ };
50
+
51
+ it("passes a complete template", () => {
52
+ const body = "## Problem\nSomething is broken\n\n## Proposed Solution\nFix it\n\n## Acceptance Criteria\n- [ ] Tests pass";
53
+ expect(validateIssueTemplate(body, config).ok).toBe(true);
54
+ });
55
+
56
+ it("fails when missing a section", () => {
57
+ const body = "## Problem\nSomething is broken\n\n## Proposed Solution\nFix it";
58
+ const result = validateIssueTemplate(body, config);
59
+ expect(result.ok).toBe(false);
60
+ if (!result.ok) {
61
+ expect(result.missingSections).toContain("## Acceptance Criteria");
62
+ }
63
+ });
64
+
65
+ it("fails when all sections missing", () => {
66
+ const body = "Just a description with no sections";
67
+ const result = validateIssueTemplate(body, config);
68
+ expect(result.ok).toBe(false);
69
+ if (!result.ok) {
70
+ expect(result.missingSections.length).toBe(3);
71
+ }
72
+ });
73
+
74
+ it("passes with custom required sections", () => {
75
+ const customConfig = { ...config, requiredSections: ["## Description"] };
76
+ expect(validateIssueTemplate("## Description\nSome text", customConfig).ok).toBe(true);
77
+ });
78
+ });
79
+
80
+ // ═══════════════════════════════════════
81
+ // Dependency parsing
82
+ // ═══════════════════════════════════════
83
+ describe("parseDependencies", () => {
84
+ const config = {
85
+ ...DEFAULT_CONFIG,
86
+ dependencyPattern: "(?:Depends on|Blocked by|Requires)\\s+#(\\d+)",
87
+ };
88
+
89
+ it("parses Depends on", () => {
90
+ const deps = parseDependencies("Depends on #42", config);
91
+ expect(deps).toEqual(["42"]);
92
+ });
93
+
94
+ it("parses Blocked by", () => {
95
+ const deps = parseDependencies("Blocked by #123", config);
96
+ expect(deps).toEqual(["123"]);
97
+ });
98
+
99
+ it("parses Requires", () => {
100
+ const deps = parseDependencies("Requires #789", config);
101
+ expect(deps).toEqual(["789"]);
102
+ });
103
+
104
+ it("parses multiple dependencies", () => {
105
+ const deps = parseDependencies("Depends on #42 and Blocked by #123", config);
106
+ expect(deps).toContain("42");
107
+ expect(deps).toContain("123");
108
+ expect(deps.length).toBe(2);
109
+ });
110
+
111
+ it("returns empty for no dependencies", () => {
112
+ expect(parseDependencies("No dependencies here", config)).toEqual([]);
113
+ });
114
+
115
+ it("is case insensitive", () => {
116
+ const deps = parseDependencies("depends on #42 and blocked by #99", config);
117
+ expect(deps).toContain("42");
118
+ expect(deps).toContain("99");
119
+ });
120
+
121
+ it("deduplicates", () => {
122
+ const deps = parseDependencies("Depends on #42 and Depends on #42", config);
123
+ expect(deps).toEqual(["42"]);
124
+ });
125
+ });
126
+
127
+ // ═══════════════════════════════════════
128
+ // Conventional commit parsing
129
+ // ═══════════════════════════════════════
130
+ describe("parseConventionalCommits", () => {
131
+ it("parses feat commits", () => {
132
+ const log = "commit abc12345\nfeat(api): add new endpoint\n---";
133
+ const commits = parseConventionalCommits(log);
134
+ expect(commits.length).toBe(1);
135
+ expect(commits[0].type).toBe("feat");
136
+ expect(commits[0].scope).toBe("api");
137
+ expect(commits[0].subject).toBe("add new endpoint");
138
+ expect(commits[0].hash).toBe("abc12345");
139
+ });
140
+
141
+ it("parses fix commits without scope", () => {
142
+ const log = "commit def67890\nfix: resolve null pointer\n---";
143
+ const commits = parseConventionalCommits(log);
144
+ expect(commits.length).toBe(1);
145
+ expect(commits[0].type).toBe("fix");
146
+ expect(commits[0].scope).toBe("");
147
+ expect(commits[0].subject).toBe("resolve null pointer");
148
+ });
149
+
150
+ it("skips non-conventional commits", () => {
151
+ const log = "commit ghi11111\nUpdated some stuff\n---";
152
+ const commits = parseConventionalCommits(log);
153
+ expect(commits.length).toBe(0);
154
+ });
155
+
156
+ it("parses multiple commits", () => {
157
+ const log = "commit aaa11111\nfeat: first feature\n---\n\ncommit bbb22222\nfix: bug fix\n---";
158
+ const commits = parseConventionalCommits(log);
159
+ expect(commits.length).toBe(2);
160
+ expect(commits[0].type).toBe("feat");
161
+ expect(commits[1].type).toBe("fix");
162
+ });
163
+
164
+ it("handles chore and refactor types", () => {
165
+ const log = "commit ccc33333\nchore(deps): update packages\n---\n\ncommit ddd44444\nrefactor: clean up\n---";
166
+ const commits = parseConventionalCommits(log);
167
+ expect(commits.length).toBe(2);
168
+ expect(commits[0].type).toBe("chore");
169
+ expect(commits[1].type).toBe("refactor");
170
+ });
171
+ });
172
+
173
+ // ═══════════════════════════════════════
174
+ // Helpers
175
+ // ═══════════════════════════════════════
176
+ describe("exec helper", () => {
177
+ it("returns ok for valid command", () => {
178
+ const r = exec("echo project-test");
179
+ expect(r.ok).toBe(true);
180
+ expect(r.stdout).toBe("project-test");
181
+ });
182
+
183
+ it("returns not ok for invalid command", () => {
184
+ const r = exec("nonexistent-cmd-xyz-999 2>/dev/null");
185
+ expect(r.ok).toBe(false);
186
+ });
187
+ });
188
+
189
+ // ═══════════════════════════════════════
190
+ // New config fields for issue CRUD
191
+ // ═══════════════════════════════════════
192
+ describe("issue config defaults", () => {
193
+ it("has default issue labels", () => {
194
+ expect(DEFAULT_CONFIG.issueLabels).toContain("enhancement");
195
+ expect(DEFAULT_CONFIG.issueLabels).toContain("bug");
196
+ expect(DEFAULT_CONFIG.issueLabels).toContain("documentation");
197
+ expect(DEFAULT_CONFIG.issueLabels).toContain("question");
198
+ });
199
+
200
+ it("has create flags defaulting to false", () => {
201
+ expect(DEFAULT_CONFIG.issueCreateRequireComplexity).toBe(false);
202
+ expect(DEFAULT_CONFIG.issueCreateRequireArea).toBe(false);
203
+ });
204
+
205
+ it("parses issueLabels from projectrc.yml", () => {
206
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "project-test-"));
207
+ fs.writeFileSync(path.join(tmp, ".projectrc.yml"), [
208
+ 'issueLabels: "enhancement,bug,frontend,backend"',
209
+ ].join("\n"));
210
+ const config = loadConfig(tmp);
211
+ expect(config.issueLabels).toEqual(["enhancement", "bug", "frontend", "backend"]);
212
+ fs.rmSync(tmp, { recursive: true, force: true });
213
+ });
214
+
215
+ it("parses issueCreateRequire flags", () => {
216
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "project-test-"));
217
+ fs.writeFileSync(path.join(tmp, ".projectrc.yml"), [
218
+ "issueCreateRequireComplexity: true",
219
+ "issueCreateRequireArea: true",
220
+ ].join("\n"));
221
+ const config = loadConfig(tmp);
222
+ expect(config.issueCreateRequireComplexity).toBe(true);
223
+ expect(config.issueCreateRequireArea).toBe(true);
224
+ fs.rmSync(tmp, { recursive: true, force: true });
225
+ });
226
+ });
227
+
228
+ // ═══════════════════════════════════════
229
+ // Tool definitions
230
+ // ═══════════════════════════════════════
231
+ describe("issue CRUD tool definitions", () => {
232
+ it("createTool has proper metadata", () => {
233
+ expect(createTool.name).toBe("project_create_issue");
234
+ expect(createTool.label).toBe("Create Issue");
235
+ expect(createTool.description).toContain("Create a new issue");
236
+ expect(createTool.parameters).toBeDefined();
237
+ });
238
+
239
+ it("updateTool has proper metadata", () => {
240
+ expect(updateTool.name).toBe("project_update_issue");
241
+ expect(updateTool.label).toBe("Update Issue");
242
+ expect(updateTool.description).toContain("Update an existing issue");
243
+ // issue_id is required, all other fields optional
244
+ const props = (updateTool.parameters as any)?.properties;
245
+ expect(props["issue_id"]).toBeDefined();
246
+ expect(props["title"]).toBeDefined();
247
+ expect(props["body"]).toBeDefined();
248
+ expect(props["state"]).toBeDefined();
249
+ expect(props["labels"]).toBeDefined();
250
+ expect(props["milestone"]).toBeDefined();
251
+ expect(props["assignee"]).toBeDefined();
252
+ });
253
+
254
+ it("listTool has proper metadata", () => {
255
+ expect(listTool.name).toBe("project_list_issues");
256
+ expect(listTool.label).toBe("List Issues");
257
+ expect(listTool.description).toContain("Search and list issues");
258
+ const props = (listTool.parameters as any)?.properties;
259
+ expect(props["state"]).toBeDefined();
260
+ expect(props["labels"]).toBeDefined();
261
+ expect(props["milestone"]).toBeDefined();
262
+ expect(props["assignee"]).toBeDefined();
263
+ expect(props["q"]).toBeDefined();
264
+ expect(props["limit"]).toBeDefined();
265
+ expect(props["page"]).toBeDefined();
266
+ });
267
+
268
+ it("getTool has proper metadata", () => {
269
+ expect(getTool.name).toBe("project_get_issue");
270
+ expect(getTool.label).toBe("Get Issue");
271
+ expect(getTool.description).toContain("full details");
272
+ const props = (getTool.parameters as any)?.properties;
273
+ expect(props["issue_id"]).toBeDefined();
274
+ expect(props["include_comments"]).toBeDefined();
275
+ });
276
+ });
277
+
278
+ // ═══════════════════════════════════════
279
+ // Issue body validation (reused by create/update)
280
+ // ═══════════════════════════════════════
281
+ describe("issue create body validation", () => {
282
+ const config = {
283
+ ...DEFAULT_CONFIG,
284
+ requiredSections: ["## Problem", "## Proposed Solution", "## Acceptance Criteria"],
285
+ };
286
+
287
+ it("accepts a well-formed issue body", () => {
288
+ const body = "## Problem\nBug exists\n\n## Proposed Solution\nFix it\n\n## Acceptance Criteria\n- [ ] Done";
289
+ expect(validateIssueTemplate(body, config).ok).toBe(true);
290
+ });
291
+
292
+ it("rejects body missing required sections", () => {
293
+ const body = "## Problem\nBug exists";
294
+ const result = validateIssueTemplate(body, config);
295
+ expect(result.ok).toBe(false);
296
+ if (!result.ok) {
297
+ expect(result.missingSections.length).toBeGreaterThan(0);
298
+ }
299
+ });
300
+ });
package/src/config.ts ADDED
@@ -0,0 +1,57 @@
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
+ issueLabels: string[];
10
+ issueCreateRequireComplexity: boolean;
11
+ issueCreateRequireArea: boolean;
12
+ releaseNoteGroups: string[];
13
+ releaseNoteIncludeHashes: boolean;
14
+ dependencyPattern: string;
15
+ }
16
+
17
+ export const DEFAULT_CONFIG: ProjectConfig = {
18
+ maxWip: 3,
19
+ requiredSections: ["## Problem", "## Proposed Solution", "## Acceptance Criteria"],
20
+ complexityLevels: ["trivial", "small", "medium", "large", "epic"],
21
+ areas: [],
22
+ issueLabels: ["enhancement", "bug", "documentation", "question"],
23
+ issueCreateRequireComplexity: false,
24
+ issueCreateRequireArea: false,
25
+ releaseNoteGroups: ["feat", "fix", "perf", "refactor", "chore", "docs", "test", "ci", "build"],
26
+ releaseNoteIncludeHashes: false,
27
+ dependencyPattern: "(?:Depends on|Blocked by|Requires)\\s+#(\\d+)",
28
+ };
29
+
30
+ export function loadConfig(cwd: string): ProjectConfig {
31
+ const configPath = path.join(cwd, ".projectrc.yml");
32
+ if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
33
+ try {
34
+ const content = fs.readFileSync(configPath, "utf-8");
35
+ const result: Record<string, unknown> = {};
36
+ for (const line of content.split("\n")) {
37
+ const m = line.match(/^\s*([\w][\w.]*):\s*(.+)$/);
38
+ if (m) {
39
+ let val = m[2].trim();
40
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
41
+ result[m[1]] = val;
42
+ }
43
+ }
44
+ return {
45
+ maxWip: parseInt(result["maxWip"] as string) || DEFAULT_CONFIG.maxWip,
46
+ requiredSections: (result["requiredSections"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.requiredSections,
47
+ complexityLevels: (result["complexityLevels"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.complexityLevels,
48
+ areas: (result["areas"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || [],
49
+ issueLabels: (result["issueLabels"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.issueLabels,
50
+ issueCreateRequireComplexity: result["issueCreateRequireComplexity"] === "true",
51
+ issueCreateRequireArea: result["issueCreateRequireArea"] === "true",
52
+ releaseNoteGroups: (result["releaseNoteGroups"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.releaseNoteGroups,
53
+ releaseNoteIncludeHashes: result["releaseNoteIncludeHashes"] === "true",
54
+ dependencyPattern: (result["dependencyPattern"] as string) || DEFAULT_CONFIG.dependencyPattern,
55
+ };
56
+ } catch { return { ...DEFAULT_CONFIG }; }
57
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,40 @@
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 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 }> {
20
+ const base = `http://127.0.0.1:3001/api/v1/repos/${opts.repo}`;
21
+ const url = `${base}${path}`;
22
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
23
+ if (opts.token) headers["Authorization"] = `token ${opts.token}`;
24
+
25
+ try {
26
+ const res = await fetch(url, {
27
+ method,
28
+ headers,
29
+ body: body ? JSON.stringify(body) : undefined,
30
+ });
31
+ const text = await res.text();
32
+ if (!res.ok) {
33
+ const lines = text.split("\n");
34
+ return { ok: false, data: null, error: text || lines.slice(0, -1).join("\n") || "API error" };
35
+ }
36
+ try { return { ok: true, data: JSON.parse(text) }; } catch { return { ok: true, data: text }; }
37
+ } catch (e: any) {
38
+ return { ok: false, data: null, error: e.message || "Network error" };
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * pi-project-gate — Project Orchestration Gate
3
+ *
4
+ * Tools: project_check, project_start, project_status, project_release_notes,
5
+ * project_create_issue, project_update_issue, project_list_issues, project_get_issue
6
+ * Config: .projectrc.yml
7
+ */
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { checkTool, startTool, statusTool, releaseTool } from "./tools/project";
10
+ import { createTool, updateTool, listTool, getTool } from "./tools/issues";
11
+
12
+ export default function (pi: ExtensionAPI) {
13
+ pi.registerTool(checkTool);
14
+ pi.registerTool(startTool);
15
+ pi.registerTool(statusTool);
16
+ pi.registerTool(releaseTool);
17
+ pi.registerTool(createTool);
18
+ pi.registerTool(updateTool);
19
+ pi.registerTool(listTool);
20
+ pi.registerTool(getTool);
21
+ pi.on("session_shutdown", () => { delete (globalThis as any).__project_issueId; });
22
+ }
@@ -0,0 +1,328 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "../config";
4
+ import { resolveGitea, giteaApi } from "../helpers";
5
+ import { validateIssueTemplate } from "../validate";
6
+
7
+ // ─── Create Issue ───────────────────────────────────────────────────────────────
8
+
9
+ export const createTool = {
10
+ name: "project_create_issue" as const,
11
+ label: "Create Issue",
12
+ description:
13
+ "Create a new issue with structured body. Validates required template sections before creation.",
14
+ parameters: Type.Object({
15
+ title: Type.String({ description: "Issue title" }),
16
+ body: Type.String({ description: "Issue body in markdown (must include required sections)" }),
17
+ labels: Type.Optional(Type.Array(Type.String({}), { description: "Labels to apply" })),
18
+ milestone: Type.Optional(Type.String({ description: "Milestone title or ID" })),
19
+ assignee: Type.Optional(Type.String({ description: "Username to assign" })),
20
+ }),
21
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
22
+ const config = loadConfig(ctx.cwd);
23
+ const opts = resolveGitea(ctx.cwd);
24
+
25
+ // Validate template
26
+ const tpl = validateIssueTemplate(params.body, config);
27
+ if (!tpl.ok) {
28
+ return {
29
+ content: [
30
+ {
31
+ type: "text",
32
+ text: `❌ Issue body is missing required sections:\n - ${tpl.missingSections.join("\n - ")}`,
33
+ },
34
+ ],
35
+ isError: true,
36
+ details: { missingSections: tpl.missingSections },
37
+ };
38
+ }
39
+
40
+ // Detect complexity from body
41
+ const complexity = config.complexityLevels.find((l) =>
42
+ params.body.toLowerCase().includes(l.toLowerCase()),
43
+ );
44
+
45
+ // Build payload
46
+ const payload: Record<string, unknown> = {
47
+ title: params.title,
48
+ body: params.body,
49
+ };
50
+ if (params.labels && params.labels.length > 0) payload.labels = params.labels;
51
+ if (params.milestone) payload.milestone = params.milestone;
52
+ if (params.assignee) payload.assignee = params.assignee;
53
+
54
+ const r = await giteaApi("/issues", "POST", payload, opts, ctx.cwd);
55
+ if (!r.ok || !r.data) {
56
+ return {
57
+ content: [{ type: "text", text: `❌ Failed to create issue: ${r.error || "unknown error"}` }],
58
+ isError: true,
59
+ details: {},
60
+ };
61
+ }
62
+
63
+ const issue = r.data as Record<string, unknown>;
64
+ const lines = [
65
+ `✅ Issue #${issue.number} created: "${issue.title}"`,
66
+ ` URL: ${issue.html_url || `http://127.0.0.1:3001/${opts.repo}/issues/${issue.number}`}`,
67
+ ];
68
+ if (complexity) lines.push(` Complexity: ${complexity}`);
69
+ if (params.labels?.length) lines.push(` Labels: ${params.labels.join(", ")}`);
70
+ if (params.milestone) lines.push(` Milestone: ${params.milestone}`);
71
+ if (params.assignee) lines.push(` Assignee: ${params.assignee}`);
72
+
73
+ return {
74
+ content: [{ type: "text", text: lines.join("\n") }],
75
+ details: { issueId: issue.number, url: issue.html_url },
76
+ };
77
+ },
78
+ };
79
+
80
+ // ─── Update Issue ───────────────────────────────────────────────────────────────
81
+
82
+ export const updateTool = {
83
+ name: "project_update_issue" as const,
84
+ label: "Update Issue",
85
+ description:
86
+ "Update an existing issue — title, body, state (open/closed), labels, milestone, or assignee.",
87
+ parameters: Type.Object({
88
+ issue_id: Type.String({ description: "Issue number or ID" }),
89
+ title: Type.Optional(Type.String({ description: "New title" })),
90
+ body: Type.Optional(Type.String({ description: "New body in markdown" })),
91
+ state: Type.Optional(
92
+ Type.String({ description: 'State: "open" or "closed"' }),
93
+ ),
94
+ labels: Type.Optional(Type.Array(Type.String({}), { description: "Replacement labels" })),
95
+ milestone: Type.Optional(Type.String({ description: "Milestone title or ID, or null to remove" })),
96
+ assignee: Type.Optional(Type.String({ description: "Username to assign, or empty string to unassign" })),
97
+ }),
98
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
99
+ const config = loadConfig(ctx.cwd);
100
+ const opts = resolveGitea(ctx.cwd);
101
+ const issueId = String(params.issue_id).replace(/^#/, "");
102
+
103
+ // Fetch current issue to verify it exists
104
+ const current = await giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
105
+ if (!current.ok || !current.data) {
106
+ return {
107
+ content: [{ type: "text", text: `❌ Issue #${issueId} not found.` }],
108
+ isError: true,
109
+ details: {},
110
+ };
111
+ }
112
+
113
+ // Validate body template if body is being updated
114
+ if (params.body) {
115
+ const tpl = validateIssueTemplate(params.body, config);
116
+ if (!tpl.ok) {
117
+ return {
118
+ content: [
119
+ {
120
+ type: "text",
121
+ text: `❌ Updated body is missing required sections:\n - ${tpl.missingSections.join("\n - ")}`,
122
+ },
123
+ ],
124
+ isError: true,
125
+ details: { missingSections: tpl.missingSections },
126
+ };
127
+ }
128
+ }
129
+
130
+ // Build patch payload — only include fields that were provided
131
+ const payload: Record<string, unknown> = {};
132
+ if (params.title !== undefined) payload.title = params.title;
133
+ if (params.body !== undefined) payload.body = params.body;
134
+ if (params.state !== undefined) payload.state = params.state;
135
+ if (params.labels !== undefined) payload.labels = params.labels;
136
+ if (params.milestone !== undefined) payload.milestone = params.milestone;
137
+ if (params.assignee !== undefined) payload.assignee = params.assignee;
138
+
139
+ if (Object.keys(payload).length === 0) {
140
+ return {
141
+ content: [{ type: "text", text: "⚠️ No fields to update." }],
142
+ isError: true,
143
+ details: {},
144
+ };
145
+ }
146
+
147
+ const r = await giteaApi(`/issues/${issueId}`, "PATCH", payload, opts, ctx.cwd);
148
+ if (!r.ok || !r.data) {
149
+ return {
150
+ content: [{ type: "text", text: `❌ Failed to update issue: ${r.error || "unknown error"}` }],
151
+ isError: true,
152
+ details: {},
153
+ };
154
+ }
155
+
156
+ const issue = r.data as Record<string, unknown>;
157
+ const changes: string[] = [];
158
+ if (params.title !== undefined) changes.push("title");
159
+ if (params.body !== undefined) changes.push("body");
160
+ if (params.state !== undefined) changes.push(`state → ${params.state}`);
161
+ if (params.labels !== undefined) changes.push("labels");
162
+ if (params.milestone !== undefined) changes.push("milestone");
163
+ if (params.assignee !== undefined) changes.push("assignee");
164
+
165
+ return {
166
+ content: [
167
+ {
168
+ type: "text",
169
+ text: `✅ Issue #${issueId} updated: "${issue.title}"\n Changed: ${changes.join(", ")}`,
170
+ },
171
+ ],
172
+ details: { issueId, changed: changes },
173
+ };
174
+ },
175
+ };
176
+
177
+ // ─── List Issues ────────────────────────────────────────────────────────────────
178
+
179
+ export const listTool = {
180
+ name: "project_list_issues" as const,
181
+ label: "List Issues",
182
+ description:
183
+ "Search and list issues with optional filters: state, labels, milestone, assignee, keyword, and pagination.",
184
+ parameters: Type.Object({
185
+ state: Type.Optional(Type.String({ description: 'Filter by state: "open" or "closed" (default: open)' })),
186
+ labels: Type.Optional(Type.String({ description: "Comma-separated label names" })),
187
+ milestone: Type.Optional(Type.String({ description: "Filter by milestone title" })),
188
+ assignee: Type.Optional(Type.String({ description: "Filter by assignee username" })),
189
+ q: Type.Optional(Type.String({ description: "Full-text search query (searches title + body)" })),
190
+ limit: Type.Optional(Type.Number({ description: "Max issues to return (default: 20, max: 100)" })),
191
+ page: Type.Optional(Type.Number({ description: "Page number (default: 1)" })),
192
+ }),
193
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
194
+ const opts = resolveGitea(ctx.cwd);
195
+ const queryParts: string[] = [];
196
+ queryParts.push(`state=${params.state || "open"}`);
197
+ queryParts.push(`limit=${Math.min(params.limit || 20, 100)}`);
198
+ queryParts.push(`page=${params.page || 1}`);
199
+ if (params.labels) queryParts.push(`labels=${encodeURIComponent(params.labels)}`);
200
+ if (params.milestone) queryParts.push(`milestone=${encodeURIComponent(params.milestone)}`);
201
+ if (params.assignee) queryParts.push(`assignee=${encodeURIComponent(params.assignee)}`);
202
+ if (params.q) queryParts.push(`q=${encodeURIComponent(params.q)}`);
203
+
204
+ const r = await giteaApi(`/issues?${queryParts.join("&")}`, "GET", null, opts, ctx.cwd);
205
+ if (!r.ok) {
206
+ return {
207
+ content: [{ type: "text", text: `❌ Failed to list issues: ${r.error || "unknown error"}` }],
208
+ isError: true,
209
+ details: {},
210
+ };
211
+ }
212
+
213
+ const issues = Array.isArray(r.data) ? r.data : [];
214
+ if (issues.length === 0) {
215
+ return {
216
+ content: [{ type: "text", text: "No issues found." }],
217
+ details: { count: 0 },
218
+ };
219
+ }
220
+
221
+ const lines = [`📋 Issues (${issues.length} found)`];
222
+ if (params.q) lines.push(` Search: "${params.q}"`);
223
+ lines.push("");
224
+
225
+ for (const issue of issues as any[]) {
226
+ const labels =
227
+ issue.labels && issue.labels.length > 0
228
+ ? ` [${issue.labels.map((l: any) => l.name).join(", ")}]`
229
+ : "";
230
+ const assignee = issue.assignee ? ` (👤 ${issue.assignee.login})` : "";
231
+ lines.push(
232
+ ` #${issue.number} ${issue.state === "closed" ? "🔒" : "🟢"} ${issue.title}${labels}${assignee}`,
233
+ );
234
+ }
235
+
236
+ return {
237
+ content: [{ type: "text", text: lines.join("\n") }],
238
+ details: { count: issues.length, issues: issues.map((i: any) => i.number) },
239
+ };
240
+ },
241
+ };
242
+
243
+ // ─── Get Issue ───────────────────────────────────────────────────────────────────
244
+
245
+ export const getTool = {
246
+ name: "project_get_issue" as const,
247
+ label: "Get Issue",
248
+ description:
249
+ "Get full details of an issue including its body, labels, milestone, assignee, and recent comments.",
250
+ parameters: Type.Object({
251
+ issue_id: Type.String({ description: "Issue number or ID" }),
252
+ include_comments: Type.Optional(
253
+ Type.Boolean({ description: "Include recent comments (default: true)" }),
254
+ ),
255
+ }),
256
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
257
+ const opts = resolveGitea(ctx.cwd);
258
+ const issueId = String(params.issue_id).replace(/^#/, "");
259
+
260
+ const r = await giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
261
+ if (!r.ok || !r.data) {
262
+ return {
263
+ content: [{ type: "text", text: `❌ Issue #${issueId} not found.` }],
264
+ isError: true,
265
+ details: {},
266
+ };
267
+ }
268
+
269
+ const issue = r.data as Record<string, unknown>;
270
+ const labels = Array.isArray(issue.labels)
271
+ ? (issue.labels as any[]).map((l) => l.name).join(", ")
272
+ : "(none)";
273
+
274
+ const lines = [
275
+ `📋 Issue #${issueId}`,
276
+ ` Title: ${issue.title}`,
277
+ ` State: ${issue.state} | Created: ${String(issue.created_at).slice(0, 10)}`,
278
+ ` Author: ${(issue.user as any)?.login || "?"}`,
279
+ ` Assignee: ${(issue.assignee as any)?.login || "(unassigned)"}`,
280
+ ` Milestone: ${(issue.milestone as any)?.title || "(none)"}`,
281
+ ` Labels: ${labels}`,
282
+ ` URL: ${issue.html_url || `http://127.0.0.1:3001/${opts.repo}/issues/${issueId}`}`,
283
+ "",
284
+ "─── Body ───",
285
+ issue.body || "(empty)",
286
+ "",
287
+ ];
288
+
289
+ // Fetch comments
290
+ const includeComments = params.include_comments !== false;
291
+ if (includeComments) {
292
+ const cr = await giteaApi(
293
+ `/issues/${issueId}/comments?limit=20`,
294
+ "GET",
295
+ null,
296
+ opts,
297
+ ctx.cwd,
298
+ );
299
+ const comments = Array.isArray(cr.data) ? cr.data : [];
300
+ if (comments.length > 0) {
301
+ lines.push(`─── Comments (${comments.length}) ───`);
302
+ for (const c of comments as any[]) {
303
+ const date = String(c.created_at).slice(0, 10);
304
+ const user = c.user?.login || "?";
305
+ const body = (c.body || "").split("\n").slice(0, 5).join("\n");
306
+ lines.push(`\n [${date}] ${user}:`);
307
+ for (const bl of body.split("\n")) {
308
+ lines.push(` ${bl}`);
309
+ }
310
+ if ((c.body || "").split("\n").length > 5) lines.push(" ...");
311
+ }
312
+ } else {
313
+ lines.push("─── Comments ───");
314
+ lines.push(" (none)");
315
+ }
316
+ }
317
+
318
+ return {
319
+ content: [{ type: "text", text: lines.join("\n") }],
320
+ details: {
321
+ issueId,
322
+ state: issue.state,
323
+ title: issue.title,
324
+ url: issue.html_url,
325
+ },
326
+ };
327
+ },
328
+ };
@@ -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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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
+ };
@@ -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
+ }