@doccov/api 0.6.0 → 0.6.2

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # @doccov/api
2
2
 
3
+ ## 0.6.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - @doccov/sdk@0.20.0
9
+
10
+ ## 0.6.1
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies
15
+ - @doccov/sdk@0.19.0
16
+
3
17
  ## 0.6.0
4
18
 
5
19
  ### Minor Changes
@@ -0,0 +1,174 @@
1
+ import type { VercelResponse } from '@vercel/node';
2
+ import { db } from '../../../_db/client';
3
+ import { getPlanLimits, type Plan } from '../../../_db/limits';
4
+ import { createHandler } from '../../../_lib/handler';
5
+ import { type SessionRequest, withSession } from '../../../_lib/middleware/with-session';
6
+
7
+ export const config = { runtime: 'nodejs', maxDuration: 30 };
8
+
9
+ interface Snapshot {
10
+ version: string | null;
11
+ coverageScore: number;
12
+ documentedExports: number;
13
+ totalExports: number;
14
+ driftCount: number;
15
+ }
16
+
17
+ interface Insight {
18
+ type: 'improvement' | 'regression' | 'prediction' | 'milestone';
19
+ message: string;
20
+ severity: 'info' | 'warning' | 'success';
21
+ }
22
+
23
+ function generateInsights(snapshots: Snapshot[]): Insight[] {
24
+ const insights: Insight[] = [];
25
+ if (snapshots.length < 2) return insights;
26
+
27
+ const first = snapshots[0];
28
+ const last = snapshots[snapshots.length - 1];
29
+ const diff = last.coverageScore - first.coverageScore;
30
+
31
+ if (diff > 0) {
32
+ insights.push({
33
+ type: 'improvement',
34
+ message: `Coverage increased ${diff.toFixed(0)}% since ${first.version || 'first snapshot'}`,
35
+ severity: 'success',
36
+ });
37
+ } else if (diff < 0) {
38
+ insights.push({
39
+ type: 'regression',
40
+ message: `Coverage decreased ${Math.abs(diff).toFixed(0)}% since ${first.version || 'first snapshot'}`,
41
+ severity: 'warning',
42
+ });
43
+ }
44
+
45
+ if (diff > 0 && last.coverageScore < 100) {
46
+ const remaining = 100 - last.coverageScore;
47
+ const avgGainPerSnapshot = diff / (snapshots.length - 1);
48
+ if (avgGainPerSnapshot > 0) {
49
+ const snapshotsToComplete = Math.ceil(remaining / avgGainPerSnapshot);
50
+ insights.push({
51
+ type: 'prediction',
52
+ message: `At current pace, 100% coverage in ~${snapshotsToComplete} releases`,
53
+ severity: 'info',
54
+ });
55
+ }
56
+ }
57
+
58
+ const milestones = [50, 75, 90, 100];
59
+ for (const milestone of milestones) {
60
+ const crossedAt = snapshots.findIndex(
61
+ (s, i) => i > 0 && s.coverageScore >= milestone && snapshots[i - 1].coverageScore < milestone,
62
+ );
63
+ if (crossedAt > 0) {
64
+ insights.push({
65
+ type: 'milestone',
66
+ message: `Reached ${milestone}% coverage at ${snapshots[crossedAt].version || `snapshot ${crossedAt + 1}`}`,
67
+ severity: 'success',
68
+ });
69
+ }
70
+ }
71
+
72
+ return insights.slice(0, 5);
73
+ }
74
+
75
+ function detectRegression(
76
+ snapshots: Snapshot[],
77
+ ): { fromVersion: string; toVersion: string; coverageDrop: number; exportsLost: number } | null {
78
+ if (snapshots.length < 2) return null;
79
+
80
+ const recent = snapshots.slice(-5);
81
+ for (let i = 1; i < recent.length; i++) {
82
+ const prev = recent[i - 1];
83
+ const curr = recent[i];
84
+ const drop = prev.coverageScore - curr.coverageScore;
85
+
86
+ if (drop >= 3) {
87
+ return {
88
+ fromVersion: prev.version || `v${i}`,
89
+ toVersion: curr.version || `v${i + 1}`,
90
+ coverageDrop: Math.round(drop),
91
+ exportsLost: prev.documentedExports - curr.documentedExports,
92
+ };
93
+ }
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ export default createHandler({
100
+ GET: withSession(async (req: SessionRequest, res: VercelResponse) => {
101
+ const { projectId } = req.query as { projectId: string };
102
+ const range = (req.query.range as string) || '30d';
103
+ const limit = (req.query.limit as string) || '50';
104
+
105
+ const projectWithOrg = await db
106
+ .selectFrom('projects')
107
+ .innerJoin('org_members', 'org_members.orgId', 'projects.orgId')
108
+ .innerJoin('organizations', 'organizations.id', 'projects.orgId')
109
+ .where('projects.id', '=', projectId)
110
+ .where('org_members.userId', '=', req.session.user.id)
111
+ .select(['projects.id', 'projects.name', 'organizations.plan'])
112
+ .executeTakeFirst();
113
+
114
+ if (!projectWithOrg) {
115
+ return res.status(404).json({ error: 'Project not found' });
116
+ }
117
+
118
+ const planLimits = getPlanLimits(projectWithOrg.plan as Plan);
119
+ if (planLimits.historyDays === 0) {
120
+ return res.status(403).json({
121
+ error: 'Coverage trends require Team plan or higher',
122
+ upgrade: 'https://doccov.com/pricing',
123
+ });
124
+ }
125
+
126
+ let dateFilter: Date | null = null;
127
+ const now = new Date();
128
+ const maxDays = planLimits.historyDays;
129
+
130
+ const rangeDays: Record<string, number> = {
131
+ '7d': Math.min(7, maxDays),
132
+ '30d': Math.min(30, maxDays),
133
+ '90d': Math.min(90, maxDays),
134
+ };
135
+
136
+ if (range in rangeDays) {
137
+ const days = rangeDays[range];
138
+ dateFilter = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
139
+ } else if (range === 'all' || range === 'versions') {
140
+ dateFilter = new Date(now.getTime() - maxDays * 24 * 60 * 60 * 1000);
141
+ }
142
+
143
+ let query = db
144
+ .selectFrom('coverage_snapshots')
145
+ .where('projectId', '=', projectId)
146
+ .orderBy('createdAt', 'desc')
147
+ .limit(parseInt(limit, 10));
148
+
149
+ if (dateFilter) {
150
+ query = query.where('createdAt', '>=', dateFilter);
151
+ }
152
+
153
+ const snapshots = await query
154
+ .select([
155
+ 'id',
156
+ 'version',
157
+ 'branch',
158
+ 'commitSha',
159
+ 'coverageScore',
160
+ 'documentedExports',
161
+ 'totalExports',
162
+ 'driftCount',
163
+ 'source',
164
+ 'createdAt',
165
+ ])
166
+ .execute();
167
+
168
+ const chronological = snapshots.reverse();
169
+ const insights = generateInsights(chronological);
170
+ const regression = detectRegression(chronological);
171
+
172
+ res.json({ snapshots: chronological, insights, regression });
173
+ }),
174
+ });
@@ -0,0 +1,65 @@
1
+ import type { VercelResponse } from '@vercel/node';
2
+ import { nanoid } from 'nanoid';
3
+ import { db } from '../../../_db/client';
4
+ import { createHandler } from '../../../_lib/handler';
5
+ import { type SessionRequest, withSession } from '../../../_lib/middleware/with-session';
6
+
7
+ export const config = { runtime: 'nodejs', maxDuration: 30 };
8
+
9
+ export default createHandler({
10
+ POST: withSession(async (req: SessionRequest, res: VercelResponse) => {
11
+ const { projectId } = req.query as { projectId: string };
12
+ const body = req.body as {
13
+ version?: string;
14
+ branch?: string;
15
+ commitSha?: string;
16
+ coverageScore: number;
17
+ documentedExports: number;
18
+ totalExports: number;
19
+ driftCount?: number;
20
+ source?: 'ci' | 'manual' | 'scheduled';
21
+ };
22
+
23
+ const membership = await db
24
+ .selectFrom('projects')
25
+ .innerJoin('org_members', 'org_members.orgId', 'projects.orgId')
26
+ .where('projects.id', '=', projectId)
27
+ .where('org_members.userId', '=', req.session.user.id)
28
+ .where('org_members.role', 'in', ['owner', 'admin'])
29
+ .select(['projects.id'])
30
+ .executeTakeFirst();
31
+
32
+ if (!membership) {
33
+ return res.status(403).json({ error: 'Forbidden' });
34
+ }
35
+
36
+ const snapshot = await db
37
+ .insertInto('coverage_snapshots')
38
+ .values({
39
+ id: nanoid(21),
40
+ projectId,
41
+ version: body.version || null,
42
+ branch: body.branch || null,
43
+ commitSha: body.commitSha || null,
44
+ coverageScore: body.coverageScore,
45
+ documentedExports: body.documentedExports,
46
+ totalExports: body.totalExports,
47
+ driftCount: body.driftCount || 0,
48
+ source: body.source || 'manual',
49
+ })
50
+ .returningAll()
51
+ .executeTakeFirst();
52
+
53
+ await db
54
+ .updateTable('projects')
55
+ .set({
56
+ coverageScore: body.coverageScore,
57
+ driftCount: body.driftCount || 0,
58
+ lastAnalyzedAt: new Date(),
59
+ })
60
+ .where('id', '=', projectId)
61
+ .execute();
62
+
63
+ res.status(201).json({ snapshot });
64
+ }),
65
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccov/api",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "DocCov API - Badge endpoint and coverage services",
5
5
  "keywords": [
6
6
  "doccov",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@ai-sdk/anthropic": "^2.0.55",
32
- "@doccov/sdk": "^0.18.0",
32
+ "@doccov/sdk": "^0.20.0",
33
33
  "@hono/node-server": "^1.14.3",
34
34
  "@openpkg-ts/spec": "^0.10.0",
35
35
  "@polar-sh/hono": "^0.5.3",
package/src/index.ts CHANGED
@@ -2,22 +2,10 @@ import { Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
3
  import { logger } from 'hono/logger';
4
4
  import { anonymousRateLimit } from './middleware/anonymous-rate-limit';
5
- import { requireApiKey } from './middleware/api-key-auth';
6
- import { orgRateLimit } from './middleware/org-rate-limit';
7
5
  import { rateLimit } from './middleware/rate-limit';
8
- import { aiRoute } from './routes/ai';
9
- import { apiKeysRoute } from './routes/api-keys';
10
- import { authRoute } from './routes/auth';
11
6
  import { badgeRoute } from './routes/badge';
12
- import { billingRoute } from './routes/billing';
13
- import { coverageRoute } from './routes/coverage';
14
7
  import { demoRoute } from './routes/demo';
15
- import { githubAppRoute } from './routes/github-app';
16
- import { invitesRoute } from './routes/invites';
17
- import { orgsRoute } from './routes/orgs';
18
- import { planRoute } from './routes/plan';
19
8
  import { specRoute } from './routes/spec';
20
- import { specV1Route } from './routes/spec-v1';
21
9
 
22
10
  const app = new Hono();
23
11
 
@@ -31,36 +19,15 @@ app.use(
31
19
  }),
32
20
  );
33
21
 
34
- // Rate limit /plan endpoint: 10 requests per minute per IP
35
- app.use(
36
- '/plan',
37
- rateLimit({
38
- windowMs: 60 * 1000,
39
- max: 10,
40
- message: 'Too many plan requests. Please try again in a minute.',
41
- }),
42
- );
43
-
44
22
  // Health check
45
23
  app.get('/', (c) => {
46
24
  return c.json({
47
25
  name: 'DocCov API',
48
26
  version: '0.5.0',
49
27
  endpoints: {
50
- auth: '/auth/*',
51
- apiKeys: '/api-keys/*',
52
28
  badge: '/badge/:owner/:repo',
53
- billing: '/billing/*',
54
- coverage: '/coverage/*',
55
- github: '/github/* (App install, webhooks)',
56
- invites: '/invites/:token',
57
- orgs: '/orgs/*',
58
- plan: '/plan',
59
- spec: '/spec/diff (POST, session auth)',
60
- v1: {
61
- ai: '/v1/ai/generate (POST), /v1/ai/quota (GET)',
62
- spec: '/v1/spec/diff (POST, API key)',
63
- },
29
+ demo: '/demo/plan, /demo/execute',
30
+ spec: '/spec/:owner/:repo',
64
31
  health: '/health',
65
32
  },
66
33
  });
@@ -70,41 +37,30 @@ app.get('/health', (c) => {
70
37
  return c.json({ status: 'ok', timestamp: new Date().toISOString() });
71
38
  });
72
39
 
73
- // Public endpoints (no auth)
74
- // Anonymous rate limit: 10 requests per day per IP
40
+ // Badge endpoint (public, rate-limited)
75
41
  app.use(
76
42
  '/badge/*',
77
43
  anonymousRateLimit({
78
44
  windowMs: 24 * 60 * 60 * 1000, // 24 hours
79
- max: 10,
80
- message: 'Rate limit reached. Sign up free for 100/day.',
81
- upgradeUrl: 'https://doccov.com/signup',
45
+ max: 100,
46
+ message: 'Rate limit reached.',
82
47
  }),
83
48
  );
84
49
  app.route('/badge', badgeRoute);
85
50
 
86
- // Semi-public endpoints (invite info is public, acceptance requires auth)
87
- app.route('/invites', invitesRoute);
88
-
89
- // GitHub App (install/callback need auth, webhook is public)
90
- app.route('/github', githubAppRoute);
91
-
92
51
  // Demo endpoint (public, rate-limited)
52
+ app.use(
53
+ '/demo/*',
54
+ rateLimit({
55
+ windowMs: 60 * 1000,
56
+ max: 10,
57
+ message: 'Too many requests. Please try again in a minute.',
58
+ }),
59
+ );
93
60
  app.route('/demo', demoRoute);
94
61
 
95
- // Dashboard endpoints (session auth)
96
- app.route('/auth', authRoute);
97
- app.route('/api-keys', apiKeysRoute);
98
- app.route('/billing', billingRoute);
99
- app.route('/coverage', coverageRoute);
100
- app.route('/orgs', orgsRoute);
101
- app.route('/plan', planRoute);
62
+ // Spec endpoint (public, cached)
102
63
  app.route('/spec', specRoute);
103
64
 
104
- // API endpoints (API key required)
105
- app.use('/v1/*', requireApiKey(), orgRateLimit());
106
- app.route('/v1/ai', aiRoute);
107
- app.route('/v1/spec', specV1Route);
108
-
109
65
  // Vercel serverless handler + Bun auto-serves this export
110
66
  export default app;
@@ -205,10 +205,114 @@ badgeRoute.get('/:owner/:repo', async (c) => {
205
205
  }
206
206
  });
207
207
 
208
- // GET /badge/:owner/:repo.svg (alias)
209
- badgeRoute.get('/:owner/:repo.svg', async (c) => {
208
+ // GET /badge/:owner/:repo/json - Shields.io endpoint format
209
+ // https://shields.io/badges/endpoint-badge
210
+ badgeRoute.get('/:owner/:repo/json', async (c) => {
210
211
  const { owner, repo } = c.req.param();
211
- const repoName = repo.replace(/\.svg$/, '');
212
- const query = new URL(c.req.url).search;
213
- return c.redirect(`/badge/${owner}/${repoName}${query}`);
212
+
213
+ const ref = c.req.query('ref') ?? c.req.query('branch') ?? 'main';
214
+ const specPath = c.req.query('path') ?? c.req.query('package') ?? 'openpkg.json';
215
+
216
+ try {
217
+ const spec = await fetchSpec(owner, repo, { ref, path: specPath });
218
+
219
+ if (!spec) {
220
+ return c.json(
221
+ { schemaVersion: 1, label: 'docs', message: 'not found', color: 'lightgrey' },
222
+ 404,
223
+ { 'Cache-Control': 'no-cache' },
224
+ );
225
+ }
226
+
227
+ const validation = validateSpec(spec);
228
+ if (!validation.ok) {
229
+ return c.json(
230
+ { schemaVersion: 1, label: 'docs', message: 'invalid', color: 'lightgrey' },
231
+ 422,
232
+ { 'Cache-Control': 'no-cache' },
233
+ );
234
+ }
235
+
236
+ const coverageScore =
237
+ (spec as { docs?: { coverageScore?: number } }).docs?.coverageScore ??
238
+ computeCoverageScore(spec);
239
+
240
+ return c.json(
241
+ {
242
+ schemaVersion: 1,
243
+ label: 'docs',
244
+ message: `${coverageScore}%`,
245
+ color: getColorForScore(coverageScore),
246
+ },
247
+ 200,
248
+ { 'Cache-Control': 'public, max-age=300, stale-if-error=3600' },
249
+ );
250
+ } catch {
251
+ return c.json(
252
+ { schemaVersion: 1, label: 'docs', message: 'error', color: 'red' },
253
+ 500,
254
+ { 'Cache-Control': 'no-cache' },
255
+ );
256
+ }
257
+ });
258
+
259
+ // GET /badge/:owner/:repo/drift - Drift badge variant
260
+ badgeRoute.get('/:owner/:repo/drift', async (c) => {
261
+ const { owner, repo } = c.req.param();
262
+
263
+ const ref = c.req.query('ref') ?? c.req.query('branch') ?? 'main';
264
+ const specPath = c.req.query('path') ?? c.req.query('package') ?? 'openpkg.json';
265
+ const style = (c.req.query('style') ?? 'flat') as BadgeStyle;
266
+
267
+ try {
268
+ const spec = await fetchSpec(owner, repo, { ref, path: specPath });
269
+
270
+ if (!spec) {
271
+ const svg = generateBadgeSvg({
272
+ label: 'drift',
273
+ message: 'not found',
274
+ color: 'lightgrey',
275
+ style,
276
+ });
277
+ return c.body(svg, 404, CACHE_HEADERS_ERROR);
278
+ }
279
+
280
+ // Compute drift score from exports with drift issues
281
+ const exports = spec.exports ?? [];
282
+ const exportsWithDrift = exports.filter((e) => {
283
+ const docs = (e as { docs?: { drift?: unknown[] } }).docs;
284
+ return docs?.drift && Array.isArray(docs.drift) && docs.drift.length > 0;
285
+ });
286
+ const driftScore = exports.length === 0 ? 0 : Math.round((exportsWithDrift.length / exports.length) * 100);
287
+
288
+ // Lower drift is better
289
+ const color = getDriftColor(driftScore);
290
+
291
+ const svg = generateBadgeSvg({
292
+ label: 'drift',
293
+ message: `${driftScore}%`,
294
+ color,
295
+ style,
296
+ });
297
+
298
+ return c.body(svg, 200, CACHE_HEADERS_SUCCESS);
299
+ } catch {
300
+ const svg = generateBadgeSvg({
301
+ label: 'drift',
302
+ message: 'error',
303
+ color: 'red',
304
+ style,
305
+ });
306
+ return c.body(svg, 500, CACHE_HEADERS_ERROR);
307
+ }
214
308
  });
309
+
310
+ function getDriftColor(score: number): BadgeColor {
311
+ // Inverse of coverage - lower is better
312
+ if (score <= 5) return 'brightgreen';
313
+ if (score <= 10) return 'green';
314
+ if (score <= 20) return 'yellowgreen';
315
+ if (score <= 30) return 'yellow';
316
+ if (score <= 50) return 'orange';
317
+ return 'red';
318
+ }