dev-loops 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/.pi/dev-loop/defaults.yaml +477 -0
- package/AGENTS.md +25 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/agents/dev-loop.agent.md +82 -0
- package/agents/developer.agent.md +37 -0
- package/agents/docs.agent.md +33 -0
- package/agents/fixer.agent.md +53 -0
- package/agents/quality.agent.md +28 -0
- package/agents/refiner.agent.md +87 -0
- package/agents/review.agent.md +64 -0
- package/cli/index.mjs +424 -0
- package/extension/README.md +233 -0
- package/extension/checks.ts +94 -0
- package/extension/index.ts +131 -0
- package/extension/post-merge-update.ts +512 -0
- package/extension/presentation.ts +107 -0
- package/lib/dev-loops-core.mjs +284 -0
- package/package.json +103 -0
- package/scripts/README.md +1007 -0
- package/scripts/_cli-primitives.mjs +10 -0
- package/scripts/_core-helpers.mjs +30 -0
- package/scripts/docs/validate-links.mjs +567 -0
- package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
- package/scripts/github/_review-thread-mutations.mjs +214 -0
- package/scripts/github/capture-review-threads.mjs +180 -0
- package/scripts/github/create-draft-pr.mjs +108 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
- package/scripts/github/detect-linked-issue-pr.mjs +331 -0
- package/scripts/github/manage-sub-issues.mjs +394 -0
- package/scripts/github/probe-copilot-review.mjs +323 -0
- package/scripts/github/ready-for-review.mjs +93 -0
- package/scripts/github/reconcile-draft-gate.mjs +328 -0
- package/scripts/github/reply-resolve-review-thread.mjs +42 -0
- package/scripts/github/reply-resolve-review-threads.mjs +329 -0
- package/scripts/github/request-copilot-review.mjs +551 -0
- package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
- package/scripts/github/stage-reviewer-draft.mjs +191 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
- package/scripts/github/verify-fresh-review-context.mjs +125 -0
- package/scripts/github/write-gate-findings-log.mjs +212 -0
- package/scripts/loop/_checkpoint-io.mjs +55 -0
- package/scripts/loop/_checkpoint-paths.mjs +28 -0
- package/scripts/loop/_handoff-contract.mjs +230 -0
- package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
- package/scripts/loop/_loop-evidence.mjs +32 -0
- package/scripts/loop/_pr-runner-coordination.mjs +611 -0
- package/scripts/loop/_stale-runner-detection.mjs +145 -0
- package/scripts/loop/_steering-state-file.mjs +134 -0
- package/scripts/loop/build-handoff-envelope.mjs +181 -0
- package/scripts/loop/checkpoint-contract.mjs +49 -0
- package/scripts/loop/conductor-monitor.mjs +1850 -0
- package/scripts/loop/conductor.mjs +214 -0
- package/scripts/loop/copilot-pr-handoff.mjs +493 -0
- package/scripts/loop/debt-remediate.mjs +304 -0
- package/scripts/loop/detect-change-scope.mjs +102 -0
- package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
- package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
- package/scripts/loop/detect-internal-only-pr.mjs +270 -0
- package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
- package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
- package/scripts/loop/detect-stale-runner.mjs +250 -0
- package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
- package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
- package/scripts/loop/info.mjs +267 -0
- package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
- package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
- package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
- package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
- package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
- package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
- package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
- package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
- package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
- package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
- package/scripts/loop/inspect-run-viewer.mjs +82 -0
- package/scripts/loop/inspect-run.mjs +382 -0
- package/scripts/loop/outer-loop.mjs +419 -0
- package/scripts/loop/pr-runner-coordination.mjs +143 -0
- package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
- package/scripts/loop/pre-flight-gate.mjs +236 -0
- package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
- package/scripts/loop/pre-push-main-guard.mjs +103 -0
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
- package/scripts/loop/print-gates.mjs +42 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
- package/scripts/loop/run-conductor-cycle.mjs +322 -0
- package/scripts/loop/run-queue.mjs +124 -0
- package/scripts/loop/run-refinement-audit.mjs +513 -0
- package/scripts/loop/run-watch-cycle.mjs +358 -0
- package/scripts/loop/steer-loop.mjs +841 -0
- package/scripts/loop/ui-designer-review-contract.mjs +76 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
- package/scripts/projects/add-queue-item.mjs +528 -0
- package/scripts/projects/ensure-queue-board.mjs +837 -0
- package/scripts/projects/list-queue-items.mjs +489 -0
- package/scripts/projects/move-queue-item.mjs +549 -0
- package/scripts/projects/reorder-queue-item.mjs +518 -0
- package/scripts/refine/_refine-helpers.mjs +258 -0
- package/scripts/refine/prose-linkage-detector.mjs +92 -0
- package/scripts/refine/refinement-completeness-checker.mjs +88 -0
- package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
- package/scripts/refine/tree-integrity-validator.mjs +211 -0
- package/scripts/refine/verify.mjs +178 -0
- package/scripts/repo-wiki-local.mjs +156 -0
- package/scripts/repo-wiki.mjs +119 -0
- package/skills/copilot-pr-followup/SKILL.md +380 -0
- package/skills/dev-loop/SKILL.md +141 -0
- package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
- package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
- package/skills/dev-loop/scripts/init-phase.mjs +71 -0
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
- package/skills/dev-loop/scripts/phase-files.mjs +29 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
- package/skills/dev-loop/scripts/render-template.mjs +82 -0
- package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
- package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
- package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
- package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
- package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
- package/skills/dev-loop/templates/dev-mode-review.md +17 -0
- package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
- package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
- package/skills/dev-loop/templates/phase-doc.md +27 -0
- package/skills/dev-loop/templates/phase-summary.md +13 -0
- package/skills/dev-loop/templates/phase-variant.md +15 -0
- package/skills/dev-loop/templates/retrospective.md +11 -0
- package/skills/dev-loop/templates/review.md +32 -0
- package/skills/dev-loop/templates/ui-vision-review.md +55 -0
- package/skills/docs/acceptance-criteria-verification.md +21 -0
- package/skills/docs/anti-patterns.md +21 -0
- package/skills/docs/artifact-authority-contract.md +119 -0
- package/skills/docs/confirmation-rules.md +28 -0
- package/skills/docs/copilot-ci-status-contract.md +52 -0
- package/skills/docs/copilot-loop-operations.md +233 -0
- package/skills/docs/debt-remediation-contract.md +107 -0
- package/skills/docs/entrypoint-strategies.md +115 -0
- package/skills/docs/epic-tree-refinement-procedure.md +234 -0
- package/skills/docs/issue-intake-procedure.md +235 -0
- package/skills/docs/main-agent-contract.md +72 -0
- package/skills/docs/merge-preconditions.md +29 -0
- package/skills/docs/pr-lifecycle-contract.md +209 -0
- package/skills/docs/public-dev-loop-contract.md +497 -0
- package/skills/docs/retrospective-checkpoint-contract.md +159 -0
- package/skills/docs/stop-conditions.md +29 -0
- package/skills/docs/structural-quality.md +42 -0
- package/skills/docs/tracker-first-loop-state.md +281 -0
- package/skills/docs/validation-policy.md +27 -0
- package/skills/docs/workflow-handoff-contract.md +135 -0
- package/skills/final-approval/SKILL.md +19 -0
- package/skills/local-implementation/SKILL.md +640 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
3
|
+
import { parseIssueNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
5
|
+
import { detectLinkedIssuePr } from "../github/detect-linked-issue-pr.mjs";
|
|
6
|
+
import { detectCopilotSessionActivity } from "./detect-copilot-session-activity.mjs";
|
|
7
|
+
const USAGE = `Usage: detect-initial-copilot-pr-state.mjs --repo <owner/name> --issue <number>
|
|
8
|
+
Detect whether an assigned issue is still on the bootstrap-only Copilot draft PR
|
|
9
|
+
or has moved into normal linked-PR follow-up.
|
|
10
|
+
Required:
|
|
11
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
12
|
+
--issue <number> Issue number
|
|
13
|
+
States:
|
|
14
|
+
no_linked_pr
|
|
15
|
+
prior_linked_pr_closed_unmerged
|
|
16
|
+
copilot_session_active
|
|
17
|
+
waiting_for_initial_copilot_implementation
|
|
18
|
+
linked_pr_ready_for_followup
|
|
19
|
+
Success output (stdout, JSON):
|
|
20
|
+
{
|
|
21
|
+
"ok": true,
|
|
22
|
+
"repo": "owner/name",
|
|
23
|
+
"issue": 59,
|
|
24
|
+
"state": "no_linked_pr"|"prior_linked_pr_closed_unmerged"|"copilot_session_active"|"waiting_for_initial_copilot_implementation"|"linked_pr_ready_for_followup",
|
|
25
|
+
"prNumber": 79|null,
|
|
26
|
+
"prUrl": "..."|null,
|
|
27
|
+
"headBranch": "..."|null,
|
|
28
|
+
"authorLogin": "Copilot"|null,
|
|
29
|
+
"isDraft": true|false|null,
|
|
30
|
+
"changedFiles": 0|null,
|
|
31
|
+
"commitCount": 1|null,
|
|
32
|
+
"soleCommitHeadline": "Initial plan"|null,
|
|
33
|
+
"sessionActivity": "active"|"concluded"|"idle"|null,
|
|
34
|
+
"sessionRunId": 123|null,
|
|
35
|
+
"sessionRunName": "..."|null,
|
|
36
|
+
"sessionRunStatus": "..."|null,
|
|
37
|
+
"sessionRunConclusion": string|null,
|
|
38
|
+
"sessionRunCreatedAt": "..."|null,
|
|
39
|
+
"sessionConfidence": "high"|null
|
|
40
|
+
}
|
|
41
|
+
Error output (stderr, JSON):
|
|
42
|
+
Argument/usage errors:
|
|
43
|
+
{ "ok": false, "error": "...", "usage": "..." }
|
|
44
|
+
gh/runtime failures:
|
|
45
|
+
{ "ok": false, "error": "..." }`.trim();
|
|
46
|
+
export const LINKED_PR_STATE = Object.freeze({
|
|
47
|
+
NO_LINKED_PR: "no_linked_pr",
|
|
48
|
+
PRIOR_LINKED_PR_CLOSED_UNMERGED: "prior_linked_pr_closed_unmerged",
|
|
49
|
+
COPILOT_SESSION_ACTIVE: "copilot_session_active",
|
|
50
|
+
WAITING_FOR_INITIAL_COPILOT_IMPLEMENTATION: "waiting_for_initial_copilot_implementation",
|
|
51
|
+
LINKED_PR_READY_FOR_FOLLOWUP: "linked_pr_ready_for_followup",
|
|
52
|
+
});
|
|
53
|
+
const INITIAL_COPILOT_PR_FACTS_QUERY = [
|
|
54
|
+
"query($owner:String!, $name:String!, $pr:Int!) {",
|
|
55
|
+
" repository(owner:$owner, name:$name) {",
|
|
56
|
+
" pullRequest(number:$pr) {",
|
|
57
|
+
" number",
|
|
58
|
+
" url",
|
|
59
|
+
" headRefName",
|
|
60
|
+
" state",
|
|
61
|
+
" isDraft",
|
|
62
|
+
" changedFiles",
|
|
63
|
+
" repository { nameWithOwner }",
|
|
64
|
+
" author {",
|
|
65
|
+
" __typename",
|
|
66
|
+
" login",
|
|
67
|
+
" }",
|
|
68
|
+
" commits(first: 2) {",
|
|
69
|
+
" totalCount",
|
|
70
|
+
" nodes {",
|
|
71
|
+
" commit {",
|
|
72
|
+
" messageHeadline",
|
|
73
|
+
" }",
|
|
74
|
+
" }",
|
|
75
|
+
" }",
|
|
76
|
+
" }",
|
|
77
|
+
" }",
|
|
78
|
+
"}",
|
|
79
|
+
].join("\n");
|
|
80
|
+
const parseError = buildParseError(USAGE);
|
|
81
|
+
export function parseDetectInitialCopilotPrStateCliArgs(argv) {
|
|
82
|
+
const args = [...argv];
|
|
83
|
+
const options = {
|
|
84
|
+
help: false,
|
|
85
|
+
repo: undefined,
|
|
86
|
+
issue: undefined,
|
|
87
|
+
};
|
|
88
|
+
while (args.length > 0) {
|
|
89
|
+
const token = args.shift();
|
|
90
|
+
if (token === "--help" || token === "-h") {
|
|
91
|
+
options.help = true;
|
|
92
|
+
return options;
|
|
93
|
+
}
|
|
94
|
+
if (token === "--repo") {
|
|
95
|
+
options.repo = requireOptionValue(args, "--repo", parseError).trim();
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (token === "--issue") {
|
|
99
|
+
options.issue = parseIssueNumber(requireOptionValue(args, "--issue", parseError), parseError);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
103
|
+
}
|
|
104
|
+
if (options.repo === undefined || options.issue === undefined) {
|
|
105
|
+
throw parseError("detect-initial-copilot-pr-state requires both --repo <owner/name> and --issue <number>");
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
parseRepoSlug(options.repo);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
111
|
+
}
|
|
112
|
+
return options;
|
|
113
|
+
}
|
|
114
|
+
function buildQueryArgs({ owner, name, pr }) {
|
|
115
|
+
return [
|
|
116
|
+
"api",
|
|
117
|
+
"graphql",
|
|
118
|
+
"--field",
|
|
119
|
+
`owner=${owner}`,
|
|
120
|
+
"--field",
|
|
121
|
+
`name=${name}`,
|
|
122
|
+
"-F",
|
|
123
|
+
`pr=${pr}`,
|
|
124
|
+
"--field",
|
|
125
|
+
`query=${INITIAL_COPILOT_PR_FACTS_QUERY}`,
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
function getRequiredString(value, fieldName) {
|
|
129
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
130
|
+
throw new Error(`Missing required PR facts: ${fieldName}`);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
function getRequiredBoolean(value, fieldName) {
|
|
135
|
+
if (typeof value !== "boolean") {
|
|
136
|
+
throw new Error(`Missing required PR facts: ${fieldName}`);
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
function getRequiredNonNegativeInteger(value, fieldName) {
|
|
141
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
142
|
+
throw new Error(`Missing required PR facts: ${fieldName}`);
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
function getRequiredPositiveInteger(value, fieldName) {
|
|
147
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
148
|
+
throw new Error(`Missing required PR facts: ${fieldName}`);
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
function normalizeRepoForComparison(repo) {
|
|
153
|
+
return typeof repo === "string" ? repo.trim().toLowerCase() : "";
|
|
154
|
+
}
|
|
155
|
+
function isCopilotAuthored(authorLogin) {
|
|
156
|
+
const normalized = String(authorLogin).trim().toLowerCase();
|
|
157
|
+
return normalized === "copilot"
|
|
158
|
+
|| normalized === "copilot-swe-agent"
|
|
159
|
+
|| normalized === "app/copilot-swe-agent"
|
|
160
|
+
|| normalized === "copilot-swe-agent[bot]";
|
|
161
|
+
}
|
|
162
|
+
function classifyInitialCopilotPrState({ repo, facts }) {
|
|
163
|
+
const isBootstrapOnly = facts.state === "OPEN"
|
|
164
|
+
&& normalizeRepoForComparison(facts.repository) === normalizeRepoForComparison(repo)
|
|
165
|
+
&& facts.isDraft
|
|
166
|
+
&& isCopilotAuthored(facts.authorLogin)
|
|
167
|
+
&& facts.commitCount === 1
|
|
168
|
+
&& facts.changedFiles === 0
|
|
169
|
+
&& facts.soleCommitHeadline === "Initial plan";
|
|
170
|
+
if (facts.sessionActivity === "active") {
|
|
171
|
+
return LINKED_PR_STATE.COPILOT_SESSION_ACTIVE;
|
|
172
|
+
}
|
|
173
|
+
return isBootstrapOnly
|
|
174
|
+
? LINKED_PR_STATE.WAITING_FOR_INITIAL_COPILOT_IMPLEMENTATION
|
|
175
|
+
: LINKED_PR_STATE.LINKED_PR_READY_FOR_FOLLOWUP;
|
|
176
|
+
}
|
|
177
|
+
async function fetchLinkedPrFacts({ repo, prNumber }, { env, ghCommand }) {
|
|
178
|
+
const { owner, name } = parseRepoSlug(repo);
|
|
179
|
+
const result = await runChild(
|
|
180
|
+
ghCommand,
|
|
181
|
+
buildQueryArgs({ owner, name, pr: prNumber }),
|
|
182
|
+
env,
|
|
183
|
+
);
|
|
184
|
+
if (result.code !== 0) {
|
|
185
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
186
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
187
|
+
}
|
|
188
|
+
const payload = parseJsonText(result.stdout);
|
|
189
|
+
const pr = payload?.data?.repository?.pullRequest;
|
|
190
|
+
if (!pr || typeof pr !== "object") {
|
|
191
|
+
throw new Error(`Missing required PR facts: data.repository.pullRequest for linked PR #${prNumber}`);
|
|
192
|
+
}
|
|
193
|
+
const commitCount = getRequiredNonNegativeInteger(pr?.commits?.totalCount, "pullRequest.commits.totalCount");
|
|
194
|
+
const commitNode = Array.isArray(pr?.commits?.nodes) ? pr.commits.nodes[0] : null;
|
|
195
|
+
const soleCommitHeadline = commitCount === 1
|
|
196
|
+
? getRequiredString(commitNode?.commit?.messageHeadline, "pullRequest.commits.nodes[0].commit.messageHeadline")
|
|
197
|
+
: null;
|
|
198
|
+
return {
|
|
199
|
+
number: getRequiredPositiveInteger(pr.number, "pullRequest.number"),
|
|
200
|
+
url: getRequiredString(pr.url, "pullRequest.url"),
|
|
201
|
+
headBranch: getRequiredString(pr.headRefName, "pullRequest.headRefName"),
|
|
202
|
+
state: getRequiredString(pr.state, "pullRequest.state"),
|
|
203
|
+
isDraft: getRequiredBoolean(pr.isDraft, "pullRequest.isDraft"),
|
|
204
|
+
changedFiles: getRequiredNonNegativeInteger(pr.changedFiles, "pullRequest.changedFiles"),
|
|
205
|
+
repository: getRequiredString(pr?.repository?.nameWithOwner, "pullRequest.repository.nameWithOwner"),
|
|
206
|
+
authorLogin: getRequiredString(pr?.author?.login, "pullRequest.author.login"),
|
|
207
|
+
commitCount,
|
|
208
|
+
soleCommitHeadline,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export async function detectInitialCopilotPrState({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
212
|
+
const linked = await detectLinkedIssuePr({ repo, issue }, { env, ghCommand });
|
|
213
|
+
if (!linked.hasOpenLinkedPr || linked.prNumber === null) {
|
|
214
|
+
if (linked.hasPriorClosedUnmergedPr) {
|
|
215
|
+
return {
|
|
216
|
+
ok: true,
|
|
217
|
+
repo,
|
|
218
|
+
issue,
|
|
219
|
+
state: LINKED_PR_STATE.PRIOR_LINKED_PR_CLOSED_UNMERGED,
|
|
220
|
+
prNumber: linked.priorClosedUnmergedPrNumber ?? null,
|
|
221
|
+
prUrl: linked.priorClosedUnmergedPrUrl ?? null,
|
|
222
|
+
headBranch: null,
|
|
223
|
+
authorLogin: null,
|
|
224
|
+
isDraft: null,
|
|
225
|
+
changedFiles: null,
|
|
226
|
+
commitCount: null,
|
|
227
|
+
soleCommitHeadline: null,
|
|
228
|
+
sessionActivity: null,
|
|
229
|
+
sessionRunId: null,
|
|
230
|
+
sessionRunName: null,
|
|
231
|
+
sessionRunStatus: null,
|
|
232
|
+
sessionRunConclusion: null,
|
|
233
|
+
sessionRunCreatedAt: null,
|
|
234
|
+
sessionConfidence: null,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
repo,
|
|
240
|
+
issue,
|
|
241
|
+
state: LINKED_PR_STATE.NO_LINKED_PR,
|
|
242
|
+
prNumber: null,
|
|
243
|
+
prUrl: null,
|
|
244
|
+
headBranch: null,
|
|
245
|
+
authorLogin: null,
|
|
246
|
+
isDraft: null,
|
|
247
|
+
changedFiles: null,
|
|
248
|
+
commitCount: null,
|
|
249
|
+
soleCommitHeadline: null,
|
|
250
|
+
sessionActivity: null,
|
|
251
|
+
sessionRunId: null,
|
|
252
|
+
sessionRunName: null,
|
|
253
|
+
sessionRunStatus: null,
|
|
254
|
+
sessionRunConclusion: null,
|
|
255
|
+
sessionRunCreatedAt: null,
|
|
256
|
+
sessionConfidence: null,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const facts = await fetchLinkedPrFacts({ repo, prNumber: linked.prNumber }, { env, ghCommand });
|
|
260
|
+
let sessionActivity = null;
|
|
261
|
+
if (facts.isDraft && isCopilotAuthored(facts.authorLogin)) {
|
|
262
|
+
sessionActivity = await detectCopilotSessionActivity(
|
|
263
|
+
{
|
|
264
|
+
repo,
|
|
265
|
+
branch: facts.headBranch,
|
|
266
|
+
},
|
|
267
|
+
{ env, ghCommand },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
repo,
|
|
273
|
+
issue,
|
|
274
|
+
state: classifyInitialCopilotPrState({
|
|
275
|
+
repo,
|
|
276
|
+
facts: {
|
|
277
|
+
...facts,
|
|
278
|
+
sessionActivity: sessionActivity?.activity ?? null,
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
prNumber: facts.number,
|
|
282
|
+
prUrl: facts.url,
|
|
283
|
+
headBranch: facts.headBranch,
|
|
284
|
+
authorLogin: facts.authorLogin,
|
|
285
|
+
isDraft: facts.isDraft,
|
|
286
|
+
changedFiles: facts.changedFiles,
|
|
287
|
+
commitCount: facts.commitCount,
|
|
288
|
+
soleCommitHeadline: facts.soleCommitHeadline,
|
|
289
|
+
sessionActivity: sessionActivity?.activity ?? null,
|
|
290
|
+
sessionRunId: sessionActivity?.runId ?? null,
|
|
291
|
+
sessionRunName: sessionActivity?.runName ?? null,
|
|
292
|
+
sessionRunStatus: sessionActivity?.runStatus ?? null,
|
|
293
|
+
sessionRunConclusion: sessionActivity?.runConclusion ?? null,
|
|
294
|
+
sessionRunCreatedAt: sessionActivity?.runCreatedAt ?? null,
|
|
295
|
+
sessionConfidence: sessionActivity?.confidence ?? null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
export async function runCli(
|
|
299
|
+
argv = process.argv.slice(2),
|
|
300
|
+
{ stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
|
|
301
|
+
) {
|
|
302
|
+
const options = parseDetectInitialCopilotPrStateCliArgs(argv);
|
|
303
|
+
if (options.help) {
|
|
304
|
+
stdout.write(`${USAGE}\n`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const result = await detectInitialCopilotPrState(
|
|
308
|
+
{ repo: options.repo, issue: options.issue },
|
|
309
|
+
{ env, ghCommand },
|
|
310
|
+
);
|
|
311
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
312
|
+
}
|
|
313
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
314
|
+
runCli().catch((error) => {
|
|
315
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { statSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
6
|
+
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
7
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: detect-internal-only-pr.mjs --repo <owner/name> --pr <number> [--config <path>]
|
|
10
|
+
Detect whether a PR only touches internal tooling files (scripts, docs, tests, config)
|
|
11
|
+
and should suppress external Copilot review.
|
|
12
|
+
|
|
13
|
+
Required:
|
|
14
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
15
|
+
--pr <number> Pull request number
|
|
16
|
+
Optional:
|
|
17
|
+
--config <path> Path to .devloops (default: auto-detect, tries .devloops then .pi/dev-loop/settings.*)
|
|
18
|
+
--label-check Also check for explicit "internal_only" label on the PR
|
|
19
|
+
Output (stdout, JSON):
|
|
20
|
+
{ "ok": true, "internalOnly": true|false, "files": ["path1", "path2", ...],
|
|
21
|
+
"reason": "...", "repo": "...", "pr": N }
|
|
22
|
+
Exit codes:
|
|
23
|
+
0 Success
|
|
24
|
+
1 Argument error or gh failure`.trim();
|
|
25
|
+
|
|
26
|
+
const parseError = buildParseError(USAGE);
|
|
27
|
+
|
|
28
|
+
// Shipped default patterns used as fallback when no config is found.
|
|
29
|
+
const SHIPPED_DEFAULT_PATTERNS = [
|
|
30
|
+
"^scripts/",
|
|
31
|
+
"^docs/",
|
|
32
|
+
"^skills/docs/",
|
|
33
|
+
"^\\.pi/",
|
|
34
|
+
"^\\.github/",
|
|
35
|
+
"^test/",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function findRepoRoot(cwd = process.cwd()) {
|
|
39
|
+
let dir = cwd;
|
|
40
|
+
while (true) {
|
|
41
|
+
try {
|
|
42
|
+
const s = statSync(path.join(dir, ".git"));
|
|
43
|
+
if (s.isDirectory() || s.isFile()) return dir;
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
const parent = path.dirname(dir);
|
|
47
|
+
if (parent === dir) return null;
|
|
48
|
+
dir = parent;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load internal path patterns from config, with precedence:
|
|
54
|
+
* 1. --config flag (explicit path)
|
|
55
|
+
* 2. Auto-detect from repo root (.devloops first, then .pi/dev-loop/settings.* or overrides.*)
|
|
56
|
+
* 3. Shipped defaults
|
|
57
|
+
*
|
|
58
|
+
* Returns a flat array of regex pattern strings.
|
|
59
|
+
* Falls back to shipped defaults when parsed patterns are empty or invalid.
|
|
60
|
+
*/
|
|
61
|
+
function loadInternalPathPatterns(configPath) {
|
|
62
|
+
// --config flag takes priority
|
|
63
|
+
if (configPath) {
|
|
64
|
+
const patterns = tryLoadFromFile(configPath);
|
|
65
|
+
if (patterns && patterns.length > 0) return patterns;
|
|
66
|
+
return [...SHIPPED_DEFAULT_PATTERNS];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Auto-detect from repo root
|
|
70
|
+
const repoRoot = findRepoRoot();
|
|
71
|
+
if (repoRoot) {
|
|
72
|
+
const candidates = [
|
|
73
|
+
// .devloops at repo root (primary)
|
|
74
|
+
path.join(repoRoot, ".devloops"),
|
|
75
|
+
path.join(repoRoot, ".devloops.yaml"),
|
|
76
|
+
path.join(repoRoot, ".devloops.yml"),
|
|
77
|
+
path.join(repoRoot, ".devloops.json"),
|
|
78
|
+
// Legacy .pi/dev-loop/settings.* or overrides.* (deprecated)
|
|
79
|
+
path.join(repoRoot, ".pi", "dev-loop", "settings.yaml"),
|
|
80
|
+
path.join(repoRoot, ".pi", "dev-loop", "settings.yml"),
|
|
81
|
+
path.join(repoRoot, ".pi", "dev-loop", "settings.json"),
|
|
82
|
+
path.join(repoRoot, ".pi", "dev-loop", "overrides.yaml"),
|
|
83
|
+
path.join(repoRoot, ".pi", "dev-loop", "overrides.yml"),
|
|
84
|
+
path.join(repoRoot, ".pi", "dev-loop", "overrides.json"),
|
|
85
|
+
];
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
const patterns = tryLoadFromFile(candidate);
|
|
88
|
+
if (patterns && patterns.length > 0) return patterns;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fall back to shipped defaults
|
|
93
|
+
return [...SHIPPED_DEFAULT_PATTERNS];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function tryLoadFromFile(filePath) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(filePath, "utf8");
|
|
99
|
+
const parsed = filePath.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
|
|
100
|
+
const patterns = parsed?.internalPathPatterns;
|
|
101
|
+
if (Array.isArray(patterns)) {
|
|
102
|
+
const trimmed = patterns.filter(p => typeof p === "string" && p.trim()).map(p => p.trim());
|
|
103
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildPatternMatchers(patterns) {
|
|
111
|
+
return patterns.map(p => {
|
|
112
|
+
try {
|
|
113
|
+
return new RegExp(p);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}).filter(r => r !== null);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function parseCliArgs(argv) {
|
|
121
|
+
const args = [...argv];
|
|
122
|
+
const options = {
|
|
123
|
+
help: false,
|
|
124
|
+
repo: undefined,
|
|
125
|
+
pr: undefined,
|
|
126
|
+
config: undefined,
|
|
127
|
+
labelCheck: false,
|
|
128
|
+
};
|
|
129
|
+
while (args.length > 0) {
|
|
130
|
+
const token = args.shift();
|
|
131
|
+
if (token === "--help" || token === "-h") {
|
|
132
|
+
options.help = true;
|
|
133
|
+
return options;
|
|
134
|
+
}
|
|
135
|
+
if (token === "--repo") {
|
|
136
|
+
options.repo = requireOptionValue(args, "--repo", parseError).trim();
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (token === "--pr") {
|
|
140
|
+
options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (token === "--config") {
|
|
144
|
+
options.config = requireOptionValue(args, "--config", parseError).trim();
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (token === "--label-check") {
|
|
148
|
+
options.labelCheck = true;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
152
|
+
}
|
|
153
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
154
|
+
throw parseError("detect-internal-only-pr requires both --repo <owner/name> and --pr <number>");
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
parseRepoSlug(options.repo);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
160
|
+
}
|
|
161
|
+
return options;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function fetchPrFiles({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
165
|
+
const result = await runChild(
|
|
166
|
+
ghCommand,
|
|
167
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "files", "--jq", ".files[].path"],
|
|
168
|
+
env,
|
|
169
|
+
);
|
|
170
|
+
if (result.code !== 0) {
|
|
171
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
172
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
173
|
+
}
|
|
174
|
+
const paths = result.stdout.trim().split("\n").filter(Boolean);
|
|
175
|
+
return paths;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function fetchPrLabels({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
179
|
+
const result = await runChild(
|
|
180
|
+
ghCommand,
|
|
181
|
+
["pr", "view", String(pr), "--repo", repo, "--json", "labels", "--jq", ".labels[].name"],
|
|
182
|
+
env,
|
|
183
|
+
);
|
|
184
|
+
if (result.code !== 0) {
|
|
185
|
+
return []; // Best-effort: label check failure is not fatal
|
|
186
|
+
}
|
|
187
|
+
const labels = result.stdout.trim().split("\n").filter(Boolean);
|
|
188
|
+
return labels;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Detect whether a PR is internal-only using a configurable whitelist.
|
|
193
|
+
*
|
|
194
|
+
* Single-whitelist logic:
|
|
195
|
+
* - If ALL changed files match at least one internal pattern → internalOnly=true
|
|
196
|
+
* - If ANY changed file doesn't match any pattern → internalOnly=false
|
|
197
|
+
* - No blacklist needed — a non-matching file is consumer-facing by definition.
|
|
198
|
+
*/
|
|
199
|
+
export async function detectInternalOnly(options, { env = process.env, ghCommand = "gh" } = {}) {
|
|
200
|
+
const patterns = loadInternalPathPatterns(options.config);
|
|
201
|
+
const matchers = buildPatternMatchers(patterns);
|
|
202
|
+
const files = await fetchPrFiles(options, { env, ghCommand });
|
|
203
|
+
|
|
204
|
+
if (files.length === 0) {
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
internalOnly: false,
|
|
208
|
+
files: [],
|
|
209
|
+
reason: "No files changed; cannot determine internal-only status",
|
|
210
|
+
repo: options.repo,
|
|
211
|
+
pr: options.pr,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Single whitelist: any non-matching file → NOT internal-only
|
|
216
|
+
const nonMatching = files.filter(f => !matchers.some(r => r.test(f)));
|
|
217
|
+
if (nonMatching.length > 0) {
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
internalOnly: false,
|
|
221
|
+
files,
|
|
222
|
+
reason: `Consumer-facing file(s) changed: ${nonMatching.join(", ")}`,
|
|
223
|
+
repo: options.repo,
|
|
224
|
+
pr: options.pr,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check for explicit internal_only label if requested (confirmation only)
|
|
229
|
+
if (options.labelCheck) {
|
|
230
|
+
const labels = await fetchPrLabels(options, { env, ghCommand });
|
|
231
|
+
if (labels.includes("internal_only")) {
|
|
232
|
+
// Label confirms — path check already passed
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
internalOnly: true,
|
|
239
|
+
files,
|
|
240
|
+
reason: `All ${files.length} changed file(s) are internal tooling only (scripts/docs/tests/config)`,
|
|
241
|
+
repo: options.repo,
|
|
242
|
+
pr: options.pr,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function runCli(
|
|
247
|
+
argv = process.argv.slice(2),
|
|
248
|
+
{
|
|
249
|
+
stdout = process.stdout,
|
|
250
|
+
env = process.env,
|
|
251
|
+
ghCommand = "gh",
|
|
252
|
+
} = {},
|
|
253
|
+
) {
|
|
254
|
+
const options = parseCliArgs(argv);
|
|
255
|
+
if (options.help) {
|
|
256
|
+
stdout.write(`${USAGE}\n`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const result = await detectInternalOnly(options, { env, ghCommand });
|
|
260
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
264
|
+
runCli().catch((error) => {
|
|
265
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export { findRepoRoot, loadInternalPathPatterns, buildPatternMatchers, tryLoadFromFile, SHIPPED_DEFAULT_PATTERNS };
|