@doccov/api 0.3.6 → 0.4.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,270 @@
1
+ import { Hono } from 'hono';
2
+ import { nanoid } from 'nanoid';
3
+ import { auth } from '../auth/config';
4
+ import { db } from '../db/client';
5
+
6
+ type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
7
+
8
+ type Env = {
9
+ Variables: {
10
+ session: NonNullable<Session>;
11
+ };
12
+ };
13
+
14
+ export const coverageRoute = new Hono<Env>();
15
+
16
+ // Middleware: require auth
17
+ coverageRoute.use('*', async (c, next) => {
18
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
19
+ if (!session) {
20
+ return c.json({ error: 'Unauthorized' }, 401);
21
+ }
22
+ c.set('session', session);
23
+ await next();
24
+ });
25
+
26
+ // Get coverage history for a project
27
+ coverageRoute.get('/projects/:projectId/history', async (c) => {
28
+ const session = c.get('session');
29
+ const { projectId } = c.req.param();
30
+ const { range = '30d', limit = '50' } = c.req.query();
31
+
32
+ // Verify user has access to project
33
+ const project = await db
34
+ .selectFrom('projects')
35
+ .innerJoin('org_members', 'org_members.orgId', 'projects.orgId')
36
+ .where('projects.id', '=', projectId)
37
+ .where('org_members.userId', '=', session.user.id)
38
+ .select(['projects.id', 'projects.name'])
39
+ .executeTakeFirst();
40
+
41
+ if (!project) {
42
+ return c.json({ error: 'Project not found' }, 404);
43
+ }
44
+
45
+ // Calculate date filter based on range
46
+ let dateFilter: Date | null = null;
47
+ const now = new Date();
48
+ switch (range) {
49
+ case '7d':
50
+ dateFilter = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
51
+ break;
52
+ case '30d':
53
+ dateFilter = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
54
+ break;
55
+ case '90d':
56
+ dateFilter = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
57
+ break;
58
+ // 'all' and 'versions' - no date filter
59
+ }
60
+
61
+ let query = db
62
+ .selectFrom('coverage_snapshots')
63
+ .where('projectId', '=', projectId)
64
+ .orderBy('createdAt', 'desc')
65
+ .limit(parseInt(limit, 10));
66
+
67
+ if (dateFilter) {
68
+ query = query.where('createdAt', '>=', dateFilter);
69
+ }
70
+
71
+ const snapshots = await query
72
+ .select([
73
+ 'id',
74
+ 'version',
75
+ 'branch',
76
+ 'commitSha',
77
+ 'coveragePercent',
78
+ 'documentedCount',
79
+ 'totalCount',
80
+ 'descriptionCount',
81
+ 'paramsCount',
82
+ 'returnsCount',
83
+ 'examplesCount',
84
+ 'driftCount',
85
+ 'source',
86
+ 'createdAt',
87
+ ])
88
+ .execute();
89
+
90
+ // Reverse to get chronological order
91
+ const chronological = snapshots.reverse();
92
+
93
+ // Calculate insights
94
+ const insights = generateInsights(chronological);
95
+
96
+ // Detect regressions
97
+ const regression = detectRegression(chronological);
98
+
99
+ return c.json({
100
+ snapshots: chronological,
101
+ insights,
102
+ regression,
103
+ });
104
+ });
105
+
106
+ // Record a new coverage snapshot
107
+ coverageRoute.post('/projects/:projectId/snapshots', async (c) => {
108
+ const session = c.get('session');
109
+ const { projectId } = c.req.param();
110
+ const body = await c.req.json<{
111
+ version?: string;
112
+ branch?: string;
113
+ commitSha?: string;
114
+ coveragePercent: number;
115
+ documentedCount: number;
116
+ totalCount: number;
117
+ descriptionCount?: number;
118
+ paramsCount?: number;
119
+ returnsCount?: number;
120
+ examplesCount?: number;
121
+ driftCount?: number;
122
+ source?: 'ci' | 'manual' | 'scheduled';
123
+ }>();
124
+
125
+ // Verify user has admin access to project
126
+ const membership = await db
127
+ .selectFrom('projects')
128
+ .innerJoin('org_members', 'org_members.orgId', 'projects.orgId')
129
+ .where('projects.id', '=', projectId)
130
+ .where('org_members.userId', '=', session.user.id)
131
+ .where('org_members.role', 'in', ['owner', 'admin'])
132
+ .select(['projects.id'])
133
+ .executeTakeFirst();
134
+
135
+ if (!membership) {
136
+ return c.json({ error: 'Forbidden' }, 403);
137
+ }
138
+
139
+ const snapshot = await db
140
+ .insertInto('coverage_snapshots')
141
+ .values({
142
+ id: nanoid(21),
143
+ projectId,
144
+ version: body.version || null,
145
+ branch: body.branch || null,
146
+ commitSha: body.commitSha || null,
147
+ coveragePercent: body.coveragePercent,
148
+ documentedCount: body.documentedCount,
149
+ totalCount: body.totalCount,
150
+ descriptionCount: body.descriptionCount || null,
151
+ paramsCount: body.paramsCount || null,
152
+ returnsCount: body.returnsCount || null,
153
+ examplesCount: body.examplesCount || null,
154
+ driftCount: body.driftCount || 0,
155
+ source: body.source || 'manual',
156
+ })
157
+ .returningAll()
158
+ .executeTakeFirst();
159
+
160
+ // Update project's latest coverage
161
+ await db
162
+ .updateTable('projects')
163
+ .set({
164
+ coverageScore: body.coveragePercent,
165
+ driftCount: body.driftCount || 0,
166
+ lastAnalyzedAt: new Date(),
167
+ })
168
+ .where('id', '=', projectId)
169
+ .execute();
170
+
171
+ return c.json({ snapshot }, 201);
172
+ });
173
+
174
+ // Helper: Generate insights from coverage data
175
+ interface Snapshot {
176
+ version: string | null;
177
+ coveragePercent: number;
178
+ documentedCount: number;
179
+ totalCount: number;
180
+ driftCount: number;
181
+ }
182
+
183
+ interface Insight {
184
+ type: 'improvement' | 'regression' | 'prediction' | 'milestone';
185
+ message: string;
186
+ severity: 'info' | 'warning' | 'success';
187
+ }
188
+
189
+ function generateInsights(snapshots: Snapshot[]): Insight[] {
190
+ const insights: Insight[] = [];
191
+ if (snapshots.length < 2) return insights;
192
+
193
+ const first = snapshots[0];
194
+ const last = snapshots[snapshots.length - 1];
195
+ const diff = last.coveragePercent - first.coveragePercent;
196
+
197
+ // Overall improvement/regression
198
+ if (diff > 0) {
199
+ insights.push({
200
+ type: 'improvement',
201
+ message: `Coverage increased ${diff.toFixed(0)}% since ${first.version || 'first snapshot'}`,
202
+ severity: 'success',
203
+ });
204
+ } else if (diff < 0) {
205
+ insights.push({
206
+ type: 'regression',
207
+ message: `Coverage decreased ${Math.abs(diff).toFixed(0)}% since ${first.version || 'first snapshot'}`,
208
+ severity: 'warning',
209
+ });
210
+ }
211
+
212
+ // Predict time to 100%
213
+ if (diff > 0 && last.coveragePercent < 100) {
214
+ const remaining = 100 - last.coveragePercent;
215
+ const avgGainPerSnapshot = diff / (snapshots.length - 1);
216
+ if (avgGainPerSnapshot > 0) {
217
+ const snapshotsToComplete = Math.ceil(remaining / avgGainPerSnapshot);
218
+ insights.push({
219
+ type: 'prediction',
220
+ message: `At current pace, 100% coverage in ~${snapshotsToComplete} releases`,
221
+ severity: 'info',
222
+ });
223
+ }
224
+ }
225
+
226
+ // Check for milestones
227
+ const milestones = [50, 75, 90, 100];
228
+ for (const milestone of milestones) {
229
+ const crossedAt = snapshots.findIndex(
230
+ (s, i) =>
231
+ i > 0 && s.coveragePercent >= milestone && snapshots[i - 1].coveragePercent < milestone,
232
+ );
233
+ if (crossedAt > 0) {
234
+ insights.push({
235
+ type: 'milestone',
236
+ message: `Reached ${milestone}% coverage at ${snapshots[crossedAt].version || `snapshot ${crossedAt + 1}`}`,
237
+ severity: 'success',
238
+ });
239
+ }
240
+ }
241
+
242
+ return insights.slice(0, 5); // Limit to 5 insights
243
+ }
244
+
245
+ // Helper: Detect recent regression
246
+ function detectRegression(
247
+ snapshots: Snapshot[],
248
+ ): { fromVersion: string; toVersion: string; coverageDrop: number; exportsLost: number } | null {
249
+ if (snapshots.length < 2) return null;
250
+
251
+ // Look at last 5 snapshots for recent regressions
252
+ const recent = snapshots.slice(-5);
253
+ for (let i = 1; i < recent.length; i++) {
254
+ const prev = recent[i - 1];
255
+ const curr = recent[i];
256
+ const drop = prev.coveragePercent - curr.coveragePercent;
257
+
258
+ if (drop >= 3) {
259
+ // 3% or more drop
260
+ return {
261
+ fromVersion: prev.version || `v${i}`,
262
+ toVersion: curr.version || `v${i + 1}`,
263
+ coverageDrop: Math.round(drop),
264
+ exportsLost: prev.documentedCount - curr.documentedCount,
265
+ };
266
+ }
267
+ }
268
+
269
+ return null;
270
+ }
@@ -0,0 +1,138 @@
1
+ import { Hono } from 'hono';
2
+ import { nanoid } from 'nanoid';
3
+ import { auth } from '../auth/config';
4
+ import { db } from '../db/client';
5
+
6
+ type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
7
+
8
+ type Env = {
9
+ Variables: {
10
+ session: NonNullable<Session>;
11
+ };
12
+ };
13
+
14
+ export const orgsRoute = new Hono<Env>();
15
+
16
+ // Middleware: require auth
17
+ orgsRoute.use('*', async (c, next) => {
18
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
19
+ if (!session) {
20
+ return c.json({ error: 'Unauthorized' }, 401);
21
+ }
22
+ c.set('session', session);
23
+ await next();
24
+ });
25
+
26
+ // List user's organizations
27
+ orgsRoute.get('/', async (c) => {
28
+ const session = c.get('session');
29
+
30
+ const memberships = await db
31
+ .selectFrom('org_members')
32
+ .innerJoin('organizations', 'organizations.id', 'org_members.orgId')
33
+ .where('org_members.userId', '=', session.user.id)
34
+ .select([
35
+ 'organizations.id',
36
+ 'organizations.name',
37
+ 'organizations.slug',
38
+ 'organizations.plan',
39
+ 'organizations.isPersonal',
40
+ 'organizations.aiCallsUsed',
41
+ 'org_members.role',
42
+ ])
43
+ .execute();
44
+
45
+ return c.json({ organizations: memberships });
46
+ });
47
+
48
+ // Get single org by slug
49
+ orgsRoute.get('/:slug', async (c) => {
50
+ const session = c.get('session');
51
+ const { slug } = c.req.param();
52
+
53
+ const org = await db
54
+ .selectFrom('organizations')
55
+ .innerJoin('org_members', 'org_members.orgId', 'organizations.id')
56
+ .where('organizations.slug', '=', slug)
57
+ .where('org_members.userId', '=', session.user.id)
58
+ .select([
59
+ 'organizations.id',
60
+ 'organizations.name',
61
+ 'organizations.slug',
62
+ 'organizations.plan',
63
+ 'organizations.isPersonal',
64
+ 'organizations.aiCallsUsed',
65
+ 'organizations.aiCallsResetAt',
66
+ 'org_members.role',
67
+ ])
68
+ .executeTakeFirst();
69
+
70
+ if (!org) {
71
+ return c.json({ error: 'Organization not found' }, 404);
72
+ }
73
+
74
+ return c.json({ organization: org });
75
+ });
76
+
77
+ // Get org's projects
78
+ orgsRoute.get('/:slug/projects', async (c) => {
79
+ const session = c.get('session');
80
+ const { slug } = c.req.param();
81
+
82
+ // Verify membership
83
+ const membership = await db
84
+ .selectFrom('org_members')
85
+ .innerJoin('organizations', 'organizations.id', 'org_members.orgId')
86
+ .where('organizations.slug', '=', slug)
87
+ .where('org_members.userId', '=', session.user.id)
88
+ .select(['org_members.orgId'])
89
+ .executeTakeFirst();
90
+
91
+ if (!membership) {
92
+ return c.json({ error: 'Organization not found' }, 404);
93
+ }
94
+
95
+ const projects = await db
96
+ .selectFrom('projects')
97
+ .where('orgId', '=', membership.orgId)
98
+ .selectAll()
99
+ .execute();
100
+
101
+ return c.json({ projects });
102
+ });
103
+
104
+ // Create a project
105
+ orgsRoute.post('/:slug/projects', async (c) => {
106
+ const session = c.get('session');
107
+ const { slug } = c.req.param();
108
+ const body = await c.req.json<{ name: string; fullName: string; isPrivate?: boolean }>();
109
+
110
+ // Verify owner/admin membership
111
+ const membership = await db
112
+ .selectFrom('org_members')
113
+ .innerJoin('organizations', 'organizations.id', 'org_members.orgId')
114
+ .where('organizations.slug', '=', slug)
115
+ .where('org_members.userId', '=', session.user.id)
116
+ .where('org_members.role', 'in', ['owner', 'admin'])
117
+ .select(['org_members.orgId'])
118
+ .executeTakeFirst();
119
+
120
+ if (!membership) {
121
+ return c.json({ error: 'Forbidden' }, 403);
122
+ }
123
+
124
+ const project = await db
125
+ .insertInto('projects')
126
+ .values({
127
+ id: nanoid(21),
128
+ orgId: membership.orgId,
129
+ name: body.name,
130
+ fullName: body.fullName,
131
+ isPrivate: body.isPrivate ?? false,
132
+ defaultBranch: 'main',
133
+ })
134
+ .returningAll()
135
+ .executeTakeFirst();
136
+
137
+ return c.json({ project }, 201);
138
+ });
@@ -1,81 +1,11 @@
1
1
  /**
2
- * Plan route for local development (mirrors api/plan.ts)
3
- * Does NOT use Vercel Sandbox - only GitHub API + Anthropic Claude
2
+ * Plan route - TODO: implement plan-agent
4
3
  */
