@doccov/api 0.5.0 → 0.6.1
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 +13 -0
- package/api/coverage/projects/[projectId]/history.ts +174 -0
- package/api/coverage/projects/[projectId]/snapshots.ts +65 -0
- package/api/index.ts +105 -26
- package/migrations/005_coverage_sdk_field_names.ts +41 -0
- package/package.json +3 -3
- package/src/index.ts +16 -54
- package/src/routes/badge.ts +109 -5
- package/src/routes/coverage.ts +19 -32
- package/src/routes/demo.ts +323 -14
- package/src/routes/github-app.ts +6 -6
- package/src/routes/spec-v1.ts +165 -0
- package/src/routes/spec.ts +186 -0
- package/src/utils/github-checks.ts +231 -11
- package/src/utils/remote-analyzer.ts +12 -12
- package/src/utils/spec-cache.ts +131 -0
- package/src/utils/spec-diff-core.ts +406 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @doccov/api
|
|
2
2
|
|
|
3
|
+
## 0.6.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- @doccov/sdk@0.19.0
|
|
9
|
+
|
|
10
|
+
## 0.6.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- feat: add spec routes with caching/diff, auth system, cleanup unused pages
|
|
15
|
+
|
|
3
16
|
## 0.5.0
|
|
4
17
|
|
|
5
18
|
### 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/api/index.ts
CHANGED
|
@@ -12,9 +12,10 @@ import type {
|
|
|
12
12
|
BuildPlanStep,
|
|
13
13
|
BuildPlanStepResult,
|
|
14
14
|
BuildPlanTarget,
|
|
15
|
+
EnrichedOpenPkg,
|
|
15
16
|
GitHubProjectContext,
|
|
16
17
|
} from '@doccov/sdk';
|
|
17
|
-
import { fetchGitHubContext, parseScanGitHubUrl } from '@doccov/sdk';
|
|
18
|
+
import { enrichSpec, fetchGitHubContext, parseScanGitHubUrl } from '@doccov/sdk';
|
|
18
19
|
import type { OpenPkg } from '@openpkg-ts/spec';
|
|
19
20
|
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
20
21
|
import { Sandbox } from '@vercel/sandbox';
|
|
@@ -163,6 +164,9 @@ const LOCAL_BINARIES = new Set([
|
|
|
163
164
|
'publint',
|
|
164
165
|
'attw',
|
|
165
166
|
'are-the-types-wrong',
|
|
167
|
+
// Monorepo tools
|
|
168
|
+
'lerna',
|
|
169
|
+
'nx',
|
|
166
170
|
]);
|
|
167
171
|
|
|
168
172
|
/**
|
|
@@ -213,28 +217,53 @@ interface SpecSummary {
|
|
|
213
217
|
types: number;
|
|
214
218
|
documented: number;
|
|
215
219
|
undocumented: number;
|
|
220
|
+
driftCount: number;
|
|
221
|
+
topUndocumented: string[];
|
|
222
|
+
topDrift: Array<{ name: string; issue: string }>;
|
|
216
223
|
}
|
|
217
224
|
|
|
218
225
|
function createSpecSummary(spec: OpenPkg): SpecSummary {
|
|
219
|
-
|
|
220
|
-
const
|
|
226
|
+
// Enrich spec to get coverage and drift data
|
|
227
|
+
const enriched = enrichSpec(spec) as EnrichedOpenPkg;
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
spec.exports?.filter((e) => e.description && e.description.trim().length > 0).length ?? 0;
|
|
225
|
-
const undocumented = exports - documented;
|
|
229
|
+
const totalExports = enriched.exports?.length ?? 0;
|
|
230
|
+
const types = enriched.types?.length ?? 0;
|
|
226
231
|
|
|
227
|
-
//
|
|
228
|
-
const
|
|
232
|
+
// Use SDK coverage data
|
|
233
|
+
const coverageScore = enriched.docs?.coverageScore ?? 0;
|
|
234
|
+
const documented = enriched.docs?.documented ?? 0;
|
|
235
|
+
const undocumented = totalExports - documented;
|
|
236
|
+
|
|
237
|
+
// Collect drift issues from enriched exports
|
|
238
|
+
const driftItems: Array<{ name: string; issue: string }> = [];
|
|
239
|
+
const undocumentedNames: string[] = [];
|
|
240
|
+
|
|
241
|
+
for (const exp of enriched.exports ?? []) {
|
|
242
|
+
// Collect undocumented exports (no description)
|
|
243
|
+
if (!exp.description || exp.description.trim().length === 0) {
|
|
244
|
+
undocumentedNames.push(exp.name);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Collect drift issues
|
|
248
|
+
for (const drift of exp.docs?.drift ?? []) {
|
|
249
|
+
driftItems.push({
|
|
250
|
+
name: exp.name,
|
|
251
|
+
issue: drift.issue ?? 'Documentation drift detected',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
229
255
|
|
|
230
256
|
return {
|
|
231
|
-
name:
|
|
232
|
-
version:
|
|
233
|
-
coverage,
|
|
234
|
-
exports,
|
|
257
|
+
name: enriched.meta?.name ?? 'unknown',
|
|
258
|
+
version: enriched.meta?.version ?? '0.0.0',
|
|
259
|
+
coverage: coverageScore,
|
|
260
|
+
exports: totalExports,
|
|
235
261
|
types,
|
|
236
262
|
documented,
|
|
237
263
|
undocumented,
|
|
264
|
+
driftCount: driftItems.length,
|
|
265
|
+
topUndocumented: undocumentedNames.slice(0, 5),
|
|
266
|
+
topDrift: driftItems.slice(0, 5),
|
|
238
267
|
};
|
|
239
268
|
}
|
|
240
269
|
|
|
@@ -345,6 +374,27 @@ Install Commands by Package Manager:
|
|
|
345
374
|
- bun with lockfile: ["bun", "install", "--frozen-lockfile"]
|
|
346
375
|
- bun without lockfile: ["bun", "install"]
|
|
347
376
|
|
|
377
|
+
Monorepo Build Strategy (CRITICAL):
|
|
378
|
+
When a target package is specified in a monorepo, ONLY build that package and its dependencies:
|
|
379
|
+
|
|
380
|
+
- Lerna monorepos: Use "lerna run build --scope=<package-name> --include-dependencies"
|
|
381
|
+
Example for @stacks/transactions: ["lerna", "run", "build", "--scope=@stacks/transactions", "--include-dependencies"]
|
|
382
|
+
|
|
383
|
+
- Turbo monorepos: Use "turbo run build --filter=<package-name>"
|
|
384
|
+
Example: ["turbo", "run", "build", "--filter=@stacks/transactions"]
|
|
385
|
+
|
|
386
|
+
- Nx monorepos: Use "nx run <project>:build"
|
|
387
|
+
Example: ["nx", "run", "transactions:build"]
|
|
388
|
+
|
|
389
|
+
- pnpm workspaces: Use "pnpm --filter <package-name> run build"
|
|
390
|
+
Example: ["pnpm", "--filter", "@stacks/transactions", "run", "build"]
|
|
391
|
+
|
|
392
|
+
- yarn workspaces: Use "yarn workspace <package-name> run build"
|
|
393
|
+
Example: ["yarn", "workspace", "@stacks/transactions", "run", "build"]
|
|
394
|
+
|
|
395
|
+
NEVER run a full monorepo build (npm run build at root) when a target package is specified.
|
|
396
|
+
This avoids building 20+ packages when we only need one.
|
|
397
|
+
|
|
348
398
|
General Guidelines:
|
|
349
399
|
- For TypeScript projects, look for "types" or "exports" fields in package.json
|
|
350
400
|
- For monorepos, focus on the target package if specified
|
|
@@ -386,11 +436,22 @@ function transformToBuildPlan(
|
|
|
386
436
|
context: GitHubProjectContext,
|
|
387
437
|
options: GenerateBuildPlanOptions,
|
|
388
438
|
): BuildPlan {
|
|
439
|
+
// Derive package directory from entry points (e.g., "packages/transactions/src/index.ts" -> "packages/transactions")
|
|
440
|
+
let rootPath: string | undefined;
|
|
441
|
+
if (options.targetPackage && output.entryPoints.length > 0) {
|
|
442
|
+
const firstEntry = output.entryPoints[0];
|
|
443
|
+
// Match patterns like "packages/foo/src/..." or "packages/foo/dist/..."
|
|
444
|
+
const match = firstEntry.match(/^(packages\/[^/]+)\//);
|
|
445
|
+
if (match) {
|
|
446
|
+
rootPath = match[1];
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
389
450
|
const target: BuildPlanTarget = {
|
|
390
451
|
type: 'github',
|
|
391
452
|
repoUrl: `https://github.com/${context.metadata.owner}/${context.metadata.repo}`,
|
|
392
453
|
ref: context.ref,
|
|
393
|
-
rootPath
|
|
454
|
+
rootPath,
|
|
394
455
|
entryPoints: output.entryPoints,
|
|
395
456
|
};
|
|
396
457
|
|
|
@@ -745,13 +806,22 @@ async function handleExecute(req: VercelRequest, res: VercelResponse): Promise<v
|
|
|
745
806
|
const specFilePath = plan.target.rootPath
|
|
746
807
|
? `${plan.target.rootPath}/openpkg.json`
|
|
747
808
|
: 'openpkg.json';
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
809
|
+
|
|
810
|
+
let spec: OpenPkg;
|
|
811
|
+
try {
|
|
812
|
+
const specStream = await sandbox.readFile({ path: specFilePath });
|
|
813
|
+
const chunks: Buffer[] = [];
|
|
814
|
+
for await (const chunk of specStream as AsyncIterable<Buffer>) {
|
|
815
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
816
|
+
}
|
|
817
|
+
const specContent = Buffer.concat(chunks).toString('utf-8');
|
|
818
|
+
spec = JSON.parse(specContent) as OpenPkg;
|
|
819
|
+
} catch (readError) {
|
|
820
|
+
console.error(`Failed to read ${specFilePath}:`, readError);
|
|
821
|
+
throw new Error(
|
|
822
|
+
`Failed to read spec file (${specFilePath}): ${readError instanceof Error ? readError.message : 'Unknown error'}`,
|
|
823
|
+
);
|
|
752
824
|
}
|
|
753
|
-
const specContent = Buffer.concat(chunks).toString('utf-8');
|
|
754
|
-
const spec = JSON.parse(specContent) as OpenPkg;
|
|
755
825
|
const summary = createSpecSummary(spec);
|
|
756
826
|
|
|
757
827
|
const result = {
|
|
@@ -931,13 +1001,22 @@ async function handleExecuteStream(req: VercelRequest, res: VercelResponse): Pro
|
|
|
931
1001
|
const specFilePath = plan.target.rootPath
|
|
932
1002
|
? `${plan.target.rootPath}/openpkg.json`
|
|
933
1003
|
: 'openpkg.json';
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1004
|
+
|
|
1005
|
+
let spec: OpenPkg;
|
|
1006
|
+
try {
|
|
1007
|
+
const specStream = await sandbox.readFile({ path: specFilePath });
|
|
1008
|
+
const chunks: Buffer[] = [];
|
|
1009
|
+
for await (const chunk of specStream as AsyncIterable<Buffer>) {
|
|
1010
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1011
|
+
}
|
|
1012
|
+
const specContent = Buffer.concat(chunks).toString('utf-8');
|
|
1013
|
+
spec = JSON.parse(specContent) as OpenPkg;
|
|
1014
|
+
} catch (readError) {
|
|
1015
|
+
console.error(`Failed to read ${specFilePath}:`, readError);
|
|
1016
|
+
throw new Error(
|
|
1017
|
+
`Failed to read spec file (${specFilePath}): ${readError instanceof Error ? readError.message : 'Unknown error'}`,
|
|
1018
|
+
);
|
|
938
1019
|
}
|
|
939
|
-
const specContent = Buffer.concat(chunks).toString('utf-8');
|
|
940
|
-
const spec = JSON.parse(specContent) as OpenPkg;
|
|
941
1020
|
const summary = createSpecSummary(spec);
|
|
942
1021
|
|
|
943
1022
|
const result = {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create coverage_snapshots table with SDK-aligned field names.
|
|
5
|
+
*/
|
|
6
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
7
|
+
await db.schema
|
|
8
|
+
.createTable('coverage_snapshots')
|
|
9
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
10
|
+
.addColumn('project_id', 'text', (col) =>
|
|
11
|
+
col.notNull().references('projects.id').onDelete('cascade'),
|
|
12
|
+
)
|
|
13
|
+
.addColumn('version', 'text')
|
|
14
|
+
.addColumn('branch', 'text')
|
|
15
|
+
.addColumn('commit_sha', 'text')
|
|
16
|
+
// SDK-aligned field names
|
|
17
|
+
.addColumn('coverage_score', 'integer', (col) => col.notNull())
|
|
18
|
+
.addColumn('documented_exports', 'integer', (col) => col.notNull())
|
|
19
|
+
.addColumn('total_exports', 'integer', (col) => col.notNull())
|
|
20
|
+
.addColumn('drift_count', 'integer', (col) => col.notNull().defaultTo(0))
|
|
21
|
+
.addColumn('source', 'text', (col) => col.notNull().defaultTo('manual'))
|
|
22
|
+
.addColumn('created_at', 'timestamptz', (col) => col.defaultTo(db.fn('now')))
|
|
23
|
+
.execute();
|
|
24
|
+
|
|
25
|
+
// Index for efficient project history queries
|
|
26
|
+
await db.schema
|
|
27
|
+
.createIndex('idx_coverage_snapshots_project_id')
|
|
28
|
+
.on('coverage_snapshots')
|
|
29
|
+
.column('project_id')
|
|
30
|
+
.execute();
|
|
31
|
+
|
|
32
|
+
await db.schema
|
|
33
|
+
.createIndex('idx_coverage_snapshots_created_at')
|
|
34
|
+
.on('coverage_snapshots')
|
|
35
|
+
.column('created_at')
|
|
36
|
+
.execute();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
40
|
+
await db.schema.dropTable('coverage_snapshots').execute();
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doccov/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "DocCov API - Badge endpoint and coverage services",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"doccov",
|
|
@@ -29,8 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@ai-sdk/anthropic": "^2.0.55",
|
|
32
|
-
"@doccov/
|
|
33
|
-
"@doccov/sdk": "^0.18.0",
|
|
32
|
+
"@doccov/sdk": "^0.19.0",
|
|
34
33
|
"@hono/node-server": "^1.14.3",
|
|
35
34
|
"@openpkg-ts/spec": "^0.10.0",
|
|
36
35
|
"@polar-sh/hono": "^0.5.3",
|
|
@@ -42,6 +41,7 @@
|
|
|
42
41
|
"ms": "^2.1.3",
|
|
43
42
|
"nanoid": "^5.0.0",
|
|
44
43
|
"pg": "^8.13.0",
|
|
44
|
+
"vercel": "^50.1.3",
|
|
45
45
|
"zod": "^3.25.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -2,20 +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 {
|
|
16
|
-
import { invitesRoute } from './routes/invites';
|
|
17
|
-
import { orgsRoute } from './routes/orgs';
|
|
18
|
-
import { planRoute } from './routes/plan';
|
|
8
|
+
import { specRoute } from './routes/spec';
|
|
19
9
|
|
|
20
10
|
const app = new Hono();
|
|
21
11
|
|
|
@@ -29,34 +19,15 @@ app.use(
|
|
|
29
19
|
}),
|
|
30
20
|
);
|
|
31
21
|
|
|
32
|
-
// Rate limit /plan endpoint: 10 requests per minute per IP
|
|
33
|
-
app.use(
|
|
34
|
-
'/plan',
|
|
35
|
-
rateLimit({
|
|
36
|
-
windowMs: 60 * 1000,
|
|
37
|
-
max: 10,
|
|
38
|
-
message: 'Too many plan requests. Please try again in a minute.',
|
|
39
|
-
}),
|
|
40
|
-
);
|
|
41
|
-
|
|
42
22
|
// Health check
|
|
43
23
|
app.get('/', (c) => {
|
|
44
24
|
return c.json({
|
|
45
25
|
name: 'DocCov API',
|
|
46
26
|
version: '0.5.0',
|
|
47
27
|
endpoints: {
|
|
48
|
-
auth: '/auth/*',
|
|
49
|
-
apiKeys: '/api-keys/*',
|
|
50
28
|
badge: '/badge/:owner/:repo',
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
github: '/github/* (App install, webhooks)',
|
|
54
|
-
invites: '/invites/:token',
|
|
55
|
-
orgs: '/orgs/*',
|
|
56
|
-
plan: '/plan',
|
|
57
|
-
v1: {
|
|
58
|
-
ai: '/v1/ai/generate (POST), /v1/ai/quota (GET)',
|
|
59
|
-
},
|
|
29
|
+
demo: '/demo/plan, /demo/execute',
|
|
30
|
+
spec: '/spec/:owner/:repo',
|
|
60
31
|
health: '/health',
|
|
61
32
|
},
|
|
62
33
|
});
|
|
@@ -66,39 +37,30 @@ app.get('/health', (c) => {
|
|
|
66
37
|
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
67
38
|
});
|
|
68
39
|
|
|
69
|
-
//
|
|
70
|
-
// Anonymous rate limit: 10 requests per day per IP
|
|
40
|
+
// Badge endpoint (public, rate-limited)
|
|
71
41
|
app.use(
|
|
72
42
|
'/badge/*',
|
|
73
43
|
anonymousRateLimit({
|
|
74
44
|
windowMs: 24 * 60 * 60 * 1000, // 24 hours
|
|
75
|
-
max:
|
|
76
|
-
message: 'Rate limit reached.
|
|
77
|
-
upgradeUrl: 'https://doccov.com/signup',
|
|
45
|
+
max: 100,
|
|
46
|
+
message: 'Rate limit reached.',
|
|
78
47
|
}),
|
|
79
48
|
);
|
|
80
49
|
app.route('/badge', badgeRoute);
|
|
81
50
|
|
|
82
|
-
// Semi-public endpoints (invite info is public, acceptance requires auth)
|
|
83
|
-
app.route('/invites', invitesRoute);
|
|
84
|
-
|
|
85
|
-
// GitHub App (install/callback need auth, webhook is public)
|
|
86
|
-
app.route('/github', githubAppRoute);
|
|
87
|
-
|
|
88
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
|
+
);
|
|
89
60
|
app.route('/demo', demoRoute);
|
|
90
61
|
|
|
91
|
-
//
|
|
92
|
-
app.route('/
|
|
93
|
-
app.route('/api-keys', apiKeysRoute);
|
|
94
|
-
app.route('/billing', billingRoute);
|
|
95
|
-
app.route('/coverage', coverageRoute);
|
|
96
|
-
app.route('/orgs', orgsRoute);
|
|
97
|
-
app.route('/plan', planRoute);
|
|
98
|
-
|
|
99
|
-
// API endpoints (API key required)
|
|
100
|
-
app.use('/v1/*', requireApiKey(), orgRateLimit());
|
|
101
|
-
app.route('/v1/ai', aiRoute);
|
|
62
|
+
// Spec endpoint (public, cached)
|
|
63
|
+
app.route('/spec', specRoute);
|
|
102
64
|
|
|
103
65
|
// Vercel serverless handler + Bun auto-serves this export
|
|
104
66
|
export default app;
|