agent-relay-server 0.61.2 → 0.62.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.
Files changed (174) hide show
  1. package/docs/openapi.json +479 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DVvlvi4B.js → MessageBubble-BP0Ym3xb.js} +2 -2
  4. package/public/assets/{MessageBubble-DVvlvi4B.js.map → MessageBubble-BP0Ym3xb.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-DKc3gdou.js +2 -0
  6. package/public/assets/PlanGraphPanel-DKc3gdou.js.map +1 -0
  7. package/public/assets/{activity-IYl3MEXC.js → activity-CRLG6ICe.js} +2 -2
  8. package/public/assets/{activity-IYl3MEXC.js.map → activity-CRLG6ICe.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BfGTTbeY.js → agent-profiles-DItyEkXP.js} +2 -2
  10. package/public/assets/{agent-profiles-BfGTTbeY.js.map → agent-profiles-DItyEkXP.js.map} +1 -1
  11. package/public/assets/{agents-BquvREQr.js → agents-5mKiZ1rz.js} +2 -2
  12. package/public/assets/{agents-BquvREQr.js.map → agents-5mKiZ1rz.js.map} +1 -1
  13. package/public/assets/{analytics-De0cEZGx.js → analytics-J5pPcS-v.js} +3 -3
  14. package/public/assets/{analytics-De0cEZGx.js.map → analytics-J5pPcS-v.js.map} +1 -1
  15. package/public/assets/{automation-Ddbermbi.js → automation-BG2HWK5N.js} +2 -2
  16. package/public/assets/{automation-Ddbermbi.js.map → automation-BG2HWK5N.js.map} +1 -1
  17. package/public/assets/{badge-DhLyCPag.js → badge-CZhIdlTJ.js} +2 -2
  18. package/public/assets/{badge-DhLyCPag.js.map → badge-CZhIdlTJ.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-DSyWKluD.js → branch-state-badge-BZMoxs65.js} +2 -2
  20. package/public/assets/{branch-state-badge-DSyWKluD.js.map → branch-state-badge-BZMoxs65.js.map} +1 -1
  21. package/public/assets/{button-K20wAZG1.js → button-2taQeNHW.js} +2 -2
  22. package/public/assets/{button-K20wAZG1.js.map → button-2taQeNHW.js.map} +1 -1
  23. package/public/assets/card-DzIsAI41.js +2 -0
  24. package/public/assets/{card-CcZ9hLI7.js.map → card-DzIsAI41.js.map} +1 -1
  25. package/public/assets/channels-B2PeqiRT.js +2 -0
  26. package/public/assets/{channels-BG0Nib7y.js.map → channels-B2PeqiRT.js.map} +1 -1
  27. package/public/assets/chat-CLRJVagA.js +2 -0
  28. package/public/assets/{chat-Dx6qWNxb.js.map → chat-CLRJVagA.js.map} +1 -1
  29. package/public/assets/{connectors-DWh7MwV_.js → connectors-Bzvkxsgo.js} +2 -2
  30. package/public/assets/{connectors-DWh7MwV_.js.map → connectors-Bzvkxsgo.js.map} +1 -1
  31. package/public/assets/copy-button-HEkwi6xX.js +2 -0
  32. package/public/assets/{copy-button-eF3cKp4Q.js.map → copy-button-HEkwi6xX.js.map} +1 -1
  33. package/public/assets/display-DzNvOu1j.js.map +1 -1
  34. package/public/assets/dist-1rXN5FN9.js +2 -0
  35. package/public/assets/{dist-YpHfnnIA.js.map → dist-1rXN5FN9.js.map} +1 -1
  36. package/public/assets/{dist-1dhfdhm9.js → dist-AbEQuFoK.js} +2 -2
  37. package/public/assets/{dist-1dhfdhm9.js.map → dist-AbEQuFoK.js.map} +1 -1
  38. package/public/assets/{dist-COQuunEv.js → dist-B7p1zkvf.js} +2 -2
  39. package/public/assets/{dist-COQuunEv.js.map → dist-B7p1zkvf.js.map} +1 -1
  40. package/public/assets/{dist-Ci5Y37nj.js → dist-CG10hM1-.js} +2 -2
  41. package/public/assets/{dist-Ci5Y37nj.js.map → dist-CG10hM1-.js.map} +1 -1
  42. package/public/assets/dist-Cfm5_zPB.js +2 -0
  43. package/public/assets/{dist-qd198ib9.js.map → dist-Cfm5_zPB.js.map} +1 -1
  44. package/public/assets/{dist-BrQsqBbc.js → dist-Cgw85UcE.js} +2 -2
  45. package/public/assets/{dist-BrQsqBbc.js.map → dist-Cgw85UcE.js.map} +1 -1
  46. package/public/assets/{dist-CoROafNg.js → dist-D3_TJKTC.js} +2 -2
  47. package/public/assets/{dist-CoROafNg.js.map → dist-D3_TJKTC.js.map} +1 -1
  48. package/public/assets/{dist-I-MFUv51.js → dist-DQkLq0hs.js} +2 -2
  49. package/public/assets/{dist-I-MFUv51.js.map → dist-DQkLq0hs.js.map} +1 -1
  50. package/public/assets/{dist-UQGKwqZH.js → dist-D_sl7u_f.js} +2 -2
  51. package/public/assets/{dist-UQGKwqZH.js.map → dist-D_sl7u_f.js.map} +1 -1
  52. package/public/assets/{dist-yGOq6sB3.js → dist-Ilb3r1im.js} +2 -2
  53. package/public/assets/{dist-yGOq6sB3.js.map → dist-Ilb3r1im.js.map} +1 -1
  54. package/public/assets/dist-Pf_3Gf38.js +2 -0
  55. package/public/assets/{dist-BLBF7qqr.js.map → dist-Pf_3Gf38.js.map} +1 -1
  56. package/public/assets/{es2015-CRxz9lOj.js → es2015-GSepNaIS.js} +2 -2
  57. package/public/assets/{es2015-CRxz9lOj.js.map → es2015-GSepNaIS.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-DMm6jeqi.js → formatted-body-impl-QqHxdMGP.js} +2 -2
  59. package/public/assets/{formatted-body-impl-DMm6jeqi.js.map → formatted-body-impl-QqHxdMGP.js.map} +1 -1
  60. package/public/assets/index-CCp1V35c.js +22 -0
  61. package/public/assets/index-CCp1V35c.js.map +1 -0
  62. package/public/assets/index-DiR-fqy_.css +2 -0
  63. package/public/assets/{input-BHYNpXAG.js → input-BsNqlONJ.js} +2 -2
  64. package/public/assets/{input-BHYNpXAG.js.map → input-BsNqlONJ.js.map} +1 -1
  65. package/public/assets/{insights-C7wKt1_l.js → insights-D-EyDfoZ.js} +2 -2
  66. package/public/assets/{insights-C7wKt1_l.js.map → insights-D-EyDfoZ.js.map} +1 -1
  67. package/public/assets/{integrations-CG9qyHUf.js → integrations-D1kc-yNi.js} +2 -2
  68. package/public/assets/{integrations-CG9qyHUf.js.map → integrations-D1kc-yNi.js.map} +1 -1
  69. package/public/assets/{lib-Df4aph5u.js → lib-bXPu6xCL.js} +2 -2
  70. package/public/assets/{lib-Df4aph5u.js.map → lib-bXPu6xCL.js.map} +1 -1
  71. package/public/assets/lucide-react-DWYjU-AF.js +9 -0
  72. package/public/assets/{lucide-react-L2UxFDic.js.map → lucide-react-DWYjU-AF.js.map} +1 -1
  73. package/public/assets/{maintenance-CjpvnV_U.js → maintenance-COqID7aq.js} +2 -2
  74. package/public/assets/{maintenance-CjpvnV_U.js.map → maintenance-COqID7aq.js.map} +1 -1
  75. package/public/assets/{managed-agents-CdfuBKJA.js → managed-agents-b4B8qewQ.js} +2 -2
  76. package/public/assets/{managed-agents-CdfuBKJA.js.map → managed-agents-b4B8qewQ.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js → markdown-preview-impl-D7o8jS07.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js.map → markdown-preview-impl-D7o8jS07.js.map} +1 -1
  79. package/public/assets/memory-Q0qF7_i3.js +2 -0
  80. package/public/assets/{memory-xKWduhyr.js.map → memory-Q0qF7_i3.js.map} +1 -1
  81. package/public/assets/{messages-BpZYOUtJ.js → messages-m4EpkK6s.js} +2 -2
  82. package/public/assets/{messages-BpZYOUtJ.js.map → messages-m4EpkK6s.js.map} +1 -1
  83. package/public/assets/{orchestrators-BrJvGIOc.js → orchestrators-CnAtGfUN.js} +2 -2
  84. package/public/assets/{orchestrators-BrJvGIOc.js.map → orchestrators-CnAtGfUN.js.map} +1 -1
  85. package/public/assets/{overview-BUORwePi.js → overview-D7CF88ik.js} +2 -2
  86. package/public/assets/{overview-BUORwePi.js.map → overview-D7CF88ik.js.map} +1 -1
  87. package/public/assets/pairs-Q6PlFJtH.js +2 -0
  88. package/public/assets/{pairs-DR0GizDN.js.map → pairs-Q6PlFJtH.js.map} +1 -1
  89. package/public/assets/{react-dom-CrP_AtcL.js → react-dom-CPghDLpB.js} +2 -2
  90. package/public/assets/{react-dom-CrP_AtcL.js.map → react-dom-CPghDLpB.js.map} +1 -1
  91. package/public/assets/{security-DLwSqTc_.js → security-BeV-6SdA.js} +2 -2
  92. package/public/assets/{security-DLwSqTc_.js.map → security-BeV-6SdA.js.map} +1 -1
  93. package/public/assets/{select-pEru111r.js → select-Dchc0_Jg.js} +2 -2
  94. package/public/assets/{select-pEru111r.js.map → select-Dchc0_Jg.js.map} +1 -1
  95. package/public/assets/{settings-B-ueGvwV.js → settings-hPwL-31u.js} +3 -3
  96. package/public/assets/{settings-B-ueGvwV.js.map → settings-hPwL-31u.js.map} +1 -1
  97. package/public/assets/store-Buwkqv_7.js +9 -0
  98. package/public/assets/store-Buwkqv_7.js.map +1 -0
  99. package/public/assets/tasks-DKUMPEgk.js +2 -0
  100. package/public/assets/{tasks-C2rRQm6E.js.map → tasks-DKUMPEgk.js.map} +1 -1
  101. package/public/assets/teams-T5fFsdOX.js +2 -0
  102. package/public/assets/teams-T5fFsdOX.js.map +1 -0
  103. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js → terminal-viewer-impl-Bk0u_Mav.js} +3 -3
  104. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js.map → terminal-viewer-impl-Bk0u_Mav.js.map} +1 -1
  105. package/public/assets/{use-keyboard-viewport-DrRH_QzG.js → use-keyboard-viewport-NKw1O5gC.js} +2 -2
  106. package/public/assets/{use-keyboard-viewport-DrRH_QzG.js.map → use-keyboard-viewport-NKw1O5gC.js.map} +1 -1
  107. package/public/assets/{work-queue-CkIYDAZD.js → work-queue-D26jok09.js} +2 -2
  108. package/public/assets/{work-queue-CkIYDAZD.js.map → work-queue-D26jok09.js.map} +1 -1
  109. package/public/assets/{workspaces-CYm5bLg9.js → workspaces-BMj6zx23.js} +3 -3
  110. package/public/assets/{workspaces-CYm5bLg9.js.map → workspaces-BMj6zx23.js.map} +1 -1
  111. package/public/index.html +21 -21
  112. package/src/automations.ts +40 -7
  113. package/src/branch-landed.ts +5 -0
  114. package/src/bus.ts +3 -0
  115. package/src/cli/workspace.ts +9 -17
  116. package/src/db/activity.ts +191 -3
  117. package/src/db/integrations.ts +1 -0
  118. package/src/db/issue-associations.ts +128 -0
  119. package/src/db/issue-lifecycle.ts +187 -0
  120. package/src/db/issue-refs.ts +254 -0
  121. package/src/db/mappers.ts +2 -0
  122. package/src/db/migrations.ts +47 -0
  123. package/src/db/schema.ts +2 -2
  124. package/src/issue-tracker/adapter.ts +24 -0
  125. package/src/issue-tracker/github.ts +136 -0
  126. package/src/issue-tracker/index.ts +13 -0
  127. package/src/issue-tracker/registry.ts +26 -0
  128. package/src/maintenance.ts +22 -169
  129. package/src/mcp-issue-activity-tools.ts +114 -0
  130. package/src/mcp-issue-association-tools.ts +130 -0
  131. package/src/mcp-issue-lifecycle-tools.ts +55 -0
  132. package/src/mcp-issue-ref-tools.ts +174 -0
  133. package/src/mcp-team-tools.ts +1 -1
  134. package/src/mcp.ts +3 -3
  135. package/src/provider-config-store.ts +30 -8
  136. package/src/routes/activity.ts +6 -1
  137. package/src/routes/commands.ts +29 -0
  138. package/src/routes/index.ts +19 -0
  139. package/src/routes/issue-activity.ts +78 -0
  140. package/src/routes/issue-associations.ts +76 -0
  141. package/src/routes/issue-lifecycle.ts +34 -0
  142. package/src/routes/issue-refs.ts +137 -0
  143. package/src/routes/orchestrator.ts +42 -1
  144. package/src/security.ts +8 -0
  145. package/src/services/issue-activity.ts +30 -0
  146. package/src/services/issue-associations.ts +72 -0
  147. package/src/services/issue-lifecycle.ts +308 -0
  148. package/src/services/issue-refs.ts +32 -0
  149. package/src/services/plan-graph.ts +40 -2
  150. package/src/services/team.ts +4 -0
  151. package/src/token-db.ts +3 -3
  152. package/src/workspace-actions.ts +8 -1
  153. package/src/workspace-auto-merge.ts +228 -0
  154. package/src/workspace-probe.ts +125 -0
  155. package/public/assets/PlanGraphPanel-Dby7wq9c.js +0 -2
  156. package/public/assets/PlanGraphPanel-Dby7wq9c.js.map +0 -1
  157. package/public/assets/card-CcZ9hLI7.js +0 -2
  158. package/public/assets/channels-BG0Nib7y.js +0 -2
  159. package/public/assets/chat-Dx6qWNxb.js +0 -2
  160. package/public/assets/copy-button-eF3cKp4Q.js +0 -2
  161. package/public/assets/dist-BLBF7qqr.js +0 -2
  162. package/public/assets/dist-YpHfnnIA.js +0 -2
  163. package/public/assets/dist-qd198ib9.js +0 -2
  164. package/public/assets/index-B3wOeAPO.js +0 -21
  165. package/public/assets/index-B3wOeAPO.js.map +0 -1
  166. package/public/assets/index-Dn_GDXbi.css +0 -2
  167. package/public/assets/lucide-react-L2UxFDic.js +0 -9
  168. package/public/assets/memory-xKWduhyr.js +0 -2
  169. package/public/assets/pairs-DR0GizDN.js +0 -2
  170. package/public/assets/store-B8xP6z6T.js +0 -9
  171. package/public/assets/store-B8xP6z6T.js.map +0 -1
  172. package/public/assets/tasks-C2rRQm6E.js +0 -2
  173. package/public/assets/teams-Bo0sdBTp.js +0 -2
  174. package/public/assets/teams-Bo0sdBTp.js.map +0 -1