5
4
 
6
- import { fetchGitHubContext, parseScanGitHubUrl } from '@doccov/sdk';
7
5
  import { Hono } from 'hono';
8
- import { generateBuildPlan } from '../../lib/plan-agent';
9
6
 
10
7
  export const planRoute = new Hono();
11
8
 
12
9
  planRoute.post('/', async (c) => {
13
- const body = await c.req.json<{ url: string; ref?: string; package?: string }>();
14
-
15
- if (!body.url) {
16
- return c.json({ error: 'url is required' }, 400);
17
- }
18
-
19
- // Validate URL format
20
- let repoUrl: string;
21
- try {
22
- const parsed = parseScanGitHubUrl(body.url);
23
- if (!parsed) {
24
- return c.json({ error: 'Invalid GitHub URL' }, 400);
25
- }
26
- repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
27
- } catch {
28
- return c.json({ error: 'Invalid GitHub URL' }, 400);
29
- }
30
-
31
- try {
32
- // Fetch project context from GitHub
33
- const context = await fetchGitHubContext(repoUrl, body.ref);
34
-
35
- // Check for private repos
36
- if (context.metadata.isPrivate) {
37
- return c.json(
38
- {
39
- error: 'Private repositories are not supported',
40
- hint: 'Use a public repository or run doccov locally',
41
- },
42
- 403,
43
- );
44
- }
45
-
46
- // Generate build plan using AI
47
- const plan = await generateBuildPlan(context, {
48
- targetPackage: body.package,
49
- });
50
-
51
- return c.json({
52
- plan,
53
- context: {
54
- owner: context.metadata.owner,
55
- repo: context.metadata.repo,
56
- ref: context.ref,
57
- packageManager: context.packageManager,
58
- isMonorepo: context.workspace.isMonorepo,
59
- },
60
- });
61
- } catch (error) {
62
- console.error('Plan generation error:', error);
63
-
64
- if (error instanceof Error) {
65
- if (error.message.includes('404') || error.message.includes('not found')) {
66
- return c.json({ error: 'Repository not found' }, 404);
67
- }
68
- if (error.message.includes('rate limit')) {
69
- return c.json({ error: 'GitHub API rate limit exceeded' }, 429);
70
- }
71
- }
72
-
73
- return c.json(
74
- {
75
- error: 'Failed to generate build plan',
76
- message: error instanceof Error ? error.message : 'Unknown error',
77
- },
78
- 500,
79
- );
80
- }
10
+ return c.json({ error: 'Plan endpoint not yet implemented' }, 501);
81
11
  });
