delivery-friction-analyzer 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/docs/contracts/friction-metrics.md +52 -0
- package/docs/contracts/friction-report.md +131 -0
- package/docs/contracts/normalized-entities.md +39 -0
- package/docs/contracts/target-repository.md +24 -0
- package/docs/reference/github-access-coverage.md +25 -0
- package/docs/reference/github-data-inventory.md +55 -0
- package/docs/reference/release-automation.md +65 -0
- package/docs/reference/repository-profile.md +55 -0
- package/fixtures/github/mcp-writing/profile.json +73 -0
- package/package.json +48 -0
- package/release-log.md +106 -0
- package/schemas/normalized-entities.schema.json +342 -0
- package/schemas/repository-profile.schema.json +92 -0
- package/schemas/target-repository.schema.json +40 -0
- package/src/cli/analyze-github.js +597 -0
- package/src/collect/coverage.js +82 -0
- package/src/collect/gh-provider.js +279 -0
- package/src/collect/github-source-bundle.js +455 -0
- package/src/contracts/target-repository.js +75 -0
- package/src/github/comment-source.js +57 -0
- package/src/metrics/friction.js +414 -0
- package/src/normalize/github-fixture.js +168 -0
- package/src/profile/file-role.js +76 -0
- package/src/profile/pr-class.js +107 -0
- package/src/report/evidence-artifacts.js +403 -0
- package/src/report/friction-report.js +1301 -0
- package/src/report/generate-report.js +85 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { validateTargetRepository } from "../contracts/target-repository.js";
|
|
2
|
+
import {
|
|
3
|
+
COVERAGE_STATUS,
|
|
4
|
+
classifyCoverageStatus,
|
|
5
|
+
coverageEntry,
|
|
6
|
+
mergeCoverageEntries,
|
|
7
|
+
redactDiagnostic,
|
|
8
|
+
} from "./coverage.js";
|
|
9
|
+
|
|
10
|
+
export const GITHUB_SOURCE_BUNDLE_VERSION = "github-source-bundle.v1";
|
|
11
|
+
|
|
12
|
+
const REPOSITORY_SLUG = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
|
|
13
|
+
function parseRepositoryInput(input) {
|
|
14
|
+
if (typeof input === "string") {
|
|
15
|
+
const match = input.match(REPOSITORY_SLUG);
|
|
16
|
+
if (!match) {
|
|
17
|
+
throw new Error("repository must use owner/name with GitHub-safe owner and name segments.");
|
|
18
|
+
}
|
|
19
|
+
return { owner: match[1], name: match[2] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (input && typeof input === "object") {
|
|
23
|
+
return { owner: input.owner, name: input.name };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
throw new Error("repository must be an owner/name string or an object with owner and name.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requirePullRequestLimit(limit) {
|
|
30
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
31
|
+
throw new Error("PR sample limit must be an integer between 1 and 100.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function visibilityOf(repository) {
|
|
36
|
+
if (repository.visibility === "public" || repository.private === false) return "public";
|
|
37
|
+
if (repository.visibility === "private" || repository.private === true) return "private";
|
|
38
|
+
return "unknown";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mapTargetRepository({ owner, name, repositoryMetadata, analysisPullRequestLimit, isValidationTarget }) {
|
|
42
|
+
const targetRepository = {
|
|
43
|
+
owner,
|
|
44
|
+
name,
|
|
45
|
+
defaultBranch: repositoryMetadata.default_branch ?? repositoryMetadata.defaultBranch ?? "main",
|
|
46
|
+
visibility: visibilityOf(repositoryMetadata),
|
|
47
|
+
analysisPullRequestLimit,
|
|
48
|
+
isValidationTarget,
|
|
49
|
+
};
|
|
50
|
+
const errors = validateTargetRepository(targetRepository);
|
|
51
|
+
if (errors.length > 0) {
|
|
52
|
+
throw new Error(`collected target repository metadata is invalid: ${errors.join(" ")}`);
|
|
53
|
+
}
|
|
54
|
+
return targetRepository;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mapRepositoryMetadata(repository) {
|
|
58
|
+
return {
|
|
59
|
+
id: repository.id ?? null,
|
|
60
|
+
name: repository.name ?? null,
|
|
61
|
+
owner: repository.owner?.login ?? repository.owner?.name ?? null,
|
|
62
|
+
fullName: repository.full_name ?? repository.fullName ?? null,
|
|
63
|
+
defaultBranch: repository.default_branch ?? repository.defaultBranch ?? null,
|
|
64
|
+
visibility: visibilityOf(repository),
|
|
65
|
+
isPrivate: repository.private ?? null,
|
|
66
|
+
htmlUrl: repository.html_url ?? repository.url ?? null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mapCommit(commit) {
|
|
71
|
+
return {
|
|
72
|
+
oid: commit.oid ?? commit.sha ?? commit.commit?.oid ?? commit.commit?.sha ?? null,
|
|
73
|
+
authoredDate: commit.authoredDate ?? commit.commit?.author?.date ?? null,
|
|
74
|
+
committedDate: commit.committedDate ?? commit.commit?.committer?.date ?? null,
|
|
75
|
+
messageHeadline: commit.messageHeadline ?? commit.commit?.message?.split("\n")[0] ?? null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mapReview(review) {
|
|
80
|
+
const id = review.id ?? review.databaseId;
|
|
81
|
+
return {
|
|
82
|
+
id: id == null ? null : String(id),
|
|
83
|
+
author: review.author ?? null,
|
|
84
|
+
submittedAt: review.submittedAt ?? review.submitted_at ?? null,
|
|
85
|
+
state: review.state ?? null,
|
|
86
|
+
commitOid: review.commitOid ?? review.commit?.oid ?? null,
|
|
87
|
+
generatedCommentCount: review.generatedCommentCount ?? null,
|
|
88
|
+
failedAttempt: Boolean(review.failedAttempt),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mapReviewThreads(threads) {
|
|
93
|
+
let truncatedCommentThreads = 0;
|
|
94
|
+
const nodes = (threads.nodes ?? []).map(thread => ({
|
|
95
|
+
id: thread.id,
|
|
96
|
+
isResolved: Boolean(thread.isResolved),
|
|
97
|
+
isOutdated: Boolean(thread.isOutdated),
|
|
98
|
+
path: thread.path ?? null,
|
|
99
|
+
line: thread.line ?? null,
|
|
100
|
+
comments: (thread.comments?.nodes ?? thread.comments ?? []).map(comment => ({
|
|
101
|
+
databaseId: comment.databaseId ?? comment.id ?? null,
|
|
102
|
+
author: comment.author ?? null,
|
|
103
|
+
path: comment.path ?? thread.path ?? null,
|
|
104
|
+
line: comment.line ?? null,
|
|
105
|
+
originalLine: comment.originalLine ?? null,
|
|
106
|
+
createdAt: comment.createdAt ?? null,
|
|
107
|
+
updatedAt: comment.updatedAt ?? null,
|
|
108
|
+
url: comment.url ?? null,
|
|
109
|
+
})),
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
for (const thread of threads.nodes ?? []) {
|
|
113
|
+
if (thread.comments?.pageInfo?.hasNextPage) {
|
|
114
|
+
truncatedCommentThreads += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
reviewThreads: {
|
|
120
|
+
source: "graphql:repository.pullRequest.reviewThreads",
|
|
121
|
+
totalCount: threads.totalCount ?? nodes.length,
|
|
122
|
+
nodes,
|
|
123
|
+
},
|
|
124
|
+
truncatedCommentThreads,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function unavailableReviewThreads() {
|
|
129
|
+
return {
|
|
130
|
+
source: "unavailable",
|
|
131
|
+
totalCount: 0,
|
|
132
|
+
nodes: [],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function mapStatusCheck(check) {
|
|
137
|
+
return {
|
|
138
|
+
__typename: check.__typename ?? check.type ?? "CheckRun",
|
|
139
|
+
name: check.name ?? null,
|
|
140
|
+
context: check.context ?? null,
|
|
141
|
+
workflowName: check.workflowName ?? check.workflow?.name ?? null,
|
|
142
|
+
status: check.status ?? check.state ?? null,
|
|
143
|
+
conclusion: check.conclusion ?? check.state ?? null,
|
|
144
|
+
startedAt: check.startedAt ?? null,
|
|
145
|
+
completedAt: check.completedAt ?? null,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function summarizeConclusions(workflowRuns = []) {
|
|
150
|
+
const conclusions = {};
|
|
151
|
+
for (const run of workflowRuns) {
|
|
152
|
+
const conclusion = String(run.conclusion ?? run.status ?? "unknown").toLowerCase();
|
|
153
|
+
conclusions[conclusion] = (conclusions[conclusion] ?? 0) + 1;
|
|
154
|
+
}
|
|
155
|
+
return conclusions;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function mapWorkflowRuns(data) {
|
|
159
|
+
const workflowRuns = data.workflow_runs ?? data.workflowRuns ?? [];
|
|
160
|
+
return {
|
|
161
|
+
source: "rest:/repos/{owner}/{repo}/actions/runs?branch={branch}&event=pull_request",
|
|
162
|
+
totalCount: data.total_count ?? data.totalCount ?? workflowRuns.length,
|
|
163
|
+
conclusions: summarizeConclusions(workflowRuns),
|
|
164
|
+
runs: workflowRuns.map(run => ({
|
|
165
|
+
id: run.id ?? null,
|
|
166
|
+
name: run.name ?? null,
|
|
167
|
+
workflowName: run.workflow_name ?? run.workflowName ?? run.name ?? null,
|
|
168
|
+
headSha: run.head_sha ?? run.headSha ?? null,
|
|
169
|
+
headBranch: run.head_branch ?? run.headBranch ?? null,
|
|
170
|
+
event: run.event ?? null,
|
|
171
|
+
status: run.status ?? null,
|
|
172
|
+
conclusion: run.conclusion ?? null,
|
|
173
|
+
createdAt: run.created_at ?? run.createdAt ?? null,
|
|
174
|
+
updatedAt: run.updated_at ?? run.updatedAt ?? null,
|
|
175
|
+
runStartedAt: run.run_started_at ?? run.runStartedAt ?? null,
|
|
176
|
+
htmlUrl: run.html_url ?? run.url ?? null,
|
|
177
|
+
})),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function unavailableWorkflowRuns() {
|
|
182
|
+
return {
|
|
183
|
+
source: "unavailable",
|
|
184
|
+
totalCount: null,
|
|
185
|
+
conclusions: {},
|
|
186
|
+
runs: [],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function mapPullRequestDetails(details) {
|
|
191
|
+
return {
|
|
192
|
+
number: details.number,
|
|
193
|
+
title: details.title,
|
|
194
|
+
author: details.author ?? null,
|
|
195
|
+
url: details.url,
|
|
196
|
+
state: details.state,
|
|
197
|
+
createdAt: details.createdAt ?? null,
|
|
198
|
+
mergedAt: details.mergedAt ?? null,
|
|
199
|
+
updatedAt: details.updatedAt ?? null,
|
|
200
|
+
baseRefName: details.baseRefName ?? null,
|
|
201
|
+
headRefName: details.headRefName ?? null,
|
|
202
|
+
headRefOid: details.headRefOid ?? null,
|
|
203
|
+
additions: details.additions ?? 0,
|
|
204
|
+
deletions: details.deletions ?? 0,
|
|
205
|
+
changedFiles: details.changedFiles ?? 0,
|
|
206
|
+
prOpenDiff: {
|
|
207
|
+
source: "unavailable",
|
|
208
|
+
confidence: "unavailable",
|
|
209
|
+
reason: "Historical PR-open diff data is not available from the M1 live collector.",
|
|
210
|
+
},
|
|
211
|
+
commits: (details.commits ?? []).map(mapCommit),
|
|
212
|
+
files: (details.files ?? []).map(file => ({
|
|
213
|
+
path: file.path ?? file.filename ?? null,
|
|
214
|
+
additions: file.additions ?? 0,
|
|
215
|
+
deletions: file.deletions ?? 0,
|
|
216
|
+
changeType: file.changeType ?? file.status ?? null,
|
|
217
|
+
})),
|
|
218
|
+
reviews: (details.reviews ?? []).map(mapReview),
|
|
219
|
+
reviewThreads: unavailableReviewThreads(),
|
|
220
|
+
statusCheckRollup: (details.statusCheckRollup ?? []).map(mapStatusCheck),
|
|
221
|
+
workflowRuns: unavailableWorkflowRuns(),
|
|
222
|
+
coverage: {
|
|
223
|
+
prOpenDiff: coverageEntry({
|
|
224
|
+
family: "pr_open_diff",
|
|
225
|
+
source: "historical_snapshot",
|
|
226
|
+
status: COVERAGE_STATUS.unavailable,
|
|
227
|
+
diagnostics: ["PR-open diff snapshots are unavailable in M1; final merge diff is kept separately."],
|
|
228
|
+
downstreamImpact: "Diff growth metrics must remain unavailable.",
|
|
229
|
+
}),
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function attempt({ family, source, downstreamImpact, run }) {
|
|
235
|
+
try {
|
|
236
|
+
const value = await run();
|
|
237
|
+
return {
|
|
238
|
+
value,
|
|
239
|
+
coverage: coverageEntry({
|
|
240
|
+
family,
|
|
241
|
+
source,
|
|
242
|
+
status: COVERAGE_STATUS.available,
|
|
243
|
+
downstreamImpact,
|
|
244
|
+
}),
|
|
245
|
+
};
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return {
|
|
248
|
+
value: null,
|
|
249
|
+
coverage: coverageEntry({
|
|
250
|
+
family,
|
|
251
|
+
source,
|
|
252
|
+
status: classifyCoverageStatus(error),
|
|
253
|
+
diagnostics: [redactDiagnostic(error?.message ?? error)],
|
|
254
|
+
downstreamImpact,
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function buildCoverageSummary(entries) {
|
|
261
|
+
const statuses = new Set(entries.map(entry => entry.status));
|
|
262
|
+
if (statuses.has(COVERAGE_STATUS.rateLimited)) return COVERAGE_STATUS.rateLimited;
|
|
263
|
+
if (statuses.size === 1 && statuses.has(COVERAGE_STATUS.unavailable)) return COVERAGE_STATUS.unavailable;
|
|
264
|
+
if (statuses.has(COVERAGE_STATUS.unavailable) || statuses.has(COVERAGE_STATUS.partial)) return COVERAGE_STATUS.partial;
|
|
265
|
+
return COVERAGE_STATUS.available;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function collectGitHubSourceBundle({
|
|
269
|
+
repository,
|
|
270
|
+
owner,
|
|
271
|
+
name,
|
|
272
|
+
limit,
|
|
273
|
+
provider,
|
|
274
|
+
collectedAt = new Date().toISOString(),
|
|
275
|
+
analysisPullRequestLimit,
|
|
276
|
+
isValidationTarget = false,
|
|
277
|
+
} = {}) {
|
|
278
|
+
if (!provider) {
|
|
279
|
+
throw new Error("provider is required.");
|
|
280
|
+
}
|
|
281
|
+
const targetPullRequestLimit = analysisPullRequestLimit ?? limit;
|
|
282
|
+
requirePullRequestLimit(targetPullRequestLimit);
|
|
283
|
+
const targetInput = repository ? parseRepositoryInput(repository) : { owner, name };
|
|
284
|
+
const targetNameErrors = validateTargetRepository({
|
|
285
|
+
...targetInput,
|
|
286
|
+
defaultBranch: "main",
|
|
287
|
+
visibility: "unknown",
|
|
288
|
+
analysisPullRequestLimit: targetPullRequestLimit,
|
|
289
|
+
isValidationTarget,
|
|
290
|
+
}).filter(error => !error.includes("defaultBranch") && !error.includes("visibility"));
|
|
291
|
+
if (targetNameErrors.length > 0) {
|
|
292
|
+
throw new Error(targetNameErrors.join(" "));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const repositoryMetadata = await provider.getRepository(targetInput);
|
|
296
|
+
const targetRepository = mapTargetRepository({
|
|
297
|
+
...targetInput,
|
|
298
|
+
repositoryMetadata,
|
|
299
|
+
analysisPullRequestLimit: targetPullRequestLimit,
|
|
300
|
+
isValidationTarget,
|
|
301
|
+
});
|
|
302
|
+
const repositoryCoverage = coverageEntry({
|
|
303
|
+
family: "repository_metadata",
|
|
304
|
+
source: "rest:/repos/{owner}/{repo}",
|
|
305
|
+
status: COVERAGE_STATUS.available,
|
|
306
|
+
downstreamImpact: "Required for target repository identity and visibility.",
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const languagesAttempt = await attempt({
|
|
310
|
+
family: "languages",
|
|
311
|
+
source: "rest:/repos/{owner}/{repo}/languages",
|
|
312
|
+
downstreamImpact: "Language context omitted when unavailable; file roles must still come from repository profile later.",
|
|
313
|
+
run: () => provider.getLanguages(targetInput),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const inventory = (await provider.listMergedPullRequests({ ...targetInput, limit: targetPullRequestLimit }))
|
|
317
|
+
.sort((left, right) => String(right.mergedAt ?? "").localeCompare(String(left.mergedAt ?? "")))
|
|
318
|
+
.slice(0, targetPullRequestLimit);
|
|
319
|
+
const inventoryCoverage = coverageEntry({
|
|
320
|
+
family: "pull_request_inventory",
|
|
321
|
+
source: "gh pr list --state merged --search \"is:merged sort:merged-desc\"",
|
|
322
|
+
status: COVERAGE_STATUS.available,
|
|
323
|
+
downstreamImpact: "Required to select latest merged pull requests.",
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const pullRequests = [];
|
|
327
|
+
const reviewThreadCoverages = [];
|
|
328
|
+
const workflowRunCoverages = [];
|
|
329
|
+
|
|
330
|
+
for (const inventoryItem of inventory) {
|
|
331
|
+
const details = await provider.getPullRequest({ ...targetInput, number: inventoryItem.number });
|
|
332
|
+
const pr = mapPullRequestDetails(details);
|
|
333
|
+
|
|
334
|
+
const reviewThreadsAttempt = await attempt({
|
|
335
|
+
family: "review_threads",
|
|
336
|
+
source: "graphql:repository.pullRequest.reviewThreads",
|
|
337
|
+
downstreamImpact: "Thread resolution and threaded comment metrics are unavailable for affected PRs.",
|
|
338
|
+
run: () => provider.getReviewThreads({ ...targetInput, number: pr.number }),
|
|
339
|
+
});
|
|
340
|
+
if (reviewThreadsAttempt.value) {
|
|
341
|
+
const mappedReviewThreads = mapReviewThreads(reviewThreadsAttempt.value);
|
|
342
|
+
pr.reviewThreads = mappedReviewThreads.reviewThreads;
|
|
343
|
+
if (mappedReviewThreads.truncatedCommentThreads > 0) {
|
|
344
|
+
reviewThreadsAttempt.coverage = coverageEntry({
|
|
345
|
+
family: "review_threads",
|
|
346
|
+
source: "graphql:repository.pullRequest.reviewThreads",
|
|
347
|
+
status: COVERAGE_STATUS.partial,
|
|
348
|
+
diagnostics: [
|
|
349
|
+
`PR #${pr.number} has ${mappedReviewThreads.truncatedCommentThreads} review thread(s) with more than 100 comments; nested comment pagination is deferred from M1.`,
|
|
350
|
+
],
|
|
351
|
+
downstreamImpact: "Thread counts are available, but review-comment totals may be incomplete for affected PRs.",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
pr.coverage.reviewThreads = reviewThreadsAttempt.coverage;
|
|
356
|
+
reviewThreadCoverages.push(reviewThreadsAttempt.coverage);
|
|
357
|
+
|
|
358
|
+
if (pr.headRefName) {
|
|
359
|
+
const workflowRunsAttempt = await attempt({
|
|
360
|
+
family: "workflow_runs",
|
|
361
|
+
source: "rest:/repos/{owner}/{repo}/actions/runs?branch={branch}&event=pull_request",
|
|
362
|
+
downstreamImpact: "Workflow churn history is unavailable for affected PRs; final status check rollup remains available.",
|
|
363
|
+
run: () => provider.getWorkflowRuns({ ...targetInput, branch: pr.headRefName }),
|
|
364
|
+
});
|
|
365
|
+
if (workflowRunsAttempt.value) {
|
|
366
|
+
pr.workflowRuns = mapWorkflowRuns(workflowRunsAttempt.value);
|
|
367
|
+
if (pr.workflowRuns.totalCount > pr.workflowRuns.runs.length) {
|
|
368
|
+
workflowRunsAttempt.coverage = coverageEntry({
|
|
369
|
+
family: "workflow_runs",
|
|
370
|
+
source: "rest:/repos/{owner}/{repo}/actions/runs?branch={branch}&event=pull_request",
|
|
371
|
+
status: COVERAGE_STATUS.partial,
|
|
372
|
+
diagnostics: [
|
|
373
|
+
`PR #${pr.number} collected ${pr.workflowRuns.runs.length} of ${pr.workflowRuns.totalCount} workflow run(s).`,
|
|
374
|
+
],
|
|
375
|
+
downstreamImpact: "Workflow churn history may be incomplete for affected PRs; final status check rollup remains available.",
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
pr.coverage.workflowRuns = workflowRunsAttempt.coverage;
|
|
380
|
+
workflowRunCoverages.push(workflowRunsAttempt.coverage);
|
|
381
|
+
} else {
|
|
382
|
+
const branchCoverage = coverageEntry({
|
|
383
|
+
family: "workflow_runs",
|
|
384
|
+
source: "rest:/repos/{owner}/{repo}/actions/runs?branch={branch}&event=pull_request",
|
|
385
|
+
status: COVERAGE_STATUS.unavailable,
|
|
386
|
+
diagnostics: [`PR #${pr.number} has no accessible headRefName for branch-based workflow-run lookup.`],
|
|
387
|
+
downstreamImpact: "Workflow churn history is unavailable for this PR; final status check rollup remains available.",
|
|
388
|
+
});
|
|
389
|
+
pr.coverage.workflowRuns = branchCoverage;
|
|
390
|
+
workflowRunCoverages.push(branchCoverage);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
pullRequests.push(pr);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const apiFamilies = [
|
|
397
|
+
repositoryCoverage,
|
|
398
|
+
languagesAttempt.coverage,
|
|
399
|
+
inventoryCoverage,
|
|
400
|
+
coverageEntry({
|
|
401
|
+
family: "pull_request_details",
|
|
402
|
+
source: "gh pr view --json",
|
|
403
|
+
status: COVERAGE_STATUS.available,
|
|
404
|
+
attempts: pullRequests.length,
|
|
405
|
+
downstreamImpact: "Required for PR metadata, final diff, files, commits, reviews, and check rollups.",
|
|
406
|
+
}),
|
|
407
|
+
mergeCoverageEntries({
|
|
408
|
+
family: "review_threads",
|
|
409
|
+
source: "graphql:repository.pullRequest.reviewThreads",
|
|
410
|
+
entries: reviewThreadCoverages,
|
|
411
|
+
downstreamImpact: "Thread resolution and threaded comment metrics are unavailable for affected PRs.",
|
|
412
|
+
}),
|
|
413
|
+
mergeCoverageEntries({
|
|
414
|
+
family: "workflow_runs",
|
|
415
|
+
source: "rest:/repos/{owner}/{repo}/actions/runs?branch={branch}&event=pull_request",
|
|
416
|
+
entries: workflowRunCoverages,
|
|
417
|
+
downstreamImpact: "Workflow churn history is unavailable for affected PRs; final status check rollup remains available.",
|
|
418
|
+
}),
|
|
419
|
+
coverageEntry({
|
|
420
|
+
family: "pr_open_diff",
|
|
421
|
+
source: "historical_snapshot",
|
|
422
|
+
status: COVERAGE_STATUS.unavailable,
|
|
423
|
+
attempts: pullRequests.length,
|
|
424
|
+
diagnostics: ["PR-open diff reconstruction and snapshot capture are intentionally not implemented in M1."],
|
|
425
|
+
downstreamImpact: "Diff growth metrics must remain unavailable.",
|
|
426
|
+
}),
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
schemaVersion: GITHUB_SOURCE_BUNDLE_VERSION,
|
|
431
|
+
collectedAt,
|
|
432
|
+
collector: {
|
|
433
|
+
name: "github-live-collector",
|
|
434
|
+
provider: provider.kind ?? "custom",
|
|
435
|
+
},
|
|
436
|
+
targetRepository,
|
|
437
|
+
repositoryMetadata: mapRepositoryMetadata(repositoryMetadata),
|
|
438
|
+
selection: {
|
|
439
|
+
strategy: "latest_merged_pull_requests",
|
|
440
|
+
requestedLimit: targetPullRequestLimit,
|
|
441
|
+
collectedCount: pullRequests.length,
|
|
442
|
+
source: "gh pr list --state merged --search \"is:merged sort:merged-desc\"",
|
|
443
|
+
},
|
|
444
|
+
coverage: {
|
|
445
|
+
status: buildCoverageSummary(apiFamilies),
|
|
446
|
+
apiFamilies,
|
|
447
|
+
},
|
|
448
|
+
languageDistribution: {
|
|
449
|
+
source: "rest:/repos/{owner}/{repo}/languages",
|
|
450
|
+
bytesByLanguage: languagesAttempt.value ?? {},
|
|
451
|
+
coverage: languagesAttempt.coverage,
|
|
452
|
+
},
|
|
453
|
+
pullRequests,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const OWNER_OR_NAME = /^[A-Za-z0-9_.-]+$/;
|
|
2
|
+
const REF_NAME = /^[A-Za-z0-9._/-]+$/;
|
|
3
|
+
|
|
4
|
+
function validateRepoPart(value, label) {
|
|
5
|
+
if (typeof value !== "string" || !OWNER_OR_NAME.test(value)) {
|
|
6
|
+
return `${label} must be a GitHub owner/name segment using letters, numbers, dots, underscores, or dashes.`;
|
|
7
|
+
}
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function validateTargetRepository(input, { productRepository } = {}) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
if (!input || typeof input !== "object") {
|
|
14
|
+
return ["target repository input must be an object."];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const [key, label] of [["owner", "owner"], ["name", "name"]]) {
|
|
18
|
+
const error = validateRepoPart(input[key], label);
|
|
19
|
+
if (error) errors.push(error);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof input.defaultBranch !== "string" || !REF_NAME.test(input.defaultBranch)) {
|
|
23
|
+
errors.push("defaultBranch must be a non-empty Git ref name.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!Number.isInteger(input.analysisPullRequestLimit) || input.analysisPullRequestLimit < 1 || input.analysisPullRequestLimit > 100) {
|
|
27
|
+
errors.push("analysisPullRequestLimit must be an integer between 1 and 100.");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!["public", "private", "unknown"].includes(input.visibility)) {
|
|
31
|
+
errors.push("visibility must be public, private, or unknown.");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (input.isValidationTarget !== undefined && typeof input.isValidationTarget !== "boolean") {
|
|
35
|
+
errors.push("isValidationTarget must be a boolean when provided.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const normalizedInputOwner = typeof input.owner === "string" ? input.owner.toLowerCase() : null;
|
|
39
|
+
const normalizedInputName = typeof input.name === "string" ? input.name.toLowerCase() : null;
|
|
40
|
+
const normalizedProductOwner = typeof productRepository?.owner === "string" ? productRepository.owner.toLowerCase() : null;
|
|
41
|
+
const normalizedProductName = typeof productRepository?.name === "string" ? productRepository.name.toLowerCase() : null;
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
normalizedInputOwner
|
|
45
|
+
&& normalizedInputName
|
|
46
|
+
&& normalizedProductOwner
|
|
47
|
+
&& normalizedProductName
|
|
48
|
+
&& normalizedInputOwner === normalizedProductOwner
|
|
49
|
+
&& normalizedInputName === normalizedProductName
|
|
50
|
+
) {
|
|
51
|
+
errors.push("target repository must be distinct from the product repository.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return errors;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeTargetRepository(input, options = {}) {
|
|
58
|
+
const errors = validateTargetRepository(input, options);
|
|
59
|
+
if (errors.length > 0) {
|
|
60
|
+
return { ok: false, errors };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
targetRepository: {
|
|
66
|
+
owner: input.owner,
|
|
67
|
+
name: input.name,
|
|
68
|
+
fullName: `${input.owner}/${input.name}`,
|
|
69
|
+
defaultBranch: input.defaultBranch,
|
|
70
|
+
visibility: input.visibility,
|
|
71
|
+
analysisPullRequestLimit: input.analysisPullRequestLimit,
|
|
72
|
+
isValidationTarget: input.isValidationTarget ?? false,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const SOURCE = {
|
|
2
|
+
copilot: "copilot",
|
|
3
|
+
human: "human_reviewer",
|
|
4
|
+
authorReply: "author_reply",
|
|
5
|
+
githubActions: "github_actions_bot",
|
|
6
|
+
dependencyBot: "dependency_bot",
|
|
7
|
+
codeScanning: "code_scanning",
|
|
8
|
+
unknownBot: "unknown_bot",
|
|
9
|
+
unknown: "unknown",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const COMMENT_SOURCES = Object.freeze(Object.values(SOURCE));
|
|
13
|
+
|
|
14
|
+
export function classifyCommentSource(author = {}, { pullRequestAuthorLogin } = {}) {
|
|
15
|
+
const login = String(author.login ?? "").toLowerCase();
|
|
16
|
+
const type = String(author.type ?? author.__typename ?? "").toLowerCase();
|
|
17
|
+
const url = String(author.htmlUrl ?? author.html_url ?? "").toLowerCase();
|
|
18
|
+
const prAuthorLogin = String(pullRequestAuthorLogin ?? "").toLowerCase();
|
|
19
|
+
|
|
20
|
+
if (login === "copilot" || login === "copilot-pull-request-reviewer" || url.includes("/apps/copilot-pull-request-reviewer")) {
|
|
21
|
+
return SOURCE.copilot;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (prAuthorLogin && login === prAuthorLogin) {
|
|
25
|
+
return SOURCE.authorReply;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (login === "github-actions[bot]" || login === "github-actions" || login.includes("github-actions")) {
|
|
29
|
+
return SOURCE.githubActions;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (login.startsWith("dependabot") || login.includes("renovate")) {
|
|
33
|
+
return SOURCE.dependencyBot;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (login.includes("codeql") || login.includes("code-scanning") || login.includes("github-code-scanning")) {
|
|
37
|
+
return SOURCE.codeScanning;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (type === "bot" || login.endsWith("[bot]")) {
|
|
41
|
+
return SOURCE.unknownBot;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (type === "user" || author.authorAssociation) {
|
|
45
|
+
return SOURCE.human;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return SOURCE.unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function groupByCommentSource(comments, options = {}) {
|
|
52
|
+
const grouped = Object.fromEntries(COMMENT_SOURCES.map(source => [source, 0]));
|
|
53
|
+
for (const comment of comments ?? []) {
|
|
54
|
+
grouped[classifyCommentSource(comment.author, options)] += 1;
|
|
55
|
+
}
|
|
56
|
+
return grouped;
|
|
57
|
+
}
|