@@ -0,0 +1,187 @@
1
+ import { getDb, ValidationError } from "./connection.ts";
2
+
3
+ export const ISSUE_LIFECYCLE_ACTIONS = ["close", "reopen"] as const;
4
+ export const ISSUE_LIFECYCLE_STATUSES = ["succeeded", "failed"] as const;
5
+ export type IssueLifecycleAction = (typeof ISSUE_LIFECYCLE_ACTIONS)[number];
6
+ export type IssueLifecycleStatus = (typeof ISSUE_LIFECYCLE_STATUSES)[number];
7
+
8
+ export interface IssueLifecycleActionRecord {
9
+ id: number;
10
+ issueRefId: string;
11
+ action: IssueLifecycleAction;
12
+ landKey: string;
13
+ workspaceId?: string;
14
+ branch?: string;
15
+ baseRef?: string;
16
+ commitSha?: string;
17
+ commitSubject?: string;
18
+ agentId?: string;
19
+ source?: string;
20
+ comment?: string;
21
+ status: IssueLifecycleStatus;
22
+ error?: string;
23
+ createdAt: number;
24
+ updatedAt: number;
25
+ }
26
+
27
+ export interface UpsertIssueLifecycleActionInput {
28
+ issueRefId: string;
29
+ action: IssueLifecycleAction;
30
+ landKey: string;
31
+ workspaceId?: string;
32
+ branch?: string;
33
+ baseRef?: string;
34
+ commitSha?: string;
35
+ commitSubject?: string;
36
+ agentId?: string;
37
+ source?: string;
38
+ comment?: string;
39
+ status: IssueLifecycleStatus;
40
+ error?: string;
41
+ }
42
+
43
+ interface IssueLifecycleActionRow {
44
+ id: number;
45
+ issue_ref_id: string;
46
+ action: IssueLifecycleAction;
47
+ land_key: string;
48
+ workspace_id: string | null;
49
+ branch: string | null;
50
+ base_ref: string | null;
51
+ commit_sha: string | null;
52
+ commit_subject: string | null;
53
+ agent_id: string | null;
54
+ source: string | null;
55
+ comment: string | null;
56
+ status: IssueLifecycleStatus;
57
+ error: string | null;
58
+ created_at: number;
59
+ updated_at: number;
60
+ }
61
+
62
+ function cleanText(value: string | undefined, field: string, max: number): string | undefined {
63
+ if (value === undefined) return undefined;
64
+ const cleaned = value.trim();
65
+ if (!cleaned) return undefined;
66
+ if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
67
+ return cleaned;
68
+ }
69
+
70
+ function cleanRequiredText(value: string, field: string, max: number): string {
71
+ const cleaned = cleanText(value, field, max);
72
+ if (!cleaned) throw new ValidationError(`${field} required`);
73
+ return cleaned;
74
+ }
75
+
76
+ function cleanAction(value: IssueLifecycleAction): IssueLifecycleAction {
77
+ if (!ISSUE_LIFECYCLE_ACTIONS.includes(value)) throw new ValidationError(`action must be one of: ${ISSUE_LIFECYCLE_ACTIONS.join(", ")}`);
78
+ return value;
79
+ }
80
+
81
+ function cleanStatus(value: IssueLifecycleStatus): IssueLifecycleStatus {
82
+ if (!ISSUE_LIFECYCLE_STATUSES.includes(value)) throw new ValidationError(`status must be one of: ${ISSUE_LIFECYCLE_STATUSES.join(", ")}`);
83
+ return value;
84
+ }
85
+
86
+ function rowToIssueLifecycleAction(row: IssueLifecycleActionRow): IssueLifecycleActionRecord {
87
+ return {
88
+ id: row.id,
89
+ issueRefId: row.issue_ref_id,
90
+ action: row.action,
91
+ landKey: row.land_key,
92
+ ...(row.workspace_id ? { workspaceId: row.workspace_id } : {}),
93
+ ...(row.branch ? { branch: row.branch } : {}),
94
+ ...(row.base_ref ? { baseRef: row.base_ref } : {}),
95
+ ...(row.commit_sha ? { commitSha: row.commit_sha } : {}),
96
+ ...(row.commit_subject ? { commitSubject: row.commit_subject } : {}),
97
+ ...(row.agent_id ? { agentId: row.agent_id } : {}),
98
+ ...(row.source ? { source: row.source } : {}),
99
+ ...(row.comment ? { comment: row.comment } : {}),
100
+ status: row.status,
101
+ ...(row.error ? { error: row.error } : {}),
102
+ createdAt: row.created_at,
103
+ updatedAt: row.updated_at,
104
+ };
105
+ }
106
+
107
+ export function getSuccessfulIssueLifecycleAction(issueRefId: string, action: IssueLifecycleAction, landKey: string): IssueLifecycleActionRecord | null {
108
+ const row = getDb().query(`
109
+ SELECT * FROM issue_lifecycle_actions
110
+ WHERE issue_ref_id = ? AND action = ? AND land_key = ? AND status = 'succeeded'
111
+ `).get(
112
+ cleanRequiredText(issueRefId, "issueRefId", 120),
113
+ cleanAction(action),
114
+ cleanRequiredText(landKey, "landKey", 240),
115
+ ) as IssueLifecycleActionRow | undefined;
116
+ return row ? rowToIssueLifecycleAction(row) : null;
117
+ }
118
+
119
+ export function listSuccessfulCloseActionsForCommit(input: { commitSha?: string; commitSubject?: string }): IssueLifecycleActionRecord[] {
120
+ const commitSha = cleanText(input.commitSha, "commitSha", 64);
121
+ const commitSubject = cleanText(input.commitSubject, "commitSubject", 500);
122
+ if (!commitSha && !commitSubject) return [];
123
+ const conditions: string[] = ["action = 'close'", "status = 'succeeded'"];
124
+ const params: string[] = [];
125
+ if (commitSha) {
126
+ conditions.push("commit_sha = ?");
127
+ params.push(commitSha);
128
+ }
129
+ if (commitSubject) {
130
+ conditions.push("commit_subject = ?");
131
+ params.push(commitSubject);
132
+ }
133
+ const rows = getDb().query(`
134
+ SELECT * FROM issue_lifecycle_actions
135
+ WHERE ${conditions.join(" AND ")}
136
+ ORDER BY updated_at DESC, id DESC
137
+ `).all(...params) as IssueLifecycleActionRow[];
138
+ return rows.map(rowToIssueLifecycleAction);
139
+ }
140
+
141
+ export function upsertIssueLifecycleAction(input: UpsertIssueLifecycleActionInput): IssueLifecycleActionRecord {
142
+ const now = Date.now();
143
+ const issueRefId = cleanRequiredText(input.issueRefId, "issueRefId", 120);
144
+ const action = cleanAction(input.action);
145
+ const landKey = cleanRequiredText(input.landKey, "landKey", 240);
146
+ getDb().query(`
147
+ INSERT INTO issue_lifecycle_actions (
148
+ issue_ref_id, action, land_key, workspace_id, branch, base_ref, commit_sha, commit_subject,
149
+ agent_id, source, comment, status, error, created_at, updated_at
150
+ )
151
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
152
+ ON CONFLICT(issue_ref_id, action, land_key) DO UPDATE SET
153
+ workspace_id = excluded.workspace_id,
154
+ branch = excluded.branch,
155
+ base_ref = excluded.base_ref,
156
+ commit_sha = excluded.commit_sha,
157
+ commit_subject = excluded.commit_subject,
158
+ agent_id = excluded.agent_id,
159
+ source = excluded.source,
160
+ comment = excluded.comment,
161
+ status = excluded.status,
162
+ error = excluded.error,
163
+ updated_at = excluded.updated_at
164
+ `).run(
165
+ issueRefId,
166
+ action,
167
+ landKey,
168
+ cleanText(input.workspaceId, "workspaceId", 160) ?? null,
169
+ cleanText(input.branch, "branch", 240) ?? null,
170
+ cleanText(input.baseRef, "baseRef", 120) ?? null,
171
+ cleanText(input.commitSha, "commitSha", 64) ?? null,
172
+ cleanText(input.commitSubject, "commitSubject", 500) ?? null,
173
+ cleanText(input.agentId, "agentId", 200) ?? null,
174
+ cleanText(input.source, "source", 120) ?? null,
175
+ cleanText(input.comment, "comment", 2000) ?? null,
176
+ cleanStatus(input.status),
177
+ cleanText(input.error, "error", 2000) ?? null,
178
+ now,
179
+ now,
180
+ );
181
+ const row = getDb().query(`
182
+ SELECT * FROM issue_lifecycle_actions
183
+ WHERE issue_ref_id = ? AND action = ? AND land_key = ?
184
+ `).get(issueRefId, action, landKey) as IssueLifecycleActionRow | undefined;
185
+ if (!row) throw new ValidationError("issue lifecycle action could not be recorded");
186
+ return rowToIssueLifecycleAction(row);
187
+ }
@@ -0,0 +1,254 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb, ValidationError } from "./connection.ts";
3
+ import { ISSUE_PROVIDERS, ISSUE_REF_STATUSES, type IssueProvider, type IssueRef, type IssueRefStatus } from "../types";
4
+
5
+ interface IssueRefRow {
6
+ id: string;
7
+ provider: IssueProvider;
8
+ repo: string;
9
+ external_key: string;
10
+ url: string;
11
+ title: string | null;
12
+ status: IssueRefStatus;
13
+ status_synced_at: number | null;
14
+ created_at: number;
15
+ updated_at: number;
16
+ }
17
+
18
+ export interface UpsertIssueRefInput {
19
+ provider: IssueProvider;
20
+ repo: string;
21
+ externalKey: string;
22
+ url: string;
23
+ title?: string;
24
+ status?: IssueRefStatus;
25
+ statusSyncedAt?: number;
26
+ }
27
+
28
+ export interface ResolveIssueRefByExternalKeyInput {
29
+ provider: IssueProvider;
30
+ repo: string;
31
+ externalKey: string;
32
+ url?: string;
33
+ title?: string;
34
+ status?: IssueRefStatus;
35
+ statusSyncedAt?: number;
36
+ }
37
+
38
+ export interface ResolveIssueRefFromStringInput {
39
+ ref: string;
40
+ defaultRepo?: string;
41
+ provider?: IssueProvider;
42
+ title?: string;
43
+ status?: IssueRefStatus;
44
+ statusSyncedAt?: number;
45
+ }
46
+
47
+ export interface ListIssueRefsOptions {
48
+ provider?: IssueProvider;
49
+ repo?: string;
50
+ status?: IssueRefStatus;
51
+ limit?: number;
52
+ }
53
+
54
+ export interface UpdateIssueRefInput {
55
+ url?: string;
56
+ title?: string;
57
+ status?: IssueRefStatus;
58
+ statusSyncedAt?: number;
59
+ }
60
+
61
+ export function issueRefId(): string {
62
+ return `issue_${randomUUID()}`;
63
+ }
64
+
65
+ function rowToIssueRef(row: IssueRefRow): IssueRef {
66
+ return {
67
+ id: row.id,
68
+ provider: row.provider,
69
+ repo: row.repo,
70
+ externalKey: row.external_key,
71
+ url: row.url,
72
+ ...(row.title ? { title: row.title } : {}),
73
+ status: row.status,
74
+ ...(row.status_synced_at !== null ? { statusSyncedAt: row.status_synced_at } : {}),
75
+ createdAt: row.created_at,
76
+ updatedAt: row.updated_at,
77
+ };
78
+ }
79
+
80
+ function cleanProvider(value: IssueProvider): IssueProvider {
81
+ if (!ISSUE_PROVIDERS.includes(value)) {
82
+ throw new ValidationError(`provider must be one of: ${ISSUE_PROVIDERS.join(", ")}`);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ function cleanStatus(value: IssueRefStatus | undefined): IssueRefStatus {
88
+ if (value === undefined) return "unknown";
89
+ if (!ISSUE_REF_STATUSES.includes(value)) {
90
+ throw new ValidationError(`status must be one of: ${ISSUE_REF_STATUSES.join(", ")}`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function requiredText(value: string | undefined, field: string, max: number): string {
96
+ if (typeof value !== "string") throw new ValidationError(`${field} required`);
97
+ const cleaned = value.trim();
98
+ if (!cleaned) throw new ValidationError(`${field} required`);
99
+ if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
100
+ return cleaned;
101
+ }
102
+
103
+ function optionalText(value: string | undefined, field: string, max: number): string | undefined {
104
+ if (value === undefined) return undefined;
105
+ const cleaned = requiredText(value, field, max);
106
+ return cleaned || undefined;
107
+ }
108
+
109
+ function cleanSyncedAt(value: number | undefined): number | undefined {
110
+ if (value === undefined) return undefined;
111
+ if (!Number.isSafeInteger(value) || value < 0) throw new ValidationError("statusSyncedAt must be a non-negative integer");
112
+ return value;
113
+ }
114
+
115
+ function githubIssueUrl(repo: string, externalKey: string): string {
116
+ return `https://github.com/${repo}/issues/${externalKey}`;
117
+ }
118
+
119
+ function resolveUrl(provider: IssueProvider, repo: string, externalKey: string, url: string | undefined): string {
120
+ const cleaned = optionalText(url, "url", 2000);
121
+ if (cleaned) return cleaned;
122
+ if (provider === "github") return githubIssueUrl(repo, externalKey);
123
+ throw new ValidationError("url required for non-github issue refs");
124
+ }
125
+
126
+ export function getIssueRef(id: string): IssueRef | null {
127
+ const row = getDb().query("SELECT * FROM issue_refs WHERE id = ?").get(id) as IssueRefRow | undefined;
128
+ return row ? rowToIssueRef(row) : null;
129
+ }
130
+
131
+ export function getIssueRefByExternalKey(provider: IssueProvider, repo: string, externalKey: string): IssueRef | null {
132
+ const row = getDb().query("SELECT * FROM issue_refs WHERE provider = ? AND repo = ? AND external_key = ?")
133
+ .get(cleanProvider(provider), requiredText(repo, "repo", 500), requiredText(externalKey, "externalKey", 240)) as IssueRefRow | undefined;
134
+ return row ? rowToIssueRef(row) : null;
135
+ }
136
+
137
+ export function upsertIssueRef(input: UpsertIssueRefInput): IssueRef {
138
+ const provider = cleanProvider(input.provider);
139
+ const repo = requiredText(input.repo, "repo", 500);
140
+ const externalKey = requiredText(input.externalKey, "externalKey", 240);
141
+ const url = resolveUrl(provider, repo, externalKey, input.url);
142
+ const existing = getIssueRefByExternalKey(provider, repo, externalKey);
143
+ const title = optionalText(input.title, "title", 500);
144
+ const status = input.status === undefined && existing ? existing.status : cleanStatus(input.status);
145
+ const statusSyncedAt = cleanSyncedAt(input.statusSyncedAt);
146
+ const now = Date.now();
147
+ if (existing) {
148
+ getDb().query(`
149
+ UPDATE issue_refs
150
+ SET url = ?, title = ?, status = ?, status_synced_at = ?, updated_at = ?
151
+ WHERE id = ?
152
+ `).run(url, title ?? existing.title ?? null, status, statusSyncedAt ?? existing.statusSyncedAt ?? null, now, existing.id);
153
+ return getIssueRef(existing.id)!;
154
+ }
155
+ const id = issueRefId();
156
+ getDb().query(`
157
+ INSERT INTO issue_refs (id, provider, repo, external_key, url, title, status, status_synced_at, created_at, updated_at)
158
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
159
+ `).run(id, provider, repo, externalKey, url, title ?? null, status, statusSyncedAt ?? null, now, now);
160
+ return getIssueRef(id)!;
161
+ }
162
+
163
+ export function resolveIssueRefByExternalKey(input: ResolveIssueRefByExternalKeyInput): IssueRef {
164
+ const provider = cleanProvider(input.provider);
165
+ const repo = requiredText(input.repo, "repo", 500);
166
+ const externalKey = requiredText(input.externalKey, "externalKey", 240);
167
+ const existing = getIssueRefByExternalKey(provider, repo, externalKey);
168
+ if (existing && input.url === undefined && input.title === undefined && input.status === undefined && input.statusSyncedAt === undefined) {
169
+ return existing;
170
+ }
171
+ return upsertIssueRef({
172
+ provider,
173
+ repo,
174
+ externalKey,
175
+ url: input.url ?? existing?.url ?? resolveUrl(provider, repo, externalKey, undefined),
176
+ title: input.title ?? existing?.title,
177
+ status: input.status ?? existing?.status,
178
+ statusSyncedAt: input.statusSyncedAt ?? existing?.statusSyncedAt,
179
+ });
180
+ }
181
+
182
+ export function resolveIssueRefFromString(input: ResolveIssueRefFromStringInput): IssueRef {
183
+ const ref = requiredText(input.ref, "ref", 1000);
184
+ if (ref.startsWith("issue_")) {
185
+ const existing = getIssueRef(ref);
186
+ if (!existing) throw new ValidationError(`issue ref ${ref} not found`);
187
+ return existing;
188
+ }
189
+ const provider = cleanProvider(input.provider ?? "github");
190
+ if (provider !== "github") throw new ValidationError("string issue refs are only supported for github in this foundation resolver");
191
+ const urlMatch = ref.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/([0-9]+)(?:[/?#].*)?$/);
192
+ const ownerRepoMatch = ref.match(/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([0-9]+)$/);
193
+ const bareMatch = ref.match(/^#([0-9]+)$/);
194
+ const repo = urlMatch ? `${urlMatch[1]}/${urlMatch[2]}` : ownerRepoMatch ? ownerRepoMatch[1] : bareMatch ? input.defaultRepo : undefined;
195
+ const externalKey = urlMatch ? urlMatch[3] : ownerRepoMatch ? ownerRepoMatch[2] : bareMatch ? bareMatch[1] : undefined;
196
+ if (!repo || !externalKey) throw new ValidationError("ref must be issue_<id>, #number, owner/repo#number, or a GitHub issue URL");
197
+ return resolveIssueRefByExternalKey({
198
+ provider,
199
+ repo: requiredText(repo, "repo", 500),
200
+ externalKey,
201
+ url: githubIssueUrl(repo, externalKey),
202
+ title: input.title,
203
+ status: input.status,
204
+ statusSyncedAt: input.statusSyncedAt,
205
+ });
206
+ }
207
+
208
+ export function listIssueRefs(opts: ListIssueRefsOptions = {}): IssueRef[] {
209
+ const conditions: string[] = [];
210
+ const params: Array<string | number> = [];
211
+ if (opts.provider) {
212
+ conditions.push("provider = ?");
213
+ params.push(cleanProvider(opts.provider));
214
+ }
215
+ if (opts.repo) {
216
+ conditions.push("repo = ?");
217
+ params.push(requiredText(opts.repo, "repo", 500));
218
+ }
219
+ if (opts.status) {
220
+ conditions.push("status = ?");
221
+ params.push(cleanStatus(opts.status));
222
+ }
223
+ const limit = opts.limit ?? 100;
224
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 500) throw new ValidationError("limit must be between 1 and 500");
225
+ params.push(limit);
226
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
227
+ const rows = getDb().query(`
228
+ SELECT * FROM issue_refs
229
+ ${where}
230
+ ORDER BY updated_at DESC, created_at DESC, id DESC
231
+ LIMIT ?
232
+ `).all(...params) as IssueRefRow[];
233
+ return rows.map(rowToIssueRef);
234
+ }
235
+
236
+ export function updateIssueRef(id: string, input: UpdateIssueRefInput): IssueRef | null {
237
+ const existing = getIssueRef(requiredText(id, "id", 120));
238
+ if (!existing) return null;
239
+ const url = optionalText(input.url, "url", 2000) ?? existing.url;
240
+ const title = input.title === undefined ? existing.title : optionalText(input.title, "title", 500);
241
+ const status = input.status === undefined ? existing.status : cleanStatus(input.status);
242
+ const statusSyncedAt = input.statusSyncedAt === undefined ? existing.statusSyncedAt : cleanSyncedAt(input.statusSyncedAt);
243
+ getDb().query(`
244
+ UPDATE issue_refs
245
+ SET url = ?, title = ?, status = ?, status_synced_at = ?, updated_at = ?
246
+ WHERE id = ?
247
+ `).run(url, title ?? null, status, statusSyncedAt ?? null, Date.now(), existing.id);
248
+ return getIssueRef(existing.id);
249
+ }
250
+
251
+ export function deleteIssueRef(id: string): boolean {
252
+ const result = getDb().query("DELETE FROM issue_refs WHERE id = ?").run(requiredText(id, "id", 120));
253
+ return result.changes > 0;
254
+ }
package/src/db/mappers.ts CHANGED
@@ -377,6 +377,8 @@ export function rowToActivityEvent(row: any): ActivityEvent {
377
377
  pairId: row.pair_id ?? undefined,
378
378
  taskId: row.task_id ?? undefined,
379
379
  agentId: row.agent_id ?? undefined,
380
+ issueRefId: row.issue_ref_id ?? undefined,
381
+ issueAction: row.issue_action ?? undefined,
380
382
  metadata: parseJson(row.metadata, {}),
381
383
  createdAt: row.created_at,
382
384
  };
@@ -117,6 +117,53 @@ export function applyMigrations(): void {
117
117
  normalizeExistingMessageReactions();
118
118
 
119
119
  // Migrations
120
+ getDb().run(`
121
+ CREATE TABLE IF NOT EXISTS issue_refs (
122
+ id TEXT PRIMARY KEY,
123
+ provider TEXT NOT NULL,
124
+ repo TEXT NOT NULL,
125
+ external_key TEXT NOT NULL,
126
+ url TEXT NOT NULL,
127
+ title TEXT,
128
+ status TEXT NOT NULL DEFAULT 'unknown',
129
+ status_synced_at INTEGER,
130
+ created_at INTEGER NOT NULL,
131
+ updated_at INTEGER NOT NULL,
132
+ UNIQUE(provider, repo, external_key)
133
+ )
134
+ `);
135
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_issue_refs_external ON issue_refs(provider, repo, external_key)");
136
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_issue_refs_updated ON issue_refs(updated_at DESC)");
137
+ getDb().run(`
138
+ CREATE TABLE IF NOT EXISTS issue_associations (
139
+ issue_ref_id TEXT NOT NULL REFERENCES issue_refs(id) ON DELETE CASCADE,
140
+ entity_type TEXT NOT NULL CHECK (entity_type IN ('team', 'plan-node', 'epic')),
141
+ entity_id TEXT NOT NULL,
142
+ created_at INTEGER NOT NULL,
143
+ PRIMARY KEY (issue_ref_id, entity_type, entity_id)
144
+ )
145
+ `);
146
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_issue_associations_entity ON issue_associations(entity_type, entity_id, created_at DESC)");
147
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_issue_associations_issue ON issue_associations(issue_ref_id, created_at DESC)");
148
+ getDb().run(`CREATE TABLE IF NOT EXISTS issue_lifecycle_actions (
149
+ id INTEGER PRIMARY KEY AUTOINCREMENT, issue_ref_id TEXT NOT NULL REFERENCES issue_refs(id) ON DELETE CASCADE,
150
+ action TEXT NOT NULL CHECK (action IN ('close', 'reopen')), land_key TEXT NOT NULL, workspace_id TEXT, branch TEXT, base_ref TEXT, commit_sha TEXT, commit_subject TEXT,
151
+ agent_id TEXT, source TEXT, comment TEXT, status TEXT NOT NULL CHECK (status IN ('succeeded', 'failed')),
152
+ error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(issue_ref_id, action, land_key))`);
153
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_issue_lifecycle_commit ON issue_lifecycle_actions(commit_sha, action, status)");
154
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_issue_lifecycle_subject ON issue_lifecycle_actions(commit_subject, action, status)");
155
+
156
+ const activityCols = getDb().query("PRAGMA table_info(activity_events)").all() as any[];
157
+ const activityColNames = activityCols.map((c: any) => c.name);
158
+ if (!activityColNames.includes("issue_ref_id")) {
159
+ getDb().run("ALTER TABLE activity_events ADD COLUMN issue_ref_id TEXT");
160
+ }
161
+ if (!activityColNames.includes("issue_action")) {
162
+ getDb().run("ALTER TABLE activity_events ADD COLUMN issue_action TEXT");
163
+ }
164
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_activity_issue ON activity_events(issue_ref_id, created_at)");
165
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_activity_agent_issue ON activity_events(agent_id, issue_ref_id, created_at)");
166
+
120
167
  getDb().run(`
121
168
  CREATE TABLE IF NOT EXISTS workspace_merge_pauses (
122
169
  id TEXT PRIMARY KEY,
package/src/db/schema.ts CHANGED
@@ -671,13 +671,13 @@ export function initDb(path: string = "agent-relay.db"): Database {
671
671
  message_id INTEGER,
672
672
  pair_id TEXT,
673
673
  task_id INTEGER,
674
- agent_id TEXT,
674
+ agent_id TEXT, issue_ref_id TEXT, issue_action TEXT,
675
675
  metadata TEXT NOT NULL DEFAULT '{}',
676
676
  created_at INTEGER NOT NULL
677
677
  );
678
678
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
679
679
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
680
- CREATE INDEX IF NOT EXISTS idx_activity_agent ON activity_events(agent_id, created_at);
680
+ CREATE INDEX IF NOT EXISTS idx_activity_agent ON activity_events(agent_id, created_at); CREATE INDEX IF NOT EXISTS idx_activity_issue ON activity_events(issue_ref_id, created_at); CREATE INDEX IF NOT EXISTS idx_activity_agent_issue ON activity_events(agent_id, issue_ref_id, created_at);
681
681
 
682
682
  CREATE TABLE IF NOT EXISTS channels (
683
683
  id TEXT PRIMARY KEY,
@@ -0,0 +1,24 @@
1
+ import type { IssueRef, IssueRefStatus } from "../types";
2
+
3
+ export interface IssueTrackerStatus {
4
+ status: IssueRefStatus;
5
+ title: string;
6
+ }
7
+
8
+ export interface IssueTrackerCloseOptions {
9
+ comment?: string;
10
+ labels?: string[];
11
+ }
12
+
13
+ export interface IssueTrackerReopenOptions {
14
+ comment?: string;
15
+ }
16
+
17
+ export interface IssueTrackerAdapter {
18
+ resolveLink(ref: IssueRef): string;
19
+ getStatus(ref: IssueRef): Promise<IssueTrackerStatus>;
20
+ close(ref: IssueRef, options?: IssueTrackerCloseOptions): Promise<void>;
21
+ reopen(ref: IssueRef, options?: IssueTrackerReopenOptions): Promise<void>;
22
+ comment(ref: IssueRef, body: string): Promise<void>;
23
+ applyLabels(ref: IssueRef, labels: string[]): Promise<void>;
24
+ }
@@ -0,0 +1,136 @@
1
+ import type { IssueRef, IssueRefStatus } from "../types";
2
+ import type { IssueTrackerAdapter, IssueTrackerCloseOptions, IssueTrackerReopenOptions, IssueTrackerStatus } from "./adapter";
3
+
4
+ export const GITHUB_FIXED_LABEL = "fixed";
5
+ export const GITHUB_CREDENTIAL_REQUIREMENT = "GitHub issue tracker adapter requires gh authenticated with a token that has issues:write scope";
6
+
7
+ type GitHubApiMethod = "GET" | "PATCH" | "POST";
8
+
9
+ export interface GitHubApiRequest {
10
+ method: GitHubApiMethod;
11
+ path: string;
12
+ body?: Record<string, unknown>;
13
+ }
14
+
15
+ export type GitHubApi = (request: GitHubApiRequest) => Promise<unknown>;
16
+
17
+ interface GitHubIssuePayload {
18
+ state?: unknown;
19
+ title?: unknown;
20
+ html_url?: unknown;
21
+ }
22
+
23
+ function textFromBytes(value: unknown): string {
24
+ if (!value) return "";
25
+ if (typeof value === "string") return value;
26
+ if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
27
+ return String(value);
28
+ }
29
+
30
+ async function ghApi(request: GitHubApiRequest): Promise<unknown> {
31
+ // Deploy gate for #474: the host running lifecycle automation must provide gh
32
+ // auth backed by a GitHub token with `issues:write` scope.
33
+ const args = ["gh", "api", "--method", request.method, request.path, "--header", "Accept: application/vnd.github+json"];
34
+ appendBodyArgs(args, request.body);
35
+ const proc = Bun.spawnSync(args, {
36
+ stdin: "ignore",
37
+ stdout: "pipe",
38
+ stderr: "pipe",
39
+ env: process.env,
40
+ });
41
+ const stdout = textFromBytes(proc.stdout).trim();
42
+ if (proc.exitCode !== 0) {
43
+ const stderr = textFromBytes(proc.stderr).trim();
44
+ throw new Error(`${GITHUB_CREDENTIAL_REQUIREMENT}; gh api failed: ${stderr || stdout || `exit ${proc.exitCode}`}`);
45
+ }
46
+ if (!stdout) return {};
47
+ try {
48
+ return JSON.parse(stdout);
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function appendBodyArgs(args: string[], body: Record<string, unknown> | undefined): void {
55
+ if (!body) return;
56
+ for (const [key, value] of Object.entries(body)) {
57
+ if (value === undefined || value === null) continue;
58
+ if (Array.isArray(value)) {
59
+ for (const item of value) {
60
+ if (item === undefined || item === null) continue;
61
+ args.push("--raw-field", `${key}[]=${String(item)}`);
62
+ }
63
+ continue;
64
+ }
65
+ args.push(typeof value === "string" ? "--raw-field" : "--field", `${key}=${String(value)}`);
66
+ }
67
+ }
68
+
69
+ function githubIssuePath(ref: IssueRef): string {
70
+ assertGitHubIssueRef(ref);
71
+ return `/repos/${ref.repo}/issues/${ref.externalKey}`;
72
+ }
73
+
74
+ function assertGitHubIssueRef(ref: IssueRef): void {
75
+ if (ref.provider !== "github") throw new Error(`GitHub adapter cannot handle ${ref.provider} issue refs`);
76
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(ref.repo)) throw new Error("GitHub issue ref repo must be owner/repo");
77
+ if (!/^[0-9]+$/.test(ref.externalKey)) throw new Error("GitHub issue ref externalKey must be an issue number");
78
+ }
79
+
80
+ function statusFromState(state: unknown): IssueRefStatus {
81
+ if (state === "open" || state === "closed") return state;
82
+ return "unknown";
83
+ }
84
+
85
+ function titleFromIssue(value: unknown): string {
86
+ const titleValue = value && typeof value === "object" ? (value as GitHubIssuePayload).title : undefined;
87
+ const title = typeof titleValue === "string" ? titleValue.trim() : "";
88
+ return title || "Untitled issue";
89
+ }
90
+
91
+ function uniqueLabels(labels: string[]): string[] {
92
+ return [...new Set(labels.map((label) => label.trim()).filter(Boolean))];
93
+ }
94
+
95
+ export class GitHubIssueTrackerAdapter implements IssueTrackerAdapter {
96
+ constructor(private readonly api: GitHubApi = ghApi) {}
97
+
98
+ resolveLink(ref: IssueRef): string {
99
+ assertGitHubIssueRef(ref);
100
+ return ref.url || `https://github.com/${ref.repo}/issues/${ref.externalKey}`;
101
+ }
102
+
103
+ async getStatus(ref: IssueRef): Promise<IssueTrackerStatus> {
104
+ const issue = await this.api({ method: "GET", path: githubIssuePath(ref) }) as GitHubIssuePayload;
105
+ return { status: statusFromState(issue.state), title: titleFromIssue(issue) };
106
+ }
107
+
108
+ async close(ref: IssueRef, options: IssueTrackerCloseOptions = {}): Promise<void> {
109
+ await this.api({ method: "PATCH", path: githubIssuePath(ref), body: { state: "closed" } });
110
+ await this.applyLabels(ref, [GITHUB_FIXED_LABEL, ...(options.labels ?? [])]);
111
+ if (options.comment) await this.comment(ref, options.comment);
112
+ }
113
+
114
+ async reopen(ref: IssueRef, options: IssueTrackerReopenOptions = {}): Promise<void> {
115
+ await this.api({ method: "PATCH", path: githubIssuePath(ref), body: { state: "open" } });
116
+ if (options.comment) await this.comment(ref, options.comment);
117
+ }
118
+
119
+ async comment(ref: IssueRef, body: string): Promise<void> {
120
+ const trimmed = body.trim();
121
+ if (!trimmed) throw new Error("GitHub issue comment body required");
122
+ await this.api({ method: "POST", path: `${githubIssuePath(ref)}/comments`, body: { body: trimmed } });
123
+ }
124
+
125
+ async applyLabels(ref: IssueRef, labels: string[]): Promise<void> {
126
+ const cleaned = uniqueLabels(labels);
127
+ if (cleaned.length === 0) return;
128
+ await this.api({ method: "POST", path: `${githubIssuePath(ref)}/labels`, body: { labels: cleaned } });
129
+ }
130
+ }
131
+
132
+ export function createGitHubIssueTrackerAdapter(api?: GitHubApi): IssueTrackerAdapter {
133
+ return new GitHubIssueTrackerAdapter(api);
134
+ }
135
+
136
+ export const githubIssueTrackerAdapter = createGitHubIssueTrackerAdapter();