@dev-loops/core 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/bin/capture-deep-persona-signals.mjs +143 -0
- package/bin/ensure-phase-files.mjs +7 -0
- package/bin/log-bash-exit-1.mjs +7 -0
- package/bin/parse-review-threads.mjs +7 -0
- package/package.json +78 -0
- package/src/analysis/change-classifier.mjs +146 -0
- package/src/analysis/diff-analyzer.mjs +285 -0
- package/src/bash-exit-one.mjs +130 -0
- package/src/cli/helpers.mjs +22 -0
- package/src/cli/primitives.mjs +70 -0
- package/src/cli/retry-wrapper.mjs +169 -0
- package/src/cli/subcommand-runner.mjs +246 -0
- package/src/config/config.mjs +965 -0
- package/src/debt/cluster.mjs +240 -0
- package/src/debt/debt-finding.mjs +68 -0
- package/src/debt/debt-signal.mjs +46 -0
- package/src/debt/deep-persona-signals.mjs +266 -0
- package/src/debt/remediation-to-issue.mjs +121 -0
- package/src/debt/score.mjs +127 -0
- package/src/debt/shape.mjs +214 -0
- package/src/github/copilot-helpers.mjs +343 -0
- package/src/github/repo-slug.mjs +105 -0
- package/src/github/review-threads.mjs +343 -0
- package/src/harness/adapter.mjs +57 -0
- package/src/harness/index.mjs +3 -0
- package/src/harness/noop-adapter.mjs +22 -0
- package/src/harness/pi-adapter.mjs +47 -0
- package/src/loop/async-start-contract.mjs +170 -0
- package/src/loop/conductor-routing.mjs +817 -0
- package/src/loop/copilot-ci-status.mjs +255 -0
- package/src/loop/copilot-loop-iterations.mjs +161 -0
- package/src/loop/copilot-loop-state.mjs +510 -0
- package/src/loop/handoff-envelope.mjs +800 -0
- package/src/loop/issue-refinement-artifact.mjs +268 -0
- package/src/loop/lifecycle-state.mjs +342 -0
- package/src/loop/phase-files.mjs +187 -0
- package/src/loop/policy-constants.mjs +17 -0
- package/src/loop/pr-gate-coordination.mjs +1278 -0
- package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
- package/src/loop/public-dev-loop-routing.mjs +1746 -0
- package/src/loop/queue-board-ordering.mjs +38 -0
- package/src/loop/queue-board-sync.mjs +223 -0
- package/src/loop/queue-driver.mjs +164 -0
- package/src/loop/queue-parallel.mjs +190 -0
- package/src/loop/queue-state.mjs +230 -0
- package/src/loop/retrospective-checkpoint.mjs +178 -0
- package/src/loop/reviewer-loop-state.mjs +456 -0
- package/src/loop/run-inspection.mjs +604 -0
- package/src/loop/steering.mjs +793 -0
- package/src/loop/timeout-policy.mjs +73 -0
- package/src/loop/tracker-first-loop-state.mjs +87 -0
- package/src/loop/tracker-pr-state.mjs +301 -0
- package/src/loop/worktree-guard.mjs +141 -0
- package/src/refinement/ac-dod-matrix.mjs +95 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared deterministic helpers for Copilot-related GitHub data.
|
|
3
|
+
*
|
|
4
|
+
* These are pure functions with no filesystem or network dependencies.
|
|
5
|
+
* Owner: packages/core — reusable deterministic logic consumed by both
|
|
6
|
+
* scripts and other packages/core modules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
10
|
+
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
11
|
+
const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
|
|
12
|
+
|
|
13
|
+
export function isCopilotLogin(login) {
|
|
14
|
+
return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeTimestamp(value) {
|
|
18
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const timestamp = Date.parse(value);
|
|
23
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function extractReviewCommitSha(review) {
|
|
27
|
+
const graphqlSha = typeof review?.commit?.oid === "string" ? review.commit.oid.trim() : "";
|
|
28
|
+
const restSha = typeof review?.commit_id === "string" ? review.commit_id.trim() : "";
|
|
29
|
+
const sha = graphqlSha || restSha;
|
|
30
|
+
return sha.length > 0 ? sha : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stripOptionalCodeTicks(value) {
|
|
34
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
35
|
+
if (trimmed.startsWith("`") && trimmed.endsWith("`") && trimmed.length >= 2) {
|
|
36
|
+
return trimmed.slice(1, -1).trim();
|
|
37
|
+
}
|
|
38
|
+
return trimmed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stripGateCommentMarkdown(rawLine) {
|
|
42
|
+
let line = rawLine.trim();
|
|
43
|
+
if (line.length === 0) {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
line = line.replace(/^#{1,6}\s+/u, "");
|
|
47
|
+
line = line.replace(/\*\*/gu, "");
|
|
48
|
+
return line.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeGateReviewName(value) {
|
|
52
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
53
|
+
return GATE_REVIEW_NAMES.has(normalized) ? normalized : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeGateReviewVerdict(value) {
|
|
57
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
58
|
+
return GATE_REVIEW_VERDICTS.has(normalized) ? normalized : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeGateReviewHeadSha(value) {
|
|
62
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
63
|
+
return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseGateReviewCommentFields(body) {
|
|
67
|
+
if (typeof body !== "string" || body.trim().length === 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fields = {
|
|
72
|
+
gate: null,
|
|
73
|
+
headSha: null,
|
|
74
|
+
verdict: null,
|
|
75
|
+
findingsSummary: null,
|
|
76
|
+
nextAction: null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (const rawLine of body.split(/\r?\n/u)) {
|
|
80
|
+
const stripped = stripGateCommentMarkdown(rawLine);
|
|
81
|
+
if (stripped.length === 0) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const line = stripped;
|
|
85
|
+
|
|
86
|
+
let match = line.match(/^(?:[-*]\s*)?(?:gate(?:\s+name)?|gate\s+review)\s*:\s*(.+)$/iu);
|
|
87
|
+
if (match) {
|
|
88
|
+
fields.gate = normalizeGateReviewName(match[1]);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
match = line.match(/^(?:[-*]\s*)?(?:head\s+sha(?:\s+reviewed)?|reviewed\s+head\s+sha)\s*:\s*(.+)$/iu);
|
|
93
|
+
if (match) {
|
|
94
|
+
fields.headSha = normalizeGateReviewHeadSha(match[1]);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
match = line.match(/^(?:[-*]\s*)?verdict\s*:\s*(.+)$/iu);
|
|
99
|
+
if (match) {
|
|
100
|
+
fields.verdict = normalizeGateReviewVerdict(match[1]);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
match = line.match(/^(?:[-*]\s*)?(?:findings(?:\s+summary)?|summary)\s*:\s*(.+)$/iu);
|
|
105
|
+
if (match) {
|
|
106
|
+
fields.findingsSummary = match[1].trim();
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
match = line.match(/^(?:[-*]\s*)?next\s+action\s*:\s*(.+)$/iu);
|
|
111
|
+
if (match) {
|
|
112
|
+
fields.nextAction = match[1].trim();
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Lenient fallback: detect gate name and head SHA anywhere in body
|
|
118
|
+
// Handles comments posted via other tools without structured field format
|
|
119
|
+
if (!fields.gate || !fields.headSha) {
|
|
120
|
+
const flatBody = body.replace(/\*\*/gu, "").replace(/`/gu, "");
|
|
121
|
+
|
|
122
|
+
if (!fields.gate) {
|
|
123
|
+
const canonicalGateNames = [...GATE_REVIEW_NAMES].join("|");
|
|
124
|
+
const gateMatch = flatBody.match(
|
|
125
|
+
new RegExp(`\\b(${canonicalGateNames})\\b`, "iu")
|
|
126
|
+
);
|
|
127
|
+
if (gateMatch) {
|
|
128
|
+
fields.gate = normalizeGateReviewName(gateMatch[1]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!fields.headSha) {
|
|
133
|
+
// Prefer SHA following a "head" context marker to avoid false
|
|
134
|
+
// matches on plain-text numeric IDs (issue/comment IDs, etc.)
|
|
135
|
+
// Example: "pre_approval_gate for head e284c2e341" or "commit abc1234def"
|
|
136
|
+
const ctxShaMatch = flatBody.match(
|
|
137
|
+
/\b(?:head|sha|commit)\b\s*(?:sha)?\s*[:=]?\s*`?\b([0-9a-f]{7,64})\b`?/iu
|
|
138
|
+
);
|
|
139
|
+
if (ctxShaMatch) {
|
|
140
|
+
fields.headSha = normalizeGateReviewHeadSha(ctxShaMatch[1]);
|
|
141
|
+
} else {
|
|
142
|
+
// Fallback: any hex token, strip known URL/id noise first
|
|
143
|
+
const cleanBody = flatBody.replace(
|
|
144
|
+
/https:\/\/github\.com\/[^\s]+#issuecomment-\d+/g, ""
|
|
145
|
+
);
|
|
146
|
+
const shaMatch = cleanBody.match(/\b([0-9a-f]{7,64})\b/iu);
|
|
147
|
+
if (shaMatch) {
|
|
148
|
+
fields.headSha = normalizeGateReviewHeadSha(shaMatch[1]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!fields.gate || !fields.headSha) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return fields;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function parseGateReviewCommentBody(body) {
|
|
162
|
+
const parsed = parseGateReviewCommentFields(body);
|
|
163
|
+
if (!parsed || !parsed.verdict || !parsed.findingsSummary || !parsed.nextAction) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return parsed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseGateReviewCommentMarkerBody(body) {
|
|
170
|
+
const fields = parseGateReviewCommentFields(body);
|
|
171
|
+
if (!fields || !fields.gate || !fields.headSha) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
gate: fields.gate,
|
|
177
|
+
headSha: fields.headSha,
|
|
178
|
+
verdict: fields.verdict,
|
|
179
|
+
findingsSummary: fields.findingsSummary,
|
|
180
|
+
nextAction: fields.nextAction,
|
|
181
|
+
contractComplete: Boolean(fields.verdict && fields.findingsSummary && fields.nextAction),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function summarizeGateReviewComments(comments) {
|
|
186
|
+
const summary = {
|
|
187
|
+
draft_gate: null,
|
|
188
|
+
pre_approval_gate: null,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const entries = Array.isArray(comments) ? comments : [];
|
|
192
|
+
|
|
193
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
194
|
+
const comment = entries[index];
|
|
195
|
+
const parsed = parseGateReviewCommentBody(comment?.body);
|
|
196
|
+
if (!parsed) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const updatedAtMs = normalizeTimestamp(comment?.updated_at ?? comment?.updatedAt ?? comment?.created_at ?? comment?.createdAt);
|
|
201
|
+
const candidate = {
|
|
202
|
+
visible: true,
|
|
203
|
+
gate: parsed.gate,
|
|
204
|
+
headSha: parsed.headSha,
|
|
205
|
+
verdict: parsed.verdict,
|
|
206
|
+
findingsSummary: parsed.findingsSummary,
|
|
207
|
+
nextAction: parsed.nextAction,
|
|
208
|
+
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
209
|
+
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
210
|
+
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
211
|
+
? (comment.updated_at ?? comment.updatedAt).trim()
|
|
212
|
+
: typeof (comment?.created_at ?? comment?.createdAt) === "string"
|
|
213
|
+
? (comment.created_at ?? comment.createdAt).trim()
|
|
214
|
+
: null,
|
|
215
|
+
updatedAtMs,
|
|
216
|
+
arrayIndex: index,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const current = summary[parsed.gate];
|
|
220
|
+
if (!current || (candidate.updatedAtMs ?? -1) > (current.updatedAtMs ?? -1) || ((candidate.updatedAtMs ?? -1) === (current.updatedAtMs ?? -1) && candidate.arrayIndex > current.arrayIndex)) {
|
|
221
|
+
summary[parsed.gate] = candidate;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return summary;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
229
|
+
const summary = {
|
|
230
|
+
draft_gate: null,
|
|
231
|
+
pre_approval_gate: null,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const entries = Array.isArray(comments) ? comments : [];
|
|
235
|
+
const normalizedHeadSha = normalizeGateReviewHeadSha(headSha);
|
|
236
|
+
|
|
237
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
238
|
+
const comment = entries[index];
|
|
239
|
+
const parsed = parseGateReviewCommentMarkerBody(comment?.body);
|
|
240
|
+
if (!parsed) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (normalizedHeadSha && parsed.headSha !== normalizedHeadSha) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const updatedAtMs = normalizeTimestamp(comment?.updated_at ?? comment?.updatedAt ?? comment?.created_at ?? comment?.createdAt);
|
|
249
|
+
const candidate = {
|
|
250
|
+
visible: true,
|
|
251
|
+
gate: parsed.gate,
|
|
252
|
+
headSha: parsed.headSha,
|
|
253
|
+
verdict: parsed.verdict,
|
|
254
|
+
findingsSummary: parsed.findingsSummary,
|
|
255
|
+
nextAction: parsed.nextAction,
|
|
256
|
+
contractComplete: parsed.contractComplete,
|
|
257
|
+
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
258
|
+
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
259
|
+
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
260
|
+
? (comment.updated_at ?? comment.updatedAt).trim()
|
|
261
|
+
: typeof (comment?.created_at ?? comment?.createdAt) === "string"
|
|
262
|
+
? (comment.created_at ?? comment.createdAt).trim()
|
|
263
|
+
: null,
|
|
264
|
+
updatedAtMs,
|
|
265
|
+
arrayIndex: index,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const current = summary[parsed.gate];
|
|
269
|
+
if (!current || (candidate.updatedAtMs ?? -1) > (current.updatedAtMs ?? -1) || ((candidate.updatedAtMs ?? -1) === (current.updatedAtMs ?? -1) && candidate.arrayIndex > current.arrayIndex)) {
|
|
270
|
+
summary[parsed.gate] = candidate;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return summary;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs } = {}) {
|
|
278
|
+
const allReviews = Array.isArray(reviews) ? reviews : [];
|
|
279
|
+
const copilotReviews = allReviews.filter((review) => isCopilotLogin(review?.author?.login));
|
|
280
|
+
|
|
281
|
+
// When draft gate has re-passed on a different head, only count reviews
|
|
282
|
+
// after the most recent draft gate approval to prevent round accumulation.
|
|
283
|
+
const effectiveReviews = draftGateResetAtMs != null && draftGateResetAtMs > 0
|
|
284
|
+
? copilotReviews.filter((review) => {
|
|
285
|
+
const state = typeof review?.state === "string" ? review.state.toUpperCase() : "";
|
|
286
|
+
const reviewCommitSha = extractReviewCommitSha(review);
|
|
287
|
+
const reviewOnCurrentHead = headSha !== null && reviewCommitSha === headSha;
|
|
288
|
+
// Always retain PENDING reviews on the current head so
|
|
289
|
+
// hasPendingReviewOnCurrentHead stays accurate even when
|
|
290
|
+
// submittedAt is null (common for PENDING GitHub reviews).
|
|
291
|
+
if (state === "PENDING" && reviewOnCurrentHead) {
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
const submittedAtMs = normalizeTimestamp(review?.submittedAt ?? review?.submitted_at);
|
|
295
|
+
return submittedAtMs !== null && submittedAtMs > draftGateResetAtMs;
|
|
296
|
+
})
|
|
297
|
+
: copilotReviews;
|
|
298
|
+
|
|
299
|
+
let hasPendingReviewOnCurrentHead = false;
|
|
300
|
+
let hasSubmittedReviewOnCurrentHead = false;
|
|
301
|
+
let latestSubmittedReviewOnCurrentHeadAt = null;
|
|
302
|
+
let completedCopilotReviewRounds = 0;
|
|
303
|
+
|
|
304
|
+
for (const review of effectiveReviews) {
|
|
305
|
+
const state = typeof review?.state === "string" ? review.state.toUpperCase() : "";
|
|
306
|
+
const reviewCommitSha = extractReviewCommitSha(review);
|
|
307
|
+
const reviewOnCurrentHead = headSha !== null && reviewCommitSha === headSha;
|
|
308
|
+
|
|
309
|
+
if (SUBMITTED_REVIEW_STATES.has(state)) {
|
|
310
|
+
completedCopilotReviewRounds += 1;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!reviewOnCurrentHead) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (state === "PENDING") {
|
|
318
|
+
hasPendingReviewOnCurrentHead = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (SUBMITTED_REVIEW_STATES.has(state)) {
|
|
323
|
+
hasSubmittedReviewOnCurrentHead = true;
|
|
324
|
+
const submittedAt = typeof review?.submittedAt === "string" ? review.submittedAt : null;
|
|
325
|
+
if (submittedAt !== null && (latestSubmittedReviewOnCurrentHeadAt === null || submittedAt > latestSubmittedReviewOnCurrentHeadAt)) {
|
|
326
|
+
latestSubmittedReviewOnCurrentHeadAt = submittedAt;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
copilotReviews,
|
|
333
|
+
copilotReviewIds: copilotReviews
|
|
334
|
+
.map((review) => review?.id)
|
|
335
|
+
.filter((id) => id !== null && id !== undefined)
|
|
336
|
+
.map((id) => String(id)),
|
|
337
|
+
copilotReviewPresent: copilotReviews.length > 0,
|
|
338
|
+
completedCopilotReviewRounds,
|
|
339
|
+
hasPendingReviewOnCurrentHead,
|
|
340
|
+
hasSubmittedReviewOnCurrentHead,
|
|
341
|
+
latestSubmittedReviewOnCurrentHeadAt,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function isSafeRepoSegment(segment) {
|
|
4
|
+
return typeof segment === "string"
|
|
5
|
+
&& segment.length > 0
|
|
6
|
+
&& segment !== "."
|
|
7
|
+
&& segment !== ".."
|
|
8
|
+
&& !/[\\/]/.test(segment)
|
|
9
|
+
&& !/\s/.test(segment);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseRepoSlug(
|
|
13
|
+
repo,
|
|
14
|
+
{ errorMessage = "--repo must match <owner/name>", lowercase = false } = {},
|
|
15
|
+
) {
|
|
16
|
+
return parseRepoSlugParts(repo, { errorMessage, lowercase });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseRepoSlugParts(
|
|
20
|
+
repo,
|
|
21
|
+
{ errorMessage = "repo must match <owner/name>", lowercase = false } = {},
|
|
22
|
+
) {
|
|
23
|
+
if (typeof repo !== "string") {
|
|
24
|
+
throw new Error(errorMessage);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const trimmed = repo.trim();
|
|
28
|
+
const [rawOwner, rawName, ...rest] = trimmed.split("/");
|
|
29
|
+
|
|
30
|
+
if (rest.length > 0 || !isSafeRepoSegment(rawOwner) || !isSafeRepoSegment(rawName)) {
|
|
31
|
+
throw new Error(errorMessage);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const owner = lowercase ? rawOwner.toLowerCase() : rawOwner;
|
|
35
|
+
const name = lowercase ? rawName.toLowerCase() : rawName;
|
|
36
|
+
return { owner, name };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeRepoSlug(
|
|
40
|
+
repo,
|
|
41
|
+
{ errorMessage = "repo must match <owner/name>" } = {},
|
|
42
|
+
) {
|
|
43
|
+
const { owner, name } = parseRepoSlugParts(repo, { errorMessage, lowercase: true });
|
|
44
|
+
return `${owner}/${name}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Lenient variant: trims and lowercases a slug string. Returns null for
|
|
49
|
+
* non-strings, empty strings, or strings that cannot be trimmed to a
|
|
50
|
+
* non-empty value. Does NOT validate owner/name structure.
|
|
51
|
+
*/
|
|
52
|
+
export function tryNormalizeRepoSlug(slug) {
|
|
53
|
+
if (typeof slug !== "string") {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const trimmed = slug.trim();
|
|
57
|
+
return trimmed.length > 0 ? trimmed.toLowerCase() : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function repoSlugEquals(left, right) {
|
|
61
|
+
const normalizedLeft = tryNormalizeRepoSlug(left);
|
|
62
|
+
const normalizedRight = tryNormalizeRepoSlug(right);
|
|
63
|
+
if (normalizedLeft === null || normalizedRight === null) {
|
|
64
|
+
return left === right;
|
|
65
|
+
}
|
|
66
|
+
return normalizedLeft === normalizedRight;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function dedupeRepoSlugOptions(options) {
|
|
70
|
+
const uniqueOptions = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const option of options) {
|
|
73
|
+
if (typeof option !== "string") {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const trimmed = option.trim();
|
|
77
|
+
const normalized = tryNormalizeRepoSlug(trimmed);
|
|
78
|
+
if (normalized === null || seen.has(normalized)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
seen.add(normalized);
|
|
82
|
+
uniqueOptions.push(trimmed);
|
|
83
|
+
}
|
|
84
|
+
return uniqueOptions;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Auto-detect <owner/name> from `git remote get-url origin`.
|
|
88
|
+
* Returns the slug string on success, or null when detection fails
|
|
89
|
+
* (no origin remote, not a git repo, or unparseable URL).
|
|
90
|
+
* Does NOT throw — callers should add their own context-specific error messages.
|
|
91
|
+
*/
|
|
92
|
+
export function detectRepoSlug(cwd) {
|
|
93
|
+
try {
|
|
94
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
95
|
+
cwd,
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
98
|
+
}).trim();
|
|
99
|
+
const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
100
|
+
if (!match) return null;
|
|
101
|
+
return `${match[1]}/${match[2]}`;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|