@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,288 @@
1
+ import { getPlanLimits, type Plan } from '@doccov/db';
2
+ import { Hono } from 'hono';
3
+ import { nanoid } from 'nanoid';
4
+ import { auth } from '../auth/config';
5
+ import { db } from '../db/client';
6
+
7
+ type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
8
+
9
+ type Env = {
10
+ Variables: {
11
+ session: NonNullable<Session>;
12
+ };
13
+ };
14
+
15
+ export const coverageRoute = new Hono<Env>();
16
+
17
+ // Middleware: require auth
18
+ coverageRoute.use('*', async (c, next) => {
19
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
20
+ if (!session) {
21
+ return c.json({ error: 'Unauthorized' }, 401);
22
+ }
23
+ c.set('session', session);
24
+ await next();
25
+ });
26
+
27
+ // Get coverage history for a project
28
+ coverageRoute.get('/projects/:projectId/history', async (c) => {
29
+ const session = c.get('session');
30
+ const { projectId } = c.req.param();
31
+ const { range = '30d', limit = '50' } = c.req.query();
32
+
33
+ // Verify user has access to project and get org plan
34
+ const projectWithOrg = await db
35
+ .selectFrom('projects')
36
+ .innerJoin('org_members', 'org_members.orgId', 'projects.orgId')
37
+ .innerJoin('organizations', 'organizations.id', 'projects.orgId')
38
+ .where('projects.id', '=', projectId)
39
+ .where('org_members.userId', '=', session.user.id)
40
+ .select(['projects.id', 'projects.name', 'organizations.plan'])
41
+ .executeTakeFirst();
42
+
43
+ if (!projectWithOrg) {
44
+ return c.json({ error: 'Project not found' }, 404);
45
+ }
46
+
47
+ // Check plan limits for trends access
48
+ const planLimits = getPlanLimits(projectWithOrg.plan as Plan);
49
+ if (planLimits.historyDays === 0) {
50
+ return c.json(
51
+ {
52
+ error: 'Coverage trends require Team plan or higher',
53
+ upgrade: 'https://doccov.com/pricing',
54
+ },
55
+ 403,
56
+ );
57
+ }
58
+
59
+ // Calculate date filter based on range (capped by plan limit)
60
+ let dateFilter: Date | null = null;
61
+ const now = new Date();
62
+ const maxDays = planLimits.historyDays;
63
+
64
+ // Map range to days, capped by plan limit
65
+ const rangeDays: Record<string, number> = {
66
+ '7d': Math.min(7, maxDays),
67
+ '30d': Math.min(30, maxDays),
68
+ '90d': Math.min(90, maxDays),
69
+ };
70
+
71
+ if (range in rangeDays) {
72
+ const days = rangeDays[range];
73
+ dateFilter = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
74
+ } else if (range === 'all' || range === 'versions') {
75
+ // Still cap by plan limit
76
+ dateFilter = new Date(now.getTime() - maxDays * 24 * 60 * 60 * 1000);
77
+ }
78
+
79
+ let query = db
80
+ .selectFrom('coverage_snapshots')
81
+ .where('projectId', '=', projectId)
82
+ .orderBy('createdAt', 'desc')
83
+ .limit(parseInt(limit, 10));
84
+
85
+ if (dateFilter) {
86
+ query = query.where('createdAt', '>=', dateFilter);
87
+ }
88
+
89
+ const snapshots = await query
90
+ .select([
91
+ 'id',
92
+ 'version',
93
+ 'branch',
94
+ 'commitSha',
95
+ 'coveragePercent',
96
+ 'documentedCount',
97
+ 'totalCount',
98
+ 'descriptionCount',
99
+ 'paramsCount',
100
+ 'returnsCount',
101
+ 'examplesCount',
102
+ 'driftCount',
103
+ 'source',
104
+ 'createdAt',
105
+ ])
106
+ .execute();
107
+
108
+ // Reverse to get chronological order
109
+ const chronological = snapshots.reverse();
110
+
111
+ // Calculate insights
112
+ const insights = generateInsights(chronological);
113
+
114
+ // Detect regressions
115
+ const regression = detectRegression(chronological);
116
+
117
+ return c.json({
118
+ snapshots: chronological,
119
+ insights,
120
+ regression,
121
+ });
122
+ });
123
+
124
+ // Record a new coverage snapshot
125
+ coverageRoute.post('/projects/:projectId/snapshots', async (c) => {
126
+ const session = c.get('session');
127
+ const { projectId } = c.req.param();
128
+ const body = await c.req.json<{
129
+ version?: string;
130
+ branch?: string;
131
+ commitSha?: string;
132
+ coveragePercent: number;
133
+ documentedCount: number;
134
+ totalCount: number;
135
+ descriptionCount?: number;
136
+ paramsCount?: number;
137
+ returnsCount?: number;
138
+ examplesCount?: number;
139
+ driftCount?: number;
140
+ source?: 'ci' | 'manual' | 'scheduled';
141
+ }>();
142
+
143
+ // Verify user has admin access to project
144
+ const membership = await db
145
+ .selectFrom('projects')
146
+ .innerJoin('org_members', 'org_members.orgId', 'projects.orgId')
147
+ .where('projects.id', '=', projectId)
148
+ .where('org_members.userId', '=', session.user.id)
149
+ .where('org_members.role', 'in', ['owner', 'admin'])
150
+ .select(['projects.id'])
151
+ .executeTakeFirst();
152
+
153
+ if (!membership) {
154
+ return c.json({ error: 'Forbidden' }, 403);
155
+ }
156
+
157
+ const snapshot = await db
158
+ .insertInto('coverage_snapshots')
159
+ .values({
160
+ id: nanoid(21),
161
+ projectId,
162
+ version: body.version || null,
163
+ branch: body.branch || null,
164
+ commitSha: body.commitSha || null,
165
+ coveragePercent: body.coveragePercent,
166
+ documentedCount: body.documentedCount,
167
+ totalCount: body.totalCount,
168
+ descriptionCount: body.descriptionCount || null,
169
+ paramsCount: body.paramsCount || null,
170
+ returnsCount: body.returnsCount || null,
171
+ examplesCount: body.examplesCount || null,
172
+ driftCount: body.driftCount || 0,
173
+ source: body.source || 'manual',
174
+ })
175
+ .returningAll()
176
+ .executeTakeFirst();
177
+
178
+ // Update project's latest coverage
179
+ await db
180
+ .updateTable('projects')
181
+ .set({
182
+ coverageScore: body.coveragePercent,
183
+ driftCount: body.driftCount || 0,
184
+ lastAnalyzedAt: new Date(),
185
+ })
186
+ .where('id', '=', projectId)
187
+ .execute();
188
+
189
+ return c.json({ snapshot }, 201);
190
+ });
191
+
192
+ // Helper: Generate insights from coverage data
193
+ interface Snapshot {
194
+ version: string | null;
195
+ coveragePercent: number;
196
+ documentedCount: number;
197
+ totalCount: number;
198
+ driftCount: number;
199
+ }
200
+
201
+ interface Insight {
202
+ type: 'improvement' | 'regression' | 'prediction' | 'milestone';
203
+ message: string;
204
+ severity: 'info' | 'warning' | 'success';
205
+ }
206
+
207
+ function generateInsights(snapshots: Snapshot[]): Insight[] {
208
+ const insights: Insight[] = [];
209
+ if (snapshots.length < 2) return insights;
210
+
211
+ const first = snapshots[0];
212
+ const last = snapshots[snapshots.length - 1];
213
+ const diff = last.coveragePercent - first.coveragePercent;
214
+
215
+ // Overall improvement/regression
216
+ if (diff > 0) {
217
+ insights.push({
218
+ type: 'improvement',
219
+ message: `Coverage increased ${diff.toFixed(0)}% since ${first.version || 'first snapshot'}`,
220
+ severity: 'success',
221
+ });
222
+ } else if (diff < 0) {
223
+ insights.push({
224
+ type: 'regression',
225
+ message: `Coverage decreased ${Math.abs(diff).toFixed(0)}% since ${first.version || 'first snapshot'}`,
226
+ severity: 'warning',
227
+ });
228
+ }
229
+
230
+ // Predict time to 100%
231
+ if (diff > 0 && last.coveragePercent < 100) {
232
+ const remaining = 100 - last.coveragePercent;
233
+ const avgGainPerSnapshot = diff / (snapshots.length - 1);
234
+ if (avgGainPerSnapshot > 0) {
235
+ const snapshotsToComplete = Math.ceil(remaining / avgGainPerSnapshot);
236
+ insights.push({
237
+ type: 'prediction',
238
+ message: `At current pace, 100% coverage in ~${snapshotsToComplete} releases`,
239
+ severity: 'info',
240
+ });
241
+ }
242
+ }
243
+
244
+ // Check for milestones
245
+ const milestones = [50, 75, 90, 100];
246
+ for (const milestone of milestones) {
247
+ const crossedAt = snapshots.findIndex(
248
+ (s, i) =>
249
+ i > 0 && s.coveragePercent >= milestone && snapshots[i - 1].coveragePercent < milestone,
250
+ );
251
+ if (crossedAt > 0) {
252
+ insights.push({
253
+ type: 'milestone',
254
+ message: `Reached ${milestone}% coverage at ${snapshots[crossedAt].version || `snapshot ${crossedAt + 1}`}`,
255
+ severity: 'success',
256
+ });
257
+ }
258
+ }
259
+
260
+ return insights.slice(0, 5); // Limit to 5 insights
261
+ }
262
+
263
+ // Helper: Detect recent regression
264
+ function detectRegression(
265
+ snapshots: Snapshot[],
266
+ ): { fromVersion: string; toVersion: string; coverageDrop: number; exportsLost: number } | null {
267
+ if (snapshots.length < 2) return null;
268
+
269
+ // Look at last 5 snapshots for recent regressions
270
+ const recent = snapshots.slice(-5);
271
+ for (let i = 1; i < recent.length; i++) {
272
+ const prev = recent[i - 1];
273
+ const curr = recent[i];
274
+ const drop = prev.coveragePercent - curr.coveragePercent;
275
+
276
+ if (drop >= 3) {
277
+ // 3% or more drop
278
+ return {
279
+ fromVersion: prev.version || `v${i}`,
280
+ toVersion: curr.version || `v${i + 1}`,
281
+ coverageDrop: Math.round(drop),
282
+ exportsLost: prev.documentedCount - curr.documentedCount,
283
+ };
284
+ }
285
+ }
286
+
287
+ return null;
288
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Demo route - public "Try it Now" endpoint for analyzing npm packages
3
+ * Uses the /plan and /execute-stream endpoints from the Vercel API
4
+ */
5
+
6
+ import { Hono } from 'hono';
7
+ import { streamSSE } from 'hono/streaming';
8
+ import { anonymousRateLimit } from '../middleware/anonymous-rate-limit';
9
+
10
+ export const demoRoute = new Hono();
11
+
12
+ // Vercel API URL (where /plan and /execute-stream live)
13
+ const VERCEL_API_URL = process.env.VERCEL_API_URL || 'https://api-khaki-phi.vercel.app';
14
+
15
+ // Rate limit: 5 analyses per hour per IP
16
+ demoRoute.use(
17
+ '*',
18
+ anonymousRateLimit({
19
+ windowMs: 60 * 60 * 1000, // 1 hour
20
+ max: 5,
21
+ message: 'Demo limit reached. Sign up for unlimited access.',
22
+ upgradeUrl: 'https://doccov.com/pricing',
23
+ }),
24
+ );
25
+
26
+ /**
27
+ * npm package info from registry
28
+ */
29
+ interface NpmPackageInfo {
30
+ name: string;
31
+ version: string;
32
+ description?: string;
33
+ repository?: string;
34
+ }
35
+
36
+ /**
37
+ * Fetch package info from npm registry
38
+ */
39
+ async function fetchNpmPackage(packageName: string): Promise<NpmPackageInfo> {
40
+ const encodedName = encodeURIComponent(packageName);
41
+ const url = `https://registry.npmjs.org/${encodedName}/latest`;
42
+
43
+ const res = await fetch(url, {
44
+ headers: { Accept: 'application/json' },
45
+ });
46
+
47
+ if (!res.ok) {
48
+ if (res.status === 404) {
49
+ throw new Error(`Package "${packageName}" not found on npm`);
50
+ }
51
+ throw new Error(`npm registry error: ${res.status}`);
52
+ }
53
+
54
+ const data = (await res.json()) as {
55
+ name: string;
56
+ version: string;
57
+ description?: string;
58
+ repository?: string | { type?: string; url?: string };
59
+ };
60
+
61
+ // Extract and normalize GitHub URL from repository field
62
+ let repoUrl: string | undefined;
63
+ if (data.repository) {
64
+ if (typeof data.repository === 'string') {
65
+ repoUrl = data.repository;
66
+ } else if (data.repository.url) {
67
+ // Normalize: git+https://github.com/... or git://github.com/...
68
+ repoUrl = data.repository.url
69
+ .replace(/^git\+/, '')
70
+ .replace(/^git:\/\//, 'https://')
71
+ .replace(/\.git$/, '');
72
+ }
73
+ }
74
+
75
+ // Validate it's a GitHub URL
76
+ if (repoUrl && !repoUrl.includes('github.com')) {
77
+ repoUrl = undefined;
78
+ }
79
+
80
+ return {
81
+ name: data.name,
82
+ version: data.version,
83
+ description: data.description,
84
+ repository: repoUrl,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Analysis result summary (matches SpecSummary from Vercel API)
90
+ */
91
+ interface AnalysisSummary {
92
+ packageName: string;
93
+ version: string;
94
+ coverage: number;
95
+ exportCount: number;
96
+ documentedCount: number;
97
+ undocumentedCount: number;
98
+ driftCount: number;
99
+ topUndocumented: string[];
100
+ topDrift: Array<{ name: string; issue: string }>;
101
+ }
102
+
103
+ // GET /demo/analyze?package=lodash
104
+ demoRoute.get('/analyze', async (c) => {
105
+ const packageName = c.req.query('package');
106
+
107
+ if (!packageName) {
108
+ return c.json({ error: 'Package name required' }, 400);
109
+ }
110
+
111
+ // Validate package name (basic sanitation)
112
+ if (!/^(@[\w-]+\/)?[\w.-]+$/.test(packageName)) {
113
+ return c.json({ error: 'Invalid package name format' }, 400);
114
+ }
115
+
116
+ return streamSSE(c, async (stream) => {
117
+ const sendEvent = async (
118
+ type: 'status' | 'log' | 'result' | 'error',
119
+ data: { step?: string; message?: string; data?: unknown },
120
+ ) => {
121
+ await stream.writeSSE({
122
+ data: JSON.stringify({ type, ...data }),
123
+ event: type === 'error' ? 'error' : type === 'result' ? 'complete' : 'progress',
124
+ });
125
+ };
126
+
127
+ try {
128
+ // Step 1: Fetch from npm registry
129
+ await sendEvent('status', {
130
+ step: 'npm',
131
+ message: `Fetching ${packageName} from npm registry...`,
132
+ });
133
+
134
+ const npmInfo = await fetchNpmPackage(packageName);
135
+
136
+ await sendEvent('log', {
137
+ message: `Found ${npmInfo.name}@${npmInfo.version}`,
138
+ });
139
+
140
+ if (!npmInfo.repository) {
141
+ await sendEvent('error', {
142
+ message: 'No GitHub repository linked to this package',
143
+ });
144
+ return;
145
+ }
146
+
147
+ await sendEvent('log', {
148
+ message: `Repository: ${npmInfo.repository}`,
149
+ });
150
+
151
+ // Step 2: Generate build plan via /plan endpoint
152
+ await sendEvent('status', {
153
+ step: 'plan',
154
+ message: 'Generating build plan...',
155
+ });
156
+
157
+ const planResponse = await fetch(`${VERCEL_API_URL}/plan`, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({
161
+ url: npmInfo.repository,
162
+ package: packageName.startsWith('@') ? packageName : undefined,
163
+ }),
164
+ });
165
+
166
+ if (!planResponse.ok) {
167
+ const errorData = (await planResponse.json()) as { error?: string };
168
+ throw new Error(errorData.error || `Plan generation failed: ${planResponse.status}`);
169
+ }
170
+
171
+ const planData = (await planResponse.json()) as {
172
+ plan: unknown;
173
+ context: { isMonorepo: boolean; packageManager: string };
174
+ };
175
+
176
+ await sendEvent('log', {
177
+ message: `Build plan ready (${planData.context.packageManager}${planData.context.isMonorepo ? ', monorepo' : ''})`,
178
+ });
179
+
180
+ // Step 3: Execute build plan via /execute-stream endpoint
181
+ await sendEvent('status', {
182
+ step: 'build',
183
+ message: 'Building and analyzing...',
184
+ });
185
+
186
+ const executeResponse = await fetch(`${VERCEL_API_URL}/execute-stream`, {
187
+ method: 'POST',
188
+ headers: { 'Content-Type': 'application/json' },
189
+ body: JSON.stringify({ plan: planData.plan }),
190
+ });
191
+
192
+ if (!executeResponse.ok || !executeResponse.body) {
193
+ throw new Error(`Execution failed: ${executeResponse.status}`);
194
+ }
195
+
196
+ // Stream the execute-stream SSE events and forward relevant ones
197
+ const reader = executeResponse.body.getReader();
198
+ const decoder = new TextDecoder();
199
+ let buffer = '';
200
+
201
+ while (true) {
202
+ const { done, value } = await reader.read();
203
+ if (done) break;
204
+
205
+ buffer += decoder.decode(value, { stream: true });
206
+ const lines = buffer.split('\n');
207
+ buffer = lines.pop() || '';
208
+
209
+ for (const line of lines) {
210
+ if (line.startsWith('event:')) {
211
+ const eventType = line.slice(7).trim();
212
+
213
+ // Get the next data line
214
+ const dataLineIndex = lines.indexOf(line) + 1;
215
+ if (dataLineIndex < lines.length && lines[dataLineIndex].startsWith('data:')) {
216
+ const dataStr = lines[dataLineIndex].slice(5).trim();
217
+ try {
218
+ const eventData = JSON.parse(dataStr) as {
219
+ stage?: string;
220
+ message?: string;
221
+ stepId?: string;
222
+ name?: string;
223
+ success?: boolean;
224
+ summary?: {
225
+ name: string;
226
+ version: string;
227
+ coverage: number;
228
+ exports: number;
229
+ documented: number;
230
+ undocumented: number;
231
+ };
232
+ error?: string;
233
+ };
234
+
235
+ // Forward progress events
236
+ if (eventType === 'progress') {
237
+ await sendEvent('log', { message: eventData.message || eventData.stage });
238
+ } else if (eventType === 'step:start') {
239
+ await sendEvent('status', {
240
+ step: eventData.stepId === 'analyze' ? 'analyze' : 'build',
241
+ message: eventData.name || `Running ${eventData.stepId}...`,
242
+ });
243
+ } else if (eventType === 'step:complete' && eventData.stepId) {
244
+ await sendEvent('log', {
245
+ message: `${eventData.stepId} completed`,
246
+ });
247
+ } else if (eventType === 'complete' && eventData.summary) {
248
+ // Transform summary to our format
249
+ const summary: AnalysisSummary = {
250
+ packageName: eventData.summary.name,
251
+ version: eventData.summary.version,
252
+ coverage: eventData.summary.coverage,
253
+ exportCount: eventData.summary.exports,
254
+ documentedCount: eventData.summary.documented,
255
+ undocumentedCount: eventData.summary.undocumented,
256
+ driftCount: 0,
257
+ topUndocumented: [],
258
+ topDrift: [],
259
+ };
260
+
261
+ await sendEvent('log', {
262
+ message: `Found ${summary.exportCount} exports, ${summary.documentedCount} documented`,
263
+ });
264
+
265
+ await sendEvent('status', {
266
+ step: 'complete',
267
+ message: 'Analysis complete!',
268
+ });
269
+
270
+ await sendEvent('result', { data: summary });
271
+ return;
272
+ } else if (eventType === 'error') {
273
+ throw new Error(eventData.error || 'Execution failed');
274
+ }
275
+ } catch (parseError) {
276
+ // Ignore JSON parse errors for incomplete data
277
+ if (parseError instanceof SyntaxError) continue;
278
+ throw parseError;
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // If we get here without a complete event, something went wrong
286
+ throw new Error('Execution completed without results');
287
+ } catch (err) {
288
+ const message = err instanceof Error ? err.message : 'Analysis failed';
289
+ await sendEvent('error', { message });
290
+ }
291
+ });
292
+ });
293
+
294
+ // Health check
295
+ demoRoute.get('/health', (c) => {
296
+ return c.json({ status: 'ok' });
297
+ });