package/src/server.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { serve } from '@hono/node-server';
2
+ import { runMigrations } from './db/migrate';
3
+ import app from './index';
4
+
5
+ const port = parseInt(process.env.PORT || '3001');
6
+
7
+ async function start() {
8
+ // Run migrations on startup
9
+ console.log('Running migrations...');
10
+ await runMigrations();
11
+
12
+ // Start server
13
+ console.log(`Starting server on port ${port}`);
14
+ serve({
15
+ fetch: app.fetch,
16
+ port,
17
+ });
18
+
19
+ console.log(`Server running at http://localhost:${port}`);
20
+ }
21
+
22
+ start().catch(console.error);
@@ -0,0 +1,20 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { nanoid } from 'nanoid';
3
+
4
+ const KEY_PREFIX = 'doccov_';
5
+
6
+ export function generateApiKey(): { key: string; hash: string; prefix: string } {
7
+ const random = nanoid(32);
8
+ const key = `${KEY_PREFIX}${random}`;
9
+ const hash = hashApiKey(key);
10
+ const prefix = key.slice(0, 12);
11
+ return { key, hash, prefix };
12
+ }
13
+
14
+ export function hashApiKey(key: string): string {
15
+ return createHash('sha256').update(key).digest('hex');
16
+ }
17
+
18
+ export function isValidKeyFormat(key: string): boolean {
19
+ return key.startsWith(KEY_PREFIX) && key.length === KEY_PREFIX.length + 32;
20
+ }
package/vercel.json CHANGED
@@ -2,7 +2,5 @@
2
2
  "framework": null,
3
3
  "installCommand": "bun install",
4
4
  "outputDirectory": ".",
5
- "rewrites": [
6
- { "source": "/(.*)", "destination": "/api" }
7
- ]
5
+ "rewrites": [{ "source": "/(.*)", "destination": "/api" }]
8
6
  }