@doccov/api 0.6.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
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.6.1",
|
|
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.
|
|
32
|
+
"@doccov/sdk": "^0.19.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
|
-
|
|
54
|
-
|
|
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
|
-
//
|
|
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:
|
|
80
|
-
message: 'Rate limit reached.
|
|
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
|
-
//
|
|
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;
|
package/src/routes/badge.ts
CHANGED
|
@@ -205,10 +205,114 @@ badgeRoute.get('/:owner/:repo', async (c) => {
|
|
|
205
205
|
}
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
-
// GET /badge/:owner/:repo.
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
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
|
+
}
|