@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 +159 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/package.json +50 -0
- package/src/__tests__/project-gate.test.ts +300 -0
- package/src/config.ts +57 -0
- package/src/helpers.ts +40 -0
- package/src/index.ts +22 -0
- package/src/tools/issues.ts +328 -0
- package/src/tools/project.ts +113 -0
- package/src/validate.ts +31 -0
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
|
+
[](https://www.npmjs.com/package/pi-project-gate)
|
|
4
|
+
[](./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
|
+
};
|
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
|
+
}
|