@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
package/src/routes/orgs.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { nanoid } from 'nanoid';
|
|
|
3
3
|
import { auth } from '../auth/config';
|
|
4
4
|
import { db } from '../db/client';
|
|
5
5
|
|
|
6
|
+
const SITE_URL = process.env.SITE_URL || 'http://localhost:3000';
|
|
7
|
+
|
|
6
8
|
type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
|
|
7
9
|
|
|
8
10
|
type Env = {
|
|
@@ -136,3 +138,250 @@ orgsRoute.post('/:slug/projects', async (c) => {
|
|
|
136
138
|
|
|
137
139
|
return c.json({ project }, 201);
|
|
138
140
|
});
|
|
141
|
+
|
|
142
|
+
// ============ Members ============
|
|
143
|
+
|
|
144
|
+
// List org members
|
|
145
|
+
orgsRoute.get('/:slug/members', async (c) => {
|
|
146
|
+
const session = c.get('session');
|
|
147
|
+
const { slug } = c.req.param();
|
|
148
|
+
|
|
149
|
+
// Verify membership
|
|
150
|
+
const org = await db
|
|
151
|
+
.selectFrom('organizations')
|
|
152
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
153
|
+
.where('organizations.slug', '=', slug)
|
|
154
|
+
.where('org_members.userId', '=', session.user.id)
|
|
155
|
+
.select(['organizations.id as orgId', 'org_members.role as myRole'])
|
|
156
|
+
.executeTakeFirst();
|
|
157
|
+
|
|
158
|
+
if (!org) {
|
|
159
|
+
return c.json({ error: 'Organization not found' }, 404);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const members = await db
|
|
163
|
+
.selectFrom('org_members')
|
|
164
|
+
.innerJoin('user', 'user.id', 'org_members.userId')
|
|
165
|
+
.where('org_members.orgId', '=', org.orgId)
|
|
166
|
+
.select([
|
|
167
|
+
'org_members.id',
|
|
168
|
+
'org_members.userId',
|
|
169
|
+
'org_members.role',
|
|
170
|
+
'org_members.createdAt',
|
|
171
|
+
'user.email',
|
|
172
|
+
'user.name',
|
|
173
|
+
'user.image',
|
|
174
|
+
])
|
|
175
|
+
.orderBy('org_members.createdAt', 'asc')
|
|
176
|
+
.execute();
|
|
177
|
+
|
|
178
|
+
return c.json({ members, myRole: org.myRole });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Create invite
|
|
182
|
+
orgsRoute.post('/:slug/invites', async (c) => {
|
|
183
|
+
const session = c.get('session');
|
|
184
|
+
const { slug } = c.req.param();
|
|
185
|
+
const body = await c.req.json<{ email: string; role: 'admin' | 'member' }>();
|
|
186
|
+
|
|
187
|
+
// Verify owner/admin
|
|
188
|
+
const org = await db
|
|
189
|
+
.selectFrom('organizations')
|
|
190
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
191
|
+
.where('organizations.slug', '=', slug)
|
|
192
|
+
.where('org_members.userId', '=', session.user.id)
|
|
193
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
194
|
+
.select(['organizations.id as orgId', 'organizations.name'])
|
|
195
|
+
.executeTakeFirst();
|
|
196
|
+
|
|
197
|
+
if (!org) {
|
|
198
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check if already a member
|
|
202
|
+
const existingMember = await db
|
|
203
|
+
.selectFrom('org_members')
|
|
204
|
+
.innerJoin('user', 'user.id', 'org_members.userId')
|
|
205
|
+
.where('org_members.orgId', '=', org.orgId)
|
|
206
|
+
.where('user.email', '=', body.email)
|
|
207
|
+
.select('org_members.id')
|
|
208
|
+
.executeTakeFirst();
|
|
209
|
+
|
|
210
|
+
if (existingMember) {
|
|
211
|
+
return c.json({ error: 'User is already a member' }, 400);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const token = nanoid(32);
|
|
215
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
216
|
+
|
|
217
|
+
const invite = await db
|
|
218
|
+
.insertInto('org_invites')
|
|
219
|
+
.values({
|
|
220
|
+
id: nanoid(21),
|
|
221
|
+
orgId: org.orgId,
|
|
222
|
+
email: body.email,
|
|
223
|
+
role: body.role || 'member',
|
|
224
|
+
token,
|
|
225
|
+
expiresAt,
|
|
226
|
+
createdBy: session.user.id,
|
|
227
|
+
})
|
|
228
|
+
.returningAll()
|
|
229
|
+
.executeTakeFirst();
|
|
230
|
+
|
|
231
|
+
const inviteUrl = `${SITE_URL}/invite/${token}`;
|
|
232
|
+
|
|
233
|
+
return c.json({ invite, inviteUrl }, 201);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// List pending invites
|
|
237
|
+
orgsRoute.get('/:slug/invites', async (c) => {
|
|
238
|
+
const session = c.get('session');
|
|
239
|
+
const { slug } = c.req.param();
|
|
240
|
+
|
|
241
|
+
// Verify owner/admin
|
|
242
|
+
const org = await db
|
|
243
|
+
.selectFrom('organizations')
|
|
244
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
245
|
+
.where('organizations.slug', '=', slug)
|
|
246
|
+
.where('org_members.userId', '=', session.user.id)
|
|
247
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
248
|
+
.select(['organizations.id as orgId'])
|
|
249
|
+
.executeTakeFirst();
|
|
250
|
+
|
|
251
|
+
if (!org) {
|
|
252
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const invites = await db
|
|
256
|
+
.selectFrom('org_invites')
|
|
257
|
+
.where('orgId', '=', org.orgId)
|
|
258
|
+
.where('expiresAt', '>', new Date())
|
|
259
|
+
.select(['id', 'email', 'role', 'expiresAt', 'createdAt'])
|
|
260
|
+
.orderBy('createdAt', 'desc')
|
|
261
|
+
.execute();
|
|
262
|
+
|
|
263
|
+
return c.json({ invites });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Delete invite
|
|
267
|
+
orgsRoute.delete('/:slug/invites/:inviteId', async (c) => {
|
|
268
|
+
const session = c.get('session');
|
|
269
|
+
const { slug, inviteId } = c.req.param();
|
|
270
|
+
|
|
271
|
+
// Verify owner/admin
|
|
272
|
+
const org = await db
|
|
273
|
+
.selectFrom('organizations')
|
|
274
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
275
|
+
.where('organizations.slug', '=', slug)
|
|
276
|
+
.where('org_members.userId', '=', session.user.id)
|
|
277
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
278
|
+
.select(['organizations.id as orgId'])
|
|
279
|
+
.executeTakeFirst();
|
|
280
|
+
|
|
281
|
+
if (!org) {
|
|
282
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await db
|
|
286
|
+
.deleteFrom('org_invites')
|
|
287
|
+
.where('id', '=', inviteId)
|
|
288
|
+
.where('orgId', '=', org.orgId)
|
|
289
|
+
.execute();
|
|
290
|
+
|
|
291
|
+
return c.json({ success: true });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Update member role
|
|
295
|
+
orgsRoute.patch('/:slug/members/:userId', async (c) => {
|
|
296
|
+
const session = c.get('session');
|
|
297
|
+
const { slug, userId } = c.req.param();
|
|
298
|
+
const body = await c.req.json<{ role: 'admin' | 'member' }>();
|
|
299
|
+
|
|
300
|
+
// Verify owner
|
|
301
|
+
const org = await db
|
|
302
|
+
.selectFrom('organizations')
|
|
303
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
304
|
+
.where('organizations.slug', '=', slug)
|
|
305
|
+
.where('org_members.userId', '=', session.user.id)
|
|
306
|
+
.where('org_members.role', '=', 'owner')
|
|
307
|
+
.select(['organizations.id as orgId'])
|
|
308
|
+
.executeTakeFirst();
|
|
309
|
+
|
|
310
|
+
if (!org) {
|
|
311
|
+
return c.json({ error: 'Only owner can change roles' }, 403);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Don't allow changing owner role
|
|
315
|
+
const targetMember = await db
|
|
316
|
+
.selectFrom('org_members')
|
|
317
|
+
.where('orgId', '=', org.orgId)
|
|
318
|
+
.where('userId', '=', userId)
|
|
319
|
+
.select('role')
|
|
320
|
+
.executeTakeFirst();
|
|
321
|
+
|
|
322
|
+
if (!targetMember) {
|
|
323
|
+
return c.json({ error: 'Member not found' }, 404);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (targetMember.role === 'owner') {
|
|
327
|
+
return c.json({ error: 'Cannot change owner role' }, 400);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await db
|
|
331
|
+
.updateTable('org_members')
|
|
332
|
+
.set({ role: body.role })
|
|
333
|
+
.where('orgId', '=', org.orgId)
|
|
334
|
+
.where('userId', '=', userId)
|
|
335
|
+
.execute();
|
|
336
|
+
|
|
337
|
+
return c.json({ success: true });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Remove member
|
|
341
|
+
orgsRoute.delete('/:slug/members/:userId', async (c) => {
|
|
342
|
+
const session = c.get('session');
|
|
343
|
+
const { slug, userId } = c.req.param();
|
|
344
|
+
|
|
345
|
+
// Verify owner/admin
|
|
346
|
+
const org = await db
|
|
347
|
+
.selectFrom('organizations')
|
|
348
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
349
|
+
.where('organizations.slug', '=', slug)
|
|
350
|
+
.where('org_members.userId', '=', session.user.id)
|
|
351
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
352
|
+
.select(['organizations.id as orgId', 'org_members.role as myRole'])
|
|
353
|
+
.executeTakeFirst();
|
|
354
|
+
|
|
355
|
+
if (!org) {
|
|
356
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Don't allow removing owner
|
|
360
|
+
const targetMember = await db
|
|
361
|
+
.selectFrom('org_members')
|
|
362
|
+
.where('orgId', '=', org.orgId)
|
|
363
|
+
.where('userId', '=', userId)
|
|
364
|
+
.select('role')
|
|
365
|
+
.executeTakeFirst();
|
|
366
|
+
|
|
367
|
+
if (!targetMember) {
|
|
368
|
+
return c.json({ error: 'Member not found' }, 404);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (targetMember.role === 'owner') {
|
|
372
|
+
return c.json({ error: 'Cannot remove owner' }, 400);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Admins can only remove members, not other admins
|
|
376
|
+
if (org.myRole === 'admin' && targetMember.role === 'admin') {
|
|
377
|
+
return c.json({ error: 'Admins cannot remove other admins' }, 403);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
await db
|
|
381
|
+
.deleteFrom('org_members')
|
|
382
|
+
.where('orgId', '=', org.orgId)
|
|
383
|
+
.where('userId', '=', userId)
|
|
384
|
+
.execute();
|
|
385
|
+
|
|
386
|
+
return c.json({ success: true });
|
|
387
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec routes (v1) - API key authenticated endpoints for programmatic access
|
|
3
|
+
*
|
|
4
|
+
* POST /v1/spec/diff - Compare two specs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { db } from '../db/client';
|
|
10
|
+
import type { ApiKeyContext } from '../middleware/api-key-auth';
|
|
11
|
+
import {
|
|
12
|
+
computeFullDiff,
|
|
13
|
+
type DiffOptions,
|
|
14
|
+
diffSpecs,
|
|
15
|
+
formatDiffResponse,
|
|
16
|
+
} from '../utils/spec-diff-core';
|
|
17
|
+
|
|
18
|
+
type Env = {
|
|
19
|
+
Variables: ApiKeyContext;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const specV1Route = new Hono<Env>();
|
|
23
|
+
|
|
24
|
+
// Request schemas
|
|
25
|
+
const GitHubDiffSchema = z.object({
|
|
26
|
+
mode: z.literal('github'),
|
|
27
|
+
owner: z.string().min(1),
|
|
28
|
+
repo: z.string().min(1),
|
|
29
|
+
base: z.string().min(1),
|
|
30
|
+
head: z.string().min(1),
|
|
31
|
+
includeDocsImpact: z.boolean().optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const SpecsDiffSchema = z.object({
|
|
35
|
+
mode: z.literal('specs'),
|
|
36
|
+
baseSpec: z.object({}).passthrough(),
|
|
37
|
+
headSpec: z.object({}).passthrough(),
|
|
38
|
+
markdownFiles: z
|
|
39
|
+
.array(
|
|
40
|
+
z.object({
|
|
41
|
+
path: z.string(),
|
|
42
|
+
content: z.string(),
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
.optional(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const DiffRequestSchema = z.discriminatedUnion('mode', [GitHubDiffSchema, SpecsDiffSchema]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* POST /v1/spec/diff - Compare two specs
|
|
52
|
+
*
|
|
53
|
+
* Supports two modes:
|
|
54
|
+
* 1. GitHub refs: Clone and compare specs from GitHub refs
|
|
55
|
+
* 2. Direct specs: Compare uploaded spec objects
|
|
56
|
+
*/
|
|
57
|
+
specV1Route.post('/diff', async (c) => {
|
|
58
|
+
const org = c.get('org');
|
|
59
|
+
|
|
60
|
+
// Parse and validate request body
|
|
61
|
+
let body: z.infer<typeof DiffRequestSchema>;
|
|
62
|
+
try {
|
|
63
|
+
const rawBody = await c.req.json();
|
|
64
|
+
body = DiffRequestSchema.parse(rawBody);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof z.ZodError) {
|
|
67
|
+
return c.json(
|
|
68
|
+
{
|
|
69
|
+
error: 'Invalid request',
|
|
70
|
+
details: err.errors,
|
|
71
|
+
},
|
|
72
|
+
400,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
if (body.mode === 'github') {
|
|
80
|
+
// GitHub mode: need to find installation for this org
|
|
81
|
+
const { owner, repo, base, head, includeDocsImpact } = body;
|
|
82
|
+
|
|
83
|
+
// Look up installation from org
|
|
84
|
+
const installation = await db
|
|
85
|
+
.selectFrom('github_installations')
|
|
86
|
+
.where('orgId', '=', org.id)
|
|
87
|
+
.select(['installationId'])
|
|
88
|
+
.executeTakeFirst();
|
|
89
|
+
|
|
90
|
+
if (!installation) {
|
|
91
|
+
return c.json(
|
|
92
|
+
{
|
|
93
|
+
error: 'No GitHub App installation found for this repository',
|
|
94
|
+
hint: 'Install the DocCov GitHub App to compare repos',
|
|
95
|
+
},
|
|
96
|
+
403,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Compute diff with timeout
|
|
101
|
+
const diffOptions: DiffOptions = {
|
|
102
|
+
includeDocsImpact,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const result = await Promise.race([
|
|
106
|
+
computeFullDiff(
|
|
107
|
+
{ owner, repo, ref: base, installationId: installation.installationId },
|
|
108
|
+
{ owner, repo, ref: head, installationId: installation.installationId },
|
|
109
|
+
diffOptions,
|
|
110
|
+
),
|
|
111
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), 60_000)),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
return c.json(formatDiffResponse(result));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Specs mode: direct comparison
|
|
118
|
+
const { baseSpec, headSpec, markdownFiles } = body;
|
|
119
|
+
|
|
120
|
+
const diff = diffSpecs(
|
|
121
|
+
baseSpec as Parameters<typeof diffSpecs>[0],
|
|
122
|
+
headSpec as Parameters<typeof diffSpecs>[1],
|
|
123
|
+
markdownFiles,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return c.json({
|
|
127
|
+
// Core diff fields
|
|
128
|
+
breaking: diff.breaking,
|
|
129
|
+
nonBreaking: diff.nonBreaking,
|
|
130
|
+
docsOnly: diff.docsOnly,
|
|
131
|
+
coverageDelta: diff.coverageDelta,
|
|
132
|
+
oldCoverage: diff.oldCoverage,
|
|
133
|
+
newCoverage: diff.newCoverage,
|
|
134
|
+
driftIntroduced: diff.driftIntroduced,
|
|
135
|
+
driftResolved: diff.driftResolved,
|
|
136
|
+
newUndocumented: diff.newUndocumented,
|
|
137
|
+
improvedExports: diff.improvedExports,
|
|
138
|
+
regressedExports: diff.regressedExports,
|
|
139
|
+
|
|
140
|
+
// Extended fields
|
|
141
|
+
memberChanges: diff.memberChanges,
|
|
142
|
+
categorizedBreaking: diff.categorizedBreaking,
|
|
143
|
+
docsImpact: diff.docsImpact,
|
|
144
|
+
|
|
145
|
+
// Metadata
|
|
146
|
+
generatedAt: new Date().toISOString(),
|
|
147
|
+
cached: false,
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err instanceof Error) {
|
|
151
|
+
if (err.message === 'TIMEOUT') {
|
|
152
|
+
return c.json({ error: 'Spec generation timed out' }, 408);
|
|
153
|
+
}
|
|
154
|
+
if (err.message.includes('not found') || err.message.includes('404')) {
|
|
155
|
+
return c.json({ error: 'Repository or ref not found' }, 404);
|
|
156
|
+
}
|
|
157
|
+
if (err.message.includes('No token')) {
|
|
158
|
+
return c.json({ error: 'GitHub App access required' }, 403);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.error('Spec diff error:', err);
|
|
163
|
+
return c.json({ error: 'Failed to compute diff' }, 500);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec routes - session authenticated endpoints for dashboard
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { auth } from '../auth/config';
|
|
8
|
+
import { db } from '../db/client';
|
|
9
|
+
import {
|
|
10
|
+
computeFullDiff,
|
|
11
|
+
type DiffOptions,
|
|
12
|
+
diffSpecs,
|
|
13
|
+
formatDiffResponse,
|
|
14
|
+
} from '../utils/spec-diff-core';
|
|
15
|
+
|
|
16
|
+
type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
|
|
17
|
+
|
|
18
|
+
type Env = {
|
|
19
|
+
Variables: {
|
|
20
|
+
session: NonNullable<Session>;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const specRoute = new Hono<Env>();
|
|
25
|
+
|
|
26
|
+
// Middleware: require session auth
|
|
27
|
+
specRoute.use('*', async (c, next) => {
|
|
28
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
29
|
+
if (!session) {
|
|
30
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
31
|
+
}
|
|
32
|
+
c.set('session', session);
|
|
33
|
+
await next();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Request schemas
|
|
37
|
+
const GitHubDiffSchema = z.object({
|
|
38
|
+
mode: z.literal('github'),
|
|
39
|
+
owner: z.string().min(1),
|
|
40
|
+
repo: z.string().min(1),
|
|
41
|
+
base: z.string().min(1),
|
|
42
|
+
head: z.string().min(1),
|
|
43
|
+
installationId: z.string().optional(),
|
|
44
|
+
includeDocsImpact: z.boolean().optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const SpecsDiffSchema = z.object({
|
|
48
|
+
mode: z.literal('specs'),
|
|
49
|
+
baseSpec: z.object({}).passthrough(),
|
|
50
|
+
headSpec: z.object({}).passthrough(),
|
|
51
|
+
markdownFiles: z
|
|
52
|
+
.array(
|
|
53
|
+
z.object({
|
|
54
|
+
path: z.string(),
|
|
55
|
+
content: z.string(),
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
.optional(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const DiffRequestSchema = z.discriminatedUnion('mode', [GitHubDiffSchema, SpecsDiffSchema]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* POST /spec/diff - Compare two specs
|
|
65
|
+
*
|
|
66
|
+
* Supports two modes:
|
|
67
|
+
* 1. GitHub refs: Clone and compare specs from GitHub refs
|
|
68
|
+
* 2. Direct specs: Compare uploaded spec objects
|
|
69
|
+
*/
|
|
70
|
+
specRoute.post('/diff', async (c) => {
|
|
71
|
+
const session = c.get('session');
|
|
72
|
+
|
|
73
|
+
// Parse and validate request body
|
|
74
|
+
let body: z.infer<typeof DiffRequestSchema>;
|
|
75
|
+
try {
|
|
76
|
+
const rawBody = await c.req.json();
|
|
77
|
+
body = DiffRequestSchema.parse(rawBody);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err instanceof z.ZodError) {
|
|
80
|
+
return c.json(
|
|
81
|
+
{
|
|
82
|
+
error: 'Invalid request',
|
|
83
|
+
details: err.errors,
|
|
84
|
+
},
|
|
85
|
+
400,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (body.mode === 'github') {
|
|
93
|
+
// GitHub mode: need to verify access and get installation
|
|
94
|
+
const { owner, repo, base, head, installationId, includeDocsImpact } = body;
|
|
95
|
+
|
|
96
|
+
// Find installation ID if not provided
|
|
97
|
+
let resolvedInstallationId = installationId;
|
|
98
|
+
|
|
99
|
+
if (!resolvedInstallationId) {
|
|
100
|
+
// Look up installation from user's orgs
|
|
101
|
+
const installation = await db
|
|
102
|
+
.selectFrom('github_installations')
|
|
103
|
+
.innerJoin('org_members', 'org_members.orgId', 'github_installations.orgId')
|
|
104
|
+
.where('org_members.userId', '=', session.user.id)
|
|
105
|
+
.select(['github_installations.installationId'])
|
|
106
|
+
.executeTakeFirst();
|
|
107
|
+
|
|
108
|
+
if (!installation) {
|
|
109
|
+
return c.json(
|
|
110
|
+
{
|
|
111
|
+
error: 'No GitHub App installation found for this repository',
|
|
112
|
+
hint: 'Install the DocCov GitHub App to compare repos',
|
|
113
|
+
},
|
|
114
|
+
403,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
resolvedInstallationId = installation.installationId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Compute diff with timeout
|
|
122
|
+
const diffOptions: DiffOptions = {
|
|
123
|
+
includeDocsImpact,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const result = await Promise.race([
|
|
127
|
+
computeFullDiff(
|
|
128
|
+
{ owner, repo, ref: base, installationId: resolvedInstallationId },
|
|
129
|
+
{ owner, repo, ref: head, installationId: resolvedInstallationId },
|
|
130
|
+
diffOptions,
|
|
131
|
+
),
|
|
132
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), 60_000)),
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
return c.json(formatDiffResponse(result));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Specs mode: direct comparison
|
|
139
|
+
const { baseSpec, headSpec, markdownFiles } = body;
|
|
140
|
+
|
|
141
|
+
const diff = diffSpecs(
|
|
142
|
+
baseSpec as Parameters<typeof diffSpecs>[0],
|
|
143
|
+
headSpec as Parameters<typeof diffSpecs>[1],
|
|
144
|
+
markdownFiles,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return c.json({
|
|
148
|
+
// Core diff fields
|
|
149
|
+
breaking: diff.breaking,
|
|
150
|
+
nonBreaking: diff.nonBreaking,
|
|
151
|
+
docsOnly: diff.docsOnly,
|
|
152
|
+
coverageDelta: diff.coverageDelta,
|
|
153
|
+
oldCoverage: diff.oldCoverage,
|
|
154
|
+
newCoverage: diff.newCoverage,
|
|
155
|
+
driftIntroduced: diff.driftIntroduced,
|
|
156
|
+
driftResolved: diff.driftResolved,
|
|
157
|
+
newUndocumented: diff.newUndocumented,
|
|
158
|
+
improvedExports: diff.improvedExports,
|
|
159
|
+
regressedExports: diff.regressedExports,
|
|
160
|
+
|
|
161
|
+
// Extended fields
|
|
162
|
+
memberChanges: diff.memberChanges,
|
|
163
|
+
categorizedBreaking: diff.categorizedBreaking,
|
|
164
|
+
docsImpact: diff.docsImpact,
|
|
165
|
+
|
|
166
|
+
// Metadata
|
|
167
|
+
generatedAt: new Date().toISOString(),
|
|
168
|
+
cached: false,
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof Error) {
|
|
172
|
+
if (err.message === 'TIMEOUT') {
|
|
173
|
+
return c.json({ error: 'Spec generation timed out' }, 408);
|
|
174
|
+
}
|
|
175
|
+
if (err.message.includes('not found') || err.message.includes('404')) {
|
|
176
|
+
return c.json({ error: 'Repository or ref not found' }, 404);
|
|
177
|
+
}
|
|
178
|
+
if (err.message.includes('No token')) {
|
|
179
|
+
return c.json({ error: 'GitHub App access required' }, 403);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.error('Spec diff error:', err);
|
|
184
|
+
return c.json({ error: 'Failed to compute diff' }, 500);
|
|
185
|
+
}
|
|
186
|
+
});
|