@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.
- package/README.md +112 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +89 -0
- package/dist/prompts.d.ts +27 -0
- package/dist/prompts.js +80 -0
- package/dist/scaffold.d.ts +5 -0
- package/dist/scaffold.js +65 -0
- package/dist/templates.d.ts +16 -0
- package/dist/templates.js +131 -0
- package/package.json +46 -0
- package/templates/full/migrations/006_pattern_discovery.sql +199 -0
- package/templates/full/migrations/007_notifications_search.sql +127 -0
- package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
- package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
- package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
- package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
- package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
- package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
- package/templates/full/workers/pattern-discovery.ts +661 -0
- package/templates/full/workers/platform-alert-router.ts +1809 -0
- package/templates/full/workers/platform-notifications.ts +424 -0
- package/templates/full/workers/platform-search.ts +480 -0
- package/templates/full/workers/platform-settings.ts +436 -0
- package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
- package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
- package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
- package/templates/full/wrangler.search.jsonc.hbs +16 -0
- package/templates/full/wrangler.settings.jsonc.hbs +23 -0
- package/templates/shared/README.md.hbs +69 -0
- package/templates/shared/config/budgets.yaml.hbs +72 -0
- package/templates/shared/config/services.yaml.hbs +45 -0
- package/templates/shared/migrations/001_core_tables.sql +117 -0
- package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
- package/templates/shared/migrations/003_feature_tracking.sql +250 -0
- package/templates/shared/migrations/004_settings_alerts.sql +452 -0
- package/templates/shared/migrations/seed.sql.hbs +4 -0
- package/templates/shared/package.json.hbs +21 -0
- package/templates/shared/scripts/sync-config.ts +242 -0
- package/templates/shared/tsconfig.json +12 -0
- package/templates/shared/workers/lib/analytics-engine.ts +357 -0
- package/templates/shared/workers/lib/billing.ts +293 -0
- package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
- package/templates/shared/workers/lib/control.ts +292 -0
- package/templates/shared/workers/lib/economics.ts +368 -0
- package/templates/shared/workers/lib/metrics.ts +103 -0
- package/templates/shared/workers/lib/platform-settings.ts +407 -0
- package/templates/shared/workers/lib/shared/allowances.ts +333 -0
- package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
- package/templates/shared/workers/lib/shared/types.ts +58 -0
- package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
- package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
- package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
- package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
- package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
- package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
- package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
- package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
- package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
- package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
- package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
- package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
- package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
- package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
- package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
- package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
- package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
- package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
- package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
- package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
- package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
- package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
- package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
- package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
- package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
- package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
- package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
- package/templates/shared/workers/platform-usage.ts +1915 -0
- package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
- package/templates/standard/migrations/005_error_collection.sql +162 -0
- package/templates/standard/workers/error-collector.ts +2670 -0
- package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
- package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
- package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
- package/templates/standard/workers/lib/error-collector/github.ts +329 -0
- package/templates/standard/workers/lib/error-collector/types.ts +262 -0
- package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
- package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
- package/templates/standard/workers/platform-sentinel.ts +1744 -0
- package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
- 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
|
+
}
|