@littlebearapps/platform-admin-sdk 1.0.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.
Files changed (94) hide show
  1. package/README.md +112 -0
  2. package/dist/index.d.ts +16 -0
  3. package/dist/index.js +89 -0
  4. package/dist/prompts.d.ts +27 -0
  5. package/dist/prompts.js +80 -0
  6. package/dist/scaffold.d.ts +5 -0
  7. package/dist/scaffold.js +65 -0
  8. package/dist/templates.d.ts +16 -0
  9. package/dist/templates.js +131 -0
  10. package/package.json +46 -0
  11. package/templates/full/migrations/006_pattern_discovery.sql +199 -0
  12. package/templates/full/migrations/007_notifications_search.sql +127 -0
  13. package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
  14. package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
  15. package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
  16. package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
  17. package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
  18. package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
  19. package/templates/full/workers/pattern-discovery.ts +661 -0
  20. package/templates/full/workers/platform-alert-router.ts +1809 -0
  21. package/templates/full/workers/platform-notifications.ts +424 -0
  22. package/templates/full/workers/platform-search.ts +480 -0
  23. package/templates/full/workers/platform-settings.ts +436 -0
  24. package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
  25. package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
  26. package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
  27. package/templates/full/wrangler.search.jsonc.hbs +16 -0
  28. package/templates/full/wrangler.settings.jsonc.hbs +23 -0
  29. package/templates/shared/README.md.hbs +69 -0
  30. package/templates/shared/config/budgets.yaml.hbs +72 -0
  31. package/templates/shared/config/services.yaml.hbs +45 -0
  32. package/templates/shared/migrations/001_core_tables.sql +117 -0
  33. package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
  34. package/templates/shared/migrations/003_feature_tracking.sql +250 -0
  35. package/templates/shared/migrations/004_settings_alerts.sql +452 -0
  36. package/templates/shared/migrations/seed.sql.hbs +4 -0
  37. package/templates/shared/package.json.hbs +21 -0
  38. package/templates/shared/scripts/sync-config.ts +242 -0
  39. package/templates/shared/tsconfig.json +12 -0
  40. package/templates/shared/workers/lib/analytics-engine.ts +357 -0
  41. package/templates/shared/workers/lib/billing.ts +293 -0
  42. package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
  43. package/templates/shared/workers/lib/control.ts +292 -0
  44. package/templates/shared/workers/lib/economics.ts +368 -0
  45. package/templates/shared/workers/lib/metrics.ts +103 -0
  46. package/templates/shared/workers/lib/platform-settings.ts +407 -0
  47. package/templates/shared/workers/lib/shared/allowances.ts +333 -0
  48. package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
  49. package/templates/shared/workers/lib/shared/types.ts +58 -0
  50. package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
  51. package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
  52. package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
  53. package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
  54. package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
  55. package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
  56. package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
  57. package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
  58. package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
  59. package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
  60. package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
  61. package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
  62. package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
  63. package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
  64. package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
  65. package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
  66. package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
  67. package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
  68. package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
  69. package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
  70. package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
  71. package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
  72. package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
  73. package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
  74. package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
  75. package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
  76. package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
  77. package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
  78. package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
  79. package/templates/shared/workers/platform-usage.ts +1915 -0
  80. package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
  81. package/templates/standard/migrations/005_error_collection.sql +162 -0
  82. package/templates/standard/workers/error-collector.ts +2670 -0
  83. package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
  84. package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
  85. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
  86. package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
  87. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
  88. package/templates/standard/workers/lib/error-collector/github.ts +329 -0
  89. package/templates/standard/workers/lib/error-collector/types.ts +262 -0
  90. package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
  91. package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
  92. package/templates/standard/workers/platform-sentinel.ts +1744 -0
  93. package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
  94. package/templates/standard/wrangler.sentinel.jsonc.hbs +45 -0
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Gap Alert Handler
3
+ *
4
+ * Processes gap alerts from platform-sentinel and creates GitHub issues
5
+ * in the correct project repository when data coverage drops below threshold.
6
+ *
7
+ * Uses existing GitHubClient and deduplication patterns from error-collector.
8
+ *
9
+ * @module workers/lib/error-collector/gap-alerts
10
+ */
11
+
12
+ import type { Env, GapAlertEvent } from './types';
13
+ import { GitHubClient } from './github';
14
+
15
+ // TODO: Set your GitHub organisation and dashboard URL
16
+ const GITHUB_ORG = 'your-github-org';
17
+ const DASHBOARD_URL = 'https://your-dashboard.example.com';
18
+ const GATUS_URL = 'https://your-status.example.com';
19
+
20
+ /**
21
+ * KV prefix for gap alert deduplication.
22
+ * Format: GAP_ALERT:{project}:{date}
23
+ * One issue per project per day maximum.
24
+ */
25
+ const GAP_ALERT_PREFIX = 'GAP_ALERT';
26
+
27
+ /**
28
+ * Labels applied to gap alert issues
29
+ */
30
+ const GAP_ALERT_LABELS = ['cf:gap-alert', 'cf:priority:p2', 'cf:auto-generated'];
31
+
32
+ /**
33
+ * Get today's date key in YYYY-MM-DD format (UTC)
34
+ */
35
+ function getDateKey(): string {
36
+ return new Date().toISOString().slice(0, 10);
37
+ }
38
+
39
+ /**
40
+ * Check if a gap alert has already been created for this project today.
41
+ *
42
+ * @returns Issue number if exists, null otherwise
43
+ */
44
+ async function checkGapAlertDedup(
45
+ kv: KVNamespace,
46
+ project: string
47
+ ): Promise<number | null> {
48
+ const key = `${GAP_ALERT_PREFIX}:${project}:${getDateKey()}`;
49
+ const existing = await kv.get(key);
50
+ return existing ? parseInt(existing, 10) : null;
51
+ }
52
+
53
+ /**
54
+ * Record that a gap alert issue was created for today.
55
+ */
56
+ async function setGapAlertDedup(
57
+ kv: KVNamespace,
58
+ project: string,
59
+ issueNumber: number
60
+ ): Promise<void> {
61
+ const key = `${GAP_ALERT_PREFIX}:${project}:${getDateKey()}`;
62
+ // TTL of 25 hours to cover the full day plus buffer
63
+ await kv.put(key, String(issueNumber), { expirationTtl: 90000 });
64
+ }
65
+
66
+ /**
67
+ * Format the GitHub issue body for a gap alert.
68
+ * Designed to provide Claude Code with full context to investigate without additional lookups.
69
+ */
70
+ function formatGapAlertBody(event: GapAlertEvent): string {
71
+ const now = new Date();
72
+ const startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
73
+ const endTime = now.toISOString();
74
+
75
+ // Format missing hours list (show first 10, then count)
76
+ let missingHoursList: string;
77
+ if (event.missingHours.length === 0) {
78
+ missingHoursList = '_No specific hours identified_';
79
+ } else if (event.missingHours.length <= 10) {
80
+ missingHoursList = event.missingHours.map((h) => `- \`${h}\``).join('\n');
81
+ } else {
82
+ const shown = event.missingHours.slice(0, 10).map((h) => `- \`${h}\``).join('\n');
83
+ missingHoursList = `${shown}\n- _... and ${event.missingHours.length - 10} more_`;
84
+ }
85
+
86
+ // Calculate gap duration and status
87
+ const gapHours = event.expectedHours - event.hoursWithData;
88
+ const hoursSinceLastData = event.lastDataHour
89
+ ? Math.round((now.getTime() - new Date(event.lastDataHour.replace(' ', 'T') + ':00:00Z').getTime()) / (1000 * 60 * 60))
90
+ : null;
91
+ const isOngoing = hoursSinceLastData !== null && hoursSinceLastData > 2;
92
+
93
+ // Format resource breakdown table
94
+ let resourceBreakdownSection = '';
95
+ if (event.resourceBreakdown && event.resourceBreakdown.length > 0) {
96
+ resourceBreakdownSection = `### Resource Coverage (last 24h)
97
+
98
+ | Resource Type | Hours | Coverage |
99
+ |---------------|-------|----------|
100
+ ${event.resourceBreakdown.map((r) => `| ${r.resourceType} | ${r.hoursWithData}/24 | ${r.coveragePct}% |`).join('\n')}
101
+
102
+ `;
103
+ }
104
+
105
+ // Collection status section
106
+ let collectionStatusSection = '';
107
+ if (event.lastDataHour || hoursSinceLastData !== null) {
108
+ collectionStatusSection = `### Collection Status
109
+
110
+ | | |
111
+ |---|---|
112
+ | **Last Data Hour** | \`${event.lastDataHour || 'Unknown'}\` |
113
+ | **Hours Since Last Data** | ${hoursSinceLastData ?? 'Unknown'} |
114
+ | **Gap Duration** | ${gapHours} hours |
115
+ | **Status** | ${isOngoing ? 'Ongoing - collection may be broken' : 'Historical gap'} |
116
+
117
+ `;
118
+ }
119
+
120
+ const repoRef = event.repository || `${GITHUB_ORG}/platform`;
121
+
122
+ return `## Data Coverage Gap Alert
123
+
124
+ | | |
125
+ |---|---|
126
+ | **Project** | \`${event.project}\` |
127
+ | **Coverage** | **${event.coveragePct}%** (threshold: 90%) |
128
+ | **Hours with data** | ${event.hoursWithData} / ${event.expectedHours} |
129
+ | **Missing hours** | ${gapHours} |
130
+ | **Period** | ${startTime.slice(0, 16)} to ${endTime.slice(0, 16)} UTC |
131
+
132
+ ${collectionStatusSection}${resourceBreakdownSection}### Missing Hours
133
+
134
+ ${missingHoursList}
135
+
136
+ ### Impact
137
+
138
+ - Usage dashboards may show incomplete data for this project
139
+ - Anomaly detection accuracy may be reduced
140
+ - Cost attribution may be affected
141
+ - Circuit breaker thresholds may not trigger correctly
142
+
143
+ ### Quick Links
144
+
145
+ - [Platform Dashboard - Usage](${DASHBOARD_URL}/usage)
146
+ - [Project Usage Details](${DASHBOARD_URL}/usage?project=${event.project})
147
+ - [CF Workers Observability](https://dash.cloudflare.com/?to=/:account/workers/observability)
148
+ - [Repository](https://github.com/${repoRef})
149
+ - [CLAUDE.md](https://github.com/${repoRef}/blob/main/CLAUDE.md)
150
+
151
+ ### Investigation Steps
152
+
153
+ **For Claude Code agents investigating this issue:**
154
+
155
+ 1. **Check if collection is currently working**:
156
+ \`\`\`bash
157
+ # Tail platform-usage logs to see if hourly collection is running
158
+ wrangler tail platform-usage --format=pretty
159
+ \`\`\`
160
+
161
+ 2. **Query recent snapshots** to identify the gap pattern:
162
+ \`\`\`bash
163
+ npx wrangler d1 execute platform-metrics --remote --command "SELECT snapshot_hour, resource_type, COUNT(*) as rows FROM resource_usage_snapshots WHERE project = '${event.project}' AND snapshot_hour >= datetime('now', '-24 hours') GROUP BY snapshot_hour, resource_type ORDER BY snapshot_hour DESC"
164
+ \`\`\`
165
+
166
+ 3. **Check platform-usage worker health**:
167
+ \`\`\`bash
168
+ curl https://platform-usage.your-subdomain.workers.dev/health
169
+ \`\`\`
170
+
171
+ 4. **Check Gatus** for platform-usage heartbeat status:
172
+ - Visit [Gatus Status Page](${GATUS_URL})
173
+ - Look for \`platform-usage\` heartbeat status
174
+
175
+ 5. **If SDK telemetry issue**, verify the project's wrangler config:
176
+ \`\`\`bash
177
+ # In the project directory, check for queue binding
178
+ grep -r "PLATFORM_TELEMETRY" wrangler*.jsonc
179
+ \`\`\`
180
+
181
+ ### Reference Documentation
182
+
183
+ - [SDK Integration Guide](https://github.com/${GITHUB_ORG}/platform/blob/main/docs/quickrefs/guides/sdk-integration-checklist.md)
184
+ - [Error Collector Integration](https://github.com/${GITHUB_ORG}/platform/blob/main/docs/quickrefs/guides/error-collector-integration.md)
185
+ - [Data Flow Architecture](https://github.com/${GITHUB_ORG}/platform/blob/main/docs/quickrefs/data-flow.md)
186
+ - [Troubleshooting Guide](https://github.com/${GITHUB_ORG}/platform/blob/main/docs/quickrefs/troubleshooting.md)
187
+
188
+ ---
189
+ Generated by [Platform Sentinel](https://github.com/${GITHUB_ORG}/platform/blob/main/workers/platform-sentinel.ts) gap detection
190
+ `;
191
+ }
192
+
193
+ /**
194
+ * Process a gap alert event and create a GitHub issue if needed.
195
+ *
196
+ * @returns Result object with issue details or skip reason
197
+ */
198
+ export async function processGapAlert(
199
+ event: GapAlertEvent,
200
+ env: Env
201
+ ): Promise<{
202
+ processed: boolean;
203
+ issueNumber?: number;
204
+ issueUrl?: string;
205
+ skipped?: string;
206
+ }> {
207
+ // Check deduplication - only one issue per project per day
208
+ const existingIssue = await checkGapAlertDedup(env.PLATFORM_CACHE, event.project);
209
+ if (existingIssue) {
210
+ console.log(`Gap alert for ${event.project} already created today: #${existingIssue}`);
211
+ return {
212
+ processed: false,
213
+ skipped: `Issue #${existingIssue} already created today for ${event.project}`,
214
+ };
215
+ }
216
+
217
+ // Determine the repository to create the issue in
218
+ if (!event.repository) {
219
+ console.warn(`No repository mapping for project ${event.project}`);
220
+ return {
221
+ processed: false,
222
+ skipped: `No repository mapping for project ${event.project}`,
223
+ };
224
+ }
225
+
226
+ // Parse owner/repo
227
+ const [owner, repo] = event.repository.split('/');
228
+ if (!owner || !repo) {
229
+ console.error(`Invalid repository format: ${event.repository}`);
230
+ return {
231
+ processed: false,
232
+ skipped: `Invalid repository format: ${event.repository}`,
233
+ };
234
+ }
235
+
236
+ // Create GitHub client
237
+ const github = new GitHubClient(env);
238
+
239
+ try {
240
+ // Create the issue
241
+ const issue = await github.createIssue({
242
+ owner,
243
+ repo,
244
+ title: `Data Coverage Gap: ${event.project} at ${event.coveragePct}%`,
245
+ body: formatGapAlertBody(event),
246
+ labels: GAP_ALERT_LABELS,
247
+ });
248
+
249
+ console.log(`Created gap alert issue #${issue.number} for ${event.project}`);
250
+
251
+ // Record deduplication
252
+ await setGapAlertDedup(env.PLATFORM_CACHE, event.project, issue.number);
253
+
254
+ // Create dashboard notification for gap alerts (P2 = medium priority)
255
+ if (env.NOTIFICATIONS_API) {
256
+ try {
257
+ await env.NOTIFICATIONS_API.fetch(
258
+ 'https://platform-notifications.internal/notifications',
259
+ {
260
+ method: 'POST',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: JSON.stringify({
263
+ category: 'gap_alert',
264
+ source: 'platform-sentinel',
265
+ source_id: String(issue.number),
266
+ title: `Data Gap: ${event.project} (${event.coveragePct}%)`,
267
+ description: `Coverage dropped below 90% threshold. ${event.missingHours.length} hours missing.`,
268
+ priority: 'medium',
269
+ action_url: issue.html_url,
270
+ action_label: 'View Issue',
271
+ project: event.project,
272
+ }),
273
+ }
274
+ );
275
+ } catch (e) {
276
+ // Non-blocking - log and continue
277
+ console.error('Failed to create dashboard notification:', e);
278
+ }
279
+ }
280
+
281
+ return {
282
+ processed: true,
283
+ issueNumber: issue.number,
284
+ issueUrl: issue.html_url,
285
+ };
286
+ } catch (error) {
287
+ console.error(`Failed to create gap alert issue for ${event.project}:`, error);
288
+ return {
289
+ processed: false,
290
+ skipped: `GitHub API error: ${error instanceof Error ? error.message : String(error)}`,
291
+ };
292
+ }
293
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * GitHub App Client
3
+ * Handles authentication and API calls for the error collector
4
+ */
5
+
6
+ import type { GitHubIssueCreate, GitHubIssueUpdate, Env } from './types';
7
+
8
+ /**
9
+ * Create a JWT for GitHub App authentication
10
+ * @see https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
11
+ */
12
+ async function createAppJWT(appId: string, privateKey: string): Promise<string> {
13
+ const now = Math.floor(Date.now() / 1000);
14
+ const payload = {
15
+ iat: now - 60, // Issued 60 seconds ago to account for clock drift
16
+ exp: now + 600, // Expires in 10 minutes
17
+ iss: appId,
18
+ };
19
+
20
+ // Import the private key
21
+ const pemContent = privateKey
22
+ .replace('-----BEGIN RSA PRIVATE KEY-----', '')
23
+ .replace('-----END RSA PRIVATE KEY-----', '')
24
+ .replace(/\s/g, '');
25
+
26
+ const binaryKey = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));
27
+
28
+ const key = await crypto.subtle.importKey(
29
+ 'pkcs8',
30
+ binaryKey,
31
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
32
+ false,
33
+ ['sign']
34
+ );
35
+
36
+ // Create JWT
37
+ const header = { alg: 'RS256', typ: 'JWT' };
38
+ const headerB64 = btoa(JSON.stringify(header))
39
+ .replace(/=/g, '')
40
+ .replace(/\+/g, '-')
41
+ .replace(/\//g, '_');
42
+ const payloadB64 = btoa(JSON.stringify(payload))
43
+ .replace(/=/g, '')
44
+ .replace(/\+/g, '-')
45
+ .replace(/\//g, '_');
46
+
47
+ const data = `${headerB64}.${payloadB64}`;
48
+ const signature = await crypto.subtle.sign(
49
+ { name: 'RSASSA-PKCS1-v1_5' },
50
+ key,
51
+ new TextEncoder().encode(data)
52
+ );
53
+
54
+ const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
55
+ .replace(/=/g, '')
56
+ .replace(/\+/g, '-')
57
+ .replace(/\//g, '_');
58
+
59
+ return `${data}.${signatureB64}`;
60
+ }
61
+
62
+ /**
63
+ * Get an installation access token from the GitHub App
64
+ */
65
+ async function getInstallationToken(
66
+ appId: string,
67
+ privateKey: string,
68
+ installationId: string
69
+ ): Promise<string> {
70
+ const jwt = await createAppJWT(appId, privateKey);
71
+
72
+ const response = await fetch(
73
+ `https://api.github.com/app/installations/${installationId}/access_tokens`,
74
+ {
75
+ method: 'POST',
76
+ headers: {
77
+ Authorization: `Bearer ${jwt}`,
78
+ Accept: 'application/vnd.github+json',
79
+ 'User-Agent': 'Platform-Error-Collector/1.0',
80
+ 'X-GitHub-Api-Version': '2022-11-28',
81
+ },
82
+ }
83
+ );
84
+
85
+ if (!response.ok) {
86
+ const text = await response.text();
87
+ throw new Error(`Failed to get installation token: ${response.status} ${text}`);
88
+ }
89
+
90
+ const data = (await response.json()) as { token: string };
91
+ return data.token;
92
+ }
93
+
94
+ /**
95
+ * GitHub API client with cached installation token
96
+ */
97
+ export class GitHubClient {
98
+ private token: string | null = null;
99
+ private tokenExpiry = 0;
100
+
101
+ constructor(private env: Env) {}
102
+
103
+ /**
104
+ * Get a valid installation token, refreshing if needed
105
+ */
106
+ private async getToken(): Promise<string> {
107
+ const now = Date.now();
108
+
109
+ // Refresh token if expired or expiring in next 5 minutes
110
+ if (!this.token || now > this.tokenExpiry - 5 * 60 * 1000) {
111
+ // Decode base64 private key if needed
112
+ let privateKey = this.env.GITHUB_APP_PRIVATE_KEY;
113
+ if (!privateKey.includes('BEGIN')) {
114
+ // It's base64 encoded
115
+ privateKey = atob(privateKey);
116
+ }
117
+
118
+ this.token = await getInstallationToken(
119
+ this.env.GITHUB_APP_ID,
120
+ privateKey,
121
+ this.env.GITHUB_APP_INSTALLATION_ID
122
+ );
123
+ // Installation tokens are valid for 1 hour
124
+ this.tokenExpiry = now + 55 * 60 * 1000;
125
+ }
126
+
127
+ return this.token;
128
+ }
129
+
130
+ /**
131
+ * Make an authenticated request to the GitHub API
132
+ */
133
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
134
+ const token = await this.getToken();
135
+
136
+ const response = await fetch(`https://api.github.com${path}`, {
137
+ method,
138
+ headers: {
139
+ Authorization: `Bearer ${token}`,
140
+ Accept: 'application/vnd.github+json',
141
+ 'User-Agent': 'Platform-Error-Collector/1.0',
142
+ 'X-GitHub-Api-Version': '2022-11-28',
143
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
144
+ },
145
+ body: body ? JSON.stringify(body) : undefined,
146
+ });
147
+
148
+ if (!response.ok) {
149
+ const text = await response.text();
150
+ throw new Error(`GitHub API error: ${response.status} ${text}`);
151
+ }
152
+
153
+ return response.json() as Promise<T>;
154
+ }
155
+
156
+ /**
157
+ * Create a new GitHub issue
158
+ */
159
+ async createIssue(params: GitHubIssueCreate): Promise<{ number: number; html_url: string }> {
160
+ const payload: Record<string, unknown> = {
161
+ title: params.title,
162
+ body: params.body,
163
+ labels: params.labels,
164
+ };
165
+
166
+ // Add issue type if specified (org must have issue types enabled)
167
+ if (params.type) {
168
+ payload.type = params.type;
169
+ }
170
+
171
+ // Add assignees if specified
172
+ if (params.assignees?.length) {
173
+ payload.assignees = params.assignees;
174
+ }
175
+
176
+ return this.request('POST', `/repos/${params.owner}/${params.repo}/issues`, payload);
177
+ }
178
+
179
+ /**
180
+ * Update an existing GitHub issue
181
+ */
182
+ async updateIssue(params: GitHubIssueUpdate): Promise<{ number: number; html_url: string }> {
183
+ const body: Record<string, unknown> = {};
184
+ if (params.body !== undefined) body.body = params.body;
185
+ if (params.state !== undefined) body.state = params.state;
186
+
187
+ return this.request(
188
+ 'PATCH',
189
+ `/repos/${params.owner}/${params.repo}/issues/${params.issue_number}`,
190
+ body
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Add a comment to an issue
196
+ */
197
+ async addComment(
198
+ owner: string,
199
+ repo: string,
200
+ issueNumber: number,
201
+ body: string
202
+ ): Promise<{ id: number }> {
203
+ return this.request('POST', `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
204
+ body,
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Add labels to an issue
210
+ */
211
+ async addLabels(
212
+ owner: string,
213
+ repo: string,
214
+ issueNumber: number,
215
+ labels: string[]
216
+ ): Promise<void> {
217
+ await this.request('POST', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, {
218
+ labels,
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Add an issue to the GitHub Project board
224
+ */
225
+ async addToProject(issueNodeId: string, projectId: string): Promise<string> {
226
+ const token = await this.getToken();
227
+
228
+ const query = `
229
+ mutation($projectId: ID!, $contentId: ID!) {
230
+ addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
231
+ item { id }
232
+ }
233
+ }
234
+ `;
235
+
236
+ const response = await fetch('https://api.github.com/graphql', {
237
+ method: 'POST',
238
+ headers: {
239
+ Authorization: `Bearer ${token}`,
240
+ 'Content-Type': 'application/json',
241
+ 'User-Agent': 'Platform-Error-Collector/1.0',
242
+ },
243
+ body: JSON.stringify({
244
+ query,
245
+ variables: { projectId, contentId: issueNodeId },
246
+ }),
247
+ });
248
+
249
+ if (!response.ok) {
250
+ const text = await response.text();
251
+ throw new Error(`GraphQL error: ${response.status} ${text}`);
252
+ }
253
+
254
+ const data = (await response.json()) as {
255
+ data?: { addProjectV2ItemById?: { item?: { id: string } } };
256
+ errors?: Array<{ message: string }>;
257
+ };
258
+
259
+ if (data.errors?.length) {
260
+ throw new Error(`GraphQL errors: ${data.errors.map((e) => e.message).join(', ')}`);
261
+ }
262
+
263
+ return data.data?.addProjectV2ItemById?.item?.id || '';
264
+ }
265
+
266
+ /**
267
+ * Get issue by number to retrieve its node_id
268
+ */
269
+ async getIssue(
270
+ owner: string,
271
+ repo: string,
272
+ issueNumber: number
273
+ ): Promise<{ node_id: string; state: string; labels?: Array<{ name: string } | string> }> {
274
+ return this.request('GET', `/repos/${owner}/${repo}/issues/${issueNumber}`);
275
+ }
276
+
277
+ /**
278
+ * Search for issues using GitHub's search API.
279
+ * Retries once on 403 (rate limit) with a 1s delay.
280
+ * @see https://docs.github.com/en/rest/search/search#search-issues-and-pull-requests
281
+ */
282
+ async searchIssues(
283
+ owner: string,
284
+ repo: string,
285
+ query: string
286
+ ): Promise<
287
+ Array<{
288
+ number: number;
289
+ state: 'open' | 'closed';
290
+ title: string;
291
+ body: string | null;
292
+ labels: Array<{ name: string }>;
293
+ }>
294
+ > {
295
+ const fullQuery = `repo:${owner}/${repo} is:issue ${query}`;
296
+ const path = `/search/issues?q=${encodeURIComponent(fullQuery)}&per_page=5`;
297
+
298
+ try {
299
+ const response = await this.request<{
300
+ total_count: number;
301
+ items: Array<{
302
+ number: number;
303
+ state: 'open' | 'closed';
304
+ title: string;
305
+ body: string | null;
306
+ labels: Array<{ name: string }>;
307
+ }>;
308
+ }>('GET', path);
309
+ return response.items || [];
310
+ } catch (error) {
311
+ // Retry once on 403 (GitHub Search API rate limit)
312
+ if (error instanceof Error && error.message.includes('403')) {
313
+ await new Promise((resolve) => setTimeout(resolve, 1000));
314
+ const response = await this.request<{
315
+ total_count: number;
316
+ items: Array<{
317
+ number: number;
318
+ state: 'open' | 'closed';
319
+ title: string;
320
+ body: string | null;
321
+ labels: Array<{ name: string }>;
322
+ }>;
323
+ }>('GET', path);
324
+ return response.items || [];
325
+ }
326
+ throw error;
327
+ }
328
+ }
329
+ }