pi-project-gate 1.1.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 +1 -1
- package/src/__tests__/project-gate.test.ts +114 -0
- package/src/config.ts +9 -0
- package/src/index.ts +7 -1
- package/src/tools/issues.ts +328 -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
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import { loadConfig, DEFAULT_CONFIG } from "../config";
|
|
3
3
|
import { validateIssueTemplate, parseDependencies, parseConventionalCommits } from "../validate";
|
|
4
4
|
import { exec } from "../helpers";
|
|
5
|
+
import { createTool, updateTool, listTool, getTool } from "../tools/issues";
|
|
5
6
|
import * as fs from "node:fs";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import * as os from "node:os";
|
|
@@ -184,3 +185,116 @@ describe("exec helper", () => {
|
|
|
184
185
|
expect(r.ok).toBe(false);
|
|
185
186
|
});
|
|
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
CHANGED
|
@@ -6,6 +6,9 @@ export interface ProjectConfig {
|
|
|
6
6
|
requiredSections: string[];
|
|
7
7
|
complexityLevels: string[];
|
|
8
8
|
areas: string[];
|
|
9
|
+
issueLabels: string[];
|
|
10
|
+
issueCreateRequireComplexity: boolean;
|
|
11
|
+
issueCreateRequireArea: boolean;
|
|
9
12
|
releaseNoteGroups: string[];
|
|
10
13
|
releaseNoteIncludeHashes: boolean;
|
|
11
14
|
dependencyPattern: string;
|
|
@@ -16,6 +19,9 @@ export const DEFAULT_CONFIG: ProjectConfig = {
|
|
|
16
19
|
requiredSections: ["## Problem", "## Proposed Solution", "## Acceptance Criteria"],
|
|
17
20
|
complexityLevels: ["trivial", "small", "medium", "large", "epic"],
|
|
18
21
|
areas: [],
|
|
22
|
+
issueLabels: ["enhancement", "bug", "documentation", "question"],
|
|
23
|
+
issueCreateRequireComplexity: false,
|
|
24
|
+
issueCreateRequireArea: false,
|
|
19
25
|
releaseNoteGroups: ["feat", "fix", "perf", "refactor", "chore", "docs", "test", "ci", "build"],
|
|
20
26
|
releaseNoteIncludeHashes: false,
|
|
21
27
|
dependencyPattern: "(?:Depends on|Blocked by|Requires)\\s+#(\\d+)",
|
|
@@ -40,6 +46,9 @@ export function loadConfig(cwd: string): ProjectConfig {
|
|
|
40
46
|
requiredSections: (result["requiredSections"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.requiredSections,
|
|
41
47
|
complexityLevels: (result["complexityLevels"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.complexityLevels,
|
|
42
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",
|
|
43
52
|
releaseNoteGroups: (result["releaseNoteGroups"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.releaseNoteGroups,
|
|
44
53
|
releaseNoteIncludeHashes: result["releaseNoteIncludeHashes"] === "true",
|
|
45
54
|
dependencyPattern: (result["dependencyPattern"] as string) || DEFAULT_CONFIG.dependencyPattern,
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-project-gate — Project Orchestration Gate
|
|
3
3
|
*
|
|
4
|
-
* Tools: project_check, project_start, project_status, project_release_notes
|
|
4
|
+
* Tools: project_check, project_start, project_status, project_release_notes,
|
|
5
|
+
* project_create_issue, project_update_issue, project_list_issues, project_get_issue
|
|
5
6
|
* Config: .projectrc.yml
|
|
6
7
|
*/
|
|
7
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
9
|
import { checkTool, startTool, statusTool, releaseTool } from "./tools/project";
|
|
10
|
+
import { createTool, updateTool, listTool, getTool } from "./tools/issues";
|
|
9
11
|
|
|
10
12
|
export default function (pi: ExtensionAPI) {
|
|
11
13
|
pi.registerTool(checkTool);
|
|
12
14
|
pi.registerTool(startTool);
|
|
13
15
|
pi.registerTool(statusTool);
|
|
14
16
|
pi.registerTool(releaseTool);
|
|
17
|
+
pi.registerTool(createTool);
|
|
18
|
+
pi.registerTool(updateTool);
|
|
19
|
+
pi.registerTool(listTool);
|
|
20
|
+
pi.registerTool(getTool);
|
|
15
21
|
pi.on("session_shutdown", () => { delete (globalThis as any).__project_issueId; });
|
|
16
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 = 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 = 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 = 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 = 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 = 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 = 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
|
+
};
|