@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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec routes (v1) - API key authenticated endpoints for programmatic access
|
|
3
|
+
*
|
|
4
|
+
* POST /v1/spec/diff - Compare two specs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { db } from '../db/client';
|
|
10
|
+
import type { ApiKeyContext } from '../middleware/api-key-auth';
|
|
11
|
+
import {
|
|
12
|
+
computeFullDiff,
|
|
13
|
+
type DiffOptions,
|
|
14
|
+
diffSpecs,
|
|
15
|
+
formatDiffResponse,
|
|
16
|
+
} from '../utils/spec-diff-core';
|
|
17
|
+
|
|
18
|
+
type Env = {
|
|
19
|
+
Variables: ApiKeyContext;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const specV1Route = new Hono<Env>();
|
|
23
|
+
|
|
24
|
+
// Request schemas
|
|
25
|
+
const GitHubDiffSchema = z.object({
|
|
26
|
+
mode: z.literal('github'),
|
|
27
|
+
owner: z.string().min(1),
|
|
28
|
+
repo: z.string().min(1),
|
|
29
|
+
base: z.string().min(1),
|
|
30
|
+
head: z.string().min(1),
|
|
31
|
+
includeDocsImpact: z.boolean().optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const SpecsDiffSchema = z.object({
|
|
35
|
+
mode: z.literal('specs'),
|
|
36
|
+
baseSpec: z.object({}).passthrough(),
|
|
37
|
+
headSpec: z.object({}).passthrough(),
|
|
38
|
+
markdownFiles: z
|
|
39
|
+
.array(
|
|
40
|
+
z.object({
|
|
41
|
+
path: z.string(),
|
|
42
|
+
content: z.string(),
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
.optional(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const DiffRequestSchema = z.discriminatedUnion('mode', [GitHubDiffSchema, SpecsDiffSchema]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* POST /v1/spec/diff - Compare two specs
|
|
52
|
+
*
|
|
53
|
+
* Supports two modes:
|
|
54
|
+
* 1. GitHub refs: Clone and compare specs from GitHub refs
|
|
55
|
+
* 2. Direct specs: Compare uploaded spec objects
|
|
56
|
+
*/
|
|
57
|
+
specV1Route.post('/diff', async (c) => {
|
|
58
|
+
const org = c.get('org');
|
|
59
|
+
|
|
60
|
+
// Parse and validate request body
|
|
61
|
+
let body: z.infer<typeof DiffRequestSchema>;
|
|
62
|
+
try {
|
|
63
|
+
const rawBody = await c.req.json();
|
|
64
|
+
body = DiffRequestSchema.parse(rawBody);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof z.ZodError) {
|
|
67
|
+
return c.json(
|
|
68
|
+
{
|
|
69
|
+
error: 'Invalid request',
|
|
70
|
+
details: err.errors,
|
|
71
|
+
},
|
|
72
|
+
400,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
if (body.mode === 'github') {
|
|
80
|
+
// GitHub mode: need to find installation for this org
|
|
81
|
+
const { owner, repo, base, head, includeDocsImpact } = body;
|
|
82
|
+
|
|
83
|
+
// Look up installation from org
|
|
84
|
+
const installation = await db
|
|
85
|
+
.selectFrom('github_installations')
|
|
86
|
+
.where('orgId', '=', org.id)
|
|
87
|
+
.select(['installationId'])
|
|
88
|
+
.executeTakeFirst();
|
|
89
|
+
|
|
90
|
+
if (!installation) {
|
|
91
|
+
return c.json(
|
|
92
|
+
{
|
|
93
|
+
error: 'No GitHub App installation found for this repository',
|
|
94
|
+
hint: 'Install the DocCov GitHub App to compare repos',
|
|
95
|
+
},
|
|
96
|
+
403,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Compute diff with timeout
|
|
101
|
+
const diffOptions: DiffOptions = {
|
|
102
|
+
includeDocsImpact,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const result = await Promise.race([
|
|
106
|
+
computeFullDiff(
|
|
107
|
+
{ owner, repo, ref: base, installationId: installation.installationId },
|
|
108
|
+
{ owner, repo, ref: head, installationId: installation.installationId },
|
|
109
|
+
diffOptions,
|
|
110
|
+
),
|
|
111
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), 60_000)),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
return c.json(formatDiffResponse(result));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Specs mode: direct comparison
|
|
118
|
+
const { baseSpec, headSpec, markdownFiles } = body;
|
|
119
|
+
|
|
120
|
+
const diff = diffSpecs(
|
|
121
|
+
baseSpec as Parameters<typeof diffSpecs>[0],
|
|
122
|
+
headSpec as Parameters<typeof diffSpecs>[1],
|
|
123
|
+
markdownFiles,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return c.json({
|
|
127
|
+
// Core diff fields
|
|
128
|
+
breaking: diff.breaking,
|
|
129
|
+
nonBreaking: diff.nonBreaking,
|
|
130
|
+
docsOnly: diff.docsOnly,
|
|
131
|
+
coverageDelta: diff.coverageDelta,
|
|
132
|
+
oldCoverage: diff.oldCoverage,
|
|
133
|
+
newCoverage: diff.newCoverage,
|
|
134
|
+
driftIntroduced: diff.driftIntroduced,
|
|
135
|
+
driftResolved: diff.driftResolved,
|
|
136
|
+
newUndocumented: diff.newUndocumented,
|
|
137
|
+
improvedExports: diff.improvedExports,
|
|
138
|
+
regressedExports: diff.regressedExports,
|
|
139
|
+
|
|
140
|
+
// Extended fields
|
|
141
|
+
memberChanges: diff.memberChanges,
|
|
142
|
+
categorizedBreaking: diff.categorizedBreaking,
|
|
143
|
+
docsImpact: diff.docsImpact,
|
|
144
|
+
|
|
145
|
+
// Metadata
|
|
146
|
+
generatedAt: new Date().toISOString(),
|
|
147
|
+
cached: false,
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err instanceof Error) {
|
|
151
|
+
if (err.message === 'TIMEOUT') {
|
|
152
|
+
return c.json({ error: 'Spec generation timed out' }, 408);
|
|
153
|
+
}
|
|
154
|
+
if (err.message.includes('not found') || err.message.includes('404')) {
|
|
155
|
+
return c.json({ error: 'Repository or ref not found' }, 404);
|
|
156
|
+
}
|
|
157
|
+
if (err.message.includes('No token')) {
|
|
158
|
+
return c.json({ error: 'GitHub App access required' }, 403);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.error('Spec diff error:', err);
|
|
163
|
+
return c.json({ error: 'Failed to compute diff' }, 500);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec routes - session authenticated endpoints for dashboard
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { auth } from '../auth/config';
|
|
8
|
+
import { db } from '../db/client';
|
|
9
|
+
import {
|
|
10
|
+
computeFullDiff,
|
|
11
|
+
type DiffOptions,
|
|
12
|
+
diffSpecs,
|
|
13
|
+
formatDiffResponse,
|
|
14
|
+
} from '../utils/spec-diff-core';
|
|
15
|
+
|
|
16
|
+
type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
|
|
17
|
+
|
|
18
|
+
type Env = {
|
|
19
|
+
Variables: {
|
|
20
|
+
session: NonNullable<Session>;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const specRoute = new Hono<Env>();
|
|
25
|
+
|
|
26
|
+
// Middleware: require session auth
|
|
27
|
+
specRoute.use('*', async (c, next) => {
|
|
28
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
29
|
+
if (!session) {
|
|
30
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
31
|
+
}
|
|
32
|
+
c.set('session', session);
|
|
33
|
+
await next();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Request schemas
|
|
37
|
+
const GitHubDiffSchema = z.object({
|
|
38
|
+
mode: z.literal('github'),
|
|
39
|
+
owner: z.string().min(1),
|
|
40
|
+
repo: z.string().min(1),
|
|
41
|
+
base: z.string().min(1),
|
|
42
|
+
head: z.string().min(1),
|
|
43
|
+
installationId: z.string().optional(),
|
|
44
|
+
includeDocsImpact: z.boolean().optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const SpecsDiffSchema = z.object({
|
|
48
|
+
mode: z.literal('specs'),
|
|
49
|
+
baseSpec: z.object({}).passthrough(),
|
|
50
|
+
headSpec: z.object({}).passthrough(),
|
|
51
|
+
markdownFiles: z
|
|
52
|
+
.array(
|
|
53
|
+
z.object({
|
|
54
|
+
path: z.string(),
|
|
55
|
+
content: z.string(),
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
.optional(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const DiffRequestSchema = z.discriminatedUnion('mode', [GitHubDiffSchema, SpecsDiffSchema]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* POST /spec/diff - Compare two specs
|
|
65
|
+
*
|
|
66
|
+
* Supports two modes:
|
|
67
|
+
* 1. GitHub refs: Clone and compare specs from GitHub refs
|
|
68
|
+
* 2. Direct specs: Compare uploaded spec objects
|
|
69
|
+
*/
|
|
70
|
+
specRoute.post('/diff', async (c) => {
|
|
71
|
+
const session = c.get('session');
|
|
72
|
+
|
|
73
|
+
// Parse and validate request body
|
|
74
|
+
let body: z.infer<typeof DiffRequestSchema>;
|
|
75
|
+
try {
|
|
76
|
+
const rawBody = await c.req.json();
|
|
77
|
+
body = DiffRequestSchema.parse(rawBody);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err instanceof z.ZodError) {
|
|
80
|
+
return c.json(
|
|
81
|
+
{
|
|
82
|
+
error: 'Invalid request',
|
|
83
|
+
details: err.errors,
|
|
84
|
+
},
|
|
85
|
+
400,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (body.mode === 'github') {
|
|
93
|
+
// GitHub mode: need to verify access and get installation
|
|
94
|
+
const { owner, repo, base, head, installationId, includeDocsImpact } = body;
|
|
95
|
+
|
|
96
|
+
// Find installation ID if not provided
|
|
97
|
+
let resolvedInstallationId = installationId;
|
|
98
|
+
|
|
99
|
+
if (!resolvedInstallationId) {
|
|
100
|
+
// Look up installation from user's orgs
|
|
101
|
+
const installation = await db
|
|
102
|
+
.selectFrom('github_installations')
|
|
103
|
+
.innerJoin('org_members', 'org_members.orgId', 'github_installations.orgId')
|
|
104
|
+
.where('org_members.userId', '=', session.user.id)
|
|
105
|
+
.select(['github_installations.installationId'])
|
|
106
|
+
.executeTakeFirst();
|
|
107
|
+
|
|
108
|
+
if (!installation) {
|
|
109
|
+
return c.json(
|
|
110
|
+
{
|
|
111
|
+
error: 'No GitHub App installation found for this repository',
|
|
112
|
+
hint: 'Install the DocCov GitHub App to compare repos',
|
|
113
|
+
},
|
|
114
|
+
403,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
resolvedInstallationId = installation.installationId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Compute diff with timeout
|
|
122
|
+
const diffOptions: DiffOptions = {
|
|
123
|
+
includeDocsImpact,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const result = await Promise.race([
|
|
127
|
+
computeFullDiff(
|
|
128
|
+
{ owner, repo, ref: base, installationId: resolvedInstallationId },
|
|
129
|
+
{ owner, repo, ref: head, installationId: resolvedInstallationId },
|
|
130
|
+
diffOptions,
|
|
131
|
+
),
|
|
132
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), 60_000)),
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
return c.json(formatDiffResponse(result));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Specs mode: direct comparison
|
|
139
|
+
const { baseSpec, headSpec, markdownFiles } = body;
|
|
140
|
+
|
|
141
|
+
const diff = diffSpecs(
|
|
142
|
+
baseSpec as Parameters<typeof diffSpecs>[0],
|
|
143
|
+
headSpec as Parameters<typeof diffSpecs>[1],
|
|
144
|
+
markdownFiles,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return c.json({
|
|
148
|
+
// Core diff fields
|
|
149
|
+
breaking: diff.breaking,
|
|
150
|
+
nonBreaking: diff.nonBreaking,
|
|
151
|
+
docsOnly: diff.docsOnly,
|
|
152
|
+
coverageDelta: diff.coverageDelta,
|
|
153
|
+
oldCoverage: diff.oldCoverage,
|
|
154
|
+
newCoverage: diff.newCoverage,
|
|
155
|
+
driftIntroduced: diff.driftIntroduced,
|
|
156
|
+
driftResolved: diff.driftResolved,
|
|
157
|
+
newUndocumented: diff.newUndocumented,
|
|
158
|
+
improvedExports: diff.improvedExports,
|
|
159
|
+
regressedExports: diff.regressedExports,
|
|
160
|
+
|
|
161
|
+
// Extended fields
|
|
162
|
+
memberChanges: diff.memberChanges,
|
|
163
|
+
categorizedBreaking: diff.categorizedBreaking,
|
|
164
|
+
docsImpact: diff.docsImpact,
|
|
165
|
+
|
|
166
|
+
// Metadata
|
|
167
|
+
generatedAt: new Date().toISOString(),
|
|
168
|
+
cached: false,
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof Error) {
|
|
172
|
+
if (err.message === 'TIMEOUT') {
|
|
173
|
+
return c.json({ error: 'Spec generation timed out' }, 408);
|
|
174
|
+
}
|
|
175
|
+
if (err.message.includes('not found') || err.message.includes('404')) {
|
|
176
|
+
return c.json({ error: 'Repository or ref not found' }, 404);
|
|
177
|
+
}
|
|
178
|
+
if (err.message.includes('No token')) {
|
|
179
|
+
return c.json({ error: 'GitHub App access required' }, 403);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.error('Spec diff error:', err);
|
|
184
|
+
return c.json({ error: 'Failed to compute diff' }, 500);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* GitHub Check Runs and PR Comments
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { SpecDiffWithDocs } from '@doccov/sdk';
|
|
5
6
|
import { getTokenByInstallationId } from './github-app';
|
|
6
7
|
|
|
7
8
|
interface AnalysisResult {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
coverageScore: number;
|
|
10
|
+
documentedExports: number;
|
|
11
|
+
totalExports: number;
|
|
11
12
|
driftCount: number;
|
|
12
13
|
qualityErrors?: number;
|
|
13
14
|
qualityWarnings?: number;
|
|
@@ -20,6 +21,15 @@ interface AnalysisDiff {
|
|
|
20
21
|
driftDelta: number;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Rich diff result from spec-diff-core
|
|
26
|
+
*/
|
|
27
|
+
export interface RichDiffResult {
|
|
28
|
+
diff: SpecDiffWithDocs;
|
|
29
|
+
base: { ref: string; sha: string };
|
|
30
|
+
head: { ref: string; sha: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/**
|
|
24
34
|
* Format a delta value with sign and styling
|
|
25
35
|
*/
|
|
@@ -49,14 +59,14 @@ export async function createCheckRun(
|
|
|
49
59
|
const hasIssues = result.driftCount > 0 || (result.qualityErrors ?? 0) > 0;
|
|
50
60
|
const conclusion = hasIssues ? 'neutral' : 'success';
|
|
51
61
|
|
|
52
|
-
const title = `Coverage: ${result.
|
|
62
|
+
const title = `Coverage: ${result.coverageScore.toFixed(1)}%${coverageDelta}`;
|
|
53
63
|
|
|
54
64
|
const summaryLines = [
|
|
55
65
|
`## Documentation Coverage`,
|
|
56
66
|
'',
|
|
57
67
|
`\`\`\``,
|
|
58
|
-
`Coverage: ${result.
|
|
59
|
-
`├─ Documented: ${result.
|
|
68
|
+
`Coverage: ${result.coverageScore.toFixed(1)}%${coverageDelta}`,
|
|
69
|
+
`├─ Documented: ${result.documentedExports}/${result.totalExports} exports`,
|
|
60
70
|
`├─ Drift: ${result.driftCount} issue${result.driftCount !== 1 ? 's' : ''}${diff ? formatDelta(-diff.driftDelta) : ''}`,
|
|
61
71
|
];
|
|
62
72
|
|
|
@@ -153,13 +163,13 @@ export async function postPRComment(
|
|
|
153
163
|
bodyLines.push('| Metric | This PR | Base | Δ |');
|
|
154
164
|
bodyLines.push('|--------|---------|------|---|');
|
|
155
165
|
bodyLines.push(
|
|
156
|
-
`| Coverage | **${result.
|
|
166
|
+
`| Coverage | **${result.coverageScore.toFixed(1)}%** | ${(result.coverageScore - diff.coverageDelta).toFixed(1)}% | ${diff.coverageDelta >= 0 ? '+' : ''}${diff.coverageDelta.toFixed(1)}% |`,
|
|
157
167
|
);
|
|
158
168
|
bodyLines.push(
|
|
159
|
-
`| Documented | ${result.
|
|
169
|
+
`| Documented | ${result.documentedExports} | ${result.documentedExports - diff.documentedDelta} | ${diff.documentedDelta >= 0 ? '+' : ''}${diff.documentedDelta} |`,
|
|
160
170
|
);
|
|
161
171
|
bodyLines.push(
|
|
162
|
-
`| Total exports | ${result.
|
|
172
|
+
`| Total exports | ${result.totalExports} | ${result.totalExports - diff.totalDelta} | ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} |`,
|
|
163
173
|
);
|
|
164
174
|
const baseDrift = result.driftCount - diff.driftDelta;
|
|
165
175
|
const driftSign = diff.driftDelta >= 0 ? '+' : '';
|
|
@@ -169,8 +179,8 @@ export async function postPRComment(
|
|
|
169
179
|
} else {
|
|
170
180
|
bodyLines.push('| Metric | Value |');
|
|
171
181
|
bodyLines.push('|--------|-------|');
|
|
172
|
-
bodyLines.push(`| Coverage | **${result.
|
|
173
|
-
bodyLines.push(`| Documented | ${result.
|
|
182
|
+
bodyLines.push(`| Coverage | **${result.coverageScore.toFixed(1)}%** |`);
|
|
183
|
+
bodyLines.push(`| Documented | ${result.documentedExports} / ${result.totalExports} |`);
|
|
174
184
|
bodyLines.push(`| Drift issues | ${result.driftCount} |`);
|
|
175
185
|
}
|
|
176
186
|
|
|
@@ -276,3 +286,213 @@ async function findExistingComment(
|
|
|
276
286
|
return null;
|
|
277
287
|
}
|
|
278
288
|
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Post enhanced PR comment with breaking changes and member changes
|
|
292
|
+
*/
|
|
293
|
+
export async function postEnhancedPRComment(
|
|
294
|
+
installationId: string,
|
|
295
|
+
owner: string,
|
|
296
|
+
repo: string,
|
|
297
|
+
prNumber: number,
|
|
298
|
+
richDiff: RichDiffResult,
|
|
299
|
+
): Promise<boolean> {
|
|
300
|
+
const token = await getTokenByInstallationId(installationId);
|
|
301
|
+
if (!token) return false;
|
|
302
|
+
|
|
303
|
+
const { diff, base, head } = richDiff;
|
|
304
|
+
|
|
305
|
+
const coverageEmoji = diff.coverageDelta > 0 ? '📈' : diff.coverageDelta < 0 ? '📉' : '➡️';
|
|
306
|
+
|
|
307
|
+
const bodyLines = [`## ${coverageEmoji} DocCov Report`, ''];
|
|
308
|
+
|
|
309
|
+
// Coverage summary
|
|
310
|
+
bodyLines.push('### Coverage');
|
|
311
|
+
bodyLines.push('| Metric | This PR | Base | Δ |');
|
|
312
|
+
bodyLines.push('|--------|---------|------|---|');
|
|
313
|
+
bodyLines.push(
|
|
314
|
+
`| Coverage | **${diff.newCoverage.toFixed(1)}%** | ${diff.oldCoverage.toFixed(1)}% | ${diff.coverageDelta >= 0 ? '+' : ''}${diff.coverageDelta.toFixed(1)}% |`,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (diff.driftIntroduced > 0 || diff.driftResolved > 0) {
|
|
318
|
+
const driftChange = diff.driftIntroduced - diff.driftResolved;
|
|
319
|
+
bodyLines.push(
|
|
320
|
+
`| Drift | ${diff.driftIntroduced > 0 ? `+${diff.driftIntroduced} new` : '0 new'} | ${diff.driftResolved > 0 ? `${diff.driftResolved} fixed` : '0 fixed'} | ${driftChange >= 0 ? '+' : ''}${driftChange} |`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
bodyLines.push('');
|
|
325
|
+
|
|
326
|
+
// Breaking changes section
|
|
327
|
+
if (diff.breaking.length > 0) {
|
|
328
|
+
bodyLines.push('### ⚠️ Breaking Changes');
|
|
329
|
+
bodyLines.push('');
|
|
330
|
+
|
|
331
|
+
// Group by severity if available
|
|
332
|
+
if (diff.categorizedBreaking && diff.categorizedBreaking.length > 0) {
|
|
333
|
+
const high = diff.categorizedBreaking.filter((b) => b.severity === 'high');
|
|
334
|
+
const medium = diff.categorizedBreaking.filter((b) => b.severity === 'medium');
|
|
335
|
+
const low = diff.categorizedBreaking.filter((b) => b.severity === 'low');
|
|
336
|
+
|
|
337
|
+
if (high.length > 0) {
|
|
338
|
+
bodyLines.push(`**🔴 High Impact (${high.length})**`);
|
|
339
|
+
for (const b of high.slice(0, 5)) {
|
|
340
|
+
bodyLines.push(`- \`${b.name}\` - ${b.reason}`);
|
|
341
|
+
}
|
|
342
|
+
if (high.length > 5) bodyLines.push(`- ...and ${high.length - 5} more`);
|
|
343
|
+
bodyLines.push('');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (medium.length > 0) {
|
|
347
|
+
bodyLines.push(`**🟡 Medium Impact (${medium.length})**`);
|
|
348
|
+
for (const b of medium.slice(0, 5)) {
|
|
349
|
+
bodyLines.push(`- \`${b.name}\` - ${b.reason}`);
|
|
350
|
+
}
|
|
351
|
+
if (medium.length > 5) bodyLines.push(`- ...and ${medium.length - 5} more`);
|
|
352
|
+
bodyLines.push('');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (low.length > 0) {
|
|
356
|
+
bodyLines.push(`**🟢 Low Impact (${low.length})**`);
|
|
357
|
+
for (const b of low.slice(0, 3)) {
|
|
358
|
+
bodyLines.push(`- \`${b.name}\` - ${b.reason}`);
|
|
359
|
+
}
|
|
360
|
+
if (low.length > 3) bodyLines.push(`- ...and ${low.length - 3} more`);
|
|
361
|
+
bodyLines.push('');
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
// Simple list (breaking is string[])
|
|
365
|
+
for (const b of diff.breaking.slice(0, 10)) {
|
|
366
|
+
bodyLines.push(`- \`${b}\``);
|
|
367
|
+
}
|
|
368
|
+
if (diff.breaking.length > 10) {
|
|
369
|
+
bodyLines.push(`- ...and ${diff.breaking.length - 10} more`);
|
|
370
|
+
}
|
|
371
|
+
bodyLines.push('');
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Member-level changes section
|
|
376
|
+
if (diff.memberChanges && diff.memberChanges.length > 0) {
|
|
377
|
+
bodyLines.push('### Method/Property Changes');
|
|
378
|
+
bodyLines.push('');
|
|
379
|
+
|
|
380
|
+
const removed = diff.memberChanges.filter((m) => m.changeType === 'removed');
|
|
381
|
+
const changed = diff.memberChanges.filter((m) => m.changeType === 'signature-changed');
|
|
382
|
+
const added = diff.memberChanges.filter((m) => m.changeType === 'added');
|
|
383
|
+
|
|
384
|
+
if (removed.length > 0) {
|
|
385
|
+
bodyLines.push(`**Removed (${removed.length})**`);
|
|
386
|
+
for (const m of removed.slice(0, 5)) {
|
|
387
|
+
bodyLines.push(`- \`${m.className}.${m.memberName}\``);
|
|
388
|
+
}
|
|
389
|
+
if (removed.length > 5) bodyLines.push(`- ...and ${removed.length - 5} more`);
|
|
390
|
+
bodyLines.push('');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (changed.length > 0) {
|
|
394
|
+
bodyLines.push(`**Signature Changed (${changed.length})**`);
|
|
395
|
+
for (const m of changed.slice(0, 5)) {
|
|
396
|
+
bodyLines.push(`- \`${m.className}.${m.memberName}\``);
|
|
397
|
+
}
|
|
398
|
+
if (changed.length > 5) bodyLines.push(`- ...and ${changed.length - 5} more`);
|
|
399
|
+
bodyLines.push('');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (added.length > 0) {
|
|
403
|
+
bodyLines.push(`**Added (${added.length})**`);
|
|
404
|
+
for (const m of added.slice(0, 3)) {
|
|
405
|
+
bodyLines.push(`- \`${m.className}.${m.memberName}\``);
|
|
406
|
+
}
|
|
407
|
+
if (added.length > 3) bodyLines.push(`- ...and ${added.length - 3} more`);
|
|
408
|
+
bodyLines.push('');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// New exports section
|
|
413
|
+
if (diff.nonBreaking.length > 0) {
|
|
414
|
+
bodyLines.push(`### ✨ New Exports (${diff.nonBreaking.length})`);
|
|
415
|
+
for (const name of diff.nonBreaking.slice(0, 5)) {
|
|
416
|
+
bodyLines.push(`- \`${name}\``);
|
|
417
|
+
}
|
|
418
|
+
if (diff.nonBreaking.length > 5) {
|
|
419
|
+
bodyLines.push(`- ...and ${diff.nonBreaking.length - 5} more`);
|
|
420
|
+
}
|
|
421
|
+
bodyLines.push('');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// New undocumented exports warning
|
|
425
|
+
if (diff.newUndocumented.length > 0) {
|
|
426
|
+
bodyLines.push(`### ⚠️ New Undocumented Exports (${diff.newUndocumented.length})`);
|
|
427
|
+
bodyLines.push('');
|
|
428
|
+
bodyLines.push('These new exports are missing documentation:');
|
|
429
|
+
for (const name of diff.newUndocumented.slice(0, 5)) {
|
|
430
|
+
bodyLines.push(`- \`${name}\``);
|
|
431
|
+
}
|
|
432
|
+
if (diff.newUndocumented.length > 5) {
|
|
433
|
+
bodyLines.push(`- ...and ${diff.newUndocumented.length - 5} more`);
|
|
434
|
+
}
|
|
435
|
+
bodyLines.push('');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Status message
|
|
439
|
+
if (diff.breaking.length > 0) {
|
|
440
|
+
bodyLines.push('❌ **Breaking changes detected.** Review carefully before merging.');
|
|
441
|
+
} else if (diff.newUndocumented.length > 0) {
|
|
442
|
+
bodyLines.push('⚠️ New exports need documentation. Run `doccov check --fix --generate`.');
|
|
443
|
+
} else if (diff.coverageDelta < 0) {
|
|
444
|
+
bodyLines.push('⚠️ Coverage decreased. Consider adding documentation.');
|
|
445
|
+
} else {
|
|
446
|
+
bodyLines.push('✅ No breaking changes. Documentation is in sync.');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
bodyLines.push('');
|
|
450
|
+
bodyLines.push(
|
|
451
|
+
`<sub>Comparing \`${base.ref}\` (${base.sha.slice(0, 7)}) → \`${head.ref}\` (${head.sha.slice(0, 7)})</sub>`,
|
|
452
|
+
);
|
|
453
|
+
bodyLines.push('');
|
|
454
|
+
bodyLines.push('---');
|
|
455
|
+
bodyLines.push('*Generated by [DocCov](https://doccov.com)*');
|
|
456
|
+
|
|
457
|
+
const body = bodyLines.join('\n');
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
const existingId = await findExistingComment(token, owner, repo, prNumber);
|
|
461
|
+
|
|
462
|
+
if (existingId) {
|
|
463
|
+
const response = await fetch(
|
|
464
|
+
`https://api.github.com/repos/${owner}/${repo}/issues/comments/${existingId}`,
|
|
465
|
+
{
|
|
466
|
+
method: 'PATCH',
|
|
467
|
+
headers: {
|
|
468
|
+
Authorization: `Bearer ${token}`,
|
|
469
|
+
Accept: 'application/vnd.github+json',
|
|
470
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
471
|
+
'Content-Type': 'application/json',
|
|
472
|
+
},
|
|
473
|
+
body: JSON.stringify({ body }),
|
|
474
|
+
},
|
|
475
|
+
);
|
|
476
|
+
return response.ok;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const response = await fetch(
|
|
480
|
+
`https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
|
|
481
|
+
{
|
|
482
|
+
method: 'POST',
|
|
483
|
+
headers: {
|
|
484
|
+
Authorization: `Bearer ${token}`,
|
|
485
|
+
Accept: 'application/vnd.github+json',
|
|
486
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
487
|
+
'Content-Type': 'application/json',
|
|
488
|
+
},
|
|
489
|
+
body: JSON.stringify({ body }),
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
return response.ok;
|
|
494
|
+
} catch (err) {
|
|
495
|
+
console.error('Error posting enhanced PR comment:', err);
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -13,9 +13,9 @@ import { getTokenByInstallationId } from './github-app';
|
|
|
13
13
|
* Result from remote analysis.
|
|
14
14
|
*/
|
|
15
15
|
export interface RemoteAnalysisResult {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
coverageScore: number;
|
|
17
|
+
documentedExports: number;
|
|
18
|
+
totalExports: number;
|
|
19
19
|
driftCount: number;
|
|
20
20
|
qualityErrors: number;
|
|
21
21
|
qualityWarnings: number;
|
|
@@ -190,9 +190,9 @@ export async function analyzeRemoteRepo(
|
|
|
190
190
|
|
|
191
191
|
// Extract metrics
|
|
192
192
|
const docs = enriched.docs;
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const
|
|
193
|
+
const coverageScore = docs?.coverageScore ?? 0;
|
|
194
|
+
const documentedExports = docs?.documented ?? 0;
|
|
195
|
+
const totalExports = docs?.total ?? 0;
|
|
196
196
|
const driftCount = docs?.drift?.length ?? 0;
|
|
197
197
|
|
|
198
198
|
// Count quality issues
|
|
@@ -207,9 +207,9 @@ export async function analyzeRemoteRepo(
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
return {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
coverageScore,
|
|
211
|
+
documentedExports,
|
|
212
|
+
totalExports,
|
|
213
213
|
driftCount,
|
|
214
214
|
qualityErrors,
|
|
215
215
|
qualityWarnings,
|
|
@@ -243,9 +243,9 @@ export function computeAnalysisDiff(
|
|
|
243
243
|
driftDelta: number;
|
|
244
244
|
} {
|
|
245
245
|
return {
|
|
246
|
-
coverageDelta: Number((head.
|
|
247
|
-
documentedDelta: head.
|
|
248
|
-
totalDelta: head.
|
|
246
|
+
coverageDelta: Number((head.coverageScore - base.coverageScore).toFixed(1)),
|
|
247
|
+
documentedDelta: head.documentedExports - base.documentedExports,
|
|
248
|
+
totalDelta: head.totalExports - base.totalExports,
|
|
249
249
|
driftDelta: head.driftCount - base.driftCount,
|
|
250
250
|
};
|
|
251
251
|
}
|