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.
- package/docs/openapi.json +479 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-DVvlvi4B.js → MessageBubble-BP0Ym3xb.js} +2 -2
- package/public/assets/{MessageBubble-DVvlvi4B.js.map → MessageBubble-BP0Ym3xb.js.map} +1 -1
- package/public/assets/PlanGraphPanel-DKc3gdou.js +2 -0
- package/public/assets/PlanGraphPanel-DKc3gdou.js.map +1 -0
- package/public/assets/{activity-IYl3MEXC.js → activity-CRLG6ICe.js} +2 -2
- package/public/assets/{activity-IYl3MEXC.js.map → activity-CRLG6ICe.js.map} +1 -1
- package/public/assets/{agent-profiles-BfGTTbeY.js → agent-profiles-DItyEkXP.js} +2 -2
- package/public/assets/{agent-profiles-BfGTTbeY.js.map → agent-profiles-DItyEkXP.js.map} +1 -1
- package/public/assets/{agents-BquvREQr.js → agents-5mKiZ1rz.js} +2 -2
- package/public/assets/{agents-BquvREQr.js.map → agents-5mKiZ1rz.js.map} +1 -1
- package/public/assets/{analytics-De0cEZGx.js → analytics-J5pPcS-v.js} +3 -3
- package/public/assets/{analytics-De0cEZGx.js.map → analytics-J5pPcS-v.js.map} +1 -1
- package/public/assets/{automation-Ddbermbi.js → automation-BG2HWK5N.js} +2 -2
- package/public/assets/{automation-Ddbermbi.js.map → automation-BG2HWK5N.js.map} +1 -1
- package/public/assets/{badge-DhLyCPag.js → badge-CZhIdlTJ.js} +2 -2
- package/public/assets/{badge-DhLyCPag.js.map → badge-CZhIdlTJ.js.map} +1 -1
- package/public/assets/{branch-state-badge-DSyWKluD.js → branch-state-badge-BZMoxs65.js} +2 -2
- package/public/assets/{branch-state-badge-DSyWKluD.js.map → branch-state-badge-BZMoxs65.js.map} +1 -1
- package/public/assets/{button-K20wAZG1.js → button-2taQeNHW.js} +2 -2
- package/public/assets/{button-K20wAZG1.js.map → button-2taQeNHW.js.map} +1 -1
- package/public/assets/card-DzIsAI41.js +2 -0
- package/public/assets/{card-CcZ9hLI7.js.map → card-DzIsAI41.js.map} +1 -1
- package/public/assets/channels-B2PeqiRT.js +2 -0
- package/public/assets/{channels-BG0Nib7y.js.map → channels-B2PeqiRT.js.map} +1 -1
- package/public/assets/chat-CLRJVagA.js +2 -0
- package/public/assets/{chat-Dx6qWNxb.js.map → chat-CLRJVagA.js.map} +1 -1
- package/public/assets/{connectors-DWh7MwV_.js → connectors-Bzvkxsgo.js} +2 -2
- package/public/assets/{connectors-DWh7MwV_.js.map → connectors-Bzvkxsgo.js.map} +1 -1
- package/public/assets/copy-button-HEkwi6xX.js +2 -0
- package/public/assets/{copy-button-eF3cKp4Q.js.map → copy-button-HEkwi6xX.js.map} +1 -1
- package/public/assets/display-DzNvOu1j.js.map +1 -1
- package/public/assets/dist-1rXN5FN9.js +2 -0
- package/public/assets/{dist-YpHfnnIA.js.map → dist-1rXN5FN9.js.map} +1 -1
- package/public/assets/{dist-1dhfdhm9.js → dist-AbEQuFoK.js} +2 -2
- package/public/assets/{dist-1dhfdhm9.js.map → dist-AbEQuFoK.js.map} +1 -1
- package/public/assets/{dist-COQuunEv.js → dist-B7p1zkvf.js} +2 -2
- package/public/assets/{dist-COQuunEv.js.map → dist-B7p1zkvf.js.map} +1 -1
- package/public/assets/{dist-Ci5Y37nj.js → dist-CG10hM1-.js} +2 -2
- package/public/assets/{dist-Ci5Y37nj.js.map → dist-CG10hM1-.js.map} +1 -1
- package/public/assets/dist-Cfm5_zPB.js +2 -0
- package/public/assets/{dist-qd198ib9.js.map → dist-Cfm5_zPB.js.map} +1 -1
- package/public/assets/{dist-BrQsqBbc.js → dist-Cgw85UcE.js} +2 -2
- package/public/assets/{dist-BrQsqBbc.js.map → dist-Cgw85UcE.js.map} +1 -1
- package/public/assets/{dist-CoROafNg.js → dist-D3_TJKTC.js} +2 -2
- package/public/assets/{dist-CoROafNg.js.map → dist-D3_TJKTC.js.map} +1 -1
- package/public/assets/{dist-I-MFUv51.js → dist-DQkLq0hs.js} +2 -2
- package/public/assets/{dist-I-MFUv51.js.map → dist-DQkLq0hs.js.map} +1 -1
- package/public/assets/{dist-UQGKwqZH.js → dist-D_sl7u_f.js} +2 -2
- package/public/assets/{dist-UQGKwqZH.js.map → dist-D_sl7u_f.js.map} +1 -1
- package/public/assets/{dist-yGOq6sB3.js → dist-Ilb3r1im.js} +2 -2
- package/public/assets/{dist-yGOq6sB3.js.map → dist-Ilb3r1im.js.map} +1 -1
- package/public/assets/dist-Pf_3Gf38.js +2 -0
- package/public/assets/{dist-BLBF7qqr.js.map → dist-Pf_3Gf38.js.map} +1 -1
- package/public/assets/{es2015-CRxz9lOj.js → es2015-GSepNaIS.js} +2 -2
- package/public/assets/{es2015-CRxz9lOj.js.map → es2015-GSepNaIS.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DMm6jeqi.js → formatted-body-impl-QqHxdMGP.js} +2 -2
- package/public/assets/{formatted-body-impl-DMm6jeqi.js.map → formatted-body-impl-QqHxdMGP.js.map} +1 -1
- package/public/assets/index-CCp1V35c.js +22 -0
- package/public/assets/index-CCp1V35c.js.map +1 -0
- package/public/assets/index-DiR-fqy_.css +2 -0
- package/public/assets/{input-BHYNpXAG.js → input-BsNqlONJ.js} +2 -2
- package/public/assets/{input-BHYNpXAG.js.map → input-BsNqlONJ.js.map} +1 -1
- package/public/assets/{insights-C7wKt1_l.js → insights-D-EyDfoZ.js} +2 -2
- package/public/assets/{insights-C7wKt1_l.js.map → insights-D-EyDfoZ.js.map} +1 -1
- package/public/assets/{integrations-CG9qyHUf.js → integrations-D1kc-yNi.js} +2 -2
- package/public/assets/{integrations-CG9qyHUf.js.map → integrations-D1kc-yNi.js.map} +1 -1
- package/public/assets/{lib-Df4aph5u.js → lib-bXPu6xCL.js} +2 -2
- package/public/assets/{lib-Df4aph5u.js.map → lib-bXPu6xCL.js.map} +1 -1
- package/public/assets/lucide-react-DWYjU-AF.js +9 -0
- package/public/assets/{lucide-react-L2UxFDic.js.map → lucide-react-DWYjU-AF.js.map} +1 -1
- package/public/assets/{maintenance-CjpvnV_U.js → maintenance-COqID7aq.js} +2 -2
- package/public/assets/{maintenance-CjpvnV_U.js.map → maintenance-COqID7aq.js.map} +1 -1
- package/public/assets/{managed-agents-CdfuBKJA.js → managed-agents-b4B8qewQ.js} +2 -2
- package/public/assets/{managed-agents-CdfuBKJA.js.map → managed-agents-b4B8qewQ.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-Cf4xCmWt.js → markdown-preview-impl-D7o8jS07.js} +2 -2
- package/public/assets/{markdown-preview-impl-Cf4xCmWt.js.map → markdown-preview-impl-D7o8jS07.js.map} +1 -1
- package/public/assets/memory-Q0qF7_i3.js +2 -0
- package/public/assets/{memory-xKWduhyr.js.map → memory-Q0qF7_i3.js.map} +1 -1
- package/public/assets/{messages-BpZYOUtJ.js → messages-m4EpkK6s.js} +2 -2
- package/public/assets/{messages-BpZYOUtJ.js.map → messages-m4EpkK6s.js.map} +1 -1
- package/public/assets/{orchestrators-BrJvGIOc.js → orchestrators-CnAtGfUN.js} +2 -2
- package/public/assets/{orchestrators-BrJvGIOc.js.map → orchestrators-CnAtGfUN.js.map} +1 -1
- package/public/assets/{overview-BUORwePi.js → overview-D7CF88ik.js} +2 -2
- package/public/assets/{overview-BUORwePi.js.map → overview-D7CF88ik.js.map} +1 -1
- package/public/assets/pairs-Q6PlFJtH.js +2 -0
- package/public/assets/{pairs-DR0GizDN.js.map → pairs-Q6PlFJtH.js.map} +1 -1
- package/public/assets/{react-dom-CrP_AtcL.js → react-dom-CPghDLpB.js} +2 -2
- package/public/assets/{react-dom-CrP_AtcL.js.map → react-dom-CPghDLpB.js.map} +1 -1
- package/public/assets/{security-DLwSqTc_.js → security-BeV-6SdA.js} +2 -2
- package/public/assets/{security-DLwSqTc_.js.map → security-BeV-6SdA.js.map} +1 -1
- package/public/assets/{select-pEru111r.js → select-Dchc0_Jg.js} +2 -2
- package/public/assets/{select-pEru111r.js.map → select-Dchc0_Jg.js.map} +1 -1
- package/public/assets/{settings-B-ueGvwV.js → settings-hPwL-31u.js} +3 -3
- package/public/assets/{settings-B-ueGvwV.js.map → settings-hPwL-31u.js.map} +1 -1
- package/public/assets/store-Buwkqv_7.js +9 -0
- package/public/assets/store-Buwkqv_7.js.map +1 -0
- package/public/assets/tasks-DKUMPEgk.js +2 -0
- package/public/assets/{tasks-C2rRQm6E.js.map → tasks-DKUMPEgk.js.map} +1 -1
- package/public/assets/teams-T5fFsdOX.js +2 -0
- package/public/assets/teams-T5fFsdOX.js.map +1 -0
- package/public/assets/{terminal-viewer-impl-BM_3qoeN.js → terminal-viewer-impl-Bk0u_Mav.js} +3 -3
- package/public/assets/{terminal-viewer-impl-BM_3qoeN.js.map → terminal-viewer-impl-Bk0u_Mav.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-DrRH_QzG.js → use-keyboard-viewport-NKw1O5gC.js} +2 -2
- package/public/assets/{use-keyboard-viewport-DrRH_QzG.js.map → use-keyboard-viewport-NKw1O5gC.js.map} +1 -1
- package/public/assets/{work-queue-CkIYDAZD.js → work-queue-D26jok09.js} +2 -2
- package/public/assets/{work-queue-CkIYDAZD.js.map → work-queue-D26jok09.js.map} +1 -1
- package/public/assets/{workspaces-CYm5bLg9.js → workspaces-BMj6zx23.js} +3 -3
- package/public/assets/{workspaces-CYm5bLg9.js.map → workspaces-BMj6zx23.js.map} +1 -1
- package/public/index.html +21 -21
- package/src/automations.ts +40 -7
- package/src/branch-landed.ts +5 -0
- package/src/bus.ts +3 -0
- package/src/cli/workspace.ts +9 -17
- package/src/db/activity.ts +191 -3
- package/src/db/integrations.ts +1 -0
- package/src/db/issue-associations.ts +128 -0
- package/src/db/issue-lifecycle.ts +187 -0
- package/src/db/issue-refs.ts +254 -0
- package/src/db/mappers.ts +2 -0
- package/src/db/migrations.ts +47 -0
- package/src/db/schema.ts +2 -2
- package/src/issue-tracker/adapter.ts +24 -0
- package/src/issue-tracker/github.ts +136 -0
- package/src/issue-tracker/index.ts +13 -0
- package/src/issue-tracker/registry.ts +26 -0
- package/src/maintenance.ts +22 -169
- package/src/mcp-issue-activity-tools.ts +114 -0
- package/src/mcp-issue-association-tools.ts +130 -0
- package/src/mcp-issue-lifecycle-tools.ts +55 -0
- package/src/mcp-issue-ref-tools.ts +174 -0
- package/src/mcp-team-tools.ts +1 -1
- package/src/mcp.ts +3 -3
- package/src/provider-config-store.ts +30 -8
- package/src/routes/activity.ts +6 -1
- package/src/routes/commands.ts +29 -0
- package/src/routes/index.ts +19 -0
- package/src/routes/issue-activity.ts +78 -0
- package/src/routes/issue-associations.ts +76 -0
- package/src/routes/issue-lifecycle.ts +34 -0
- package/src/routes/issue-refs.ts +137 -0
- package/src/routes/orchestrator.ts +42 -1
- package/src/security.ts +8 -0
- package/src/services/issue-activity.ts +30 -0
- package/src/services/issue-associations.ts +72 -0
- package/src/services/issue-lifecycle.ts +308 -0
- package/src/services/issue-refs.ts +32 -0
- package/src/services/plan-graph.ts +40 -2
- package/src/services/team.ts +4 -0
- package/src/token-db.ts +3 -3
- package/src/workspace-actions.ts +8 -1
- package/src/workspace-auto-merge.ts +228 -0
- package/src/workspace-probe.ts +125 -0
- package/public/assets/PlanGraphPanel-Dby7wq9c.js +0 -2
- package/public/assets/PlanGraphPanel-Dby7wq9c.js.map +0 -1
- package/public/assets/card-CcZ9hLI7.js +0 -2
- package/public/assets/channels-BG0Nib7y.js +0 -2
- package/public/assets/chat-Dx6qWNxb.js +0 -2
- package/public/assets/copy-button-eF3cKp4Q.js +0 -2
- package/public/assets/dist-BLBF7qqr.js +0 -2
- package/public/assets/dist-YpHfnnIA.js +0 -2
- package/public/assets/dist-qd198ib9.js +0 -2
- package/public/assets/index-B3wOeAPO.js +0 -21
- package/public/assets/index-B3wOeAPO.js.map +0 -1
- package/public/assets/index-Dn_GDXbi.css +0 -2
- package/public/assets/lucide-react-L2UxFDic.js +0 -9
- package/public/assets/memory-xKWduhyr.js +0 -2
- package/public/assets/pairs-DR0GizDN.js +0 -2
- package/public/assets/store-B8xP6z6T.js +0 -9
- package/public/assets/store-B8xP6z6T.js.map +0 -1
- package/public/assets/tasks-C2rRQm6E.js +0 -2
- package/public/assets/teams-Bo0sdBTp.js +0 -2
- 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
|
};
|
package/src/db/migrations.ts
CHANGED
|
@@ -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();
|