my own status page
at main 208 lines 7.0 kB view raw
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}