@doccov/api 0.4.0 → 0.6.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.
- package/CHANGELOG.md +19 -0
- package/api/index.ts +105 -26
- package/migrations/005_coverage_sdk_field_names.ts +41 -0
- package/package.json +4 -4
- package/src/index.ts +36 -2
- package/src/middleware/anonymous-rate-limit.ts +131 -0
- package/src/routes/ai.ts +353 -0
- package/src/routes/badge.ts +122 -32
- package/src/routes/billing.ts +65 -0
- package/src/routes/coverage.ts +53 -48
- package/src/routes/demo.ts +606 -0
- package/src/routes/github-app.ts +368 -0
- package/src/routes/invites.ts +90 -0
- package/src/routes/orgs.ts +249 -0
- package/src/routes/spec-v1.ts +165 -0
- package/src/routes/spec.ts +186 -0
- package/src/utils/github-app.ts +196 -0
- package/src/utils/github-checks.ts +498 -0
- package/src/utils/remote-analyzer.ts +251 -0
- package/src/utils/spec-cache.ts +131 -0
- package/src/utils/spec-diff-core.ts +406 -0
- package/src/utils/github.ts +0 -5
package/src/routes/ai.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI generation endpoint
|
|
3
|
+
*
|
|
4
|
+
* POST /v1/ai/generate - Generate JSDoc for undocumented exports
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
8
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
9
|
+
import { getPlanLimits, type Plan } from '@doccov/db';
|
|
10
|
+
import { generateObject } from 'ai';
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { db } from '../db/client';
|
|
14
|
+
import type { ApiKeyContext } from '../middleware/api-key-auth';
|
|
15
|
+
|
|
16
|
+
type AiVariables = {
|
|
17
|
+
Variables: ApiKeyContext;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const aiRoute = new Hono<AiVariables>();
|
|
21
|
+
|
|
22
|
+
// Schema for request body
|
|
23
|
+
const GenerateRequestSchema = z.object({
|
|
24
|
+
exports: z.array(
|
|
25
|
+
z.object({
|
|
26
|
+
name: z.string(),
|
|
27
|
+
kind: z.string(),
|
|
28
|
+
signature: z.string().optional(),
|
|
29
|
+
members: z
|
|
30
|
+
.array(
|
|
31
|
+
z.object({
|
|
32
|
+
name: z.string(),
|
|
33
|
+
type: z.string().optional(),
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
.optional(),
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
packageName: z.string().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Schema for AI-generated JSDoc
|
|
43
|
+
const JSDocGenerationSchema = z.object({
|
|
44
|
+
description: z.string().describe('1-2 sentence description of what this does'),
|
|
45
|
+
params: z
|
|
46
|
+
.array(
|
|
47
|
+
z.object({
|
|
48
|
+
name: z.string(),
|
|
49
|
+
type: z.string().optional(),
|
|
50
|
+
description: z.string(),
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
.optional()
|
|
54
|
+
.describe('Parameter descriptions for functions'),
|
|
55
|
+
returns: z
|
|
56
|
+
.object({
|
|
57
|
+
type: z.string().optional(),
|
|
58
|
+
description: z.string(),
|
|
59
|
+
})
|
|
60
|
+
.optional()
|
|
61
|
+
.describe('Return value description for functions'),
|
|
62
|
+
example: z.string().optional().describe('Working code example showing typical usage'),
|
|
63
|
+
typeParams: z
|
|
64
|
+
.array(
|
|
65
|
+
z.object({
|
|
66
|
+
name: z.string(),
|
|
67
|
+
description: z.string(),
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Type parameter descriptions for generics'),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
type JSDocGenerationResult = z.infer<typeof JSDocGenerationSchema>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get AI model - uses server-side API keys
|
|
78
|
+
*/
|
|
79
|
+
function getModel() {
|
|
80
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
81
|
+
const anthropic = createAnthropic();
|
|
82
|
+
return anthropic('claude-sonnet-4-20250514');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (process.env.OPENAI_API_KEY) {
|
|
86
|
+
const openai = createOpenAI();
|
|
87
|
+
return openai('gpt-4o-mini');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
throw new Error('No AI provider configured');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check and update AI quota for an organization
|
|
95
|
+
* Returns remaining quota or error
|
|
96
|
+
*/
|
|
97
|
+
async function checkAndUpdateQuota(
|
|
98
|
+
orgId: string,
|
|
99
|
+
plan: string,
|
|
100
|
+
currentUsed: number,
|
|
101
|
+
resetAt: Date | null,
|
|
102
|
+
callCount: number,
|
|
103
|
+
): Promise<{ allowed: boolean; remaining: number; resetAt: Date; error?: string }> {
|
|
104
|
+
const limits = getPlanLimits(plan as Plan);
|
|
105
|
+
const monthlyLimit = limits.aiCallsPerMonth;
|
|
106
|
+
|
|
107
|
+
// Check if unlimited
|
|
108
|
+
if (monthlyLimit === Infinity) {
|
|
109
|
+
return { allowed: true, remaining: Infinity, resetAt: new Date() };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if we need to reset the counter (monthly)
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const shouldReset = !resetAt || now >= resetAt;
|
|
115
|
+
|
|
116
|
+
let used = currentUsed;
|
|
117
|
+
let nextReset = resetAt || getNextMonthReset();
|
|
118
|
+
|
|
119
|
+
if (shouldReset) {
|
|
120
|
+
// Reset the counter
|
|
121
|
+
used = 0;
|
|
122
|
+
nextReset = getNextMonthReset();
|
|
123
|
+
|
|
124
|
+
await db
|
|
125
|
+
.updateTable('organizations')
|
|
126
|
+
.set({
|
|
127
|
+
aiCallsUsed: 0,
|
|
128
|
+
aiCallsResetAt: nextReset,
|
|
129
|
+
})
|
|
130
|
+
.where('id', '=', orgId)
|
|
131
|
+
.execute();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check if over limit
|
|
135
|
+
if (used + callCount > monthlyLimit) {
|
|
136
|
+
return {
|
|
137
|
+
allowed: false,
|
|
138
|
+
remaining: Math.max(0, monthlyLimit - used),
|
|
139
|
+
resetAt: nextReset,
|
|
140
|
+
error: `Monthly AI limit reached (${used}/${monthlyLimit} calls used). Set OPENAI_API_KEY or ANTHROPIC_API_KEY for unlimited.`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Increment usage
|
|
145
|
+
await db
|
|
146
|
+
.updateTable('organizations')
|
|
147
|
+
.set({
|
|
148
|
+
aiCallsUsed: used + callCount,
|
|
149
|
+
})
|
|
150
|
+
.where('id', '=', orgId)
|
|
151
|
+
.execute();
|
|
152
|
+
|
|
153
|
+
// Track in usage_records (async)
|
|
154
|
+
db.insertInto('usage_records')
|
|
155
|
+
.values({
|
|
156
|
+
id: crypto.randomUUID(),
|
|
157
|
+
orgId,
|
|
158
|
+
feature: 'ai_generate',
|
|
159
|
+
count: callCount,
|
|
160
|
+
})
|
|
161
|
+
.execute()
|
|
162
|
+
.catch(console.error);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
allowed: true,
|
|
166
|
+
remaining: monthlyLimit - (used + callCount),
|
|
167
|
+
resetAt: nextReset,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the first day of next month
|
|
173
|
+
*/
|
|
174
|
+
function getNextMonthReset(): Date {
|
|
175
|
+
const now = new Date();
|
|
176
|
+
return new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Generate JSDoc for a single export
|
|
181
|
+
*/
|
|
182
|
+
async function generateJSDocForExport(
|
|
183
|
+
exp: {
|
|
184
|
+
name: string;
|
|
185
|
+
kind: string;
|
|
186
|
+
signature?: string;
|
|
187
|
+
members?: { name: string; type?: string }[];
|
|
188
|
+
},
|
|
189
|
+
packageName?: string,
|
|
190
|
+
): Promise<JSDocGenerationResult> {
|
|
191
|
+
const membersContext =
|
|
192
|
+
exp.members && exp.members.length > 0
|
|
193
|
+
? `\n\nMembers:\n${exp.members
|
|
194
|
+
.slice(0, 10)
|
|
195
|
+
.map((m) => ` - ${m.name}${m.type ? `: ${m.type}` : ''}`)
|
|
196
|
+
.join('\n')}`
|
|
197
|
+
: '';
|
|
198
|
+
|
|
199
|
+
const prompt = `Generate JSDoc documentation for this TypeScript export.
|
|
200
|
+
|
|
201
|
+
Name: ${exp.name}
|
|
202
|
+
Kind: ${exp.kind}
|
|
203
|
+
${exp.signature ? `Signature: ${exp.signature}` : ''}${membersContext}
|
|
204
|
+
${packageName ? `Package: ${packageName}` : ''}
|
|
205
|
+
|
|
206
|
+
Requirements:
|
|
207
|
+
- Description: 1-2 sentences explaining what this does and when to use it
|
|
208
|
+
- For functions: describe each parameter and return value
|
|
209
|
+
- Example: provide a working code snippet showing typical usage
|
|
210
|
+
- Be concise but informative`;
|
|
211
|
+
|
|
212
|
+
// biome-ignore lint/suspicious/noExplicitAny: AI SDK type mismatch between LanguageModelV1/V2
|
|
213
|
+
const { object } = await generateObject({
|
|
214
|
+
model: getModel() as any,
|
|
215
|
+
schema: JSDocGenerationSchema,
|
|
216
|
+
prompt,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return object;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// POST /v1/ai/generate
|
|
223
|
+
aiRoute.post('/generate', async (c) => {
|
|
224
|
+
const org = c.get('org') as {
|
|
225
|
+
id: string;
|
|
226
|
+
plan: string;
|
|
227
|
+
aiCallsUsed: number;
|
|
228
|
+
aiCallsResetAt: Date | null;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (!org) {
|
|
232
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Parse request body
|
|
236
|
+
let body: z.infer<typeof GenerateRequestSchema>;
|
|
237
|
+
try {
|
|
238
|
+
const raw = await c.req.json();
|
|
239
|
+
body = GenerateRequestSchema.parse(raw);
|
|
240
|
+
} catch {
|
|
241
|
+
return c.json({ error: 'Invalid request body' }, 400);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (body.exports.length === 0) {
|
|
245
|
+
return c.json({ error: 'No exports provided' }, 400);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Limit batch size
|
|
249
|
+
if (body.exports.length > 20) {
|
|
250
|
+
return c.json({ error: 'Maximum 20 exports per request' }, 400);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Check quota before processing
|
|
254
|
+
const quotaCheck = await checkAndUpdateQuota(
|
|
255
|
+
org.id,
|
|
256
|
+
org.plan,
|
|
257
|
+
org.aiCallsUsed,
|
|
258
|
+
org.aiCallsResetAt,
|
|
259
|
+
body.exports.length,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (!quotaCheck.allowed) {
|
|
263
|
+
return c.json(
|
|
264
|
+
{
|
|
265
|
+
error: quotaCheck.error,
|
|
266
|
+
remaining: quotaCheck.remaining,
|
|
267
|
+
resetAt: quotaCheck.resetAt.toISOString(),
|
|
268
|
+
byok: 'Set OPENAI_API_KEY or ANTHROPIC_API_KEY for unlimited generation',
|
|
269
|
+
},
|
|
270
|
+
429,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Generate JSDoc for each export
|
|
275
|
+
const results: Array<{
|
|
276
|
+
name: string;
|
|
277
|
+
patch: JSDocGenerationResult | null;
|
|
278
|
+
error?: string;
|
|
279
|
+
}> = [];
|
|
280
|
+
|
|
281
|
+
// Process in batches of 3 to avoid rate limits
|
|
282
|
+
for (let i = 0; i < body.exports.length; i += 3) {
|
|
283
|
+
const batch = body.exports.slice(i, i + 3);
|
|
284
|
+
const promises = batch.map(async (exp) => {
|
|
285
|
+
try {
|
|
286
|
+
const patch = await generateJSDocForExport(exp, body.packageName);
|
|
287
|
+
return { name: exp.name, patch };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return {
|
|
290
|
+
name: exp.name,
|
|
291
|
+
patch: null,
|
|
292
|
+
error: err instanceof Error ? err.message : 'Generation failed',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const batchResults = await Promise.all(promises);
|
|
298
|
+
results.push(...batchResults);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const successful = results.filter((r) => r.patch !== null).length;
|
|
302
|
+
const failed = results.filter((r) => r.patch === null).length;
|
|
303
|
+
|
|
304
|
+
return c.json({
|
|
305
|
+
success: true,
|
|
306
|
+
generated: successful,
|
|
307
|
+
failed,
|
|
308
|
+
results,
|
|
309
|
+
quota: {
|
|
310
|
+
remaining: quotaCheck.remaining,
|
|
311
|
+
resetAt: quotaCheck.resetAt.toISOString(),
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// GET /v1/ai/quota - Check remaining AI quota
|
|
317
|
+
aiRoute.get('/quota', async (c) => {
|
|
318
|
+
const org = c.get('org') as {
|
|
319
|
+
id: string;
|
|
320
|
+
plan: string;
|
|
321
|
+
aiCallsUsed: number;
|
|
322
|
+
aiCallsResetAt: Date | null;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
if (!org) {
|
|
326
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const limits = getPlanLimits(org.plan as Plan);
|
|
330
|
+
const monthlyLimit = limits.aiCallsPerMonth;
|
|
331
|
+
|
|
332
|
+
// Check if reset is needed
|
|
333
|
+
const now = new Date();
|
|
334
|
+
const shouldReset = !org.aiCallsResetAt || now >= org.aiCallsResetAt;
|
|
335
|
+
|
|
336
|
+
let used = org.aiCallsUsed;
|
|
337
|
+
let resetAt = org.aiCallsResetAt || getNextMonthReset();
|
|
338
|
+
|
|
339
|
+
if (shouldReset) {
|
|
340
|
+
used = 0;
|
|
341
|
+
resetAt = getNextMonthReset();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return c.json({
|
|
345
|
+
plan: org.plan,
|
|
346
|
+
used,
|
|
347
|
+
limit: monthlyLimit === Infinity ? 'unlimited' : monthlyLimit,
|
|
348
|
+
remaining: monthlyLimit === Infinity ? 'unlimited' : Math.max(0, monthlyLimit - used),
|
|
349
|
+
resetAt: resetAt.toISOString(),
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
export { aiRoute };
|
package/src/routes/badge.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { fetchSpec } from '@doccov/sdk';
|
|
2
|
+
import { validateSpec } from '@openpkg-ts/spec';
|
|
1
3
|
import { Hono } from 'hono';
|
|
2
|
-
import { fetchSpecFromGitHub } from '../utils/github';
|
|
3
4
|
|
|
4
5
|
export const badgeRoute = new Hono();
|
|
5
6
|
|
|
7
|
+
type BadgeStyle = 'flat' | 'flat-square' | 'for-the-badge';
|
|
8
|
+
|
|
6
9
|
type BadgeColor =
|
|
7
10
|
| 'brightgreen'
|
|
8
11
|
| 'green'
|
|
@@ -16,6 +19,7 @@ interface BadgeOptions {
|
|
|
16
19
|
label: string;
|
|
17
20
|
message: string;
|
|
18
21
|
color: BadgeColor;
|
|
22
|
+
style?: BadgeStyle;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
function getColorForScore(score: number): BadgeColor {
|
|
@@ -27,22 +31,17 @@ function getColorForScore(score: number): BadgeColor {
|
|
|
27
31
|
return 'red';
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const bgColor = colors[color];
|
|
44
|
-
|
|
45
|
-
// Simple badge dimensions
|
|
34
|
+
const BADGE_COLORS: Record<BadgeColor, string> = {
|
|
35
|
+
brightgreen: '#4c1',
|
|
36
|
+
green: '#97ca00',
|
|
37
|
+
yellowgreen: '#a4a61d',
|
|
38
|
+
yellow: '#dfb317',
|
|
39
|
+
orange: '#fe7d37',
|
|
40
|
+
red: '#e05d44',
|
|
41
|
+
lightgrey: '#9f9f9f',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function generateFlatBadge(label: string, message: string, bgColor: string): string {
|
|
46
45
|
const labelWidth = label.length * 7 + 10;
|
|
47
46
|
const messageWidth = message.length * 7 + 10;
|
|
48
47
|
const totalWidth = labelWidth + messageWidth;
|
|
@@ -70,49 +69,139 @@ function generateBadgeSvg(options: BadgeOptions): string {
|
|
|
70
69
|
</svg>`;
|
|
71
70
|
}
|
|
72
71
|
|
|
72
|
+
function generateFlatSquareBadge(label: string, message: string, bgColor: string): string {
|
|
73
|
+
const labelWidth = label.length * 7 + 10;
|
|
74
|
+
const messageWidth = message.length * 7 + 10;
|
|
75
|
+
const totalWidth = labelWidth + messageWidth;
|
|
76
|
+
|
|
77
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${message}">
|
|
78
|
+
<title>${label}: ${message}</title>
|
|
79
|
+
<g shape-rendering="crispEdges">
|
|
80
|
+
<rect width="${labelWidth}" height="20" fill="#555"/>
|
|
81
|
+
<rect x="${labelWidth}" width="${messageWidth}" height="20" fill="${bgColor}"/>
|
|
82
|
+
</g>
|
|
83
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
|
84
|
+
<text x="${labelWidth / 2}" y="14">${label}</text>
|
|
85
|
+
<text x="${labelWidth + messageWidth / 2}" y="14">${message}</text>
|
|
86
|
+
</g>
|
|
87
|
+
</svg>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function generateForTheBadge(label: string, message: string, bgColor: string): string {
|
|
91
|
+
const labelUpper = label.toUpperCase();
|
|
92
|
+
const messageUpper = message.toUpperCase();
|
|
93
|
+
const labelWidth = labelUpper.length * 10 + 20;
|
|
94
|
+
const messageWidth = messageUpper.length * 10 + 20;
|
|
95
|
+
const totalWidth = labelWidth + messageWidth;
|
|
96
|
+
|
|
97
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="28" role="img" aria-label="${label}: ${message}">
|
|
98
|
+
<title>${label}: ${message}</title>
|
|
99
|
+
<g shape-rendering="crispEdges">
|
|
100
|
+
<rect width="${labelWidth}" height="28" fill="#555"/>
|
|
101
|
+
<rect x="${labelWidth}" width="${messageWidth}" height="28" fill="${bgColor}"/>
|
|
102
|
+
</g>
|
|
103
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="10" font-weight="bold">
|
|
104
|
+
<text x="${labelWidth / 2}" y="18">${labelUpper}</text>
|
|
105
|
+
<text x="${labelWidth + messageWidth / 2}" y="18">${messageUpper}</text>
|
|
106
|
+
</g>
|
|
107
|
+
</svg>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function generateBadgeSvg(options: BadgeOptions): string {
|
|
111
|
+
const { label, message, color, style = 'flat' } = options;
|
|
112
|
+
const bgColor = BADGE_COLORS[color];
|
|
113
|
+
|
|
114
|
+
switch (style) {
|
|
115
|
+
case 'flat-square':
|
|
116
|
+
return generateFlatSquareBadge(label, message, bgColor);
|
|
117
|
+
case 'for-the-badge':
|
|
118
|
+
return generateForTheBadge(label, message, bgColor);
|
|
119
|
+
default:
|
|
120
|
+
return generateFlatBadge(label, message, bgColor);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compute coverage score from spec exports if not already present.
|
|
126
|
+
*/
|
|
127
|
+
function computeCoverageScore(spec: { exports?: { description?: string }[] }): number {
|
|
128
|
+
const exports = spec.exports ?? [];
|
|
129
|
+
if (exports.length === 0) return 0;
|
|
130
|
+
|
|
131
|
+
const documented = exports.filter((e) => e.description && e.description.trim().length > 0);
|
|
132
|
+
return Math.round((documented.length / exports.length) * 100);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Cache headers: 5min max-age, stale-if-error for resilience
|
|
136
|
+
const CACHE_HEADERS_SUCCESS = {
|
|
137
|
+
'Content-Type': 'image/svg+xml',
|
|
138
|
+
'Cache-Control': 'public, max-age=300, stale-if-error=3600',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const CACHE_HEADERS_ERROR = {
|
|
142
|
+
'Content-Type': 'image/svg+xml',
|
|
143
|
+
'Cache-Control': 'no-cache',
|
|
144
|
+
};
|
|
145
|
+
|
|
73
146
|
// GET /badge/:owner/:repo
|
|
74
147
|
badgeRoute.get('/:owner/:repo', async (c) => {
|
|
75
148
|
const { owner, repo } = c.req.param();
|
|
76
|
-
|
|
149
|
+
|
|
150
|
+
// Query params for customization
|
|
151
|
+
const ref = c.req.query('ref') ?? c.req.query('branch') ?? 'main';
|
|
152
|
+
const specPath = c.req.query('path') ?? c.req.query('package') ?? 'openpkg.json';
|
|
153
|
+
const style = (c.req.query('style') ?? 'flat') as BadgeStyle;
|
|
77
154
|
|
|
78
155
|
try {
|
|
79
|
-
const spec = await
|
|
156
|
+
const spec = await fetchSpec(owner, repo, { ref, path: specPath });
|
|
80
157
|
|
|
81
158
|
if (!spec) {
|
|
82
159
|
const svg = generateBadgeSvg({
|
|
83
160
|
label: 'docs',
|
|
84
161
|
message: 'not found',
|
|
85
162
|
color: 'lightgrey',
|
|
163
|
+
style,
|
|
86
164
|
});
|
|
87
165
|
|
|
88
|
-
return c.body(svg, 404,
|
|
89
|
-
|
|
90
|
-
|
|
166
|
+
return c.body(svg, 404, CACHE_HEADERS_ERROR);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Validate spec against schema
|
|
170
|
+
const validation = validateSpec(spec);
|
|
171
|
+
if (!validation.ok) {
|
|
172
|
+
const svg = generateBadgeSvg({
|
|
173
|
+
label: 'docs',
|
|
174
|
+
message: 'invalid',
|
|
175
|
+
color: 'lightgrey',
|
|
176
|
+
style,
|
|
91
177
|
});
|
|
178
|
+
|
|
179
|
+
return c.body(svg, 422, CACHE_HEADERS_ERROR);
|
|
92
180
|
}
|
|
93
181
|
|
|
94
|
-
|
|
182
|
+
// Use docs.coverageScore if present (enriched spec), otherwise compute from exports
|
|
183
|
+
// Note: The spec type has changed - check for generation.analysis or similar patterns
|
|
184
|
+
const coverageScore =
|
|
185
|
+
(spec as { docs?: { coverageScore?: number } }).docs?.coverageScore ??
|
|
186
|
+
computeCoverageScore(spec);
|
|
187
|
+
|
|
95
188
|
const svg = generateBadgeSvg({
|
|
96
189
|
label: 'docs',
|
|
97
190
|
message: `${coverageScore}%`,
|
|
98
191
|
color: getColorForScore(coverageScore),
|
|
192
|
+
style,
|
|
99
193
|
});
|
|
100
194
|
|
|
101
|
-
return c.body(svg, 200,
|
|
102
|
-
'Content-Type': 'image/svg+xml',
|
|
103
|
-
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
|
104
|
-
});
|
|
195
|
+
return c.body(svg, 200, CACHE_HEADERS_SUCCESS);
|
|
105
196
|
} catch {
|
|
106
197
|
const svg = generateBadgeSvg({
|
|
107
198
|
label: 'docs',
|
|
108
199
|
message: 'error',
|
|
109
200
|
color: 'red',
|
|
201
|
+
style,
|
|
110
202
|
});
|
|
111
203
|
|
|
112
|
-
return c.body(svg, 500,
|
|
113
|
-
'Content-Type': 'image/svg+xml',
|
|
114
|
-
'Cache-Control': 'no-cache',
|
|
115
|
-
});
|
|
204
|
+
return c.body(svg, 500, CACHE_HEADERS_ERROR);
|
|
116
205
|
}
|
|
117
206
|
});
|
|
118
207
|
|
|
@@ -120,5 +209,6 @@ badgeRoute.get('/:owner/:repo', async (c) => {
|
|
|
120
209
|
badgeRoute.get('/:owner/:repo.svg', async (c) => {
|
|
121
210
|
const { owner, repo } = c.req.param();
|
|
122
211
|
const repoName = repo.replace(/\.svg$/, '');
|
|
123
|
-
|
|
212
|
+
const query = new URL(c.req.url).search;
|
|
213
|
+
return c.redirect(`/badge/${owner}/${repoName}${query}`);
|
|
124
214
|
});
|
package/src/routes/billing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getPlanLimits, type Plan } from '@doccov/db';
|
|
1
2
|
import { CustomerPortal, Webhooks } from '@polar-sh/hono';
|
|
2
3
|
import { Hono } from 'hono';
|
|
3
4
|
import { auth } from '../auth/config';
|
|
@@ -200,3 +201,67 @@ billingRoute.get('/status', async (c) => {
|
|
|
200
201
|
portalUrl: org.polarCustomerId ? `${API_URL}/billing/portal?orgId=${orgId}` : null,
|
|
201
202
|
});
|
|
202
203
|
});
|
|
204
|
+
|
|
205
|
+
// ============ Usage Details ============
|
|
206
|
+
billingRoute.get('/usage', async (c) => {
|
|
207
|
+
const orgId = c.req.query('orgId');
|
|
208
|
+
if (!orgId) return c.json({ error: 'orgId required' }, 400);
|
|
209
|
+
|
|
210
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
211
|
+
if (!session) return c.json({ error: 'Unauthorized' }, 401);
|
|
212
|
+
|
|
213
|
+
// Get org with member count
|
|
214
|
+
const org = await db
|
|
215
|
+
.selectFrom('organizations')
|
|
216
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
217
|
+
.where('organizations.id', '=', orgId)
|
|
218
|
+
.where('org_members.userId', '=', session.user.id)
|
|
219
|
+
.select([
|
|
220
|
+
'organizations.id',
|
|
221
|
+
'organizations.plan',
|
|
222
|
+
'organizations.aiCallsUsed',
|
|
223
|
+
'organizations.aiCallsResetAt',
|
|
224
|
+
])
|
|
225
|
+
.executeTakeFirst();
|
|
226
|
+
|
|
227
|
+
if (!org) return c.json({ error: 'Organization not found' }, 404);
|
|
228
|
+
|
|
229
|
+
// Get member count
|
|
230
|
+
const memberResult = await db
|
|
231
|
+
.selectFrom('org_members')
|
|
232
|
+
.where('orgId', '=', orgId)
|
|
233
|
+
.select(db.fn.countAll<number>().as('count'))
|
|
234
|
+
.executeTakeFirst();
|
|
235
|
+
|
|
236
|
+
const seats = memberResult?.count ?? 1;
|
|
237
|
+
const limits = getPlanLimits(org.plan as Plan);
|
|
238
|
+
|
|
239
|
+
// Calculate next reset date
|
|
240
|
+
const now = new Date();
|
|
241
|
+
const resetAt = org.aiCallsResetAt || new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
242
|
+
const shouldReset = !org.aiCallsResetAt || now >= org.aiCallsResetAt;
|
|
243
|
+
const aiUsed = shouldReset ? 0 : org.aiCallsUsed;
|
|
244
|
+
|
|
245
|
+
// Calculate pricing based on plan
|
|
246
|
+
const pricing: Record<string, number> = { free: 0, team: 15, pro: 49 };
|
|
247
|
+
const monthlyCost = (pricing[org.plan] ?? 0) * seats;
|
|
248
|
+
|
|
249
|
+
return c.json({
|
|
250
|
+
plan: org.plan,
|
|
251
|
+
seats,
|
|
252
|
+
monthlyCost,
|
|
253
|
+
aiCalls: {
|
|
254
|
+
used: aiUsed,
|
|
255
|
+
limit: limits.aiCallsPerMonth === Infinity ? 'unlimited' : limits.aiCallsPerMonth,
|
|
256
|
+
resetAt: resetAt.toISOString(),
|
|
257
|
+
},
|
|
258
|
+
analyses: {
|
|
259
|
+
limit: limits.analysesPerDay === Infinity ? 'unlimited' : limits.analysesPerDay,
|
|
260
|
+
resetAt: 'daily',
|
|
261
|
+
},
|
|
262
|
+
history: {
|
|
263
|
+
days: limits.historyDays,
|
|
264
|
+
},
|
|
265
|
+
privateRepos: limits.privateRepos === Infinity,
|
|
266
|
+
});
|
|
267
|
+
});
|