@adriandmitroca/relay 0.0.2
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/README.md +121 -0
- package/dist/assets/index-BcE2ldjQ.css +1 -0
- package/dist/assets/index-RaJgQa_m.js +15 -0
- package/dist/index.html +16 -0
- package/package.json +47 -0
- package/scripts/install-service.sh +52 -0
- package/scripts/uninstall-service.sh +10 -0
- package/src/api/config.ts +481 -0
- package/src/api/issues.ts +81 -0
- package/src/api/middleware.ts +14 -0
- package/src/api/router.ts +31 -0
- package/src/cli.ts +184 -0
- package/src/config.ts +195 -0
- package/src/constants.ts +21 -0
- package/src/daemon.ts +1096 -0
- package/src/dashboard.ts +175 -0
- package/src/db.ts +718 -0
- package/src/notifications/telegram.ts +334 -0
- package/src/queue.ts +98 -0
- package/src/sources/asana.ts +161 -0
- package/src/sources/jira.ts +255 -0
- package/src/sources/linear.ts +233 -0
- package/src/sources/sentry.ts +222 -0
- package/src/sources/types.ts +20 -0
- package/src/utils/html.ts +23 -0
- package/src/utils/logger.ts +49 -0
- package/src/worker/claude.ts +297 -0
- package/src/worker/fix.ts +195 -0
- package/src/worker/git.ts +111 -0
- package/src/worker/triage.ts +122 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import type { NormalizedIssue, Severity, SourceAdapter } from "./types.ts";
|
|
2
|
+
import type { JiraSourceConfig } from "../config.ts";
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
|
|
5
|
+
export class JiraAdapter implements SourceAdapter {
|
|
6
|
+
readonly name = "jira";
|
|
7
|
+
private configs: Map<string, JiraSourceConfig> = new Map();
|
|
8
|
+
private authHeaders: Map<string, Record<string, string>> = new Map();
|
|
9
|
+
|
|
10
|
+
constructor() {}
|
|
11
|
+
|
|
12
|
+
addProject(projectKey: string, config: JiraSourceConfig) {
|
|
13
|
+
this.configs.set(projectKey, config);
|
|
14
|
+
const encoded = btoa(`${config.email}:${config.apiToken}`);
|
|
15
|
+
this.authHeaders.set(projectKey, {
|
|
16
|
+
Authorization: `Basic ${encoded}`,
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
Accept: "application/json",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async poll(projectKey: string): Promise<NormalizedIssue[]> {
|
|
23
|
+
const config = this.configs.get(projectKey);
|
|
24
|
+
if (!config) return [];
|
|
25
|
+
|
|
26
|
+
const jql = config.filterMode === "jql" && config.jql
|
|
27
|
+
? config.jql
|
|
28
|
+
: buildJQL(config);
|
|
29
|
+
|
|
30
|
+
const url = `https://${config.host}/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&maxResults=50&fields=summary,description,status,priority,issuetype,labels,assignee,created,updated,comment`;
|
|
31
|
+
const resp = await this.request(projectKey, url);
|
|
32
|
+
if (!resp?.ok) {
|
|
33
|
+
logger.error("Jira poll failed", { status: resp?.status, project: projectKey });
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const data = (await resp.json()) as JiraSearchResult;
|
|
38
|
+
return data.issues.map((i) => this.normalize(i, projectKey, config));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async getIssueContext(issue: NormalizedIssue): Promise<string> {
|
|
42
|
+
const config = this.configs.get(issue.projectKey);
|
|
43
|
+
if (!config) return issue.body;
|
|
44
|
+
|
|
45
|
+
const url = `https://${config.host}/rest/api/3/issue/${issue.sourceId}?expand=renderedFields&fields=summary,description,comment,status,priority,issuetype,labels,subtasks`;
|
|
46
|
+
const resp = await this.request(issue.projectKey, url);
|
|
47
|
+
if (!resp?.ok) return issue.body;
|
|
48
|
+
|
|
49
|
+
const data = (await resp.json()) as JiraIssueDetail;
|
|
50
|
+
const parts: string[] = [`# ${data.fields.summary}\n`];
|
|
51
|
+
|
|
52
|
+
if (data.renderedFields?.description) {
|
|
53
|
+
parts.push(data.renderedFields.description);
|
|
54
|
+
} else if (data.fields.description) {
|
|
55
|
+
parts.push(extractAdfText(data.fields.description));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const subtasks = data.fields.subtasks ?? [];
|
|
59
|
+
if (subtasks.length) {
|
|
60
|
+
parts.push("\n## Sub-tasks");
|
|
61
|
+
for (const st of subtasks) {
|
|
62
|
+
const status = st.fields.status?.name ?? "Unknown";
|
|
63
|
+
parts.push(`- [${st.key}] ${st.fields.summary} (${status})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const comments = data.fields.comment?.comments ?? [];
|
|
68
|
+
if (comments.length) {
|
|
69
|
+
parts.push("\n## Comments");
|
|
70
|
+
for (const c of comments.slice(-10)) {
|
|
71
|
+
const author = c.author?.displayName ?? "Unknown";
|
|
72
|
+
const body = c.renderedBody ?? extractAdfText(c.body);
|
|
73
|
+
parts.push(`\n**${author}** (${c.created}):\n${body}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return parts.join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async onFixAccepted(issue: NormalizedIssue, prUrl: string): Promise<void> {
|
|
81
|
+
const config = this.configs.get(issue.projectKey);
|
|
82
|
+
if (!config) return;
|
|
83
|
+
|
|
84
|
+
// Only write to Jira if explicitly configured
|
|
85
|
+
if (!config.onAcceptTransition) return;
|
|
86
|
+
|
|
87
|
+
// Transition issue
|
|
88
|
+
await this.transitionIssue(issue.projectKey, issue.sourceId, config);
|
|
89
|
+
|
|
90
|
+
// Add comment about the fix
|
|
91
|
+
const commentUrl = `https://${config.host}/rest/api/3/issue/${issue.sourceId}/comment`;
|
|
92
|
+
await this.request(issue.projectKey, commentUrl, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
body: { type: "doc", version: 1, content: [{ type: "paragraph", content: [{ type: "text", text: `Relay fix applied: ${prUrl}` }] }] },
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async transitionIssue(projectKey: string, issueId: string, config: JiraSourceConfig) {
|
|
101
|
+
const transUrl = `https://${config.host}/rest/api/3/issue/${issueId}/transitions`;
|
|
102
|
+
const resp = await this.request(projectKey, transUrl);
|
|
103
|
+
if (!resp?.ok) return;
|
|
104
|
+
|
|
105
|
+
const data = (await resp.json()) as { transitions: Array<{ id: string; name: string }> };
|
|
106
|
+
const target = data.transitions.find((t) =>
|
|
107
|
+
t.name.toLowerCase() === config.onAcceptTransition!.toLowerCase(),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (target) {
|
|
111
|
+
await this.request(projectKey, transUrl, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
body: JSON.stringify({ transition: { id: target.id } }),
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
logger.warn("Jira transition not found", { transition: config.onAcceptTransition, available: data.transitions.map((t) => t.name) });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private normalize(raw: JiraIssue, projectKey: string, config: JiraSourceConfig): NormalizedIssue {
|
|
121
|
+
const description = raw.fields.description
|
|
122
|
+
? extractAdfText(raw.fields.description)
|
|
123
|
+
: "";
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
source: "jira",
|
|
127
|
+
sourceId: raw.key,
|
|
128
|
+
externalUrl: `https://${config.host}/browse/${raw.key}`,
|
|
129
|
+
projectKey,
|
|
130
|
+
title: `${raw.key}: ${raw.fields.summary}`,
|
|
131
|
+
body: description,
|
|
132
|
+
severity: mapJiraPriority(raw.fields.priority?.name),
|
|
133
|
+
metadata: {
|
|
134
|
+
status: raw.fields.status?.name,
|
|
135
|
+
priority: raw.fields.priority?.name,
|
|
136
|
+
issueType: raw.fields.issuetype?.name,
|
|
137
|
+
labels: raw.fields.labels ?? [],
|
|
138
|
+
assignee: raw.fields.assignee?.displayName,
|
|
139
|
+
createdAt: raw.fields.created,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async request(projectKey: string, url: string, init?: RequestInit, retries = 0): Promise<Response | null> {
|
|
145
|
+
const headers = this.authHeaders.get(projectKey);
|
|
146
|
+
if (!headers) return null;
|
|
147
|
+
|
|
148
|
+
const resp = await fetch(url, {
|
|
149
|
+
...init,
|
|
150
|
+
headers: { ...headers, ...init?.headers },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (resp.status === 429 && retries < 3) {
|
|
154
|
+
const retryAfter = resp.headers.get("retry-after");
|
|
155
|
+
const wait = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60_000;
|
|
156
|
+
logger.warn("Jira rate limited", { retryAfterMs: wait, attempt: retries + 1 });
|
|
157
|
+
await Bun.sleep(wait);
|
|
158
|
+
return this.request(projectKey, url, init, retries + 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return resp;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function buildJQL(config: JiraSourceConfig): string {
|
|
166
|
+
const clauses: string[] = [`project = "${config.projectKey}"`];
|
|
167
|
+
|
|
168
|
+
if (config.statusFilter?.length) {
|
|
169
|
+
const statuses = config.statusFilter.map((s) => `"${s}"`).join(", ");
|
|
170
|
+
clauses.push(`status IN (${statuses})`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (config.assigneeFilter) {
|
|
174
|
+
if (config.assigneeFilter === "currentUser()") {
|
|
175
|
+
clauses.push("assignee = currentUser()");
|
|
176
|
+
} else {
|
|
177
|
+
clauses.push(`assignee = "${config.assigneeFilter}"`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (config.issueTypeFilter?.length) {
|
|
182
|
+
const types = config.issueTypeFilter.map((t) => `"${t}"`).join(", ");
|
|
183
|
+
clauses.push(`issuetype IN (${types})`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (config.priorityFilter?.length) {
|
|
187
|
+
const priorities = config.priorityFilter.map((p) => `"${p}"`).join(", ");
|
|
188
|
+
clauses.push(`priority IN (${priorities})`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (config.labelFilter?.length) {
|
|
192
|
+
const labels = config.labelFilter.map((l) => `"${l}"`).join(", ");
|
|
193
|
+
clauses.push(`labels IN (${labels})`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return clauses.join(" AND ") + " ORDER BY updated DESC";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function mapJiraPriority(name?: string): Severity {
|
|
200
|
+
if (!name) return "medium";
|
|
201
|
+
const lower = name.toLowerCase();
|
|
202
|
+
if (lower === "highest" || lower === "blocker") return "critical";
|
|
203
|
+
if (lower === "high") return "high";
|
|
204
|
+
if (lower === "medium") return "medium";
|
|
205
|
+
if (lower === "low" || lower === "lowest") return "low";
|
|
206
|
+
return "medium";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function extractAdfText(doc: unknown): string {
|
|
210
|
+
if (!doc || typeof doc !== "object") return "";
|
|
211
|
+
const node = doc as { type?: string; text?: string; content?: unknown[] };
|
|
212
|
+
if (node.text) return node.text;
|
|
213
|
+
if (!node.content) return "";
|
|
214
|
+
return node.content.map(extractAdfText).join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface JiraSearchResult {
|
|
218
|
+
issues: JiraIssue[];
|
|
219
|
+
total: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface JiraIssue {
|
|
223
|
+
id: string;
|
|
224
|
+
key: string;
|
|
225
|
+
fields: {
|
|
226
|
+
summary: string;
|
|
227
|
+
description?: unknown;
|
|
228
|
+
status?: { name: string };
|
|
229
|
+
priority?: { name: string };
|
|
230
|
+
issuetype?: { name: string };
|
|
231
|
+
labels?: string[];
|
|
232
|
+
assignee?: { displayName: string; accountId: string };
|
|
233
|
+
created: string;
|
|
234
|
+
updated: string;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface JiraIssueDetail {
|
|
239
|
+
fields: {
|
|
240
|
+
summary: string;
|
|
241
|
+
description?: unknown;
|
|
242
|
+
comment?: {
|
|
243
|
+
comments: Array<{
|
|
244
|
+
author?: { displayName: string };
|
|
245
|
+
body: unknown;
|
|
246
|
+
renderedBody?: string;
|
|
247
|
+
created: string;
|
|
248
|
+
}>;
|
|
249
|
+
};
|
|
250
|
+
subtasks?: Array<{ key: string; fields: { summary: string; status?: { name: string } } }>;
|
|
251
|
+
};
|
|
252
|
+
renderedFields?: {
|
|
253
|
+
description?: string;
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { NormalizedIssue, Severity, SourceAdapter } from "./types.ts";
|
|
2
|
+
import type { LinearSourceConfig } from "../config.ts";
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
|
|
5
|
+
export class LinearAdapter implements SourceAdapter {
|
|
6
|
+
readonly name = "linear";
|
|
7
|
+
private apiKey: string;
|
|
8
|
+
private configs: Map<string, LinearSourceConfig> = new Map();
|
|
9
|
+
|
|
10
|
+
constructor(apiKey: string) {
|
|
11
|
+
this.apiKey = apiKey;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
addProject(projectKey: string, config: LinearSourceConfig) {
|
|
15
|
+
this.configs.set(projectKey, config);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async poll(projectKey: string): Promise<NormalizedIssue[]> {
|
|
19
|
+
const config = this.configs.get(projectKey);
|
|
20
|
+
if (!config) return [];
|
|
21
|
+
|
|
22
|
+
const filter = buildFilter(config);
|
|
23
|
+
const query = `
|
|
24
|
+
query($teamId: String!, $filter: IssueFilter) {
|
|
25
|
+
team(id: $teamId) {
|
|
26
|
+
issues(first: 50, filter: $filter, orderBy: updatedAt) {
|
|
27
|
+
nodes {
|
|
28
|
+
id
|
|
29
|
+
identifier
|
|
30
|
+
title
|
|
31
|
+
description
|
|
32
|
+
url
|
|
33
|
+
priority
|
|
34
|
+
state { name type }
|
|
35
|
+
labels { nodes { name } }
|
|
36
|
+
assignee { email displayName }
|
|
37
|
+
createdAt
|
|
38
|
+
updatedAt
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const resp = await this.graphql(query, { teamId: config.teamId, filter });
|
|
46
|
+
if (!resp) return [];
|
|
47
|
+
|
|
48
|
+
const issues = resp.data?.team?.issues?.nodes ?? [];
|
|
49
|
+
return issues
|
|
50
|
+
.filter((i: LinearIssue) => i.state?.type !== "completed" && i.state?.type !== "canceled")
|
|
51
|
+
.map((i: LinearIssue) => this.normalize(i, projectKey));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getIssueContext(issue: NormalizedIssue): Promise<string> {
|
|
55
|
+
const config = this.configs.get(issue.projectKey);
|
|
56
|
+
if (!config) return issue.body;
|
|
57
|
+
|
|
58
|
+
const query = `
|
|
59
|
+
query($id: String!) {
|
|
60
|
+
issue(id: $id) {
|
|
61
|
+
title
|
|
62
|
+
description
|
|
63
|
+
comments(first: 20) {
|
|
64
|
+
nodes { body user { displayName } createdAt }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const resp = await this.graphql(query, { id: issue.sourceId });
|
|
71
|
+
if (!resp) return issue.body;
|
|
72
|
+
|
|
73
|
+
const data = resp.data?.issue;
|
|
74
|
+
if (!data) return issue.body;
|
|
75
|
+
|
|
76
|
+
const parts: string[] = [`# ${data.title}\n`, data.description ?? ""];
|
|
77
|
+
|
|
78
|
+
if (data.comments?.nodes?.length) {
|
|
79
|
+
parts.push("\n## Comments");
|
|
80
|
+
for (const c of data.comments.nodes) {
|
|
81
|
+
parts.push(`\n**${c.user?.displayName ?? "Unknown"}** (${c.createdAt}):\n${c.body}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return parts.join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async onFixAccepted(issue: NormalizedIssue, prUrl: string): Promise<void> {
|
|
89
|
+
const config = this.configs.get(issue.projectKey);
|
|
90
|
+
if (!config) return;
|
|
91
|
+
|
|
92
|
+
// Only write to Linear if explicitly opted in
|
|
93
|
+
if (!config.onAcceptTransition) return;
|
|
94
|
+
|
|
95
|
+
// Transition to "Done" state
|
|
96
|
+
const stateQuery = `
|
|
97
|
+
query($teamId: String!) {
|
|
98
|
+
team(id: $teamId) {
|
|
99
|
+
states { nodes { id name type } }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
const stateResp = await this.graphql(stateQuery, { teamId: config.teamId });
|
|
105
|
+
const states = stateResp?.data?.team?.states?.nodes ?? [];
|
|
106
|
+
const doneState = states.find((s: { type: string }) => s.type === "completed");
|
|
107
|
+
|
|
108
|
+
if (doneState) {
|
|
109
|
+
const mutation = `
|
|
110
|
+
mutation($id: String!, $stateId: String!) {
|
|
111
|
+
issueUpdate(id: $id, input: { stateId: $stateId }) { success }
|
|
112
|
+
}
|
|
113
|
+
`;
|
|
114
|
+
await this.graphql(mutation, { id: issue.sourceId, stateId: doneState.id });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Add comment with PR link
|
|
118
|
+
const commentMutation = `
|
|
119
|
+
mutation($issueId: String!, $body: String!) {
|
|
120
|
+
commentCreate(input: { issueId: $issueId, body: $body }) { success }
|
|
121
|
+
}
|
|
122
|
+
`;
|
|
123
|
+
await this.graphql(commentMutation, {
|
|
124
|
+
issueId: issue.sourceId,
|
|
125
|
+
body: `Relay fix applied: ${prUrl}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private normalize(raw: LinearIssue, projectKey: string): NormalizedIssue {
|
|
130
|
+
return {
|
|
131
|
+
source: "linear",
|
|
132
|
+
sourceId: raw.id,
|
|
133
|
+
externalUrl: raw.url,
|
|
134
|
+
projectKey,
|
|
135
|
+
title: `${raw.identifier}: ${raw.title}`,
|
|
136
|
+
body: raw.description ?? "",
|
|
137
|
+
severity: mapPriority(raw.priority),
|
|
138
|
+
metadata: {
|
|
139
|
+
priority: raw.priority,
|
|
140
|
+
state: raw.state?.name,
|
|
141
|
+
labels: raw.labels?.nodes?.map((l) => l.name) ?? [],
|
|
142
|
+
assignee: raw.assignee?.displayName,
|
|
143
|
+
createdAt: raw.createdAt,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async graphql(query: string, variables: Record<string, unknown>, retries = 0): Promise<any> {
|
|
149
|
+
const resp = await fetch("https://api.linear.app/graphql", {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: {
|
|
152
|
+
Authorization: this.apiKey,
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
},
|
|
155
|
+
body: JSON.stringify({ query, variables }),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (resp.status === 429 && retries < 3) {
|
|
159
|
+
const retryAfter = resp.headers.get("retry-after");
|
|
160
|
+
const wait = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60_000;
|
|
161
|
+
logger.warn("Linear rate limited", { retryAfterMs: wait, attempt: retries + 1 });
|
|
162
|
+
await Bun.sleep(wait);
|
|
163
|
+
return this.graphql(query, variables, retries + 1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!resp.ok) {
|
|
167
|
+
logger.error("Linear GraphQL error", { status: resp.status });
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const json = await resp.json();
|
|
172
|
+
if (json.errors?.length) {
|
|
173
|
+
logger.error("Linear GraphQL errors", { errors: json.errors });
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return json;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function buildFilter(config: LinearSourceConfig): Record<string, unknown> {
|
|
182
|
+
const filter: Record<string, unknown> = {};
|
|
183
|
+
|
|
184
|
+
if (config.projectId) {
|
|
185
|
+
filter.project = { id: { eq: config.projectId } };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (config.statusFilter?.length) {
|
|
189
|
+
filter.state = { name: { in: config.statusFilter } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (config.assigneeFilter) {
|
|
193
|
+
if (config.assigneeFilter === "me") {
|
|
194
|
+
filter.assignee = { isMe: { eq: true } };
|
|
195
|
+
} else {
|
|
196
|
+
filter.assignee = { email: { eq: config.assigneeFilter } };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (config.labelFilter?.length) {
|
|
201
|
+
filter.labels = { name: { in: config.labelFilter } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (config.priorityFilter?.length) {
|
|
205
|
+
filter.priority = { in: config.priorityFilter };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return filter;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function mapPriority(priority: number): Severity {
|
|
212
|
+
switch (priority) {
|
|
213
|
+
case 1: return "critical";
|
|
214
|
+
case 2: return "high";
|
|
215
|
+
case 3: return "medium";
|
|
216
|
+
case 4: return "low";
|
|
217
|
+
default: return "medium";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface LinearIssue {
|
|
222
|
+
id: string;
|
|
223
|
+
identifier: string;
|
|
224
|
+
title: string;
|
|
225
|
+
description?: string;
|
|
226
|
+
url: string;
|
|
227
|
+
priority: number;
|
|
228
|
+
state?: { name: string; type: string };
|
|
229
|
+
labels?: { nodes: Array<{ name: string }> };
|
|
230
|
+
assignee?: { email: string; displayName: string };
|
|
231
|
+
createdAt: string;
|
|
232
|
+
updatedAt: string;
|
|
233
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { NormalizedIssue, Severity, SourceAdapter } from "./types.ts";
|
|
2
|
+
import type { SentrySourceConfig } from "../config.ts";
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
|
|
5
|
+
interface RateLimit {
|
|
6
|
+
remaining: number;
|
|
7
|
+
resetTime: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SentryAdapter implements SourceAdapter {
|
|
11
|
+
readonly name = "sentry";
|
|
12
|
+
private authToken: string;
|
|
13
|
+
private configs: Map<string, SentrySourceConfig> = new Map();
|
|
14
|
+
private rateLimit: RateLimit = { remaining: Infinity, resetTime: 0 };
|
|
15
|
+
|
|
16
|
+
constructor(authToken: string) {
|
|
17
|
+
this.authToken = authToken;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
addProject(projectKey: string, config: SentrySourceConfig) {
|
|
21
|
+
this.configs.set(projectKey, config);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async poll(projectKey: string): Promise<NormalizedIssue[]> {
|
|
25
|
+
const config = this.configs.get(projectKey);
|
|
26
|
+
if (!config) return [];
|
|
27
|
+
|
|
28
|
+
await this.waitForRateLimit();
|
|
29
|
+
|
|
30
|
+
const url = `https://sentry.io/api/0/projects/${config.org}/${config.project}/issues/?query=is:unresolved+!logger:csp&limit=25`;
|
|
31
|
+
const resp = await this.request(url);
|
|
32
|
+
if (!resp.ok) {
|
|
33
|
+
logger.error("Sentry poll failed", { status: resp.status, project: projectKey });
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const issues = (await resp.json()) as SentryIssue[];
|
|
38
|
+
return issues
|
|
39
|
+
.filter((i) => !isInfraNoiseIssue(i))
|
|
40
|
+
.map((i) => this.normalize(i, projectKey));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getIssueContext(issue: NormalizedIssue): Promise<string> {
|
|
44
|
+
const config = this.configs.get(issue.projectKey);
|
|
45
|
+
if (!config) return issue.body;
|
|
46
|
+
|
|
47
|
+
await this.waitForRateLimit();
|
|
48
|
+
|
|
49
|
+
const url = `https://sentry.io/api/0/issues/${issue.sourceId}/events/latest/`;
|
|
50
|
+
const resp = await this.request(url);
|
|
51
|
+
if (!resp.ok) {
|
|
52
|
+
logger.warn("Failed to fetch Sentry event", { issueId: issue.sourceId });
|
|
53
|
+
return issue.body;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const event = (await resp.json()) as SentryEvent;
|
|
57
|
+
const parts: string[] = [`# ${issue.title}\n`, issue.body];
|
|
58
|
+
|
|
59
|
+
if (event.entries) {
|
|
60
|
+
for (const entry of event.entries) {
|
|
61
|
+
if (entry.type === "exception" && entry.data?.values) {
|
|
62
|
+
for (const exc of entry.data.values) {
|
|
63
|
+
parts.push(`\n## Exception: ${exc.type}: ${exc.value}`);
|
|
64
|
+
if (exc.stacktrace?.frames) {
|
|
65
|
+
parts.push("\nStacktrace (most recent last):");
|
|
66
|
+
const frames = exc.stacktrace.frames.slice(-15);
|
|
67
|
+
for (const f of frames) {
|
|
68
|
+
const loc = [f.filename, f.lineno, f.colno].filter(Boolean).join(":");
|
|
69
|
+
parts.push(` ${f.function ?? "<anonymous>"} at ${loc}`);
|
|
70
|
+
if (f.context_line) {
|
|
71
|
+
parts.push(` > ${f.context_line.trim()}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (event.tags) {
|
|
81
|
+
const relevant = event.tags.filter((t) =>
|
|
82
|
+
["environment", "release", "browser", "os", "runtime"].includes(t.key),
|
|
83
|
+
);
|
|
84
|
+
if (relevant.length) {
|
|
85
|
+
parts.push("\n## Tags");
|
|
86
|
+
for (const t of relevant) parts.push(`- ${t.key}: ${t.value}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return parts.join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async onFixAccepted(issue: NormalizedIssue): Promise<void> {
|
|
94
|
+
await this.waitForRateLimit();
|
|
95
|
+
const url = `https://sentry.io/api/0/issues/${issue.sourceId}/`;
|
|
96
|
+
const resp = await this.request(url, {
|
|
97
|
+
method: "PUT",
|
|
98
|
+
body: JSON.stringify({ status: "resolved" }),
|
|
99
|
+
});
|
|
100
|
+
if (!resp.ok) {
|
|
101
|
+
logger.warn("Failed to resolve Sentry issue", { issueId: issue.sourceId });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private normalize(raw: SentryIssue, projectKey: string): NormalizedIssue {
|
|
106
|
+
return {
|
|
107
|
+
source: "sentry",
|
|
108
|
+
sourceId: raw.id,
|
|
109
|
+
externalUrl: raw.permalink,
|
|
110
|
+
projectKey,
|
|
111
|
+
title: raw.title,
|
|
112
|
+
body: raw.metadata?.value || raw.culprit || raw.title,
|
|
113
|
+
severity: this.mapSeverity(raw.level),
|
|
114
|
+
metadata: {
|
|
115
|
+
level: raw.level,
|
|
116
|
+
culprit: raw.culprit,
|
|
117
|
+
count: raw.count,
|
|
118
|
+
firstSeen: raw.firstSeen,
|
|
119
|
+
lastSeen: raw.lastSeen,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private mapSeverity(level: string): Severity {
|
|
125
|
+
switch (level) {
|
|
126
|
+
case "fatal": return "critical";
|
|
127
|
+
case "error": return "high";
|
|
128
|
+
case "warning": return "medium";
|
|
129
|
+
default: return "low";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async request(url: string, init?: RequestInit, retries = 0): Promise<Response> {
|
|
134
|
+
const resp = await fetch(url, {
|
|
135
|
+
...init,
|
|
136
|
+
headers: {
|
|
137
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
...init?.headers,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const remaining = resp.headers.get("x-sentry-rate-limit-remaining");
|
|
144
|
+
const reset = resp.headers.get("x-sentry-rate-limit-reset");
|
|
145
|
+
if (remaining) this.rateLimit.remaining = parseInt(remaining, 10);
|
|
146
|
+
if (reset) this.rateLimit.resetTime = parseFloat(reset) * 1000;
|
|
147
|
+
|
|
148
|
+
if (resp.status === 429 && retries < 3) {
|
|
149
|
+
const retryAfter = resp.headers.get("retry-after");
|
|
150
|
+
const wait = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60_000;
|
|
151
|
+
logger.warn("Sentry rate limited", { retryAfterMs: wait, attempt: retries + 1 });
|
|
152
|
+
await Bun.sleep(wait);
|
|
153
|
+
return this.request(url, init, retries + 1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return resp;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async waitForRateLimit() {
|
|
160
|
+
if (this.rateLimit.remaining <= 1 && Date.now() < this.rateLimit.resetTime) {
|
|
161
|
+
const wait = this.rateLimit.resetTime - Date.now() + 100;
|
|
162
|
+
logger.debug("Waiting for Sentry rate limit reset", { waitMs: wait });
|
|
163
|
+
await Bun.sleep(wait);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const NOISE_PATTERNS = [
|
|
169
|
+
/^Blocked '(font|connect|image|script|style|media|frame)' from /i,
|
|
170
|
+
/^Error: Timeout$/i,
|
|
171
|
+
/^TimeoutError/i,
|
|
172
|
+
/^NetworkError/i,
|
|
173
|
+
/^Network request failed/i,
|
|
174
|
+
/^Load failed$/i,
|
|
175
|
+
/^Failed to fetch$/i,
|
|
176
|
+
/^AbortError/i,
|
|
177
|
+
/^ResizeObserver loop/i,
|
|
178
|
+
/^Script error\.?$/i,
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
function isInfraNoiseIssue(issue: SentryIssue): boolean {
|
|
182
|
+
const title = issue.title;
|
|
183
|
+
if (NOISE_PATTERNS.some((p) => p.test(title))) {
|
|
184
|
+
logger.debug("Filtered noise issue", { id: issue.id, title });
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface SentryIssue {
|
|
191
|
+
id: string;
|
|
192
|
+
title: string;
|
|
193
|
+
permalink: string;
|
|
194
|
+
level: string;
|
|
195
|
+
culprit: string;
|
|
196
|
+
count: string;
|
|
197
|
+
firstSeen: string;
|
|
198
|
+
lastSeen: string;
|
|
199
|
+
metadata: { value?: string; type?: string };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface SentryEvent {
|
|
203
|
+
entries?: Array<{
|
|
204
|
+
type: string;
|
|
205
|
+
data?: {
|
|
206
|
+
values?: Array<{
|
|
207
|
+
type: string;
|
|
208
|
+
value: string;
|
|
209
|
+
stacktrace?: {
|
|
210
|
+
frames?: Array<{
|
|
211
|
+
filename?: string;
|
|
212
|
+
function?: string;
|
|
213
|
+
lineno?: number;
|
|
214
|
+
colno?: number;
|
|
215
|
+
context_line?: string;
|
|
216
|
+
}>;
|
|
217
|
+
};
|
|
218
|
+
}>;
|
|
219
|
+
};
|
|
220
|
+
}>;
|
|
221
|
+
tags?: Array<{ key: string; value: string }>;
|
|
222
|
+
}
|