@doccov/api 0.3.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * GitHub Check Runs and PR Comments
3
+ */
4
+
5
+ import { getTokenByInstallationId } from './github-app';
6
+
7
+ interface AnalysisResult {
8
+ coveragePercent: number;
9
+ documentedCount: number;
10
+ totalCount: number;
11
+ driftCount: number;
12
+ qualityErrors?: number;
13
+ qualityWarnings?: number;
14
+ }
15
+
16
+ interface AnalysisDiff {
17
+ coverageDelta: number;
18
+ documentedDelta: number;
19
+ totalDelta: number;
20
+ driftDelta: number;
21
+ }
22
+
23
+ /**
24
+ * Format a delta value with sign and styling
25
+ */
26
+ function formatDelta(delta: number, suffix = ''): string {
27
+ if (delta === 0) return '';
28
+ const sign = delta > 0 ? '+' : '';
29
+ return ` (${sign}${delta}${suffix})`;
30
+ }
31
+
32
+ /**
33
+ * Create or update a check run
34
+ */
35
+ export async function createCheckRun(
36
+ installationId: string,
37
+ owner: string,
38
+ repo: string,
39
+ sha: string,
40
+ result: AnalysisResult,
41
+ diff?: AnalysisDiff | null,
42
+ ): Promise<boolean> {
43
+ const token = await getTokenByInstallationId(installationId);
44
+ if (!token) return false;
45
+
46
+ const coverageDelta = diff ? formatDelta(diff.coverageDelta, '%') : '';
47
+
48
+ // Determine conclusion based on drift and quality
49
+ const hasIssues = result.driftCount > 0 || (result.qualityErrors ?? 0) > 0;
50
+ const conclusion = hasIssues ? 'neutral' : 'success';
51
+
52
+ const title = `Coverage: ${result.coveragePercent.toFixed(1)}%${coverageDelta}`;
53
+
54
+ const summaryLines = [
55
+ `## Documentation Coverage`,
56
+ '',
57
+ `\`\`\``,
58
+ `Coverage: ${result.coveragePercent.toFixed(1)}%${coverageDelta}`,
59
+ `├─ Documented: ${result.documentedCount}/${result.totalCount} exports`,
60
+ `├─ Drift: ${result.driftCount} issue${result.driftCount !== 1 ? 's' : ''}${diff ? formatDelta(-diff.driftDelta) : ''}`,
61
+ ];
62
+
63
+ const qualityIssues = (result.qualityErrors ?? 0) + (result.qualityWarnings ?? 0);
64
+ if (qualityIssues > 0) {
65
+ summaryLines.push(
66
+ `└─ Quality: ${result.qualityErrors ?? 0} error${(result.qualityErrors ?? 0) !== 1 ? 's' : ''}, ${result.qualityWarnings ?? 0} warning${(result.qualityWarnings ?? 0) !== 1 ? 's' : ''}`,
67
+ );
68
+ } else {
69
+ summaryLines.push(`└─ Quality: ✓ No issues`);
70
+ }
71
+
72
+ summaryLines.push(`\`\`\``);
73
+
74
+ if (diff) {
75
+ summaryLines.push('', '### Changes vs base');
76
+ summaryLines.push('| Metric | Delta |');
77
+ summaryLines.push('|--------|-------|');
78
+ summaryLines.push(
79
+ `| Coverage | ${diff.coverageDelta >= 0 ? '📈' : '📉'} ${diff.coverageDelta >= 0 ? '+' : ''}${diff.coverageDelta.toFixed(1)}% |`,
80
+ );
81
+ if (diff.documentedDelta !== 0) {
82
+ summaryLines.push(
83
+ `| Documented | ${diff.documentedDelta >= 0 ? '+' : ''}${diff.documentedDelta} |`,
84
+ );
85
+ }
86
+ if (diff.driftDelta !== 0) {
87
+ const driftChange = -diff.driftDelta; // Positive driftDelta means drift increased (bad)
88
+ summaryLines.push(`| Drift issues | ${driftChange >= 0 ? '+' : ''}${driftChange} |`);
89
+ }
90
+ }
91
+
92
+ summaryLines.push('', `[View full report →](https://doccov.com/r/${owner}/${repo}/${sha})`);
93
+
94
+ try {
95
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/check-runs`, {
96
+ method: 'POST',
97
+ headers: {
98
+ Authorization: `Bearer ${token}`,
99
+ Accept: 'application/vnd.github+json',
100
+ 'X-GitHub-Api-Version': '2022-11-28',
101
+ 'Content-Type': 'application/json',
102
+ },
103
+ body: JSON.stringify({
104
+ name: 'DocCov',
105
+ head_sha: sha,
106
+ status: 'completed',
107
+ conclusion,
108
+ output: {
109
+ title,
110
+ summary: summaryLines.join('\n'),
111
+ },
112
+ }),
113
+ });
114
+
115
+ if (!response.ok) {
116
+ console.error('Failed to create check run:', await response.text());
117
+ return false;
118
+ }
119
+
120
+ return true;
121
+ } catch (err) {
122
+ console.error('Error creating check run:', err);
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Post or update a PR comment
129
+ */
130
+ export async function postPRComment(
131
+ installationId: string,
132
+ owner: string,
133
+ repo: string,
134
+ prNumber: number,
135
+ result: AnalysisResult,
136
+ diff?: AnalysisDiff | null,
137
+ ): Promise<boolean> {
138
+ const token = await getTokenByInstallationId(installationId);
139
+ if (!token) return false;
140
+
141
+ const coverageEmoji = diff
142
+ ? diff.coverageDelta > 0
143
+ ? '📈'
144
+ : diff.coverageDelta < 0
145
+ ? '📉'
146
+ : '➡️'
147
+ : '📊';
148
+
149
+ const bodyLines = [`## ${coverageEmoji} DocCov Report`, ''];
150
+
151
+ // Summary table
152
+ if (diff) {
153
+ bodyLines.push('| Metric | This PR | Base | Δ |');
154
+ bodyLines.push('|--------|---------|------|---|');
155
+ bodyLines.push(
156
+ `| Coverage | **${result.coveragePercent.toFixed(1)}%** | ${(result.coveragePercent - diff.coverageDelta).toFixed(1)}% | ${diff.coverageDelta >= 0 ? '+' : ''}${diff.coverageDelta.toFixed(1)}% |`,
157
+ );
158
+ bodyLines.push(
159
+ `| Documented | ${result.documentedCount} | ${result.documentedCount - diff.documentedDelta} | ${diff.documentedDelta >= 0 ? '+' : ''}${diff.documentedDelta} |`,
160
+ );
161
+ bodyLines.push(
162
+ `| Total exports | ${result.totalCount} | ${result.totalCount - diff.totalDelta} | ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} |`,
163
+ );
164
+ const baseDrift = result.driftCount - diff.driftDelta;
165
+ const driftSign = diff.driftDelta >= 0 ? '+' : '';
166
+ bodyLines.push(
167
+ `| Drift issues | ${result.driftCount} | ${baseDrift} | ${driftSign}${diff.driftDelta} |`,
168
+ );
169
+ } else {
170
+ bodyLines.push('| Metric | Value |');
171
+ bodyLines.push('|--------|-------|');
172
+ bodyLines.push(`| Coverage | **${result.coveragePercent.toFixed(1)}%** |`);
173
+ bodyLines.push(`| Documented | ${result.documentedCount} / ${result.totalCount} |`);
174
+ bodyLines.push(`| Drift issues | ${result.driftCount} |`);
175
+ }
176
+
177
+ bodyLines.push('');
178
+
179
+ // Quality summary
180
+ const qualityErrors = result.qualityErrors ?? 0;
181
+ const qualityWarnings = result.qualityWarnings ?? 0;
182
+ if (qualityErrors > 0 || qualityWarnings > 0) {
183
+ bodyLines.push(
184
+ `**Quality**: ${qualityErrors} error${qualityErrors !== 1 ? 's' : ''}, ${qualityWarnings} warning${qualityWarnings !== 1 ? 's' : ''}`,
185
+ );
186
+ bodyLines.push('');
187
+ }
188
+
189
+ // Status message
190
+ if (result.driftCount > 0) {
191
+ bodyLines.push('⚠️ Documentation drift detected. Run `doccov check --fix` to update.');
192
+ } else if (qualityErrors > 0) {
193
+ bodyLines.push('❌ Quality errors found. Run `doccov check` for details.');
194
+ } else {
195
+ bodyLines.push('✅ Documentation is in sync.');
196
+ }
197
+
198
+ bodyLines.push('');
199
+ bodyLines.push('---');
200
+ bodyLines.push('*Generated by [DocCov](https://doccov.com)*');
201
+
202
+ const body = bodyLines.join('\n');
203
+
204
+ try {
205
+ // Check for existing comment
206
+ const existingId = await findExistingComment(token, owner, repo, prNumber);
207
+
208
+ if (existingId) {
209
+ // Update existing comment
210
+ const response = await fetch(
211
+ `https://api.github.com/repos/${owner}/${repo}/issues/comments/${existingId}`,
212
+ {
213
+ method: 'PATCH',
214
+ headers: {
215
+ Authorization: `Bearer ${token}`,
216
+ Accept: 'application/vnd.github+json',
217
+ 'X-GitHub-Api-Version': '2022-11-28',
218
+ 'Content-Type': 'application/json',
219
+ },
220
+ body: JSON.stringify({ body }),
221
+ },
222
+ );
223
+ return response.ok;
224
+ }
225
+
226
+ // Create new comment
227
+ const response = await fetch(
228
+ `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
229
+ {
230
+ method: 'POST',
231
+ headers: {
232
+ Authorization: `Bearer ${token}`,
233
+ Accept: 'application/vnd.github+json',
234
+ 'X-GitHub-Api-Version': '2022-11-28',
235
+ 'Content-Type': 'application/json',
236
+ },
237
+ body: JSON.stringify({ body }),
238
+ },
239
+ );
240
+
241
+ return response.ok;
242
+ } catch (err) {
243
+ console.error('Error posting PR comment:', err);
244
+ return false;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Find existing DocCov comment on a PR
250
+ */
251
+ async function findExistingComment(
252
+ token: string,
253
+ owner: string,
254
+ repo: string,
255
+ prNumber: number,
256
+ ): Promise<number | null> {
257
+ try {
258
+ const response = await fetch(
259
+ `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
260
+ {
261
+ headers: {
262
+ Authorization: `Bearer ${token}`,
263
+ Accept: 'application/vnd.github+json',
264
+ 'X-GitHub-Api-Version': '2022-11-28',
265
+ },
266
+ },
267
+ );
268
+
269
+ if (!response.ok) return null;
270
+
271
+ const comments = (await response.json()) as Array<{ id: number; body: string }>;
272
+ const existing = comments.find((c) => c.body.includes('DocCov Report'));
273
+
274
+ return existing?.id ?? null;
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Remote repository analyzer - runs DocCov analysis on GitHub repos via webhooks.
3
+ * Uses shallow clone to temp dir for full TypeScript resolution.
4
+ */
5
+
6
+ import { rm } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { DocCov, enrichSpec, type OpenPkgSpec } from '@doccov/sdk';
10
+ import { getTokenByInstallationId } from './github-app';
11
+
12
+ /**
13
+ * Result from remote analysis.
14
+ */
15
+ export interface RemoteAnalysisResult {
16
+ coveragePercent: number;
17
+ documentedCount: number;
18
+ totalCount: number;
19
+ driftCount: number;
20
+ qualityErrors: number;
21
+ qualityWarnings: number;
22
+ /** Full spec for detailed reports */
23
+ spec?: OpenPkgSpec;
24
+ }
25
+
26
+ /**
27
+ * Shallow clone a GitHub repo to a temp directory.
28
+ */
29
+ async function cloneRepo(
30
+ owner: string,
31
+ repo: string,
32
+ ref: string,
33
+ authToken: string,
34
+ ): Promise<string> {
35
+ const tmpDir = join(tmpdir(), `doccov-${owner}-${repo}-${Date.now()}`);
36
+
37
+ // Use authenticated HTTPS URL for private repos
38
+ const cloneUrl = `https://x-access-token:${authToken}@github.com/${owner}/${repo}.git`;
39
+
40
+ const proc = Bun.spawn(['git', 'clone', '--depth', '1', '--branch', ref, cloneUrl, tmpDir], {
41
+ stdout: 'pipe',
42
+ stderr: 'pipe',
43
+ });
44
+
45
+ const exitCode = await proc.exited;
46
+
47
+ if (exitCode !== 0) {
48
+ const stderr = await new Response(proc.stderr).text();
49
+
50
+ // Try fetching specific SHA if branch clone fails
51
+ if (ref.length === 40) {
52
+ // Looks like a SHA
53
+ const shallowProc = Bun.spawn(['git', 'clone', '--depth', '1', cloneUrl, tmpDir], {
54
+ stdout: 'pipe',
55
+ stderr: 'pipe',
56
+ });
57
+ await shallowProc.exited;
58
+
59
+ const fetchProc = Bun.spawn(['git', '-C', tmpDir, 'fetch', 'origin', ref], {
60
+ stdout: 'pipe',
61
+ stderr: 'pipe',
62
+ });
63
+ await fetchProc.exited;
64
+
65
+ const checkoutProc = Bun.spawn(['git', '-C', tmpDir, 'checkout', ref], {
66
+ stdout: 'pipe',
67
+ stderr: 'pipe',
68
+ });
69
+ const checkoutExit = await checkoutProc.exited;
70
+
71
+ if (checkoutExit !== 0) {
72
+ throw new Error(`Failed to checkout ${ref}: ${stderr}`);
73
+ }
74
+ } else {
75
+ throw new Error(`Failed to clone ${owner}/${repo}@${ref}: ${stderr}`);
76
+ }
77
+ }
78
+
79
+ return tmpDir;
80
+ }
81
+
82
+ /**
83
+ * Detect the entry point for a package.
84
+ */
85
+ async function detectEntryPoint(repoDir: string): Promise<string | null> {
86
+ try {
87
+ const packageJsonPath = join(repoDir, 'package.json');
88
+ const packageJson = await Bun.file(packageJsonPath).json();
89
+
90
+ // Check exports first (modern packages)
91
+ if (packageJson.exports) {
92
+ const mainExport = packageJson.exports['.'];
93
+ if (typeof mainExport === 'string') {
94
+ return mainExport.replace(/^\.\//, '');
95
+ }
96
+ if (mainExport?.import) {
97
+ const importPath =
98
+ typeof mainExport.import === 'string' ? mainExport.import : mainExport.import.default;
99
+ if (importPath) return importPath.replace(/^\.\//, '');
100
+ }
101
+ if (mainExport?.types) {
102
+ return mainExport.types.replace(/^\.\//, '');
103
+ }
104
+ }
105
+
106
+ // Check types field
107
+ if (packageJson.types) {
108
+ return packageJson.types.replace(/^\.\//, '');
109
+ }
110
+
111
+ // Check main field
112
+ if (packageJson.main) {
113
+ // Convert .js to .ts if applicable
114
+ const main = packageJson.main.replace(/^\.\//, '');
115
+ const tsMain = main.replace(/\.js$/, '.ts');
116
+ const tsxMain = main.replace(/\.js$/, '.tsx');
117
+
118
+ // Check if TS version exists
119
+ const tsFile = Bun.file(join(repoDir, tsMain));
120
+ if (await tsFile.exists()) return tsMain;
121
+
122
+ const tsxFile = Bun.file(join(repoDir, tsxMain));
123
+ if (await tsxFile.exists()) return tsxMain;
124
+
125
+ return main;
126
+ }
127
+
128
+ // Common fallbacks
129
+ const fallbacks = ['src/index.ts', 'src/index.tsx', 'index.ts', 'lib/index.ts'];
130
+ for (const fallback of fallbacks) {
131
+ const file = Bun.file(join(repoDir, fallback));
132
+ if (await file.exists()) return fallback;
133
+ }
134
+
135
+ return null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Analyze a remote GitHub repository.
143
+ *
144
+ * @param installationId - GitHub App installation ID
145
+ * @param owner - Repository owner
146
+ * @param repo - Repository name
147
+ * @param ref - Git ref (branch, tag, or SHA)
148
+ * @param includeSpec - Whether to include full spec in result
149
+ * @returns Analysis result or null if failed
150
+ */
151
+ export async function analyzeRemoteRepo(
152
+ installationId: string,
153
+ owner: string,
154
+ repo: string,
155
+ ref: string,
156
+ includeSpec = false,
157
+ ): Promise<RemoteAnalysisResult | null> {
158
+ // Get installation token
159
+ const token = await getTokenByInstallationId(installationId);
160
+ if (!token) {
161
+ console.error(`No token for installation ${installationId}`);
162
+ return null;
163
+ }
164
+
165
+ let tmpDir: string | null = null;
166
+
167
+ try {
168
+ // Clone repo to temp dir
169
+ tmpDir = await cloneRepo(owner, repo, ref, token);
170
+
171
+ // Detect entry point
172
+ const entryPoint = await detectEntryPoint(tmpDir);
173
+ if (!entryPoint) {
174
+ console.error(`No entry point found for ${owner}/${repo}`);
175
+ return null;
176
+ }
177
+
178
+ const entryPath = join(tmpDir, entryPoint);
179
+
180
+ // Run analysis
181
+ const doccov = new DocCov({
182
+ resolveExternalTypes: false, // Skip for speed
183
+ useCache: false, // No caching for webhook analysis
184
+ });
185
+
186
+ const result = await doccov.analyzeFileWithDiagnostics(entryPath);
187
+
188
+ // Enrich with coverage metrics
189
+ const enriched = enrichSpec(result.spec);
190
+
191
+ // Extract metrics
192
+ const docs = enriched.docs;
193
+ const coveragePercent = docs?.coverageScore ?? 0;
194
+ const documentedCount = docs?.documented ?? 0;
195
+ const totalCount = docs?.total ?? 0;
196
+ const driftCount = docs?.drift?.length ?? 0;
197
+
198
+ // Count quality issues
199
+ let qualityErrors = 0;
200
+ let qualityWarnings = 0;
201
+
202
+ if (docs?.quality) {
203
+ for (const item of docs.quality) {
204
+ if (item.severity === 'error') qualityErrors++;
205
+ else if (item.severity === 'warning') qualityWarnings++;
206
+ }
207
+ }
208
+
209
+ return {
210
+ coveragePercent,
211
+ documentedCount,
212
+ totalCount,
213
+ driftCount,
214
+ qualityErrors,
215
+ qualityWarnings,
216
+ spec: includeSpec ? enriched : undefined,
217
+ };
218
+ } catch (err) {
219
+ console.error(`Analysis failed for ${owner}/${repo}@${ref}:`, err);
220
+ return null;
221
+ } finally {
222
+ // Cleanup temp dir
223
+ if (tmpDir) {
224
+ try {
225
+ await rm(tmpDir, { recursive: true, force: true });
226
+ } catch {
227
+ // Ignore cleanup errors
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Compute diff between two analysis results.
235
+ */
236
+ export function computeAnalysisDiff(
237
+ base: RemoteAnalysisResult,
238
+ head: RemoteAnalysisResult,
239
+ ): {
240
+ coverageDelta: number;
241
+ documentedDelta: number;
242
+ totalDelta: number;
243
+ driftDelta: number;
244
+ } {
245
+ return {
246
+ coverageDelta: Number((head.coveragePercent - base.coveragePercent).toFixed(1)),
247
+ documentedDelta: head.documentedCount - base.documentedCount,
248
+ totalDelta: head.totalCount - base.totalCount,
249
+ driftDelta: head.driftCount - base.driftCount,
250
+ };
251
+ }
package/vercel.json CHANGED
@@ -2,7 +2,5 @@
2
2
  "framework": null,
3
3
  "installCommand": "bun install",
4
4
  "outputDirectory": ".",
5
- "rewrites": [
6
- { "source": "/(.*)", "destination": "/api" }
7
- ]
5
+ "rewrites": [{ "source": "/(.*)", "destination": "/api" }]
8
6
  }
@@ -1,5 +0,0 @@
1
- /**
2
- * Re-export GitHub utilities from SDK for backwards compatibility.
3
- * @deprecated Import directly from '@doccov/sdk' instead.
4
- */
5
- export { fetchSpec as fetchSpecFromGitHub } from '@doccov/sdk';