@doccov/api 0.3.7 → 0.5.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.
@@ -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.coveragePercent}% 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.coveragePercent,
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.coveragePercent}% 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
+ coveragePercent: project.coverageScore,
353
+ documentedCount: 0,
354
+ totalCount: 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
+ });