@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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App utilities for token management and API access
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { SignJWT } from 'jose';
|
|
6
|
+
import { db } from '../db/client';
|
|
7
|
+
|
|
8
|
+
const GITHUB_APP_ID = process.env.GITHUB_APP_ID!;
|
|
9
|
+
const GITHUB_APP_PRIVATE_KEY = process.env.GITHUB_APP_PRIVATE_KEY!;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate a JWT for GitHub App authentication
|
|
13
|
+
*/
|
|
14
|
+
async function generateAppJWT(): Promise<string> {
|
|
15
|
+
const privateKey = await importPrivateKey(GITHUB_APP_PRIVATE_KEY);
|
|
16
|
+
|
|
17
|
+
const jwt = await new SignJWT({})
|
|
18
|
+
.setProtectedHeader({ alg: 'RS256' })
|
|
19
|
+
.setIssuedAt()
|
|
20
|
+
.setIssuer(GITHUB_APP_ID)
|
|
21
|
+
.setExpirationTime('10m')
|
|
22
|
+
.sign(privateKey);
|
|
23
|
+
|
|
24
|
+
return jwt;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Import PEM private key for signing
|
|
29
|
+
*/
|
|
30
|
+
async function importPrivateKey(pem: string) {
|
|
31
|
+
// Handle escaped newlines from env vars
|
|
32
|
+
const formattedPem = pem.replace(/\\n/g, '\n');
|
|
33
|
+
|
|
34
|
+
const pemContents = formattedPem
|
|
35
|
+
.replace('-----BEGIN RSA PRIVATE KEY-----', '')
|
|
36
|
+
.replace('-----END RSA PRIVATE KEY-----', '')
|
|
37
|
+
.replace(/\s/g, '');
|
|
38
|
+
|
|
39
|
+
const binaryKey = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0));
|
|
40
|
+
|
|
41
|
+
return crypto.subtle.importKey(
|
|
42
|
+
'pkcs8',
|
|
43
|
+
binaryKey,
|
|
44
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
45
|
+
false,
|
|
46
|
+
['sign'],
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get or refresh installation access token
|
|
52
|
+
*/
|
|
53
|
+
export async function getInstallationToken(orgId: string): Promise<string | null> {
|
|
54
|
+
const installation = await db
|
|
55
|
+
.selectFrom('github_installations')
|
|
56
|
+
.where('orgId', '=', orgId)
|
|
57
|
+
.select(['id', 'installationId', 'accessToken', 'tokenExpiresAt'])
|
|
58
|
+
.executeTakeFirst();
|
|
59
|
+
|
|
60
|
+
if (!installation) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check if token is still valid (with 5 min buffer)
|
|
65
|
+
const now = new Date();
|
|
66
|
+
const expiresAt = installation.tokenExpiresAt;
|
|
67
|
+
const isExpired = !expiresAt || new Date(expiresAt.getTime() - 5 * 60 * 1000) <= now;
|
|
68
|
+
|
|
69
|
+
if (!isExpired && installation.accessToken) {
|
|
70
|
+
return installation.accessToken;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Refresh the token
|
|
74
|
+
try {
|
|
75
|
+
const jwt = await generateAppJWT();
|
|
76
|
+
const response = await fetch(
|
|
77
|
+
`https://api.github.com/app/installations/${installation.installationId}/access_tokens`,
|
|
78
|
+
{
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
Authorization: `Bearer ${jwt}`,
|
|
82
|
+
Accept: 'application/vnd.github+json',
|
|
83
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
console.error('Failed to get installation token:', await response.text());
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const data = (await response.json()) as { token: string; expires_at: string };
|
|
94
|
+
|
|
95
|
+
// Update token in database
|
|
96
|
+
await db
|
|
97
|
+
.updateTable('github_installations')
|
|
98
|
+
.set({
|
|
99
|
+
accessToken: data.token,
|
|
100
|
+
tokenExpiresAt: new Date(data.expires_at),
|
|
101
|
+
updatedAt: new Date(),
|
|
102
|
+
})
|
|
103
|
+
.where('id', '=', installation.id)
|
|
104
|
+
.execute();
|
|
105
|
+
|
|
106
|
+
return data.token;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error('Error refreshing installation token:', err);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get installation token by installation ID (for webhooks)
|
|
115
|
+
*/
|
|
116
|
+
export async function getTokenByInstallationId(installationId: string): Promise<string | null> {
|
|
117
|
+
const installation = await db
|
|
118
|
+
.selectFrom('github_installations')
|
|
119
|
+
.where('installationId', '=', installationId)
|
|
120
|
+
.select(['orgId'])
|
|
121
|
+
.executeTakeFirst();
|
|
122
|
+
|
|
123
|
+
if (!installation) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return getInstallationToken(installation.orgId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* List repositories accessible to an installation
|
|
132
|
+
*/
|
|
133
|
+
export async function listInstallationRepos(
|
|
134
|
+
orgId: string,
|
|
135
|
+
): Promise<Array<{ id: number; name: string; full_name: string; private: boolean }> | null> {
|
|
136
|
+
const token = await getInstallationToken(orgId);
|
|
137
|
+
if (!token) return null;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch('https://api.github.com/installation/repositories', {
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: `Bearer ${token}`,
|
|
143
|
+
Accept: 'application/vnd.github+json',
|
|
144
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
console.error('Failed to list repos:', await response.text());
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const data = (await response.json()) as {
|
|
154
|
+
repositories: Array<{ id: number; name: string; full_name: string; private: boolean }>;
|
|
155
|
+
};
|
|
156
|
+
return data.repositories;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error('Error listing repos:', err);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Fetch file content from a private repo
|
|
165
|
+
*/
|
|
166
|
+
export async function fetchRepoFile(
|
|
167
|
+
orgId: string,
|
|
168
|
+
owner: string,
|
|
169
|
+
repo: string,
|
|
170
|
+
path: string,
|
|
171
|
+
ref?: string,
|
|
172
|
+
): Promise<string | null> {
|
|
173
|
+
const token = await getInstallationToken(orgId);
|
|
174
|
+
if (!token) return null;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const url = new URL(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`);
|
|
178
|
+
if (ref) url.searchParams.set('ref', ref);
|
|
179
|
+
|
|
180
|
+
const response = await fetch(url.toString(), {
|
|
181
|
+
headers: {
|
|
182
|
+
Authorization: `Bearer ${token}`,
|
|
183
|
+
Accept: 'application/vnd.github.raw+json',
|
|
184
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return response.text();
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Check Runs and PR Comments
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SpecDiffWithDocs } from '@doccov/sdk';
|
|
6
|
+
import { getTokenByInstallationId } from './github-app';
|
|
7
|
+
|
|
8
|
+
interface AnalysisResult {
|
|
9
|
+
coverageScore: number;
|
|
10
|
+
documentedExports: number;
|
|
11
|
+
totalExports: number;
|
|
12
|
+
driftCount: number;
|
|
13
|
+
qualityErrors?: number;
|
|
14
|
+
qualityWarnings?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AnalysisDiff {
|
|
18
|
+
coverageDelta: number;
|
|
19
|
+
documentedDelta: number;
|
|
20
|
+
totalDelta: number;
|
|
21
|
+
driftDelta: number;
|
|
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
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Format a delta value with sign and styling
|
|
35
|
+
*/
|
|
36
|
+
function formatDelta(delta: number, suffix = ''): string {
|
|
37
|
+
if (delta === 0) return '';
|
|
38
|
+
const sign = delta > 0 ? '+' : '';
|
|
39
|
+
return ` (${sign}${delta}${suffix})`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create or update a check run
|
|
44
|
+
*/
|
|
45
|
+
export async function createCheckRun(
|
|
46
|
+
installationId: string,
|
|
47
|
+
owner: string,
|
|
48
|
+
repo: string,
|
|
49
|
+
sha: string,
|
|
50
|
+
result: AnalysisResult,
|
|
51
|
+
diff?: AnalysisDiff | null,
|
|
52
|
+
): Promise<boolean> {
|
|
53
|
+
const token = await getTokenByInstallationId(installationId);
|
|
54
|
+
if (!token) return false;
|
|
55
|
+
|
|
56
|
+
const coverageDelta = diff ? formatDelta(diff.coverageDelta, '%') : '';
|
|
57
|
+
|
|
58
|
+
// Determine conclusion based on drift and quality
|
|
59
|
+
const hasIssues = result.driftCount > 0 || (result.qualityErrors ?? 0) > 0;
|
|
60
|
+
const conclusion = hasIssues ? 'neutral' : 'success';
|
|
61
|
+
|
|
62
|
+
const title = `Coverage: ${result.coverageScore.toFixed(1)}%${coverageDelta}`;
|
|
63
|
+
|
|
64
|
+
const summaryLines = [
|
|
65
|
+
`## Documentation Coverage`,
|
|
66
|
+
'',
|
|
67
|
+
`\`\`\``,
|
|
68
|
+
`Coverage: ${result.coverageScore.toFixed(1)}%${coverageDelta}`,
|
|
69
|
+
`├─ Documented: ${result.documentedExports}/${result.totalExports} exports`,
|
|
70
|
+
`├─ Drift: ${result.driftCount} issue${result.driftCount !== 1 ? 's' : ''}${diff ? formatDelta(-diff.driftDelta) : ''}`,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const qualityIssues = (result.qualityErrors ?? 0) + (result.qualityWarnings ?? 0);
|
|
74
|
+
if (qualityIssues > 0) {
|
|
75
|
+
summaryLines.push(
|
|
76
|
+
`└─ Quality: ${result.qualityErrors ?? 0} error${(result.qualityErrors ?? 0) !== 1 ? 's' : ''}, ${result.qualityWarnings ?? 0} warning${(result.qualityWarnings ?? 0) !== 1 ? 's' : ''}`,
|
|
77
|
+
);
|
|
78
|
+
} else {
|
|
79
|
+
summaryLines.push(`└─ Quality: ✓ No issues`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
summaryLines.push(`\`\`\``);
|
|
83
|
+
|
|
84
|
+
if (diff) {
|
|
85
|
+
summaryLines.push('', '### Changes vs base');
|
|
86
|
+
summaryLines.push('| Metric | Delta |');
|
|
87
|
+
summaryLines.push('|--------|-------|');
|
|
88
|
+
summaryLines.push(
|
|
89
|
+
`| Coverage | ${diff.coverageDelta >= 0 ? '📈' : '📉'} ${diff.coverageDelta >= 0 ? '+' : ''}${diff.coverageDelta.toFixed(1)}% |`,
|
|
90
|
+
);
|
|
91
|
+
if (diff.documentedDelta !== 0) {
|
|
92
|
+
summaryLines.push(
|
|
93
|
+
`| Documented | ${diff.documentedDelta >= 0 ? '+' : ''}${diff.documentedDelta} |`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (diff.driftDelta !== 0) {
|
|
97
|
+
const driftChange = -diff.driftDelta; // Positive driftDelta means drift increased (bad)
|
|
98
|
+
summaryLines.push(`| Drift issues | ${driftChange >= 0 ? '+' : ''}${driftChange} |`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
summaryLines.push('', `[View full report →](https://doccov.com/r/${owner}/${repo}/${sha})`);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/check-runs`, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: {
|
|
108
|
+
Authorization: `Bearer ${token}`,
|
|
109
|
+
Accept: 'application/vnd.github+json',
|
|
110
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
name: 'DocCov',
|
|
115
|
+
head_sha: sha,
|
|
116
|
+
status: 'completed',
|
|
117
|
+
conclusion,
|
|
118
|
+
output: {
|
|
119
|
+
title,
|
|
120
|
+
summary: summaryLines.join('\n'),
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
console.error('Failed to create check run:', await response.text());
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return true;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error('Error creating check run:', err);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Post or update a PR comment
|
|
139
|
+
*/
|
|
140
|
+
export async function postPRComment(
|
|
141
|
+
installationId: string,
|
|
142
|
+
owner: string,
|
|
143
|
+
repo: string,
|
|
144
|
+
prNumber: number,
|
|
145
|
+
result: AnalysisResult,
|
|
146
|
+
diff?: AnalysisDiff | null,
|
|
147
|
+
): Promise<boolean> {
|
|
148
|
+
const token = await getTokenByInstallationId(installationId);
|
|
149
|
+
if (!token) return false;
|
|
150
|
+
|
|
151
|
+
const coverageEmoji = diff
|
|
152
|
+
? diff.coverageDelta > 0
|
|
153
|
+
? '📈'
|
|
154
|
+
: diff.coverageDelta < 0
|
|
155
|
+
? '📉'
|
|
156
|
+
: '➡️'
|
|
157
|
+
: '📊';
|
|
158
|
+
|
|
159
|
+
const bodyLines = [`## ${coverageEmoji} DocCov Report`, ''];
|
|
160
|
+
|
|
161
|
+
// Summary table
|
|
162
|
+
if (diff) {
|
|
163
|
+
bodyLines.push('| Metric | This PR | Base | Δ |');
|
|
164
|
+
bodyLines.push('|--------|---------|------|---|');
|
|
165
|
+
bodyLines.push(
|
|
166
|
+
`| Coverage | **${result.coverageScore.toFixed(1)}%** | ${(result.coverageScore - diff.coverageDelta).toFixed(1)}% | ${diff.coverageDelta >= 0 ? '+' : ''}${diff.coverageDelta.toFixed(1)}% |`,
|
|
167
|
+
);
|
|
168
|
+
bodyLines.push(
|
|
169
|
+
`| Documented | ${result.documentedExports} | ${result.documentedExports - diff.documentedDelta} | ${diff.documentedDelta >= 0 ? '+' : ''}${diff.documentedDelta} |`,
|
|
170
|
+
);
|
|
171
|
+
bodyLines.push(
|
|
172
|
+
`| Total exports | ${result.totalExports} | ${result.totalExports - diff.totalDelta} | ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} |`,
|
|
173
|
+
);
|
|
174
|
+
const baseDrift = result.driftCount - diff.driftDelta;
|
|
175
|
+
const driftSign = diff.driftDelta >= 0 ? '+' : '';
|
|
176
|
+
bodyLines.push(
|
|
177
|
+
`| Drift issues | ${result.driftCount} | ${baseDrift} | ${driftSign}${diff.driftDelta} |`,
|
|
178
|
+
);
|
|
179
|
+
} else {
|
|
180
|
+
bodyLines.push('| Metric | Value |');
|
|
181
|
+
bodyLines.push('|--------|-------|');
|
|
182
|
+
bodyLines.push(`| Coverage | **${result.coverageScore.toFixed(1)}%** |`);
|
|
183
|
+
bodyLines.push(`| Documented | ${result.documentedExports} / ${result.totalExports} |`);
|
|
184
|
+
bodyLines.push(`| Drift issues | ${result.driftCount} |`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
bodyLines.push('');
|
|
188
|
+
|
|
189
|
+
// Quality summary
|
|
190
|
+
const qualityErrors = result.qualityErrors ?? 0;
|
|
191
|
+
const qualityWarnings = result.qualityWarnings ?? 0;
|
|
192
|
+
if (qualityErrors > 0 || qualityWarnings > 0) {
|
|
193
|
+
bodyLines.push(
|
|
194
|
+
`**Quality**: ${qualityErrors} error${qualityErrors !== 1 ? 's' : ''}, ${qualityWarnings} warning${qualityWarnings !== 1 ? 's' : ''}`,
|
|
195
|
+
);
|
|
196
|
+
bodyLines.push('');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Status message
|
|
200
|
+
if (result.driftCount > 0) {
|
|
201
|
+
bodyLines.push('⚠️ Documentation drift detected. Run `doccov check --fix` to update.');
|
|
202
|
+
} else if (qualityErrors > 0) {
|
|
203
|
+
bodyLines.push('❌ Quality errors found. Run `doccov check` for details.');
|
|
204
|
+
} else {
|
|
205
|
+
bodyLines.push('✅ Documentation is in sync.');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
bodyLines.push('');
|
|
209
|
+
bodyLines.push('---');
|
|
210
|
+
bodyLines.push('*Generated by [DocCov](https://doccov.com)*');
|
|
211
|
+
|
|
212
|
+
const body = bodyLines.join('\n');
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
// Check for existing comment
|
|
216
|
+
const existingId = await findExistingComment(token, owner, repo, prNumber);
|
|
217
|
+
|
|
218
|
+
if (existingId) {
|
|
219
|
+
// Update existing comment
|
|
220
|
+
const response = await fetch(
|
|
221
|
+
`https://api.github.com/repos/${owner}/${repo}/issues/comments/${existingId}`,
|
|
222
|
+
{
|
|
223
|
+
method: 'PATCH',
|
|
224
|
+
headers: {
|
|
225
|
+
Authorization: `Bearer ${token}`,
|
|
226
|
+
Accept: 'application/vnd.github+json',
|
|
227
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
228
|
+
'Content-Type': 'application/json',
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify({ body }),
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
return response.ok;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Create new comment
|
|
237
|
+
const response = await fetch(
|
|
238
|
+
`https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
|
|
239
|
+
{
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: {
|
|
242
|
+
Authorization: `Bearer ${token}`,
|
|
243
|
+
Accept: 'application/vnd.github+json',
|
|
244
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
245
|
+
'Content-Type': 'application/json',
|
|
246
|
+
},
|
|
247
|
+
body: JSON.stringify({ body }),
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return response.ok;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.error('Error posting PR comment:', err);
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Find existing DocCov comment on a PR
|
|
260
|
+
*/
|
|
261
|
+
async function findExistingComment(
|
|
262
|
+
token: string,
|
|
263
|
+
owner: string,
|
|
264
|
+
repo: string,
|
|
265
|
+
prNumber: number,
|
|
266
|
+
): Promise<number | null> {
|
|
267
|
+
try {
|
|
268
|
+
const response = await fetch(
|
|
269
|
+
`https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
|
|
270
|
+
{
|
|
271
|
+
headers: {
|
|
272
|
+
Authorization: `Bearer ${token}`,
|
|
273
|
+
Accept: 'application/vnd.github+json',
|
|
274
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (!response.ok) return null;
|
|
280
|
+
|
|
281
|
+
const comments = (await response.json()) as Array<{ id: number; body: string }>;
|
|
282
|
+
const existing = comments.find((c) => c.body.includes('DocCov Report'));
|
|
283
|
+
|
|
284
|
+
return existing?.id ?? null;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
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
|
+
}
|