@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.
@@ -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
+ }