@doccov/api 0.4.0 → 0.6.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/CHANGELOG.md +19 -0
- package/api/index.ts +105 -26
- package/migrations/005_coverage_sdk_field_names.ts +41 -0
- package/package.json +4 -4
- package/src/index.ts +36 -2
- package/src/middleware/anonymous-rate-limit.ts +131 -0
- package/src/routes/ai.ts +353 -0
- package/src/routes/badge.ts +122 -32
- package/src/routes/billing.ts +65 -0
- package/src/routes/coverage.ts +53 -48
- package/src/routes/demo.ts +606 -0
- package/src/routes/github-app.ts +368 -0
- package/src/routes/invites.ts +90 -0
- package/src/routes/orgs.ts +249 -0
- package/src/routes/spec-v1.ts +165 -0
- package/src/routes/spec.ts +186 -0
- package/src/utils/github-app.ts +196 -0
- package/src/utils/github-checks.ts +498 -0
- package/src/utils/remote-analyzer.ts +251 -0
- package/src/utils/spec-cache.ts +131 -0
- package/src/utils/spec-diff-core.ts +406 -0
- package/src/utils/github.ts +0 -5
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App routes for installation and webhooks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { nanoid } from 'nanoid';
|
|
7
|
+
import { auth } from '../auth/config';
|
|
8
|
+
import { db } from '../db/client';
|
|
9
|
+
import { listInstallationRepos } from '../utils/github-app';
|
|
10
|
+
import { createCheckRun, postPRComment } from '../utils/github-checks';
|
|
11
|
+
import { analyzeRemoteRepo, computeAnalysisDiff } from '../utils/remote-analyzer';
|
|
12
|
+
|
|
13
|
+
const GITHUB_APP_WEBHOOK_SECRET = process.env.GITHUB_APP_WEBHOOK_SECRET!;
|
|
14
|
+
const SITE_URL = process.env.SITE_URL || 'http://localhost:3000';
|
|
15
|
+
|
|
16
|
+
export const githubAppRoute = new Hono();
|
|
17
|
+
|
|
18
|
+
// ============ Installation Flow ============
|
|
19
|
+
|
|
20
|
+
// Redirect to GitHub App installation
|
|
21
|
+
githubAppRoute.get('/install', async (c) => {
|
|
22
|
+
const orgId = c.req.query('orgId');
|
|
23
|
+
if (!orgId) {
|
|
24
|
+
return c.json({ error: 'orgId required' }, 400);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
28
|
+
if (!session) {
|
|
29
|
+
return c.redirect(`${SITE_URL}/login?callbackUrl=/settings`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Verify user has access to org
|
|
33
|
+
const membership = await db
|
|
34
|
+
.selectFrom('org_members')
|
|
35
|
+
.where('orgId', '=', orgId)
|
|
36
|
+
.where('userId', '=', session.user.id)
|
|
37
|
+
.where('role', 'in', ['owner', 'admin'])
|
|
38
|
+
.select('id')
|
|
39
|
+
.executeTakeFirst();
|
|
40
|
+
|
|
41
|
+
if (!membership) {
|
|
42
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Redirect to GitHub App installation with state
|
|
46
|
+
const state = Buffer.from(JSON.stringify({ orgId })).toString('base64');
|
|
47
|
+
const installUrl = `https://github.com/apps/doccov/installations/new?state=${state}`;
|
|
48
|
+
|
|
49
|
+
return c.redirect(installUrl);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Handle installation callback
|
|
53
|
+
githubAppRoute.get('/callback', async (c) => {
|
|
54
|
+
const installationId = c.req.query('installation_id');
|
|
55
|
+
const state = c.req.query('state');
|
|
56
|
+
|
|
57
|
+
if (!installationId || !state) {
|
|
58
|
+
return c.redirect(`${SITE_URL}/settings?error=missing_params`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let orgId: string;
|
|
62
|
+
try {
|
|
63
|
+
const decoded = JSON.parse(Buffer.from(state, 'base64').toString());
|
|
64
|
+
orgId = decoded.orgId;
|
|
65
|
+
} catch {
|
|
66
|
+
return c.redirect(`${SITE_URL}/settings?error=invalid_state`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
70
|
+
if (!session) {
|
|
71
|
+
return c.redirect(`${SITE_URL}/login`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check if installation already exists
|
|
75
|
+
const existing = await db
|
|
76
|
+
.selectFrom('github_installations')
|
|
77
|
+
.where('installationId', '=', installationId)
|
|
78
|
+
.select('id')
|
|
79
|
+
.executeTakeFirst();
|
|
80
|
+
|
|
81
|
+
if (existing) {
|
|
82
|
+
// Update org reference if different
|
|
83
|
+
await db
|
|
84
|
+
.updateTable('github_installations')
|
|
85
|
+
.set({ orgId, updatedAt: new Date() })
|
|
86
|
+
.where('installationId', '=', installationId)
|
|
87
|
+
.execute();
|
|
88
|
+
} else {
|
|
89
|
+
// Create new installation record
|
|
90
|
+
await db
|
|
91
|
+
.insertInto('github_installations')
|
|
92
|
+
.values({
|
|
93
|
+
id: nanoid(21),
|
|
94
|
+
orgId,
|
|
95
|
+
installationId,
|
|
96
|
+
accessToken: null,
|
|
97
|
+
tokenExpiresAt: null,
|
|
98
|
+
repos: null,
|
|
99
|
+
})
|
|
100
|
+
.execute();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Also update the org's githubInstallationId for quick lookup
|
|
104
|
+
await db
|
|
105
|
+
.updateTable('organizations')
|
|
106
|
+
.set({ githubInstallationId: installationId })
|
|
107
|
+
.where('id', '=', orgId)
|
|
108
|
+
.execute();
|
|
109
|
+
|
|
110
|
+
return c.redirect(`${SITE_URL}/settings?github=connected`);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ============ Webhook Handler ============
|
|
114
|
+
|
|
115
|
+
githubAppRoute.post('/webhook', async (c) => {
|
|
116
|
+
const signature = c.req.header('x-hub-signature-256');
|
|
117
|
+
const event = c.req.header('x-github-event');
|
|
118
|
+
|
|
119
|
+
if (!signature || !event) {
|
|
120
|
+
return c.json({ error: 'Missing headers' }, 400);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Verify webhook signature
|
|
124
|
+
const body = await c.req.text();
|
|
125
|
+
const isValid = await verifyWebhookSignature(body, signature);
|
|
126
|
+
|
|
127
|
+
if (!isValid) {
|
|
128
|
+
return c.json({ error: 'Invalid signature' }, 401);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const payload = JSON.parse(body);
|
|
132
|
+
|
|
133
|
+
// Handle different events
|
|
134
|
+
switch (event) {
|
|
135
|
+
case 'installation':
|
|
136
|
+
await handleInstallationEvent(payload);
|
|
137
|
+
break;
|
|
138
|
+
|
|
139
|
+
case 'push':
|
|
140
|
+
await handlePushEvent(payload);
|
|
141
|
+
break;
|
|
142
|
+
|
|
143
|
+
case 'pull_request':
|
|
144
|
+
await handlePullRequestEvent(payload);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return c.json({ received: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ============ API Endpoints ============
|
|
152
|
+
|
|
153
|
+
// List repos accessible via GitHub App
|
|
154
|
+
githubAppRoute.get('/repos', async (c) => {
|
|
155
|
+
const orgId = c.req.query('orgId');
|
|
156
|
+
if (!orgId) {
|
|
157
|
+
return c.json({ error: 'orgId required' }, 400);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
161
|
+
if (!session) {
|
|
162
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Verify membership
|
|
166
|
+
const membership = await db
|
|
167
|
+
.selectFrom('org_members')
|
|
168
|
+
.where('orgId', '=', orgId)
|
|
169
|
+
.where('userId', '=', session.user.id)
|
|
170
|
+
.select('id')
|
|
171
|
+
.executeTakeFirst();
|
|
172
|
+
|
|
173
|
+
if (!membership) {
|
|
174
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const repos = await listInstallationRepos(orgId);
|
|
178
|
+
|
|
179
|
+
if (!repos) {
|
|
180
|
+
return c.json(
|
|
181
|
+
{
|
|
182
|
+
error: 'No GitHub App installation found',
|
|
183
|
+
installUrl: `/github/install?orgId=${orgId}`,
|
|
184
|
+
},
|
|
185
|
+
404,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return c.json({ repos });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Check installation status
|
|
193
|
+
githubAppRoute.get('/status', async (c) => {
|
|
194
|
+
const orgId = c.req.query('orgId');
|
|
195
|
+
if (!orgId) {
|
|
196
|
+
return c.json({ error: 'orgId required' }, 400);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
200
|
+
if (!session) {
|
|
201
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const installation = await db
|
|
205
|
+
.selectFrom('github_installations')
|
|
206
|
+
.where('orgId', '=', orgId)
|
|
207
|
+
.select(['id', 'installationId', 'createdAt'])
|
|
208
|
+
.executeTakeFirst();
|
|
209
|
+
|
|
210
|
+
return c.json({
|
|
211
|
+
installed: !!installation,
|
|
212
|
+
installationId: installation?.installationId,
|
|
213
|
+
installedAt: installation?.createdAt,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ============ Helpers ============
|
|
218
|
+
|
|
219
|
+
async function verifyWebhookSignature(body: string, signature: string): Promise<boolean> {
|
|
220
|
+
try {
|
|
221
|
+
const encoder = new TextEncoder();
|
|
222
|
+
const key = await crypto.subtle.importKey(
|
|
223
|
+
'raw',
|
|
224
|
+
encoder.encode(GITHUB_APP_WEBHOOK_SECRET),
|
|
225
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
226
|
+
false,
|
|
227
|
+
['sign'],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
|
|
231
|
+
const computed = `sha256=${Array.from(new Uint8Array(sig))
|
|
232
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
233
|
+
.join('')}`;
|
|
234
|
+
|
|
235
|
+
return computed === signature;
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function handleInstallationEvent(payload: { action: string; installation: { id: number } }) {
|
|
242
|
+
const { action, installation } = payload;
|
|
243
|
+
const installationId = String(installation.id);
|
|
244
|
+
|
|
245
|
+
if (action === 'deleted' || action === 'suspend') {
|
|
246
|
+
// Remove installation
|
|
247
|
+
await db
|
|
248
|
+
.deleteFrom('github_installations')
|
|
249
|
+
.where('installationId', '=', installationId)
|
|
250
|
+
.execute();
|
|
251
|
+
|
|
252
|
+
// Clear from org
|
|
253
|
+
await db
|
|
254
|
+
.updateTable('organizations')
|
|
255
|
+
.set({ githubInstallationId: null })
|
|
256
|
+
.where('githubInstallationId', '=', installationId)
|
|
257
|
+
.execute();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function handlePushEvent(payload: {
|
|
262
|
+
installation: { id: number };
|
|
263
|
+
repository: { owner: { login: string }; name: string; default_branch?: string };
|
|
264
|
+
after: string;
|
|
265
|
+
ref: string;
|
|
266
|
+
}) {
|
|
267
|
+
// Only process pushes to default branch
|
|
268
|
+
const defaultBranch = payload.repository.default_branch ?? 'main';
|
|
269
|
+
if (!payload.ref.endsWith(`/${defaultBranch}`)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const installationId = String(payload.installation.id);
|
|
274
|
+
const { owner, name: repo } = payload.repository;
|
|
275
|
+
const sha = payload.after;
|
|
276
|
+
|
|
277
|
+
console.log(`[webhook] Push to ${owner.login}/${repo}@${defaultBranch} (${sha.slice(0, 7)})`);
|
|
278
|
+
|
|
279
|
+
// Run actual analysis
|
|
280
|
+
const result = await analyzeRemoteRepo(installationId, owner.login, repo, sha);
|
|
281
|
+
|
|
282
|
+
if (result) {
|
|
283
|
+
console.log(`[webhook] Analysis complete: ${result.coverageScore}% coverage`);
|
|
284
|
+
|
|
285
|
+
// Create check run with analysis results
|
|
286
|
+
await createCheckRun(installationId, owner.login, repo, sha, result);
|
|
287
|
+
|
|
288
|
+
// Update project in database with latest coverage
|
|
289
|
+
await db
|
|
290
|
+
.updateTable('projects')
|
|
291
|
+
.set({
|
|
292
|
+
coverageScore: result.coverageScore,
|
|
293
|
+
driftCount: result.driftCount,
|
|
294
|
+
updatedAt: new Date(),
|
|
295
|
+
})
|
|
296
|
+
.where('fullName', '=', `${owner.login}/${repo}`)
|
|
297
|
+
.execute();
|
|
298
|
+
} else {
|
|
299
|
+
console.log(`[webhook] Analysis failed for ${owner.login}/${repo}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function handlePullRequestEvent(payload: {
|
|
304
|
+
action: string;
|
|
305
|
+
installation: { id: number };
|
|
306
|
+
repository: { owner: { login: string }; name: string };
|
|
307
|
+
pull_request: {
|
|
308
|
+
number: number;
|
|
309
|
+
head: { sha: string };
|
|
310
|
+
};
|
|
311
|
+
}) {
|
|
312
|
+
const { action, installation, repository, pull_request } = payload;
|
|
313
|
+
|
|
314
|
+
// Only process opened and synchronize (new commits)
|
|
315
|
+
if (action !== 'opened' && action !== 'synchronize') {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const installationId = String(installation.id);
|
|
320
|
+
const { owner, name: repo } = repository;
|
|
321
|
+
const prNumber = pull_request.number;
|
|
322
|
+
const headSha = pull_request.head.sha;
|
|
323
|
+
|
|
324
|
+
console.log(
|
|
325
|
+
`[webhook] PR #${prNumber} ${action} on ${owner.login}/${repo} (${headSha.slice(0, 7)})`,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Analyze PR head (the changes)
|
|
329
|
+
const headResult = await analyzeRemoteRepo(installationId, owner.login, repo, headSha);
|
|
330
|
+
|
|
331
|
+
if (!headResult) {
|
|
332
|
+
console.log(`[webhook] Analysis failed for PR #${prNumber}`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
console.log(`[webhook] PR analysis complete: ${headResult.coverageScore}% coverage`);
|
|
337
|
+
|
|
338
|
+
// Try to get baseline from database or analyze base
|
|
339
|
+
let diff: ReturnType<typeof computeAnalysisDiff> | null = null;
|
|
340
|
+
|
|
341
|
+
// First check if we have cached baseline in project
|
|
342
|
+
const project = await db
|
|
343
|
+
.selectFrom('projects')
|
|
344
|
+
.where('fullName', '=', `${owner.login}/${repo}`)
|
|
345
|
+
.select(['coverageScore', 'driftCount'])
|
|
346
|
+
.executeTakeFirst();
|
|
347
|
+
|
|
348
|
+
if (project && project.coverageScore !== null) {
|
|
349
|
+
// Use cached baseline for speed
|
|
350
|
+
diff = computeAnalysisDiff(
|
|
351
|
+
{
|
|
352
|
+
coverageScore: project.coverageScore,
|
|
353
|
+
documentedExports: 0,
|
|
354
|
+
totalExports: 0,
|
|
355
|
+
driftCount: project.driftCount ?? 0,
|
|
356
|
+
qualityErrors: 0,
|
|
357
|
+
qualityWarnings: 0,
|
|
358
|
+
},
|
|
359
|
+
headResult,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Create check run and post PR comment
|
|
364
|
+
await Promise.all([
|
|
365
|
+
createCheckRun(installationId, owner.login, repo, headSha, headResult, diff),
|
|
366
|
+
postPRComment(installationId, owner.login, repo, prNumber, headResult, diff),
|
|
367
|
+
]);
|
|
368
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
import { auth } from '../auth/config';
|
|
4
|
+
import { db } from '../db/client';
|
|
5
|
+
|
|
6
|
+
export const invitesRoute = new Hono();
|
|
7
|
+
|
|
8
|
+
// Get invite info (public, for displaying on invite page)
|
|
9
|
+
invitesRoute.get('/:token', async (c) => {
|
|
10
|
+
const { token } = c.req.param();
|
|
11
|
+
|
|
12
|
+
const invite = await db
|
|
13
|
+
.selectFrom('org_invites')
|
|
14
|
+
.innerJoin('organizations', 'organizations.id', 'org_invites.orgId')
|
|
15
|
+
.where('org_invites.token', '=', token)
|
|
16
|
+
.where('org_invites.expiresAt', '>', new Date())
|
|
17
|
+
.select([
|
|
18
|
+
'org_invites.id',
|
|
19
|
+
'org_invites.email',
|
|
20
|
+
'org_invites.role',
|
|
21
|
+
'org_invites.expiresAt',
|
|
22
|
+
'organizations.name as orgName',
|
|
23
|
+
'organizations.slug as orgSlug',
|
|
24
|
+
])
|
|
25
|
+
.executeTakeFirst();
|
|
26
|
+
|
|
27
|
+
if (!invite) {
|
|
28
|
+
return c.json({ error: 'Invite not found or expired' }, 404);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return c.json({ invite });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Accept invite (requires auth)
|
|
35
|
+
invitesRoute.post('/:token/accept', async (c) => {
|
|
36
|
+
const { token } = c.req.param();
|
|
37
|
+
|
|
38
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
39
|
+
if (!session) {
|
|
40
|
+
return c.json({ error: 'Unauthorized - please sign in first' }, 401);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const invite = await db
|
|
44
|
+
.selectFrom('org_invites')
|
|
45
|
+
.where('token', '=', token)
|
|
46
|
+
.where('expiresAt', '>', new Date())
|
|
47
|
+
.selectAll()
|
|
48
|
+
.executeTakeFirst();
|
|
49
|
+
|
|
50
|
+
if (!invite) {
|
|
51
|
+
return c.json({ error: 'Invite not found or expired' }, 404);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check if already a member
|
|
55
|
+
const existingMember = await db
|
|
56
|
+
.selectFrom('org_members')
|
|
57
|
+
.where('orgId', '=', invite.orgId)
|
|
58
|
+
.where('userId', '=', session.user.id)
|
|
59
|
+
.select('id')
|
|
60
|
+
.executeTakeFirst();
|
|
61
|
+
|
|
62
|
+
if (existingMember) {
|
|
63
|
+
// Delete the invite and return success (already a member)
|
|
64
|
+
await db.deleteFrom('org_invites').where('id', '=', invite.id).execute();
|
|
65
|
+
return c.json({ success: true, message: 'Already a member' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Add as member
|
|
69
|
+
await db
|
|
70
|
+
.insertInto('org_members')
|
|
71
|
+
.values({
|
|
72
|
+
id: nanoid(21),
|
|
73
|
+
orgId: invite.orgId,
|
|
74
|
+
userId: session.user.id,
|
|
75
|
+
role: invite.role,
|
|
76
|
+
})
|
|
77
|
+
.execute();
|
|
78
|
+
|
|
79
|
+
// Delete the invite
|
|
80
|
+
await db.deleteFrom('org_invites').where('id', '=', invite.id).execute();
|
|
81
|
+
|
|
82
|
+
// Get org slug for redirect
|
|
83
|
+
const org = await db
|
|
84
|
+
.selectFrom('organizations')
|
|
85
|
+
.where('id', '=', invite.orgId)
|
|
86
|
+
.select('slug')
|
|
87
|
+
.executeTakeFirst();
|
|
88
|
+
|
|
89
|
+
return c.json({ success: true, orgSlug: org?.slug });
|
|
90
|
+
});
|