@m8i-51/shoal 0.1.10 → 0.1.11
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/.env.example +26 -1
- package/README.md +18 -4
- package/bin/init.js +176 -15
- package/bin/shoal.js +8 -0
- package/framework/scenario-designer.ts +1 -1
- package/framework/trackers/asana.ts +70 -0
- package/framework/trackers/backlog.ts +70 -0
- package/framework/trackers/github.ts +23 -0
- package/framework/trackers/index.ts +120 -0
- package/framework/trackers/jira.ts +89 -0
- package/framework/trackers/notion.ts +84 -0
- package/framework/trackers/types.ts +19 -0
- package/framework/triage.ts +12 -12
- package/package.json +1 -1
- package/run.ts +11 -14
- package/triage-only.ts +3 -4
package/.env.example
CHANGED
|
@@ -62,12 +62,37 @@ TARGET=none # example | none | カスタムターゲット名
|
|
|
62
62
|
BASE_URL=http://localhost:3000
|
|
63
63
|
|
|
64
64
|
# ================================================================
|
|
65
|
-
#
|
|
65
|
+
# Issue Tracker (optional)
|
|
66
66
|
# ================================================================
|
|
67
|
+
# カンマ区切りで複数のトラッカーを同時に有効化できます。
|
|
68
|
+
# ISSUE_TRACKERS が未設定で GITHUB_TOKEN/GITHUB_REPO がある場合は github が自動で有効になります。
|
|
67
69
|
|
|
70
|
+
# ISSUE_TRACKERS=github,backlog
|
|
71
|
+
|
|
72
|
+
# --- GitHub Issues ---
|
|
68
73
|
GITHUB_TOKEN=
|
|
69
74
|
GITHUB_REPO=owner/repo
|
|
70
75
|
|
|
76
|
+
# --- Jira ---
|
|
77
|
+
# JIRA_BASE_URL=https://yourcompany.atlassian.net
|
|
78
|
+
# JIRA_EMAIL=user@example.com
|
|
79
|
+
# JIRA_API_TOKEN=
|
|
80
|
+
# JIRA_PROJECT_KEY=PROJ
|
|
81
|
+
|
|
82
|
+
# --- Notion ---
|
|
83
|
+
# NOTION_API_KEY=
|
|
84
|
+
# NOTION_DATABASE_ID=
|
|
85
|
+
# ※ Notion のデータベースには Name (title) / Labels (multi_select) / Status (select) プロパティが必要です
|
|
86
|
+
|
|
87
|
+
# --- Backlog ---
|
|
88
|
+
# BACKLOG_SPACE=yourspace
|
|
89
|
+
# BACKLOG_API_KEY=
|
|
90
|
+
# BACKLOG_PROJECT_ID=12345
|
|
91
|
+
|
|
92
|
+
# --- Asana ---
|
|
93
|
+
# ASANA_ACCESS_TOKEN=
|
|
94
|
+
# ASANA_PROJECT_ID=
|
|
95
|
+
|
|
71
96
|
# ================================================================
|
|
72
97
|
# Run config
|
|
73
98
|
# ================================================================
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ Target App (any URL)
|
|
|
39
39
|
explore via API browse the real UI
|
|
40
40
|
│ │
|
|
41
41
|
└──────────────┬───────────────────┘
|
|
42
|
-
▼ deduplicates and files
|
|
42
|
+
▼ deduplicates and files issue tickets
|
|
43
43
|
Triage Agent
|
|
44
44
|
```
|
|
45
45
|
|
|
@@ -56,7 +56,7 @@ At the end of each run:
|
|
|
56
56
|
- **Feature suggestions** — things that would add real value
|
|
57
57
|
- **Goal gaps** — where the app falls short of what it's trying to achieve
|
|
58
58
|
|
|
59
|
-
Findings are filed as GitHub Issues or saved as a self-contained HTML report. A **web dashboard** lets you start runs, watch live progress, review findings by category, and track estimated LLM cost per run.
|
|
59
|
+
Findings are filed as issue tickets (GitHub Issues, Jira, Notion, Backlog, or Asana) or saved as a self-contained HTML report. A **web dashboard** lets you start runs, watch live progress, review findings by category, and track estimated LLM cost per run.
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
@@ -88,6 +88,7 @@ Then run:
|
|
|
88
88
|
```bash
|
|
89
89
|
shoal serve # open web dashboard at http://localhost:4000
|
|
90
90
|
shoal # or run agents directly from the terminal
|
|
91
|
+
shoal config # update settings in existing .env (e.g. issue trackers)
|
|
91
92
|
```
|
|
92
93
|
|
|
93
94
|
**Or clone and develop locally:**
|
|
@@ -128,10 +129,23 @@ Opens at `http://localhost:4000`. From there you can:
|
|
|
128
129
|
| `MAX_EXPLORERS` | `4` | API explorer agent count (0 to disable) |
|
|
129
130
|
| `MAX_BROWSERS` | `2` | Browser agent count |
|
|
130
131
|
| `ANTHROPIC_API_KEY` | — | Required |
|
|
131
|
-
| `
|
|
132
|
-
| `GITHUB_REPO` | — | `owner/repo` format |
|
|
132
|
+
| `ISSUE_TRACKERS` | — | Comma-separated list of active trackers: `github`, `jira`, `notion`, `backlog`, `asana` |
|
|
133
133
|
| `REFRESH_SPEC` | — | Set to `1` to re-run product discovery |
|
|
134
134
|
|
|
135
|
+
**Issue tracker variables** (set only what you need):
|
|
136
|
+
|
|
137
|
+
| Tracker | Variables |
|
|
138
|
+
|---|---|
|
|
139
|
+
| GitHub Issues | `GITHUB_TOKEN`, `GITHUB_REPO` (`owner/repo`) |
|
|
140
|
+
| Jira | `JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`, `JIRA_PROJECT_KEY` |
|
|
141
|
+
| Notion | `NOTION_API_KEY`, `NOTION_DATABASE_ID` ¹ |
|
|
142
|
+
| Backlog | `BACKLOG_SPACE`, `BACKLOG_API_KEY`, `BACKLOG_PROJECT_ID` |
|
|
143
|
+
| Asana | `ASANA_ACCESS_TOKEN`, `ASANA_PROJECT_ID` |
|
|
144
|
+
|
|
145
|
+
¹ The Notion database must have `Name` (title), `Labels` (multi_select), and `Status` (select) properties.
|
|
146
|
+
|
|
147
|
+
Multiple trackers can be active at the same time — findings are posted to all of them. If `ISSUE_TRACKERS` is not set but `GITHUB_TOKEN` and `GITHUB_REPO` are present, GitHub is used automatically (backward compatible).
|
|
148
|
+
|
|
135
149
|
---
|
|
136
150
|
|
|
137
151
|
## Adding a target
|
package/bin/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { intro, outro, select, text, confirm, isCancel, cancel } from "@clack/prompts";
|
|
2
|
-
import { writeFileSync, existsSync, mkdirSync } from "fs";
|
|
1
|
+
import { intro, outro, select, multiselect, text, confirm, isCancel, cancel } from "@clack/prompts";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
|
|
5
5
|
const PROVIDERS = [
|
|
@@ -101,19 +101,9 @@ export async function runInit(cwd) {
|
|
|
101
101
|
defaultValue: "http://localhost:3000",
|
|
102
102
|
}));
|
|
103
103
|
|
|
104
|
-
// ──
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
placeholder: "ghp_... leave blank to skip",
|
|
108
|
-
}));
|
|
109
|
-
if (githubToken.trim()) {
|
|
110
|
-
env.GITHUB_TOKEN = githubToken.trim();
|
|
111
|
-
const githubRepo = guard(await text({
|
|
112
|
-
message: "GitHub repo",
|
|
113
|
-
placeholder: "owner/repo",
|
|
114
|
-
}));
|
|
115
|
-
if (githubRepo.trim()) env.GITHUB_REPO = githubRepo.trim();
|
|
116
|
-
}
|
|
104
|
+
// ── Issue trackers (optional) ─────────────────────────────────────
|
|
105
|
+
const trackerEnv = await promptTrackers();
|
|
106
|
+
Object.assign(env, trackerEnv);
|
|
117
107
|
|
|
118
108
|
// ── Write .env ────────────────────────────────────────────────────
|
|
119
109
|
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
@@ -184,3 +174,174 @@ jobs:
|
|
|
184
174
|
|
|
185
175
|
outro("Created .env\n\n shoal serve — open the dashboard at http://localhost:4000\n shoal — run agents from the terminal");
|
|
186
176
|
}
|
|
177
|
+
|
|
178
|
+
// ── Tracker config helpers ─────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const TRACKER_KEYS = [
|
|
181
|
+
"ISSUE_TRACKERS",
|
|
182
|
+
"GITHUB_TOKEN", "GITHUB_REPO",
|
|
183
|
+
"JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_PROJECT_KEY",
|
|
184
|
+
"NOTION_API_KEY", "NOTION_DATABASE_ID",
|
|
185
|
+
"BACKLOG_SPACE", "BACKLOG_API_KEY", "BACKLOG_PROJECT_ID",
|
|
186
|
+
"ASANA_ACCESS_TOKEN", "ASANA_PROJECT_ID",
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
function parseEnv(content) {
|
|
190
|
+
const result = {};
|
|
191
|
+
for (const line of content.split("\n")) {
|
|
192
|
+
const trimmed = line.trim();
|
|
193
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
194
|
+
const idx = trimmed.indexOf("=");
|
|
195
|
+
if (idx === -1) continue;
|
|
196
|
+
result[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function updateEnvFile(envPath, newKeys, removeKeys) {
|
|
202
|
+
const content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
|
|
203
|
+
const lines = content.split("\n").filter((line) => {
|
|
204
|
+
const key = line.split("=")[0].trim();
|
|
205
|
+
return !removeKeys.includes(key);
|
|
206
|
+
});
|
|
207
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
208
|
+
const newLines = Object.entries(newKeys).map(([k, v]) => `${k}=${v}`);
|
|
209
|
+
writeFileSync(envPath, [...lines, "", ...newLines, ""].join("\n"), "utf-8");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function promptTrackers(existing = {}) {
|
|
213
|
+
const currentTrackers = (existing.ISSUE_TRACKERS ?? "")
|
|
214
|
+
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
215
|
+
|
|
216
|
+
const selectedTrackers = guard(await multiselect({
|
|
217
|
+
message: "Issue trackers (select all that apply; leave empty to save locally only)",
|
|
218
|
+
options: [
|
|
219
|
+
{ value: "github", label: "GitHub Issues", selected: currentTrackers.includes("github") },
|
|
220
|
+
{ value: "jira", label: "Jira", selected: currentTrackers.includes("jira") },
|
|
221
|
+
{ value: "notion", label: "Notion", selected: currentTrackers.includes("notion") },
|
|
222
|
+
{ value: "backlog", label: "Backlog", selected: currentTrackers.includes("backlog") },
|
|
223
|
+
{ value: "asana", label: "Asana", selected: currentTrackers.includes("asana") },
|
|
224
|
+
],
|
|
225
|
+
required: false,
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
const env = {};
|
|
229
|
+
|
|
230
|
+
if (selectedTrackers.length > 0) {
|
|
231
|
+
env.ISSUE_TRACKERS = selectedTrackers.join(",");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (selectedTrackers.includes("github")) {
|
|
235
|
+
env.GITHUB_TOKEN = guard(await text({
|
|
236
|
+
message: "GitHub token",
|
|
237
|
+
placeholder: "ghp_...",
|
|
238
|
+
initialValue: existing.GITHUB_TOKEN ?? "",
|
|
239
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
240
|
+
}));
|
|
241
|
+
env.GITHUB_REPO = guard(await text({
|
|
242
|
+
message: "GitHub repo",
|
|
243
|
+
placeholder: "owner/repo",
|
|
244
|
+
initialValue: existing.GITHUB_REPO ?? "",
|
|
245
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (selectedTrackers.includes("jira")) {
|
|
250
|
+
env.JIRA_BASE_URL = guard(await text({
|
|
251
|
+
message: "Jira base URL",
|
|
252
|
+
placeholder: "https://yourcompany.atlassian.net",
|
|
253
|
+
initialValue: existing.JIRA_BASE_URL ?? "",
|
|
254
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
255
|
+
}));
|
|
256
|
+
env.JIRA_EMAIL = guard(await text({
|
|
257
|
+
message: "Jira account email",
|
|
258
|
+
initialValue: existing.JIRA_EMAIL ?? "",
|
|
259
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
260
|
+
}));
|
|
261
|
+
env.JIRA_API_TOKEN = guard(await text({
|
|
262
|
+
message: "Jira API token",
|
|
263
|
+
initialValue: existing.JIRA_API_TOKEN ?? "",
|
|
264
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
265
|
+
}));
|
|
266
|
+
env.JIRA_PROJECT_KEY = guard(await text({
|
|
267
|
+
message: "Jira project key",
|
|
268
|
+
placeholder: "PROJ",
|
|
269
|
+
initialValue: existing.JIRA_PROJECT_KEY ?? "",
|
|
270
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (selectedTrackers.includes("notion")) {
|
|
275
|
+
env.NOTION_API_KEY = guard(await text({
|
|
276
|
+
message: "Notion API key",
|
|
277
|
+
placeholder: "secret_...",
|
|
278
|
+
initialValue: existing.NOTION_API_KEY ?? "",
|
|
279
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
280
|
+
}));
|
|
281
|
+
env.NOTION_DATABASE_ID = guard(await text({
|
|
282
|
+
message: "Notion database ID",
|
|
283
|
+
hint: "DB must have Name (title), Labels (multi_select), Status (select) properties",
|
|
284
|
+
initialValue: existing.NOTION_DATABASE_ID ?? "",
|
|
285
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (selectedTrackers.includes("backlog")) {
|
|
290
|
+
env.BACKLOG_SPACE = guard(await text({
|
|
291
|
+
message: "Backlog space name",
|
|
292
|
+
placeholder: "yourspace (from yourspace.backlog.com)",
|
|
293
|
+
initialValue: existing.BACKLOG_SPACE ?? "",
|
|
294
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
295
|
+
}));
|
|
296
|
+
env.BACKLOG_API_KEY = guard(await text({
|
|
297
|
+
message: "Backlog API key",
|
|
298
|
+
initialValue: existing.BACKLOG_API_KEY ?? "",
|
|
299
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
300
|
+
}));
|
|
301
|
+
env.BACKLOG_PROJECT_ID = guard(await text({
|
|
302
|
+
message: "Backlog project ID (numeric)",
|
|
303
|
+
initialValue: existing.BACKLOG_PROJECT_ID ?? "",
|
|
304
|
+
validate: (v) => /^\d+$/.test(v?.trim()) ? undefined : "Must be a number",
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (selectedTrackers.includes("asana")) {
|
|
309
|
+
env.ASANA_ACCESS_TOKEN = guard(await text({
|
|
310
|
+
message: "Asana personal access token",
|
|
311
|
+
initialValue: existing.ASANA_ACCESS_TOKEN ?? "",
|
|
312
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
313
|
+
}));
|
|
314
|
+
env.ASANA_PROJECT_ID = guard(await text({
|
|
315
|
+
message: "Asana project ID",
|
|
316
|
+
initialValue: existing.ASANA_PROJECT_ID ?? "",
|
|
317
|
+
validate: (v) => v?.trim() ? undefined : "Required",
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return env;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function runConfig(cwd) {
|
|
325
|
+
const envPath = join(cwd, ".env");
|
|
326
|
+
|
|
327
|
+
if (!existsSync(envPath)) {
|
|
328
|
+
console.log(".env not found. Run shoal init first.");
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
intro("shoal config");
|
|
333
|
+
|
|
334
|
+
const section = guard(await select({
|
|
335
|
+
message: "What do you want to configure?",
|
|
336
|
+
options: [
|
|
337
|
+
{ value: "trackers", label: "Issue trackers" },
|
|
338
|
+
],
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
if (section === "trackers") {
|
|
342
|
+
const existing = parseEnv(readFileSync(envPath, "utf-8"));
|
|
343
|
+
const newTrackerEnv = await promptTrackers(existing);
|
|
344
|
+
updateEnvFile(envPath, newTrackerEnv, TRACKER_KEYS);
|
|
345
|
+
outro("Updated .env — run shoal to apply changes");
|
|
346
|
+
}
|
|
347
|
+
}
|
package/bin/shoal.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* shoal init # interactive setup — creates .env in current directory
|
|
7
|
+
* shoal config # update settings in existing .env (e.g. issue trackers)
|
|
7
8
|
* shoal serve # web dashboard at http://localhost:4000
|
|
8
9
|
* shoal # run agents from the terminal
|
|
9
10
|
* shoal triage # triage-only mode
|
|
@@ -25,6 +26,13 @@ async function main() {
|
|
|
25
26
|
process.exit(0);
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// config — 既存の .env を対話形式で更新する
|
|
30
|
+
if (subcommand === "config") {
|
|
31
|
+
const { runConfig } = await import("./init.js");
|
|
32
|
+
await runConfig(process.cwd());
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
// serve の場合、web/dist が存在しなければ自動ビルドする
|
|
29
37
|
if (subcommand === "serve") {
|
|
30
38
|
const distIndex = join(packageRoot, "web", "dist", "index.html");
|
|
@@ -59,7 +59,7 @@ const OUTPUT_SCENARIOS_TOOL: Anthropic.Tool = {
|
|
|
59
59
|
|
|
60
60
|
export async function designScenarios(
|
|
61
61
|
spec: ProductSpec,
|
|
62
|
-
openIssues: { number: number; title: string; labels: string[] }[],
|
|
62
|
+
openIssues: { number: number | string; title: string; labels: string[] }[],
|
|
63
63
|
client: LLMClient,
|
|
64
64
|
model: string,
|
|
65
65
|
count: number = 5,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
|
|
2
|
+
|
|
3
|
+
export class AsanaTracker implements IssueTracker {
|
|
4
|
+
readonly name = "asana";
|
|
5
|
+
private token: string;
|
|
6
|
+
private projectId: string;
|
|
7
|
+
|
|
8
|
+
constructor(token: string, projectId: string) {
|
|
9
|
+
this.token = token;
|
|
10
|
+
this.projectId = projectId;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private get headers() {
|
|
14
|
+
return {
|
|
15
|
+
Authorization: `Bearer ${this.token}`,
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
Accept: "application/json",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
|
|
22
|
+
const res = await fetch("https://app.asana.com/api/1.0/tasks", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: this.headers,
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
data: {
|
|
27
|
+
name: title,
|
|
28
|
+
notes: `${body}\n\nLabels: ${labels.join(", ")}`,
|
|
29
|
+
projects: [this.projectId],
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
const data = await res.json() as {
|
|
34
|
+
data?: { gid: string; permalink_url?: string };
|
|
35
|
+
errors?: { message: string }[];
|
|
36
|
+
};
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
console.error(`[asana] failed to create task (${res.status}): ${JSON.stringify(data.errors)}`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const url = data.data?.permalink_url ?? `https://app.asana.com/0/${this.projectId}/${data.data?.gid}`;
|
|
42
|
+
console.log(`[asana] task created: ${url}`);
|
|
43
|
+
return url;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async fetchOpenIssues(): Promise<OpenIssue[]> {
|
|
47
|
+
const res = await fetch(
|
|
48
|
+
`https://app.asana.com/api/1.0/tasks?project=${this.projectId}&completed_since=now&opt_fields=gid,name&limit=50`,
|
|
49
|
+
{ headers: this.headers }
|
|
50
|
+
);
|
|
51
|
+
if (!res.ok) return [];
|
|
52
|
+
const data = await res.json() as { data?: { gid: string; name: string }[] };
|
|
53
|
+
return (data.data ?? []).map((t) => ({ number: t.gid, title: t.name, labels: [] }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async fetchClosedIssues(): Promise<ClosedIssue[]> {
|
|
57
|
+
const res = await fetch(
|
|
58
|
+
`https://app.asana.com/api/1.0/tasks?project=${this.projectId}&completed=true&opt_fields=gid,name,notes&limit=20`,
|
|
59
|
+
{ headers: this.headers }
|
|
60
|
+
);
|
|
61
|
+
if (!res.ok) return [];
|
|
62
|
+
const data = await res.json() as { data?: { gid: string; name: string; notes: string }[] };
|
|
63
|
+
return (data.data ?? []).map((t) => ({
|
|
64
|
+
number: t.gid,
|
|
65
|
+
title: t.name,
|
|
66
|
+
body: t.notes ?? "",
|
|
67
|
+
labels: [],
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
|
|
2
|
+
|
|
3
|
+
export class BacklogTracker implements IssueTracker {
|
|
4
|
+
readonly name = "backlog";
|
|
5
|
+
private baseUrl: string;
|
|
6
|
+
private apiKey: string;
|
|
7
|
+
private projectId: number;
|
|
8
|
+
|
|
9
|
+
constructor(space: string, apiKey: string, projectId: number) {
|
|
10
|
+
this.baseUrl = `https://${space}.backlog.com`;
|
|
11
|
+
this.apiKey = apiKey;
|
|
12
|
+
this.projectId = projectId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private endpoint(path: string, params?: Record<string, string>): string {
|
|
16
|
+
const q = new URLSearchParams({ apiKey: this.apiKey, ...params });
|
|
17
|
+
return `${this.baseUrl}/api/v2${path}?${q}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
|
|
21
|
+
const form = new URLSearchParams({
|
|
22
|
+
projectId: String(this.projectId),
|
|
23
|
+
summary: title,
|
|
24
|
+
description: `${body}\n\nLabels: ${labels.join(", ")}`,
|
|
25
|
+
issueTypeId: "1",
|
|
26
|
+
priorityId: "3",
|
|
27
|
+
});
|
|
28
|
+
const res = await fetch(this.endpoint("/issues"), {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
31
|
+
body: form,
|
|
32
|
+
});
|
|
33
|
+
const data = await res.json() as { issueKey?: string; message?: string };
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
console.error(`[backlog] failed to create issue (${res.status}): ${data.message}`);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const url = `${this.baseUrl}/view/${data.issueKey}`;
|
|
39
|
+
console.log(`[backlog] issue created: ${url}`);
|
|
40
|
+
return url;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async fetchOpenIssues(): Promise<OpenIssue[]> {
|
|
44
|
+
const res = await fetch(this.endpoint("/issues", {
|
|
45
|
+
"projectId[]": String(this.projectId),
|
|
46
|
+
"statusId[]": "1",
|
|
47
|
+
count: "50",
|
|
48
|
+
keyword: "feedback-agent",
|
|
49
|
+
}));
|
|
50
|
+
if (!res.ok) return [];
|
|
51
|
+
const data = await res.json() as { issueKey: string; summary: string }[];
|
|
52
|
+
return Array.isArray(data)
|
|
53
|
+
? data.map((i) => ({ number: i.issueKey, title: i.summary, labels: [] }))
|
|
54
|
+
: [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async fetchClosedIssues(): Promise<ClosedIssue[]> {
|
|
58
|
+
const res = await fetch(this.endpoint("/issues", {
|
|
59
|
+
"projectId[]": String(this.projectId),
|
|
60
|
+
"statusId[]": "4",
|
|
61
|
+
count: "20",
|
|
62
|
+
keyword: "feedback-agent",
|
|
63
|
+
}));
|
|
64
|
+
if (!res.ok) return [];
|
|
65
|
+
const data = await res.json() as { issueKey: string; summary: string; description: string }[];
|
|
66
|
+
return Array.isArray(data)
|
|
67
|
+
? data.map((i) => ({ number: i.issueKey, title: i.summary, body: i.description ?? "", labels: [] }))
|
|
68
|
+
: [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
|
|
2
|
+
import { postGitHubIssue, fetchOpenIssues as ghFetchOpen, fetchClosedIssues as ghFetchClosed } from "../github";
|
|
3
|
+
|
|
4
|
+
export class GitHubTracker implements IssueTracker {
|
|
5
|
+
readonly name = "github";
|
|
6
|
+
private opts: { token: string; repo: string };
|
|
7
|
+
|
|
8
|
+
constructor(token: string, repo: string) {
|
|
9
|
+
this.opts = { token, repo };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
|
|
13
|
+
return postGitHubIssue(title, body, labels, this.opts);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async fetchOpenIssues(): Promise<OpenIssue[]> {
|
|
17
|
+
return ghFetchOpen(this.opts);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async fetchClosedIssues(): Promise<ClosedIssue[]> {
|
|
21
|
+
return ghFetchClosed(this.opts);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
|
|
2
|
+
import { GitHubTracker } from "./github";
|
|
3
|
+
import { JiraTracker } from "./jira";
|
|
4
|
+
import { NotionTracker } from "./notion";
|
|
5
|
+
import { BacklogTracker } from "./backlog";
|
|
6
|
+
import { AsanaTracker } from "./asana";
|
|
7
|
+
|
|
8
|
+
export type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
|
|
9
|
+
|
|
10
|
+
export class AggregatedTracker implements IssueTracker {
|
|
11
|
+
readonly name = "aggregated";
|
|
12
|
+
private trackers: IssueTracker[];
|
|
13
|
+
|
|
14
|
+
constructor(trackers: IssueTracker[]) {
|
|
15
|
+
this.trackers = trackers;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get isEmpty(): boolean {
|
|
19
|
+
return this.trackers.length === 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
|
|
23
|
+
if (this.trackers.length === 0) return null;
|
|
24
|
+
const urls = await Promise.all(this.trackers.map((t) => t.createIssue(title, body, labels)));
|
|
25
|
+
return urls.find((u) => u !== null) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async fetchOpenIssues(): Promise<OpenIssue[]> {
|
|
29
|
+
const results = await Promise.all(this.trackers.map((t) => t.fetchOpenIssues()));
|
|
30
|
+
return results.flat();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async fetchClosedIssues(): Promise<ClosedIssue[]> {
|
|
34
|
+
const results = await Promise.all(this.trackers.map((t) => t.fetchClosedIssues()));
|
|
35
|
+
return results.flat();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildTrackers(): AggregatedTracker {
|
|
40
|
+
const raw = process.env.ISSUE_TRACKERS ?? "";
|
|
41
|
+
const enabled = raw
|
|
42
|
+
.split(",")
|
|
43
|
+
.map((s) => s.trim().toLowerCase())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
|
|
46
|
+
// Backward compat: if ISSUE_TRACKERS not set but GITHUB_TOKEN/GITHUB_REPO are present, default to github
|
|
47
|
+
if (enabled.length === 0 && process.env.GITHUB_TOKEN && process.env.GITHUB_REPO) {
|
|
48
|
+
enabled.push("github");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const trackers: IssueTracker[] = [];
|
|
52
|
+
|
|
53
|
+
for (const name of enabled) {
|
|
54
|
+
switch (name) {
|
|
55
|
+
case "github": {
|
|
56
|
+
const token = process.env.GITHUB_TOKEN ?? "";
|
|
57
|
+
const repo = process.env.GITHUB_REPO ?? "";
|
|
58
|
+
if (token && repo) {
|
|
59
|
+
trackers.push(new GitHubTracker(token, repo));
|
|
60
|
+
} else {
|
|
61
|
+
console.warn("[trackers] github: GITHUB_TOKEN or GITHUB_REPO not set, skipping");
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "jira": {
|
|
66
|
+
const baseUrl = process.env.JIRA_BASE_URL ?? "";
|
|
67
|
+
const email = process.env.JIRA_EMAIL ?? "";
|
|
68
|
+
const apiToken = process.env.JIRA_API_TOKEN ?? "";
|
|
69
|
+
const projectKey = process.env.JIRA_PROJECT_KEY ?? "";
|
|
70
|
+
if (baseUrl && email && apiToken && projectKey) {
|
|
71
|
+
trackers.push(new JiraTracker(baseUrl, email, apiToken, projectKey));
|
|
72
|
+
} else {
|
|
73
|
+
console.warn("[trackers] jira: JIRA_BASE_URL / JIRA_EMAIL / JIRA_API_TOKEN / JIRA_PROJECT_KEY required, skipping");
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case "notion": {
|
|
78
|
+
const token = process.env.NOTION_API_KEY ?? "";
|
|
79
|
+
const databaseId = process.env.NOTION_DATABASE_ID ?? "";
|
|
80
|
+
if (token && databaseId) {
|
|
81
|
+
trackers.push(new NotionTracker(token, databaseId));
|
|
82
|
+
} else {
|
|
83
|
+
console.warn("[trackers] notion: NOTION_API_KEY or NOTION_DATABASE_ID not set, skipping");
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "backlog": {
|
|
88
|
+
const space = process.env.BACKLOG_SPACE ?? "";
|
|
89
|
+
const apiKey = process.env.BACKLOG_API_KEY ?? "";
|
|
90
|
+
const projectId = parseInt(process.env.BACKLOG_PROJECT_ID ?? "0", 10);
|
|
91
|
+
if (space && apiKey && projectId) {
|
|
92
|
+
trackers.push(new BacklogTracker(space, apiKey, projectId));
|
|
93
|
+
} else {
|
|
94
|
+
console.warn("[trackers] backlog: BACKLOG_SPACE / BACKLOG_API_KEY / BACKLOG_PROJECT_ID required, skipping");
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case "asana": {
|
|
99
|
+
const token = process.env.ASANA_ACCESS_TOKEN ?? "";
|
|
100
|
+
const projectId = process.env.ASANA_PROJECT_ID ?? "";
|
|
101
|
+
if (token && projectId) {
|
|
102
|
+
trackers.push(new AsanaTracker(token, projectId));
|
|
103
|
+
} else {
|
|
104
|
+
console.warn("[trackers] asana: ASANA_ACCESS_TOKEN or ASANA_PROJECT_ID not set, skipping");
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
default:
|
|
109
|
+
console.warn(`[trackers] unknown tracker: "${name}"`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (trackers.length > 0) {
|
|
114
|
+
console.log(`[trackers] enabled: ${trackers.map((t) => t.name).join(", ")}`);
|
|
115
|
+
} else {
|
|
116
|
+
console.log("[trackers] no issue trackers configured — findings saved locally only");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return new AggregatedTracker(trackers);
|
|
120
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
|
|
2
|
+
|
|
3
|
+
export class JiraTracker implements IssueTracker {
|
|
4
|
+
readonly name = "jira";
|
|
5
|
+
private baseUrl: string;
|
|
6
|
+
private authHeader: string;
|
|
7
|
+
private projectKey: string;
|
|
8
|
+
|
|
9
|
+
constructor(baseUrl: string, email: string, apiToken: string, projectKey: string) {
|
|
10
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
11
|
+
this.authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`;
|
|
12
|
+
this.projectKey = projectKey;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private get headers() {
|
|
16
|
+
return {
|
|
17
|
+
Authorization: this.authHeader,
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
Accept: "application/json",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
|
|
24
|
+
const res = await fetch(`${this.baseUrl}/rest/api/3/issue`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: this.headers,
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
fields: {
|
|
29
|
+
project: { key: this.projectKey },
|
|
30
|
+
summary: title,
|
|
31
|
+
description: {
|
|
32
|
+
type: "doc",
|
|
33
|
+
version: 1,
|
|
34
|
+
content: [{ type: "paragraph", content: [{ type: "text", text: body }] }],
|
|
35
|
+
},
|
|
36
|
+
issuetype: { name: "Task" },
|
|
37
|
+
labels,
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
const data = await res.json() as { key?: string; errors?: Record<string, string> };
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
console.error(`[jira] failed to create issue (${res.status}): ${JSON.stringify(data.errors ?? data)}`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const url = `${this.baseUrl}/browse/${data.key}`;
|
|
47
|
+
console.log(`[jira] issue created: ${url}`);
|
|
48
|
+
return url;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async fetchOpenIssues(): Promise<OpenIssue[]> {
|
|
52
|
+
const jql = encodeURIComponent(
|
|
53
|
+
`project = ${this.projectKey} AND statusCategory != Done AND labels = "feedback-agent" ORDER BY created DESC`
|
|
54
|
+
);
|
|
55
|
+
const res = await fetch(
|
|
56
|
+
`${this.baseUrl}/rest/api/3/search?jql=${jql}&maxResults=50&fields=summary,labels`,
|
|
57
|
+
{ headers: this.headers }
|
|
58
|
+
);
|
|
59
|
+
if (!res.ok) return [];
|
|
60
|
+
const data = await res.json() as {
|
|
61
|
+
issues?: { key: string; fields: { summary: string; labels: string[] } }[];
|
|
62
|
+
};
|
|
63
|
+
return (data.issues ?? []).map((i) => ({
|
|
64
|
+
number: i.key,
|
|
65
|
+
title: i.fields.summary,
|
|
66
|
+
labels: i.fields.labels,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async fetchClosedIssues(): Promise<ClosedIssue[]> {
|
|
71
|
+
const jql = encodeURIComponent(
|
|
72
|
+
`project = ${this.projectKey} AND statusCategory = Done AND labels = "feedback-agent" ORDER BY updated DESC`
|
|
73
|
+
);
|
|
74
|
+
const res = await fetch(
|
|
75
|
+
`${this.baseUrl}/rest/api/3/search?jql=${jql}&maxResults=20&fields=summary,labels,description`,
|
|
76
|
+
{ headers: this.headers }
|
|
77
|
+
);
|
|
78
|
+
if (!res.ok) return [];
|
|
79
|
+
const data = await res.json() as {
|
|
80
|
+
issues?: { key: string; fields: { summary: string; labels: string[]; description: unknown } }[];
|
|
81
|
+
};
|
|
82
|
+
return (data.issues ?? []).map((i) => ({
|
|
83
|
+
number: i.key,
|
|
84
|
+
title: i.fields.summary,
|
|
85
|
+
body: typeof i.fields.description === "string" ? i.fields.description : "",
|
|
86
|
+
labels: i.fields.labels,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
|
|
2
|
+
|
|
3
|
+
export class NotionTracker implements IssueTracker {
|
|
4
|
+
readonly name = "notion";
|
|
5
|
+
private token: string;
|
|
6
|
+
private databaseId: string;
|
|
7
|
+
|
|
8
|
+
constructor(token: string, databaseId: string) {
|
|
9
|
+
this.token = token;
|
|
10
|
+
this.databaseId = databaseId;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private get headers() {
|
|
14
|
+
return {
|
|
15
|
+
Authorization: `Bearer ${this.token}`,
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
"Notion-Version": "2022-06-28",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
|
|
22
|
+
const res = await fetch("https://api.notion.com/v1/pages", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: this.headers,
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
parent: { database_id: this.databaseId },
|
|
27
|
+
properties: {
|
|
28
|
+
Name: { title: [{ text: { content: title } }] },
|
|
29
|
+
Labels: { multi_select: labels.map((l) => ({ name: l })) },
|
|
30
|
+
Status: { select: { name: "Open" } },
|
|
31
|
+
},
|
|
32
|
+
children: [
|
|
33
|
+
{
|
|
34
|
+
object: "block",
|
|
35
|
+
type: "paragraph",
|
|
36
|
+
paragraph: { rich_text: [{ type: "text", text: { content: body } }] },
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
const data = await res.json() as { id?: string; url?: string; message?: string };
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
console.error(`[notion] failed to create page (${res.status}): ${data.message}`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
console.log(`[notion] page created: ${data.url}`);
|
|
47
|
+
return data.url ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async fetchOpenIssues(): Promise<OpenIssue[]> {
|
|
51
|
+
return this._queryPages("Open");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async fetchClosedIssues(): Promise<ClosedIssue[]> {
|
|
55
|
+
const pages = await this._queryPages("Closed");
|
|
56
|
+
return pages.map((p) => ({ ...p, body: "" }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async _queryPages(status: string): Promise<{ number: string; title: string; labels: string[] }[]> {
|
|
60
|
+
const res = await fetch(`https://api.notion.com/v1/databases/${this.databaseId}/query`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: this.headers,
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
filter: { property: "Status", select: { equals: status } },
|
|
65
|
+
page_size: 50,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) return [];
|
|
69
|
+
const data = await res.json() as {
|
|
70
|
+
results?: {
|
|
71
|
+
id: string;
|
|
72
|
+
properties: {
|
|
73
|
+
Name?: { title: { plain_text: string }[] };
|
|
74
|
+
Labels?: { multi_select: { name: string }[] };
|
|
75
|
+
};
|
|
76
|
+
}[];
|
|
77
|
+
};
|
|
78
|
+
return (data.results ?? []).map((p) => ({
|
|
79
|
+
number: p.id,
|
|
80
|
+
title: p.properties.Name?.title[0]?.plain_text ?? "(no title)",
|
|
81
|
+
labels: p.properties.Labels?.multi_select.map((l) => l.name) ?? [],
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface OpenIssue {
|
|
2
|
+
number: number | string;
|
|
3
|
+
title: string;
|
|
4
|
+
labels: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ClosedIssue {
|
|
8
|
+
number: number | string;
|
|
9
|
+
title: string;
|
|
10
|
+
body: string;
|
|
11
|
+
labels: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IssueTracker {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
createIssue(title: string, body: string, labels: string[]): Promise<string | null>;
|
|
17
|
+
fetchOpenIssues(): Promise<OpenIssue[]>;
|
|
18
|
+
fetchClosedIssues(): Promise<ClosedIssue[]>;
|
|
19
|
+
}
|
package/framework/triage.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as path from "path";
|
|
|
4
4
|
import type { LLMClient } from "./llm-client";
|
|
5
5
|
import type { Finding } from "./types";
|
|
6
6
|
import { createMessageWithRetry } from "./agent-loop";
|
|
7
|
-
import {
|
|
7
|
+
import type { IssueTracker } from "./trackers/index";
|
|
8
8
|
|
|
9
9
|
const TRIAGE_TOOLS: Anthropic.Tool[] = [
|
|
10
10
|
{
|
|
@@ -14,7 +14,7 @@ const TRIAGE_TOOLS: Anthropic.Tool[] = [
|
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
name: "create_issue",
|
|
17
|
-
description: "Post feedback as
|
|
17
|
+
description: "Post feedback as an issue ticket; multiple related findings can be merged into one / フィードバックをissueチケットとして投稿する。類似フィードバックをまとめて1件にできる",
|
|
18
18
|
input_schema: {
|
|
19
19
|
type: "object",
|
|
20
20
|
properties: {
|
|
@@ -32,7 +32,7 @@ const TRIAGE_TOOLS: Anthropic.Tool[] = [
|
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
name: "skip_finding",
|
|
35
|
-
description: "Skip a finding that duplicates an existing open
|
|
35
|
+
description: "Skip a finding that duplicates an existing open issue / 既存のOpenなissueと重複するためスキップする",
|
|
36
36
|
input_schema: {
|
|
37
37
|
type: "object",
|
|
38
38
|
properties: {
|
|
@@ -55,7 +55,7 @@ export async function runTriageAgent(
|
|
|
55
55
|
findings: Finding[],
|
|
56
56
|
client: LLMClient,
|
|
57
57
|
model: string,
|
|
58
|
-
|
|
58
|
+
tracker: IssueTracker
|
|
59
59
|
): Promise<TriageResult> {
|
|
60
60
|
if (findings.length === 0) {
|
|
61
61
|
console.log("\n[triage] no findings, skipping");
|
|
@@ -64,7 +64,7 @@ export async function runTriageAgent(
|
|
|
64
64
|
|
|
65
65
|
console.log(`\n[triage] starting (findings: ${findings.length})`);
|
|
66
66
|
|
|
67
|
-
const openIssues = await fetchOpenIssues(
|
|
67
|
+
const openIssues = await tracker.fetchOpenIssues();
|
|
68
68
|
const pendingIds = new Set(findings.map((f) => f.id));
|
|
69
69
|
const issuedIds: string[] = [];
|
|
70
70
|
const skippedIds: string[] = [];
|
|
@@ -72,16 +72,16 @@ export async function runTriageAgent(
|
|
|
72
72
|
let skipped = 0;
|
|
73
73
|
|
|
74
74
|
const openIssueList = openIssues.length > 0
|
|
75
|
-
? `\n\n[Existing open
|
|
75
|
+
? `\n\n[Existing open issues (for deduplication)]\n${openIssues.map((i) => `- ${i.number}: ${i.title}`).join("\n")}`
|
|
76
76
|
: "";
|
|
77
77
|
|
|
78
78
|
const systemPrompt = `You are a feedback triage AI.
|
|
79
|
-
Organize feedback collected by multiple agents and post it as
|
|
79
|
+
Organize feedback collected by multiple agents and post it as issue tickets.
|
|
80
80
|
|
|
81
81
|
[Steps]
|
|
82
82
|
1. Call get_all_findings to review collected feedback
|
|
83
|
-
2. Merge similar/duplicate feedback into a single
|
|
84
|
-
3. Skip feedback that duplicates an existing open
|
|
83
|
+
2. Merge similar/duplicate feedback into a single issue
|
|
84
|
+
3. Skip feedback that duplicates an existing open issue using skip_finding
|
|
85
85
|
4. Post the rest with create_issue (no duplicates, only valuable findings)
|
|
86
86
|
5. Finish after processing all items${openIssueList}
|
|
87
87
|
|
|
@@ -92,8 +92,8 @@ Organize feedback collected by multiple agents and post it as GitHub Issues.
|
|
|
92
92
|
- goal-gap: the app fails to meet one of its stated goals — use only when a finding directly undermines a specific app goal
|
|
93
93
|
|
|
94
94
|
[Merging Guidelines]
|
|
95
|
-
- Multiple reports about the same screen/feature can be merged into one
|
|
96
|
-
- Merge into one
|
|
95
|
+
- Multiple reports about the same screen/feature can be merged into one issue
|
|
96
|
+
- Merge into one issue even across categories if it's the same underlying problem
|
|
97
97
|
- Include multiple perspectives in the body when merging
|
|
98
98
|
- Only post clearly valuable findings (skip operation errors or misunderstandings)
|
|
99
99
|
|
|
@@ -164,7 +164,7 @@ Organize feedback collected by multiple agents and post it as GitHub Issues.
|
|
|
164
164
|
: "";
|
|
165
165
|
const fullBody = `**Category:** ${category}\n\n${body}${screenshotSection}\n\n---\n**Reported by:** ${mergedAgents.join(", ")}\n*This Issue was auto-generated by an AI triage agent*`;
|
|
166
166
|
const cleanTitle = title.replace(/^\[[^\]]+\]\s*/i, "");
|
|
167
|
-
const url = await
|
|
167
|
+
const url = await tracker.createIssue(`[${category}] ${cleanTitle}`, fullBody, [category, "feedback-agent"]);
|
|
168
168
|
mergedIds.forEach((id) => { pendingIds.delete(id); issuedIds.push(id); });
|
|
169
169
|
issuesCreated++;
|
|
170
170
|
result = { created: true, url, mergedCount: mergedIds.length };
|
package/package.json
CHANGED
package/run.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { createMessageWithRetry, runAgentLoop, sleep, rateLimitRetries } from ".
|
|
|
18
18
|
import { collectedFindings, initRunLog, saveRunLog, saveFinding, runLog } from "./framework/findings";
|
|
19
19
|
import { loadAgents, addAgent, retireAgent } from "./framework/agent-store";
|
|
20
20
|
import { updateCoverage, computeWeightedSummary } from "./framework/coverage";
|
|
21
|
-
import {
|
|
21
|
+
import { buildTrackers } from "./framework/trackers/index";
|
|
22
22
|
import {
|
|
23
23
|
setupObservation,
|
|
24
24
|
getRecentConsoleLogs,
|
|
@@ -41,10 +41,8 @@ import { runAccountManager, loadTestAccounts, type TestAccount } from "./framewo
|
|
|
41
41
|
import { estimateCost, formatCostUSD } from "./framework/cost";
|
|
42
42
|
|
|
43
43
|
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
|
44
|
-
const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? "";
|
|
45
|
-
const GITHUB_REPO = process.env.GITHUB_REPO ?? "";
|
|
46
44
|
const REFRESH_SPEC = process.env.REFRESH_SPEC === "1";
|
|
47
|
-
const
|
|
45
|
+
const trackers = buildTrackers();
|
|
48
46
|
|
|
49
47
|
const TARGET = process.env.TARGET ?? "none";
|
|
50
48
|
let targetConfig = loadTarget(TARGET);
|
|
@@ -228,11 +226,10 @@ function makeExecutor(agentLog: AgentLog, scenarioOutcomes: ScenarioOutcome[], s
|
|
|
228
226
|
const { original_issue_number, original_issue_title, title, body } = input as {
|
|
229
227
|
original_issue_number: number; original_issue_title: string; title: string; body: string;
|
|
230
228
|
};
|
|
231
|
-
const url = await
|
|
229
|
+
const url = await trackers.createIssue(
|
|
232
230
|
`[regression] ${title}`,
|
|
233
|
-
`**Regression:** #${original_issue_number} "${original_issue_title}" has reappeared.\n\n${body}\n\n---\n*This
|
|
234
|
-
["regression", "feedback-agent"]
|
|
235
|
-
githubOptions
|
|
231
|
+
`**Regression:** #${original_issue_number} "${original_issue_title}" has reappeared.\n\n${body}\n\n---\n*This issue was auto-generated by an AI regression agent*`,
|
|
232
|
+
["regression", "feedback-agent"]
|
|
236
233
|
);
|
|
237
234
|
const check: RegressionCheck = {
|
|
238
235
|
issueNumber: Number(original_issue_number),
|
|
@@ -346,7 +343,7 @@ Take 3–5 actions, then finish.`;
|
|
|
346
343
|
|
|
347
344
|
async function runRegressionAgent(
|
|
348
345
|
agent: { id: string; name: string; persona: string; role: string },
|
|
349
|
-
closedIssues: { number: number; title: string; body: string; labels: string[] }[],
|
|
346
|
+
closedIssues: { number: number | string; title: string; body: string; labels: string[] }[],
|
|
350
347
|
productSpec: ProductSpec
|
|
351
348
|
) {
|
|
352
349
|
console.log(`\n[regression] ${agent.name} start (${closedIssues.length} issues to check)`);
|
|
@@ -448,7 +445,7 @@ const PERSONA_DESIGNER_TOOLS: Anthropic.Tool[] = [
|
|
|
448
445
|
async function runPersonaDesigner(
|
|
449
446
|
productSpec: ProductSpec,
|
|
450
447
|
orgGuidance: string,
|
|
451
|
-
openIssues: { number: number; title: string; labels: string[] }[],
|
|
448
|
+
openIssues: { number: number | string; title: string; labels: string[] }[],
|
|
452
449
|
scenarios: Scenario[],
|
|
453
450
|
testAccounts: TestAccount[] = [],
|
|
454
451
|
): Promise<void> {
|
|
@@ -1053,7 +1050,7 @@ function pickAssignment(idx: number, scenarios: Scenario[]): { scenario?: Scenar
|
|
|
1053
1050
|
async function main() {
|
|
1054
1051
|
initDirs();
|
|
1055
1052
|
// run log を最初期化しておくことで、どの段階でエラーが起きても finally で saveRunLog() が動く
|
|
1056
|
-
initRunLog(0, GITHUB_REPO);
|
|
1053
|
+
initRunLog(0, process.env.GITHUB_REPO ?? "");
|
|
1057
1054
|
|
|
1058
1055
|
// 1. product discovery (cache or live)
|
|
1059
1056
|
const browser = await chromium.launch({ headless: true });
|
|
@@ -1082,7 +1079,7 @@ async function main() {
|
|
|
1082
1079
|
const orgDesign = await designOrg(productSpec, client, defaultModel, coverageSummary.formatted);
|
|
1083
1080
|
|
|
1084
1081
|
// 3. open issues + scenario design (both feed into HR)
|
|
1085
|
-
const openIssues = await fetchOpenIssues(
|
|
1082
|
+
const openIssues = await trackers.fetchOpenIssues();
|
|
1086
1083
|
const scenarios = await designScenarios(productSpec, openIssues, client, defaultModel, 5, coverageSummary.formatted);
|
|
1087
1084
|
|
|
1088
1085
|
// 3.5. Account Manager(credentials が設定されている場合のみ)
|
|
@@ -1113,7 +1110,7 @@ async function main() {
|
|
|
1113
1110
|
console.error("No agents found. Check agents.json.");
|
|
1114
1111
|
process.exit(1);
|
|
1115
1112
|
}
|
|
1116
|
-
const closedIssues = await fetchClosedIssues(
|
|
1113
|
+
const closedIssues = await trackers.fetchClosedIssues();
|
|
1117
1114
|
|
|
1118
1115
|
// 5. エージェント数が確定したので totalAgents を更新
|
|
1119
1116
|
runLog.summary.totalAgents = allAgents.length;
|
|
@@ -1191,7 +1188,7 @@ async function main() {
|
|
|
1191
1188
|
console.log(`\n[triage] collected findings: ${collectedFindings.length}`);
|
|
1192
1189
|
let triageResult = { issued: [] as string[], skipped: [] as string[], unprocessed: [] as string[], issuesCreated: 0 };
|
|
1193
1190
|
try {
|
|
1194
|
-
triageResult = await runTriageAgent(collectedFindings, client, defaultModel,
|
|
1191
|
+
triageResult = await runTriageAgent(collectedFindings, client, defaultModel, trackers);
|
|
1195
1192
|
runLog.summary.totalIssuesPosted += triageResult.issuesCreated;
|
|
1196
1193
|
} catch (e) {
|
|
1197
1194
|
console.error("[triage] error:", e);
|
package/triage-only.ts
CHANGED
|
@@ -12,11 +12,9 @@ import * as fs from "fs";
|
|
|
12
12
|
import * as path from "path";
|
|
13
13
|
import { createLLMClient } from "./framework/llm-client";
|
|
14
14
|
import { runTriageAgent } from "./framework/triage";
|
|
15
|
+
import { buildTrackers } from "./framework/trackers/index";
|
|
15
16
|
import type { Finding } from "./framework/types";
|
|
16
17
|
|
|
17
|
-
const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? "";
|
|
18
|
-
const GITHUB_REPO = process.env.GITHUB_REPO ?? "";
|
|
19
|
-
|
|
20
18
|
function loadFindings(runId: string): Finding[] {
|
|
21
19
|
const dir = path.join(process.cwd(), "findings", runId);
|
|
22
20
|
if (!fs.existsSync(dir)) {
|
|
@@ -46,7 +44,8 @@ async function main() {
|
|
|
46
44
|
findings.forEach((f) => console.log(` - ${f.agentName}: ${f.title.slice(0, 50)}`));
|
|
47
45
|
|
|
48
46
|
const { client, defaultModel } = createLLMClient();
|
|
49
|
-
const
|
|
47
|
+
const trackers = buildTrackers();
|
|
48
|
+
const result = await runTriageAgent(findings, client, defaultModel, trackers);
|
|
50
49
|
|
|
51
50
|
console.log("\n=== トリアージ結果 ===");
|
|
52
51
|
console.log(` Issue作成: ${result.issuesCreated}件`);
|