my own status page
1// GitHub Issues integration for incidents
2
3import type { Incident } from "./types";
4import { updateIncident, addIncidentUpdate } from "./db";
5
6export function parseRepo(repoUrl: string): { owner: string; repo: string } | null {
7 const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
8 if (!match) return null;
9 return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
10}
11
12export async function createIssue(
13 token: string,
14 owner: string,
15 repo: string,
16 opts: { title: string; body: string; labels?: string[] },
17): Promise<number> {
18 const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues`, {
19 method: "POST",
20 headers: {
21 Authorization: `Bearer ${token}`,
22 Accept: "application/vnd.github+json",
23 "User-Agent": "infra-status-worker",
24 },
25 body: JSON.stringify(opts),
26 });
27 if (!res.ok) {
28 const text = await res.text();
29 throw new Error(`GitHub create issue failed: ${res.status} ${text}`);
30 }
31 const data = await res.json<{ number: number }>();
32 return data.number;
33}
34
35export async function assignIssue(
36 token: string,
37 owner: string,
38 repo: string,
39 issueNumber: number,
40 assignees: string[],
41): Promise<void> {
42 const res = await fetch(
43 `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/assignees`,
44 {
45 method: "POST",
46 headers: {
47 Authorization: `Bearer ${token}`,
48 Accept: "application/vnd.github+json",
49 "User-Agent": "infra-status-worker",
50 },
51 body: JSON.stringify({ assignees }),
52 },
53 );
54 if (!res.ok) {
55 const text = await res.text();
56 throw new Error(`GitHub assign issue failed: ${res.status} ${text}`);
57 }
58}
59
60export async function commentOnIssue(
61 token: string,
62 owner: string,
63 repo: string,
64 issueNumber: number,
65 body: string,
66): Promise<void> {
67 const res = await fetch(
68 `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
69 {
70 method: "POST",
71 headers: {
72 Authorization: `Bearer ${token}`,
73 Accept: "application/vnd.github+json",
74 "User-Agent": "infra-status-worker",
75 },
76 body: JSON.stringify({ body }),
77 },
78 );
79 if (!res.ok) {
80 const text = await res.text();
81 throw new Error(`GitHub comment failed: ${res.status} ${text}`);
82 }
83}
84
85export async function closeIssue(
86 token: string,
87 owner: string,
88 repo: string,
89 issueNumber: number,
90): Promise<void> {
91 const res = await fetch(
92 `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`,
93 {
94 method: "PATCH",
95 headers: {
96 Authorization: `Bearer ${token}`,
97 Accept: "application/vnd.github+json",
98 "User-Agent": "infra-status-worker",
99 },
100 body: JSON.stringify({ state: "closed" }),
101 },
102 );
103 if (!res.ok) {
104 const text = await res.text();
105 throw new Error(`GitHub close issue failed: ${res.status} ${text}`);
106 }
107}
108
109interface GitHubIssue {
110 state: string;
111 body: string | null;
112}
113
114interface GitHubComment {
115 id: number;
116 body: string;
117 created_at: string;
118 user: { login: string; type: string };
119}
120
121async function fetchIssue(token: string, owner: string, repo: string, issueNumber: number): Promise<GitHubIssue> {
122 const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, {
123 headers: {
124 Authorization: `Bearer ${token}`,
125 Accept: "application/vnd.github+json",
126 "User-Agent": "infra-status-worker",
127 },
128 });
129 if (!res.ok) throw new Error(`GitHub fetch issue failed: ${res.status}`);
130 return res.json();
131}
132
133async function fetchComments(token: string, owner: string, repo: string, issueNumber: number, since?: string): Promise<GitHubComment[]> {
134 let url = `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=50`;
135 if (since) url += `&since=${since}`;
136 const res = await fetch(url, {
137 headers: {
138 Authorization: `Bearer ${token}`,
139 Accept: "application/vnd.github+json",
140 "User-Agent": "infra-status-worker",
141 },
142 });
143 if (!res.ok) throw new Error(`GitHub fetch comments failed: ${res.status}`);
144 return res.json();
145}
146
147export async function syncGitHubIncidents(
148 db: D1Database,
149 kv: KVNamespace,
150 token: string,
151 incidents: Incident[],
152): Promise<void> {
153 for (const incident of incidents) {
154 if (!incident.github_repo || !incident.github_issue_number) continue;
155
156 const parsed = parseRepo(`https://github.com/${incident.github_repo}`);
157 if (!parsed) continue;
158
159 try {
160 // Check issue state (closed = resolved) and sync body edits
161 const issue = await fetchIssue(token, parsed.owner, parsed.repo, incident.github_issue_number);
162 if (issue.state === "closed" && incident.status !== "resolved") {
163 const now = Math.floor(Date.now() / 1000);
164 // Fetch latest comments to find the closing message
165 const kvKey = `gh_sync:${incident.id}:last`;
166 const lastSeen = await kv.get(kvKey);
167 const comments = await fetchComments(token, parsed.owner, parsed.repo, incident.github_issue_number, lastSeen ?? undefined);
168 const human = comments.filter((c) => c.user.type !== "Bot" && !c.body.startsWith("Automated incident detected") && !c.body.startsWith("## Triage Report") && !c.body.startsWith("Service recovered automatically"));
169
170 // Use the last human comment as the resolve message, or fall back to generic
171 const resolveMsg = human.length > 0 ? human[human.length - 1].body : "Issue closed on GitHub";
172
173 // Add any earlier human comments as investigating updates
174 for (const comment of human.slice(0, -1)) {
175 await addIncidentUpdate(db, incident.id, "investigating", comment.body);
176 }
177
178 await updateIncident(db, incident.id, { status: "resolved", resolved_at: now });
179 await addIncidentUpdate(db, incident.id, "resolved", resolveMsg);
180
181 // Track sync position (bump by 1s since GitHub's `since` is inclusive)
182 if (comments.length > 0) {
183 const latest = new Date(new Date(comments[comments.length - 1].created_at).getTime() + 1000).toISOString();
184 await kv.put(kvKey, latest, { expirationTtl: 86400 * 7 });
185 }
186 continue;
187 }
188
189 // Sync new comments since last check
190 const kvKey = `gh_sync:${incident.id}:last`;
191 const lastSeen = await kv.get(kvKey);
192 const comments = await fetchComments(token, parsed.owner, parsed.repo, incident.github_issue_number, lastSeen ?? undefined);
193
194 // Filter to human comments only (skip bots and our own posts)
195 const human = comments.filter((c) => c.user.type !== "Bot" && !c.body.startsWith("Automated incident detected") && !c.body.startsWith("## Triage Report") && !c.body.startsWith("Service recovered automatically"));
196
197 for (const comment of human) {
198 await addIncidentUpdate(db, incident.id, incident.status, comment.body);
199 }
200
201 // Track last comment time so we don't re-import (bump by 1s since GitHub's `since` is inclusive)
202 if (comments.length > 0) {
203 const latest = new Date(new Date(comments[comments.length - 1].created_at).getTime() + 1000).toISOString();
204 await kv.put(kvKey, latest, { expirationTtl: 86400 * 7 });
205 }
206 } catch (_) {} // best effort, don't block other syncs
207 }
208}