@m8i-51/shoal 0.1.10 → 0.1.12

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 CHANGED
@@ -62,12 +62,37 @@ TARGET=none # example | none | カスタムターゲット名
62
62
  BASE_URL=http://localhost:3000
63
63
 
64
64
  # ================================================================
65
- # GitHub Issues (optional)
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 GitHub Issues
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
- | `GITHUB_TOKEN` | — | Optional enables Issue creation |
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
- // ── GitHub (optional) ─────────────────────────────────────────────
105
- const githubToken = guard(await text({
106
- message: "GitHub token (optional — for Issue creation)",
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,75 @@
1
+ import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
2
+
3
+ export class AsanaTracker implements IssueTracker {
4
+ readonly name = "asana";
5
+ readonly isEmpty = false;
6
+ private token: string;
7
+ private projectId: string;
8
+
9
+ constructor(token: string, projectId: string) {
10
+ this.token = token;
11
+ this.projectId = projectId;
12
+ }
13
+
14
+ private get headers() {
15
+ return {
16
+ Authorization: `Bearer ${this.token}`,
17
+ "Content-Type": "application/json",
18
+ Accept: "application/json",
19
+ };
20
+ }
21
+
22
+ async createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
23
+ const res = await fetch("https://app.asana.com/api/1.0/tasks", {
24
+ method: "POST",
25
+ headers: this.headers,
26
+ body: JSON.stringify({
27
+ data: {
28
+ name: title,
29
+ notes: `${body}\n\nLabels: ${labels.join(", ")}`,
30
+ projects: [this.projectId],
31
+ },
32
+ }),
33
+ });
34
+ if (!res.ok) {
35
+ const msg = await res.text().catch(() => "");
36
+ console.error(`[asana] failed to create task (${res.status}): ${msg.slice(0, 200)}`);
37
+ return null;
38
+ }
39
+ const data = await res.json() as {
40
+ data?: { gid: string; permalink_url?: string };
41
+ };
42
+ if (!data.data?.gid) {
43
+ console.error("[asana] create task response missing gid");
44
+ return null;
45
+ }
46
+ const url = data.data.permalink_url ?? `https://app.asana.com/0/${this.projectId}/${data.data.gid}`;
47
+ console.log(`[asana] task created: ${url}`);
48
+ return url;
49
+ }
50
+
51
+ async fetchOpenIssues(): Promise<OpenIssue[]> {
52
+ const res = await fetch(
53
+ `https://app.asana.com/api/1.0/tasks?project=${this.projectId}&completed_since=now&opt_fields=gid,name&limit=50`,
54
+ { headers: this.headers }
55
+ );
56
+ if (!res.ok) return [];
57
+ const data = await res.json() as { data?: { gid: string; name: string }[] };
58
+ return (data.data ?? []).map((t) => ({ number: t.gid, title: t.name, labels: [] }));
59
+ }
60
+
61
+ async fetchClosedIssues(): Promise<ClosedIssue[]> {
62
+ const res = await fetch(
63
+ `https://app.asana.com/api/1.0/tasks?project=${this.projectId}&completed=true&opt_fields=gid,name,notes&limit=20`,
64
+ { headers: this.headers }
65
+ );
66
+ if (!res.ok) return [];
67
+ const data = await res.json() as { data?: { gid: string; name: string; notes: string }[] };
68
+ return (data.data ?? []).map((t) => ({
69
+ number: t.gid,
70
+ title: t.name,
71
+ body: t.notes ?? "",
72
+ labels: [],
73
+ }));
74
+ }
75
+ }
@@ -0,0 +1,76 @@
1
+ import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
2
+
3
+ export class BacklogTracker implements IssueTracker {
4
+ readonly name = "backlog";
5
+ readonly isEmpty = false;
6
+ private baseUrl: string;
7
+ private apiKey: string;
8
+ private projectId: number;
9
+
10
+ constructor(space: string, apiKey: string, projectId: number) {
11
+ this.baseUrl = `https://${space}.backlog.com`;
12
+ this.apiKey = apiKey;
13
+ this.projectId = projectId;
14
+ }
15
+
16
+ private endpoint(path: string, params?: Record<string, string>): string {
17
+ const q = new URLSearchParams({ apiKey: this.apiKey, ...params });
18
+ return `${this.baseUrl}/api/v2${path}?${q}`;
19
+ }
20
+
21
+ async createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
22
+ const form = new URLSearchParams({
23
+ projectId: String(this.projectId),
24
+ summary: title,
25
+ description: `${body}\n\nLabels: ${labels.join(", ")}`,
26
+ issueTypeId: "1",
27
+ priorityId: "3",
28
+ });
29
+ const res = await fetch(this.endpoint("/issues"), {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
32
+ body: form,
33
+ });
34
+ if (!res.ok) {
35
+ const msg = await res.text().catch(() => "");
36
+ console.error(`[backlog] failed to create issue (${res.status}): ${msg.slice(0, 200)}`);
37
+ return null;
38
+ }
39
+ const data = await res.json() as { issueKey?: string };
40
+ if (!data.issueKey) {
41
+ console.error("[backlog] create issue response missing issueKey");
42
+ return null;
43
+ }
44
+ const url = `${this.baseUrl}/view/${data.issueKey}`;
45
+ console.log(`[backlog] issue created: ${url}`);
46
+ return url;
47
+ }
48
+
49
+ async fetchOpenIssues(): Promise<OpenIssue[]> {
50
+ const res = await fetch(this.endpoint("/issues", {
51
+ "projectId[]": String(this.projectId),
52
+ "statusId[]": "1",
53
+ count: "50",
54
+ keyword: "feedback-agent",
55
+ }));
56
+ if (!res.ok) return [];
57
+ const data = await res.json() as { issueKey: string; summary: string }[];
58
+ return Array.isArray(data)
59
+ ? data.map((i) => ({ number: i.issueKey, title: i.summary, labels: [] }))
60
+ : [];
61
+ }
62
+
63
+ async fetchClosedIssues(): Promise<ClosedIssue[]> {
64
+ const res = await fetch(this.endpoint("/issues", {
65
+ "projectId[]": String(this.projectId),
66
+ "statusId[]": "4",
67
+ count: "20",
68
+ keyword: "feedback-agent",
69
+ }));
70
+ if (!res.ok) return [];
71
+ const data = await res.json() as { issueKey: string; summary: string; description: string }[];
72
+ return Array.isArray(data)
73
+ ? data.map((i) => ({ number: i.issueKey, title: i.summary, body: i.description ?? "", labels: [] }))
74
+ : [];
75
+ }
76
+ }
@@ -0,0 +1,24 @@
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
+ readonly isEmpty = false;
7
+ private opts: { token: string; repo: string };
8
+
9
+ constructor(token: string, repo: string) {
10
+ this.opts = { token, repo };
11
+ }
12
+
13
+ createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
14
+ return postGitHubIssue(title, body, labels, this.opts);
15
+ }
16
+
17
+ async fetchOpenIssues(): Promise<OpenIssue[]> {
18
+ return ghFetchOpen(this.opts);
19
+ }
20
+
21
+ async fetchClosedIssues(): Promise<ClosedIssue[]> {
22
+ return ghFetchClosed(this.opts);
23
+ }
24
+ }
@@ -0,0 +1,130 @@
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 results = await Promise.allSettled(this.trackers.map((t) => t.createIssue(title, body, labels)));
25
+ for (const r of results) {
26
+ if (r.status === "rejected") console.error("[trackers] createIssue error:", r.reason);
27
+ }
28
+ const urls = results.filter((r): r is PromiseFulfilledResult<string | null> => r.status === "fulfilled");
29
+ return urls.map((r) => r.value).find((u) => u !== null) ?? null;
30
+ }
31
+
32
+ async fetchOpenIssues(): Promise<OpenIssue[]> {
33
+ const results = await Promise.allSettled(this.trackers.map((t) => t.fetchOpenIssues()));
34
+ return results.flatMap((r) => {
35
+ if (r.status === "rejected") { console.error("[trackers] fetchOpenIssues error:", r.reason); return []; }
36
+ return r.value;
37
+ });
38
+ }
39
+
40
+ async fetchClosedIssues(): Promise<ClosedIssue[]> {
41
+ const results = await Promise.allSettled(this.trackers.map((t) => t.fetchClosedIssues()));
42
+ return results.flatMap((r) => {
43
+ if (r.status === "rejected") { console.error("[trackers] fetchClosedIssues error:", r.reason); return []; }
44
+ return r.value;
45
+ });
46
+ }
47
+ }
48
+
49
+ export function buildTrackers(): AggregatedTracker {
50
+ const raw = process.env.ISSUE_TRACKERS ?? "";
51
+ const enabled = raw
52
+ .split(",")
53
+ .map((s) => s.trim().toLowerCase())
54
+ .filter(Boolean);
55
+
56
+ // Backward compat: if ISSUE_TRACKERS not set but GITHUB_TOKEN/GITHUB_REPO are present, default to github
57
+ if (enabled.length === 0 && process.env.GITHUB_TOKEN && process.env.GITHUB_REPO) {
58
+ enabled.push("github");
59
+ }
60
+
61
+ const trackers: IssueTracker[] = [];
62
+
63
+ for (const name of enabled) {
64
+ switch (name) {
65
+ case "github": {
66
+ const token = process.env.GITHUB_TOKEN ?? "";
67
+ const repo = process.env.GITHUB_REPO ?? "";
68
+ if (token && repo) {
69
+ trackers.push(new GitHubTracker(token, repo));
70
+ } else {
71
+ console.warn("[trackers] github: GITHUB_TOKEN or GITHUB_REPO not set, skipping");
72
+ }
73
+ break;
74
+ }
75
+ case "jira": {
76
+ const baseUrl = process.env.JIRA_BASE_URL ?? "";
77
+ const email = process.env.JIRA_EMAIL ?? "";
78
+ const apiToken = process.env.JIRA_API_TOKEN ?? "";
79
+ const projectKey = process.env.JIRA_PROJECT_KEY ?? "";
80
+ if (baseUrl && email && apiToken && projectKey) {
81
+ trackers.push(new JiraTracker(baseUrl, email, apiToken, projectKey));
82
+ } else {
83
+ console.warn("[trackers] jira: JIRA_BASE_URL / JIRA_EMAIL / JIRA_API_TOKEN / JIRA_PROJECT_KEY required, skipping");
84
+ }
85
+ break;
86
+ }
87
+ case "notion": {
88
+ const token = process.env.NOTION_API_KEY ?? "";
89
+ const databaseId = process.env.NOTION_DATABASE_ID ?? "";
90
+ if (token && databaseId) {
91
+ trackers.push(new NotionTracker(token, databaseId));
92
+ } else {
93
+ console.warn("[trackers] notion: NOTION_API_KEY or NOTION_DATABASE_ID not set, skipping");
94
+ }
95
+ break;
96
+ }
97
+ case "backlog": {
98
+ const space = process.env.BACKLOG_SPACE ?? "";
99
+ const apiKey = process.env.BACKLOG_API_KEY ?? "";
100
+ const projectId = parseInt(process.env.BACKLOG_PROJECT_ID ?? "", 10);
101
+ if (space && apiKey && !isNaN(projectId)) {
102
+ trackers.push(new BacklogTracker(space, apiKey, projectId));
103
+ } else {
104
+ console.warn("[trackers] backlog: BACKLOG_SPACE / BACKLOG_API_KEY / BACKLOG_PROJECT_ID required, skipping");
105
+ }
106
+ break;
107
+ }
108
+ case "asana": {
109
+ const token = process.env.ASANA_ACCESS_TOKEN ?? "";
110
+ const projectId = process.env.ASANA_PROJECT_ID ?? "";
111
+ if (token && projectId) {
112
+ trackers.push(new AsanaTracker(token, projectId));
113
+ } else {
114
+ console.warn("[trackers] asana: ASANA_ACCESS_TOKEN or ASANA_PROJECT_ID not set, skipping");
115
+ }
116
+ break;
117
+ }
118
+ default:
119
+ console.warn(`[trackers] unknown tracker: "${name}"`);
120
+ }
121
+ }
122
+
123
+ if (trackers.length > 0) {
124
+ console.log(`[trackers] enabled: ${trackers.map((t) => t.name).join(", ")}`);
125
+ } else {
126
+ console.log("[trackers] no issue trackers configured — findings saved locally only");
127
+ }
128
+
129
+ return new AggregatedTracker(trackers);
130
+ }