@doccov/api 0.2.0 → 0.2.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 +8 -0
- package/README.md +17 -128
- package/api/examples/run.ts +252 -0
- package/api/index.ts +218 -0
- package/api/scan/detect.ts +217 -0
- package/api/scan-stream.ts +362 -0
- package/api/scan.ts +64 -0
- package/package.json +6 -4
- package/src/index.ts +5 -8
- package/src/routes/badge.ts +2 -26
- package/src/routes/leaderboard.ts +1 -1
- package/src/routes/widget.ts +178 -0
- package/src/utils/github.ts +25 -0
- package/tsconfig.json +5 -9
- package/vercel.json +9 -5
package/CHANGELOG.md
ADDED
package/README.md
CHANGED
|
@@ -1,153 +1,42 @@
|
|
|
1
1
|
# @doccov/api
|
|
2
2
|
|
|
3
|
-
DocCov API server for
|
|
3
|
+
DocCov API server for badges, widgets, and scanning.
|
|
4
4
|
|
|
5
5
|
## Endpoints
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
| Endpoint | Description |
|
|
8
|
+
|----------|-------------|
|
|
9
|
+
| `GET /badge/:owner/:repo` | Coverage badge SVG |
|
|
10
|
+
| `GET /widget/:owner/:repo` | Coverage widget SVG |
|
|
11
|
+
| `GET /leaderboard` | Public rankings |
|
|
12
|
+
| `GET /scan-stream` | SSE repo scanning |
|
|
13
|
+
| `POST /api/examples/run` | Execute code |
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
GET /badge/:owner/:repo
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Returns an SVG badge showing the documentation coverage percentage.
|
|
14
|
-
|
|
15
|
-
**Query Parameters:**
|
|
16
|
-
- `branch` - Git branch to fetch from (default: `main`)
|
|
17
|
-
|
|
18
|
-
**Example:**
|
|
19
|
-
```
|
|
20
|
-
https://api.doccov.com/badge/tanstack/query
|
|
21
|
-
```
|
|
15
|
+
## Badge
|
|
22
16
|
|
|
23
|
-
**Embed in README:**
|
|
24
17
|
```markdown
|
|
25
|
-

|
|
32
19
|
```
|
|
33
20
|
|
|
34
|
-
Scans a public GitHub repository for documentation coverage.
|
|
35
|
-
|
|
36
|
-
**Request Body:**
|
|
37
|
-
```json
|
|
38
|
-
{
|
|
39
|
-
"url": "https://github.com/owner/repo",
|
|
40
|
-
"ref": "main",
|
|
41
|
-
"package": "@scope/package-name"
|
|
42
|
-
}
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
**Response:**
|
|
46
|
-
```json
|
|
47
|
-
{
|
|
48
|
-
"jobId": "scan-123456-abc",
|
|
49
|
-
"status": "pending",
|
|
50
|
-
"pollUrl": "/scan/scan-123456-abc"
|
|
51
|
-
}
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Poll the `pollUrl` to get results when the scan completes.
|
|
55
|
-
|
|
56
|
-
### Health Check
|
|
57
|
-
|
|
58
|
-
```
|
|
59
|
-
GET /health
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
Returns API health status.
|
|
63
|
-
|
|
64
21
|
## Development
|
|
65
22
|
|
|
66
23
|
```bash
|
|
67
|
-
|
|
68
|
-
bun install
|
|
69
|
-
|
|
70
|
-
# Run in development mode (uses local CLI spawn)
|
|
24
|
+
cd packages/api
|
|
71
25
|
bun run dev
|
|
72
|
-
|
|
73
|
-
# Start production server
|
|
74
|
-
bun run start
|
|
75
26
|
```
|
|
76
27
|
|
|
77
|
-
### Local Development
|
|
78
|
-
|
|
79
|
-
In local development, the scan endpoint spawns the `doccov` CLI directly. No additional setup required.
|
|
80
|
-
|
|
81
|
-
### Production (Vercel Sandbox)
|
|
82
|
-
|
|
83
|
-
In production on Vercel, scans run in isolated [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) VMs for security and consistency.
|
|
84
|
-
|
|
85
|
-
**Setup:**
|
|
86
|
-
|
|
87
|
-
1. Link to your Vercel project:
|
|
88
|
-
```bash
|
|
89
|
-
cd packages/api
|
|
90
|
-
vercel link
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
2. Pull environment variables (includes OIDC token):
|
|
94
|
-
```bash
|
|
95
|
-
vercel env pull
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
The SDK automatically uses the `VERCEL_OIDC_TOKEN` when available. When deployed to Vercel, tokens are provided automatically.
|
|
99
|
-
|
|
100
|
-
## Environment Variables
|
|
101
|
-
|
|
102
|
-
- `PORT` - Server port (default: 3000)
|
|
103
|
-
- `VERCEL_OIDC_TOKEN` - Vercel OIDC token for sandbox authentication (auto-provided on Vercel)
|
|
104
|
-
|
|
105
28
|
## Deployment
|
|
106
29
|
|
|
107
|
-
### Deploy to Vercel
|
|
108
|
-
|
|
109
|
-
1. **Link the project** (first time only):
|
|
110
|
-
```bash
|
|
111
|
-
cd packages/api
|
|
112
|
-
vercel link
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
2. **Deploy to preview**:
|
|
116
|
-
```bash
|
|
117
|
-
vercel deploy
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
3. **Deploy to production**:
|
|
121
|
-
```bash
|
|
122
|
-
vercel deploy --prod
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
### Vercel Project Settings
|
|
126
|
-
|
|
127
|
-
When linking, use these settings:
|
|
128
|
-
- **Framework Preset**: Other
|
|
129
|
-
- **Build Command**: (leave empty, no build needed)
|
|
130
|
-
- **Output Directory**: (leave empty)
|
|
131
|
-
- **Install Command**: `bun install`
|
|
132
|
-
|
|
133
|
-
### Vercel Sandbox
|
|
134
|
-
|
|
135
|
-
The `/scan` endpoint uses [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) for isolated execution:
|
|
136
|
-
- Automatic OIDC authentication when deployed to Vercel
|
|
137
|
-
- Each scan runs in a fresh `node22` VM
|
|
138
|
-
- 4 vCPUs, 5-minute timeout per scan
|
|
139
|
-
- ~$0.01-0.05 per scan (see [pricing](https://vercel.com/docs/vercel-sandbox/pricing))
|
|
140
|
-
|
|
141
|
-
### Requirements for Sandbox
|
|
142
|
-
|
|
143
|
-
Before sandbox scans work, `@doccov/cli` must be published to npm:
|
|
144
30
|
```bash
|
|
145
|
-
|
|
31
|
+
vercel --prod
|
|
146
32
|
```
|
|
147
33
|
|
|
148
|
-
|
|
34
|
+
## Documentation
|
|
35
|
+
|
|
36
|
+
- [API Overview](../../docs/api/overview.md)
|
|
37
|
+
- [Endpoints](../../docs/api/endpoints/)
|
|
38
|
+
- [Self-Hosting](../../docs/api/self-hosting.md)
|
|
149
39
|
|
|
150
40
|
## License
|
|
151
41
|
|
|
152
42
|
MIT
|
|
153
|
-
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
6
|
+
import { Sandbox } from '@vercel/sandbox';
|
|
7
|
+
|
|
8
|
+
export const config = {
|
|
9
|
+
runtime: 'nodejs',
|
|
10
|
+
maxDuration: 30,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
interface RunExampleRequest {
|
|
14
|
+
packageName: string;
|
|
15
|
+
packageVersion?: string;
|
|
16
|
+
code: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RunExampleResponse {
|
|
20
|
+
success: boolean;
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
exitCode: number;
|
|
24
|
+
duration: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if running on Vercel (use sandbox) vs local dev (use spawn)
|
|
29
|
+
*/
|
|
30
|
+
function isVercelEnvironment(): boolean {
|
|
31
|
+
return process.env.VERCEL === '1';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run example code locally via Node spawn (development fallback)
|
|
36
|
+
*/
|
|
37
|
+
async function runExampleLocal(
|
|
38
|
+
code: string,
|
|
39
|
+
packageName: string,
|
|
40
|
+
packageVersion?: string,
|
|
41
|
+
): Promise<RunExampleResponse> {
|
|
42
|
+
const tmpDir = os.tmpdir();
|
|
43
|
+
const workDir = path.join(
|
|
44
|
+
tmpDir,
|
|
45
|
+
`doccov-example-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
46
|
+
);
|
|
47
|
+
const codeFile = path.join(workDir, 'example.ts');
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
// Create package.json
|
|
53
|
+
const pkgJson = {
|
|
54
|
+
name: 'example-runner',
|
|
55
|
+
type: 'module',
|
|
56
|
+
dependencies: {
|
|
57
|
+
[packageName]: packageVersion || 'latest',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
fs.writeFileSync(path.join(workDir, 'package.json'), JSON.stringify(pkgJson, null, 2));
|
|
61
|
+
|
|
62
|
+
// Write example code
|
|
63
|
+
fs.writeFileSync(codeFile, code);
|
|
64
|
+
|
|
65
|
+
const startTime = Date.now();
|
|
66
|
+
|
|
67
|
+
// Install dependencies
|
|
68
|
+
await new Promise<void>((resolve, reject) => {
|
|
69
|
+
const proc = spawn('npm', ['install', '--silent'], { cwd: workDir, timeout: 15000 });
|
|
70
|
+
proc.on('close', (code) =>
|
|
71
|
+
code === 0 ? resolve() : reject(new Error('npm install failed')),
|
|
72
|
+
);
|
|
73
|
+
proc.on('error', reject);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Run the example
|
|
77
|
+
return await new Promise<RunExampleResponse>((resolve) => {
|
|
78
|
+
let stdout = '';
|
|
79
|
+
let stderr = '';
|
|
80
|
+
|
|
81
|
+
const proc = spawn('node', ['--experimental-strip-types', codeFile], {
|
|
82
|
+
cwd: workDir,
|
|
83
|
+
timeout: 5000,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
proc.stdout?.on('data', (data) => {
|
|
87
|
+
stdout += data.toString();
|
|
88
|
+
});
|
|
89
|
+
proc.stderr?.on('data', (data) => {
|
|
90
|
+
stderr += data.toString();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
proc.on('close', (exitCode) => {
|
|
94
|
+
resolve({
|
|
95
|
+
success: exitCode === 0,
|
|
96
|
+
stdout,
|
|
97
|
+
stderr,
|
|
98
|
+
exitCode: exitCode ?? 1,
|
|
99
|
+
duration: Date.now() - startTime,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
proc.on('error', (error) => {
|
|
104
|
+
resolve({
|
|
105
|
+
success: false,
|
|
106
|
+
stdout,
|
|
107
|
+
stderr: error.message,
|
|
108
|
+
exitCode: 1,
|
|
109
|
+
duration: Date.now() - startTime,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
} finally {
|
|
114
|
+
// Cleanup
|
|
115
|
+
try {
|
|
116
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
117
|
+
} catch {
|
|
118
|
+
// Ignore cleanup errors
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run example code in Vercel Sandbox (production)
|
|
125
|
+
*/
|
|
126
|
+
async function runExampleInSandbox(
|
|
127
|
+
code: string,
|
|
128
|
+
packageName: string,
|
|
129
|
+
packageVersion?: string,
|
|
130
|
+
): Promise<RunExampleResponse> {
|
|
131
|
+
const startTime = Date.now();
|
|
132
|
+
const versionSpec = packageVersion ? `${packageName}@${packageVersion}` : packageName;
|
|
133
|
+
|
|
134
|
+
const sandbox = await Sandbox.create({
|
|
135
|
+
resources: { vcpus: 2 },
|
|
136
|
+
timeout: 30 * 1000,
|
|
137
|
+
runtime: 'node22',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
// Create working directory and cd into it
|
|
142
|
+
const workDir = '/tmp/doccov-run';
|
|
143
|
+
await sandbox.runCommand({
|
|
144
|
+
cmd: 'mkdir',
|
|
145
|
+
args: ['-p', workDir],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Initialize npm project with ESM support
|
|
149
|
+
await sandbox.runCommand({
|
|
150
|
+
cmd: 'sh',
|
|
151
|
+
args: ['-c', `echo '{"type":"module"}' > ${workDir}/package.json`],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Install the target package
|
|
155
|
+
const installResult = await sandbox.runCommand({
|
|
156
|
+
cmd: 'npm',
|
|
157
|
+
args: ['install', versionSpec, '--ignore-scripts', '--legacy-peer-deps'],
|
|
158
|
+
cwd: workDir,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (installResult.exitCode !== 0) {
|
|
162
|
+
const installErr = (await installResult.stderr?.()) ?? 'Unknown install error';
|
|
163
|
+
return {
|
|
164
|
+
success: false,
|
|
165
|
+
stdout: '',
|
|
166
|
+
stderr: `Failed to install ${versionSpec}: ${installErr.slice(-200)}`,
|
|
167
|
+
exitCode: installResult.exitCode ?? 1,
|
|
168
|
+
duration: Date.now() - startTime,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Write example code to working directory
|
|
173
|
+
const exampleFile = `${workDir}/example.ts`;
|
|
174
|
+
await sandbox.runCommand({
|
|
175
|
+
cmd: 'sh',
|
|
176
|
+
args: ['-c', `cat > ${exampleFile} << 'DOCCOV_EOF'\n${code}\nDOCCOV_EOF`],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Run the example from the working directory
|
|
180
|
+
const runResult = await sandbox.runCommand({
|
|
181
|
+
cmd: 'node',
|
|
182
|
+
args: ['--experimental-strip-types', exampleFile],
|
|
183
|
+
cwd: workDir,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Note: Vercel Sandbox returns stdout/stderr as async functions
|
|
187
|
+
const stdout = (await runResult.stdout?.()) ?? '';
|
|
188
|
+
const stderr = (await runResult.stderr?.()) ?? '';
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
success: runResult.exitCode === 0,
|
|
192
|
+
stdout,
|
|
193
|
+
stderr,
|
|
194
|
+
exitCode: runResult.exitCode ?? 1,
|
|
195
|
+
duration: Date.now() - startTime,
|
|
196
|
+
};
|
|
197
|
+
} finally {
|
|
198
|
+
await sandbox.stop();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
203
|
+
// CORS
|
|
204
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
205
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
206
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
207
|
+
|
|
208
|
+
if (req.method === 'OPTIONS') {
|
|
209
|
+
return res.status(200).end();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (req.method !== 'POST') {
|
|
213
|
+
return res.status(405).json({ error: 'Method not allowed' });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const body = req.body as RunExampleRequest;
|
|
217
|
+
|
|
218
|
+
if (!body.packageName) {
|
|
219
|
+
return res.status(400).json({ error: 'packageName is required' });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!body.code) {
|
|
223
|
+
return res.status(400).json({ error: 'code is required' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Strip markdown code block markers if present
|
|
227
|
+
const cleanCode = body.code
|
|
228
|
+
.replace(/^```(?:ts|typescript|js|javascript)?\n?/i, '')
|
|
229
|
+
.replace(/\n?```$/i, '')
|
|
230
|
+
.trim();
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
let result: RunExampleResponse;
|
|
234
|
+
|
|
235
|
+
if (isVercelEnvironment()) {
|
|
236
|
+
result = await runExampleInSandbox(cleanCode, body.packageName, body.packageVersion);
|
|
237
|
+
} else {
|
|
238
|
+
result = await runExampleLocal(cleanCode, body.packageName, body.packageVersion);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return res.status(200).json(result);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
244
|
+
return res.status(500).json({
|
|
245
|
+
success: false,
|
|
246
|
+
stdout: '',
|
|
247
|
+
stderr: message,
|
|
248
|
+
exitCode: 1,
|
|
249
|
+
duration: 0,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
package/api/index.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { cors } from 'hono/cors';
|
|
3
|
+
import { handle } from 'hono/vercel';
|
|
4
|
+
|
|
5
|
+
export const config = {
|
|
6
|
+
runtime: 'edge',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const app = new Hono().basePath('/');
|
|
10
|
+
|
|
11
|
+
// Middleware
|
|
12
|
+
app.use('*', cors());
|
|
13
|
+
|
|
14
|
+
// Types
|
|
15
|
+
type BadgeColor =
|
|
16
|
+
| 'brightgreen'
|
|
17
|
+
| 'green'
|
|
18
|
+
| 'yellowgreen'
|
|
19
|
+
| 'yellow'
|
|
20
|
+
| 'orange'
|
|
21
|
+
| 'red'
|
|
22
|
+
| 'lightgrey';
|
|
23
|
+
|
|
24
|
+
interface OpenPkgSpec {
|
|
25
|
+
docs?: {
|
|
26
|
+
coverageScore?: number;
|
|
27
|
+
};
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Badge helpers
|
|
32
|
+
function getColorForScore(score: number): BadgeColor {
|
|
33
|
+
if (score >= 90) return 'brightgreen';
|
|
34
|
+
if (score >= 80) return 'green';
|
|
35
|
+
if (score >= 70) return 'yellowgreen';
|
|
36
|
+
if (score >= 60) return 'yellow';
|
|
37
|
+
if (score >= 50) return 'orange';
|
|
38
|
+
return 'red';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function generateBadgeSvg(label: string, message: string, color: BadgeColor): string {
|
|
42
|
+
const colors: Record<BadgeColor, string> = {
|
|
43
|
+
brightgreen: '#4c1',
|
|
44
|
+
green: '#97ca00',
|
|
45
|
+
yellowgreen: '#a4a61d',
|
|
46
|
+
yellow: '#dfb317',
|
|
47
|
+
orange: '#fe7d37',
|
|
48
|
+
red: '#e05d44',
|
|
49
|
+
lightgrey: '#9f9f9f',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const bgColor = colors[color];
|
|
53
|
+
const labelWidth = label.length * 7 + 10;
|
|
54
|
+
const messageWidth = message.length * 7 + 10;
|
|
55
|
+
const totalWidth = labelWidth + messageWidth;
|
|
56
|
+
|
|
57
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${message}">
|
|
58
|
+
<title>${label}: ${message}</title>
|
|
59
|
+
<linearGradient id="s" x2="0" y2="100%">
|
|
60
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
61
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
62
|
+
</linearGradient>
|
|
63
|
+
<clipPath id="r">
|
|
64
|
+
<rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
|
|
65
|
+
</clipPath>
|
|
66
|
+
<g clip-path="url(#r)">
|
|
67
|
+
<rect width="${labelWidth}" height="20" fill="#555"/>
|
|
68
|
+
<rect x="${labelWidth}" width="${messageWidth}" height="20" fill="${bgColor}"/>
|
|
69
|
+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
|
|
70
|
+
</g>
|
|
71
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
|
72
|
+
<text aria-hidden="true" x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${label}</text>
|
|
73
|
+
<text x="${labelWidth / 2}" y="14">${label}</text>
|
|
74
|
+
<text aria-hidden="true" x="${labelWidth + messageWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${message}</text>
|
|
75
|
+
<text x="${labelWidth + messageWidth / 2}" y="14">${message}</text>
|
|
76
|
+
</g>
|
|
77
|
+
</svg>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function fetchSpecFromGitHub(
|
|
81
|
+
owner: string,
|
|
82
|
+
repo: string,
|
|
83
|
+
ref = 'main',
|
|
84
|
+
): Promise<OpenPkgSpec | null> {
|
|
85
|
+
const urls = [
|
|
86
|
+
`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/openpkg.json`,
|
|
87
|
+
...(ref === 'main'
|
|
88
|
+
? [`https://raw.githubusercontent.com/${owner}/${repo}/master/openpkg.json`]
|
|
89
|
+
: []),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
for (const url of urls) {
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(url);
|
|
95
|
+
if (response.ok) {
|
|
96
|
+
return (await response.json()) as OpenPkgSpec;
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Try next URL
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Root
|
|
106
|
+
app.get('/', (c) => {
|
|
107
|
+
return c.json({
|
|
108
|
+
name: 'DocCov API',
|
|
109
|
+
version: '0.2.0',
|
|
110
|
+
endpoints: {
|
|
111
|
+
health: '/health',
|
|
112
|
+
badge: '/badge/:owner/:repo',
|
|
113
|
+
spec: '/spec/:owner/:repo/:ref?',
|
|
114
|
+
specPr: '/spec/:owner/:repo/pr/:pr',
|
|
115
|
+
scan: '/scan (POST)',
|
|
116
|
+
scanStream: '/scan-stream (GET)',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Health check
|
|
122
|
+
app.get('/health', (c) => {
|
|
123
|
+
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// GET /badge/:owner/:repo
|
|
127
|
+
app.get('/badge/:owner/:repo', async (c) => {
|
|
128
|
+
const { owner, repo } = c.req.param();
|
|
129
|
+
const branch = c.req.query('branch') ?? 'main';
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const spec = await fetchSpecFromGitHub(owner, repo, branch);
|
|
133
|
+
|
|
134
|
+
if (!spec) {
|
|
135
|
+
const svg = generateBadgeSvg('docs', 'not found', 'lightgrey');
|
|
136
|
+
return c.body(svg, 404, {
|
|
137
|
+
'Content-Type': 'image/svg+xml',
|
|
138
|
+
'Cache-Control': 'no-cache',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const coverageScore = spec.docs?.coverageScore ?? 0;
|
|
143
|
+
const svg = generateBadgeSvg('docs', `${coverageScore}%`, getColorForScore(coverageScore));
|
|
144
|
+
|
|
145
|
+
return c.body(svg, 200, {
|
|
146
|
+
'Content-Type': 'image/svg+xml',
|
|
147
|
+
'Cache-Control': 'public, max-age=300',
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
const svg = generateBadgeSvg('docs', 'error', 'red');
|
|
151
|
+
return c.body(svg, 500, {
|
|
152
|
+
'Content-Type': 'image/svg+xml',
|
|
153
|
+
'Cache-Control': 'no-cache',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// GET /badge/:owner/:repo.svg (alias)
|
|
159
|
+
app.get('/badge/:owner/:repo.svg', async (c) => {
|
|
160
|
+
const owner = c.req.param('owner');
|
|
161
|
+
const repoWithSvg = c.req.param('repo.svg') ?? '';
|
|
162
|
+
const repoName = repoWithSvg.replace(/\.svg$/, '');
|
|
163
|
+
return c.redirect(`/badge/${owner}/${repoName}`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// GET /spec/:owner/:repo/pr/:pr - Must be before the :ref route
|
|
167
|
+
app.get('/spec/:owner/:repo/pr/:pr', async (c) => {
|
|
168
|
+
const { owner, repo, pr } = c.req.param();
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
// Get PR head SHA from GitHub API
|
|
172
|
+
const prResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${pr}`, {
|
|
173
|
+
headers: { 'User-Agent': 'DocCov' },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!prResponse.ok) {
|
|
177
|
+
return c.json({ error: 'PR not found' }, 404);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const prData = (await prResponse.json()) as { head: { sha: string } };
|
|
181
|
+
const headSha = prData.head.sha;
|
|
182
|
+
|
|
183
|
+
// Fetch spec from PR head
|
|
184
|
+
const specUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${headSha}/openpkg.json`;
|
|
185
|
+
const specResponse = await fetch(specUrl);
|
|
186
|
+
|
|
187
|
+
if (!specResponse.ok) {
|
|
188
|
+
return c.json({ error: 'Spec not found in PR' }, 404);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const spec = await specResponse.json();
|
|
192
|
+
return c.json(spec, 200, {
|
|
193
|
+
'Cache-Control': 'no-cache',
|
|
194
|
+
});
|
|
195
|
+
} catch {
|
|
196
|
+
return c.json({ error: 'Failed to fetch PR spec' }, 500);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// GET /spec/:owner/:repo/:ref? (default ref = main)
|
|
201
|
+
app.get('/spec/:owner/:repo/:ref?', async (c) => {
|
|
202
|
+
const { owner, repo } = c.req.param();
|
|
203
|
+
const ref = c.req.param('ref') ?? 'main';
|
|
204
|
+
|
|
205
|
+
const spec = await fetchSpecFromGitHub(owner, repo, ref);
|
|
206
|
+
|
|
207
|
+
if (!spec) {
|
|
208
|
+
return c.json({ error: 'Spec not found' }, 404);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return c.json(spec, 200, {
|
|
212
|
+
'Cache-Control': 'public, max-age=300',
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Note: /scan and /scan-stream are handled by separate Node.js functions
|
|
217
|
+
|
|
218
|
+
export default handle(app);
|