pi-project-gate 1.0.0 → 1.2.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/AGENTS.md +68 -3
- package/package.json +10 -3
- package/src/__tests__/project-gate.test.ts +300 -0
- package/src/config.ts +57 -0
- package/src/helpers.ts +28 -0
- package/src/index.ts +15 -680
- package/src/tools/issues.ts +328 -0
- package/src/tools/project.ts +113 -0
- package/src/validate.ts +31 -0
package/AGENTS.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# Project Gate — Agent Usage Guide
|
|
2
2
|
|
|
3
|
-
> You are an AI agent. Use project-gate tools to
|
|
3
|
+
> You are an AI agent. Use project-gate tools to manage issues, validate work readiness, and track progress.
|
|
4
4
|
|
|
5
|
-
## Golden
|
|
5
|
+
## Golden Rules
|
|
6
6
|
|
|
7
7
|
> **⚠️ DO NOT start work on an issue without calling `project_check()` first.**
|
|
8
8
|
> This ensures the issue has required sections, no blockers, and you haven't exceeded WIP limits.
|
|
9
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
|
+
|
|
10
13
|
## Workflow
|
|
11
14
|
|
|
12
15
|
```
|
|
@@ -74,6 +77,66 @@ Requires #789
|
|
|
74
77
|
|
|
75
78
|
If any dependency is still open, `project_start()` blocks work.
|
|
76
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
|
+
|
|
77
140
|
## Release Notes
|
|
78
141
|
|
|
79
142
|
Generate release notes from conventional commits:
|
|
@@ -88,7 +151,9 @@ project_release_notes(from="v1.0.0", to="v1.1.0") ← between two tags
|
|
|
88
151
|
|
|
89
152
|
| Problem | Solution |
|
|
90
153
|
|---|---|
|
|
91
|
-
| Issue missing sections | Add required sections to the issue body |
|
|
154
|
+
| Issue missing sections | Add required sections to the issue body, or use `project_update_issue()` |
|
|
92
155
|
| WIP limit reached | Close or merge an existing PR first |
|
|
93
156
|
| Blocked by dependency | Help resolve the blocking issue or wait for it to be merged |
|
|
94
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/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.2.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,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,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
|
+
}
|