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,82 @@
|
|
|
1
|
+
export const COVERAGE_STATUS = Object.freeze({
|
|
2
|
+
available: "available",
|
|
3
|
+
partial: "partial",
|
|
4
|
+
unavailable: "unavailable",
|
|
5
|
+
rateLimited: "rate_limited",
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const TOKEN_PATTERNS = [
|
|
9
|
+
/\bghp_[A-Za-z0-9_]+\b/g,
|
|
10
|
+
/\bgho_[A-Za-z0-9_]+\b/g,
|
|
11
|
+
/\bghu_[A-Za-z0-9_]+\b/g,
|
|
12
|
+
/\bghs_[A-Za-z0-9_]+\b/g,
|
|
13
|
+
/\bghr_[A-Za-z0-9_]+\b/g,
|
|
14
|
+
/\bgithub_pat_[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*\b/g,
|
|
15
|
+
/\b(GITHUB_TOKEN|GH_TOKEN|GH_ENTERPRISE_TOKEN)=\S+/gi,
|
|
16
|
+
/\b(authorization:\s*)(bearer|token)\s+\S+/gi,
|
|
17
|
+
/\b(token\s+)[A-Za-z0-9_./+=-]{16,}\b/gi,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const LOCAL_CREDENTIAL_PATH_PATTERNS = [
|
|
21
|
+
/\/Users\/[^/\s]+\/\.config\/gh\/hosts\.yml/g,
|
|
22
|
+
/\/Users\/[^/\s]+\/\.git-credentials/g,
|
|
23
|
+
/\/home\/[^/\s]+\/\.config\/gh\/hosts\.yml/g,
|
|
24
|
+
/\/home\/[^/\s]+\/\.git-credentials/g,
|
|
25
|
+
/[A-Z]:\\Users\\[^\\\r\n]+\\AppData\\Roaming\\GitHub CLI\\hosts\.yml/gi,
|
|
26
|
+
/[A-Z]:\\Users\\[^\\\r\n]+\\\.git-credentials/gi,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function redactDiagnostic(value) {
|
|
30
|
+
let text = String(value ?? "");
|
|
31
|
+
for (const pattern of TOKEN_PATTERNS) {
|
|
32
|
+
text = text.replace(pattern, match => {
|
|
33
|
+
const envPrefix = match.match(/^(GITHUB_TOKEN|GH_TOKEN|GH_ENTERPRISE_TOKEN)=/i)?.[0];
|
|
34
|
+
if (envPrefix) return `${envPrefix}[REDACTED]`;
|
|
35
|
+
return "[REDACTED]";
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
for (const pattern of LOCAL_CREDENTIAL_PATH_PATTERNS) {
|
|
39
|
+
text = text.replace(pattern, "[local credential path]");
|
|
40
|
+
}
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function classifyCoverageStatus(error) {
|
|
45
|
+
const text = `${error?.message ?? ""}\n${error?.stderr ?? ""}`.toLowerCase();
|
|
46
|
+
if (text.includes("rate limit") || text.includes("secondary rate") || text.includes("api rate limit exceeded")) {
|
|
47
|
+
return COVERAGE_STATUS.rateLimited;
|
|
48
|
+
}
|
|
49
|
+
return COVERAGE_STATUS.unavailable;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function coverageEntry({ family, source, status, diagnostics = [], downstreamImpact = null, attempts = 1 }) {
|
|
53
|
+
return {
|
|
54
|
+
family,
|
|
55
|
+
source,
|
|
56
|
+
status,
|
|
57
|
+
attempts,
|
|
58
|
+
diagnostics: diagnostics.map(redactDiagnostic).filter(Boolean),
|
|
59
|
+
downstreamImpact,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function mergeCoverageEntries({ family, source, entries, downstreamImpact = null }) {
|
|
64
|
+
const statuses = new Set(entries.map(entry => entry.status));
|
|
65
|
+
let status = COVERAGE_STATUS.available;
|
|
66
|
+
if (statuses.has(COVERAGE_STATUS.rateLimited)) {
|
|
67
|
+
status = COVERAGE_STATUS.rateLimited;
|
|
68
|
+
} else if (statuses.has(COVERAGE_STATUS.unavailable)) {
|
|
69
|
+
status = statuses.size > 1 ? COVERAGE_STATUS.partial : COVERAGE_STATUS.unavailable;
|
|
70
|
+
} else if (statuses.has(COVERAGE_STATUS.partial)) {
|
|
71
|
+
status = COVERAGE_STATUS.partial;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return coverageEntry({
|
|
75
|
+
family,
|
|
76
|
+
source,
|
|
77
|
+
status,
|
|
78
|
+
attempts: entries.reduce((sum, entry) => sum + (entry.attempts ?? 1), 0),
|
|
79
|
+
diagnostics: entries.flatMap(entry => entry.diagnostics ?? []),
|
|
80
|
+
downstreamImpact,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFile = promisify(execFileCallback);
|
|
5
|
+
|
|
6
|
+
const PR_VIEW_FIELDS = [
|
|
7
|
+
"additions",
|
|
8
|
+
"author",
|
|
9
|
+
"baseRefName",
|
|
10
|
+
"changedFiles",
|
|
11
|
+
"commits",
|
|
12
|
+
"createdAt",
|
|
13
|
+
"deletions",
|
|
14
|
+
"files",
|
|
15
|
+
"headRefName",
|
|
16
|
+
"headRefOid",
|
|
17
|
+
"mergedAt",
|
|
18
|
+
"number",
|
|
19
|
+
"reviews",
|
|
20
|
+
"state",
|
|
21
|
+
"statusCheckRollup",
|
|
22
|
+
"title",
|
|
23
|
+
"updatedAt",
|
|
24
|
+
"url",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const REVIEW_THREADS_QUERY = `
|
|
28
|
+
query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
|
|
29
|
+
repository(owner: $owner, name: $name) {
|
|
30
|
+
pullRequest(number: $number) {
|
|
31
|
+
reviewThreads(first: 100, after: $cursor) {
|
|
32
|
+
totalCount
|
|
33
|
+
pageInfo {
|
|
34
|
+
hasNextPage
|
|
35
|
+
endCursor
|
|
36
|
+
}
|
|
37
|
+
nodes {
|
|
38
|
+
id
|
|
39
|
+
isResolved
|
|
40
|
+
isOutdated
|
|
41
|
+
path
|
|
42
|
+
line
|
|
43
|
+
comments(first: 100) {
|
|
44
|
+
totalCount
|
|
45
|
+
pageInfo {
|
|
46
|
+
hasNextPage
|
|
47
|
+
endCursor
|
|
48
|
+
}
|
|
49
|
+
nodes {
|
|
50
|
+
databaseId
|
|
51
|
+
author {
|
|
52
|
+
login
|
|
53
|
+
__typename
|
|
54
|
+
}
|
|
55
|
+
path
|
|
56
|
+
line
|
|
57
|
+
originalLine
|
|
58
|
+
createdAt
|
|
59
|
+
updatedAt
|
|
60
|
+
url
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const TRANSIENT_PR_VIEW_RETRY_DELAYS_MS = Object.freeze([2000, 5000]);
|
|
71
|
+
|
|
72
|
+
function parseJson(stdout, args) {
|
|
73
|
+
if (String(stdout ?? "").trim() === "") {
|
|
74
|
+
throw new Error(`gh returned empty JSON output for ${args.slice(0, 3).join(" ")}.`);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(stdout);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`gh returned invalid JSON for ${args.slice(0, 3).join(" ")}: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeGhError(error) {
|
|
84
|
+
const normalized = new Error(error.message);
|
|
85
|
+
normalized.stderr = error.stderr;
|
|
86
|
+
normalized.stdout = error.stdout;
|
|
87
|
+
normalized.exitCode = error.code;
|
|
88
|
+
throw normalized;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function graphqlErrorMessage(errors) {
|
|
92
|
+
const messages = errors
|
|
93
|
+
.map(error => error?.message)
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
if (messages.length === 0) {
|
|
96
|
+
return "GitHub GraphQL returned errors.";
|
|
97
|
+
}
|
|
98
|
+
return `GitHub GraphQL returned errors: ${messages.join("; ")}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requireGraphqlPage(page, label) {
|
|
102
|
+
if (!page || !Array.isArray(page.nodes)) {
|
|
103
|
+
throw new Error(`GitHub GraphQL response did not include ${label}.`);
|
|
104
|
+
}
|
|
105
|
+
return page;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function wait(ms) {
|
|
109
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isTransientPrViewAuthError(error) {
|
|
113
|
+
const message = `${error?.message ?? ""}\n${error?.stderr ?? ""}`;
|
|
114
|
+
return message.includes("HTTP 401")
|
|
115
|
+
&& message.includes("Requires authentication")
|
|
116
|
+
&& message.includes("api.github.com/graphql");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createGhCliProvider({
|
|
120
|
+
ghPath = "gh",
|
|
121
|
+
runCommand,
|
|
122
|
+
retryDelaysMs = TRANSIENT_PR_VIEW_RETRY_DELAYS_MS,
|
|
123
|
+
sleep = wait,
|
|
124
|
+
} = {}) {
|
|
125
|
+
async function runGh(args) {
|
|
126
|
+
if (runCommand) {
|
|
127
|
+
return runCommand(args);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const { stdout } = await execFile(ghPath, args, {
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
134
|
+
});
|
|
135
|
+
return stdout;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
normalizeGhError(error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function runGhJson(args) {
|
|
142
|
+
return parseJson(await runGh(args), args);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
kind: "gh-cli",
|
|
147
|
+
|
|
148
|
+
async getRepository({ owner, name }) {
|
|
149
|
+
return runGhJson(["api", `repos/${owner}/${name}`]);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async getLanguages({ owner, name }) {
|
|
153
|
+
return runGhJson(["api", `repos/${owner}/${name}/languages`]);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async listMergedPullRequests({ owner, name, limit }) {
|
|
157
|
+
const prs = await runGhJson([
|
|
158
|
+
"pr",
|
|
159
|
+
"list",
|
|
160
|
+
"--repo",
|
|
161
|
+
`${owner}/${name}`,
|
|
162
|
+
"--state",
|
|
163
|
+
"merged",
|
|
164
|
+
"--search",
|
|
165
|
+
"is:merged sort:merged-desc",
|
|
166
|
+
"--limit",
|
|
167
|
+
String(limit),
|
|
168
|
+
"--json",
|
|
169
|
+
"number,mergedAt,updatedAt",
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
return [...prs]
|
|
173
|
+
.sort((left, right) => String(right.mergedAt ?? "").localeCompare(String(left.mergedAt ?? "")))
|
|
174
|
+
.slice(0, limit);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async getPullRequest({ owner, name, number }) {
|
|
178
|
+
const args = [
|
|
179
|
+
"pr",
|
|
180
|
+
"view",
|
|
181
|
+
String(number),
|
|
182
|
+
"--repo",
|
|
183
|
+
`${owner}/${name}`,
|
|
184
|
+
"--json",
|
|
185
|
+
PR_VIEW_FIELDS.join(","),
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
|
|
189
|
+
try {
|
|
190
|
+
return await runGhJson(args);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const retryDelayMs = retryDelaysMs[attempt];
|
|
193
|
+
if (!isTransientPrViewAuthError(error) || retryDelayMs === undefined) {
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
await sleep(retryDelayMs);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
throw new Error("unreachable");
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async getReviewThreads({ owner, name, number }) {
|
|
204
|
+
const nodes = [];
|
|
205
|
+
let totalCount = 0;
|
|
206
|
+
let cursor = null;
|
|
207
|
+
|
|
208
|
+
do {
|
|
209
|
+
const args = [
|
|
210
|
+
"api",
|
|
211
|
+
"graphql",
|
|
212
|
+
"-f",
|
|
213
|
+
`owner=${owner}`,
|
|
214
|
+
"-f",
|
|
215
|
+
`name=${name}`,
|
|
216
|
+
"-F",
|
|
217
|
+
`number=${number}`,
|
|
218
|
+
"-f",
|
|
219
|
+
`query=${REVIEW_THREADS_QUERY}`,
|
|
220
|
+
];
|
|
221
|
+
if (cursor) {
|
|
222
|
+
args.push("-f", `cursor=${cursor}`);
|
|
223
|
+
}
|
|
224
|
+
const data = await runGhJson(args);
|
|
225
|
+
if (Array.isArray(data.errors) && data.errors.length > 0) {
|
|
226
|
+
throw new Error(graphqlErrorMessage(data.errors));
|
|
227
|
+
}
|
|
228
|
+
const root = data.data ?? data;
|
|
229
|
+
const page = requireGraphqlPage(
|
|
230
|
+
root.repository?.pullRequest?.reviewThreads,
|
|
231
|
+
"repository.pullRequest.reviewThreads",
|
|
232
|
+
);
|
|
233
|
+
totalCount = page?.totalCount ?? nodes.length;
|
|
234
|
+
nodes.push(...(page?.nodes ?? []));
|
|
235
|
+
cursor = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null;
|
|
236
|
+
} while (cursor);
|
|
237
|
+
|
|
238
|
+
return { totalCount, nodes };
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async getWorkflowRuns({ owner, name, branch }) {
|
|
242
|
+
const workflowRuns = [];
|
|
243
|
+
let totalCount = 0;
|
|
244
|
+
let page = 1;
|
|
245
|
+
let lastData = {};
|
|
246
|
+
|
|
247
|
+
do {
|
|
248
|
+
const data = await runGhJson([
|
|
249
|
+
"api",
|
|
250
|
+
`repos/${owner}/${name}/actions/runs`,
|
|
251
|
+
"--method",
|
|
252
|
+
"GET",
|
|
253
|
+
"-f",
|
|
254
|
+
`branch=${branch}`,
|
|
255
|
+
"-f",
|
|
256
|
+
"event=pull_request",
|
|
257
|
+
"-f",
|
|
258
|
+
"per_page=100",
|
|
259
|
+
"-f",
|
|
260
|
+
`page=${page}`,
|
|
261
|
+
]);
|
|
262
|
+
const pageRuns = data.workflow_runs ?? data.workflowRuns ?? [];
|
|
263
|
+
lastData = data;
|
|
264
|
+
workflowRuns.push(...pageRuns);
|
|
265
|
+
totalCount = data.total_count ?? data.totalCount ?? workflowRuns.length;
|
|
266
|
+
page += 1;
|
|
267
|
+
if (pageRuns.length === 0) {
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
} while (workflowRuns.length < totalCount);
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
...lastData,
|
|
274
|
+
total_count: totalCount,
|
|
275
|
+
workflow_runs: workflowRuns,
|
|
276
|
+
};
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|