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,837 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
6
|
+
import { runChild as _runChild } from "../_cli-primitives.mjs";
|
|
7
|
+
|
|
8
|
+
const USAGE = `Usage: dev-loops project ensure --repo <owner/name> [--project <number>] [--title <title>] [--link-repo <owner/name>] [--repair-rename]
|
|
9
|
+
|
|
10
|
+
--repair-rename Rename semantically equivalent Status columns to the standard names
|
|
11
|
+
(e.g. "Ready" -> "Next Up"). Without this flag the helper only
|
|
12
|
+
reports rename candidates and leaves existing columns untouched.
|
|
13
|
+
|
|
14
|
+
Idempotent bootstrap for a GitHub Projects V2 board used as the dev-loop queue.
|
|
15
|
+
|
|
16
|
+
Creates the project board if it doesn't exist, ensures a Status field with
|
|
17
|
+
standard columns (Backlog, Next Up, In Progress, Done). Exits clean if the
|
|
18
|
+
board and Status field already exist.
|
|
19
|
+
|
|
20
|
+
When --link-repo is provided, links the project to the given repository after creation.
|
|
21
|
+
|
|
22
|
+
When --project is not provided, resolves from .devloops at repo root
|
|
23
|
+
queue.projectNumber or queue.boardTitle.
|
|
24
|
+
|
|
25
|
+
Output (stdout):
|
|
26
|
+
JSON: { ok: true, project: { id, number, title, url, statusFieldId, linkedRepo } }
|
|
27
|
+
|
|
28
|
+
Exit codes:
|
|
29
|
+
0 — board exists or was created successfully (idempotent)
|
|
30
|
+
1 — usage or argument error
|
|
31
|
+
2 — GitHub API error
|
|
32
|
+
3 — board schema/config mismatch (manual reconciliation needed)
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const VALID_ARGS = new Set(["--repo", "--project", "--title", "--link-repo", "--repair-rename", "--help", "-h"]);
|
|
36
|
+
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
const args = {}; // title default applied in runCli after settings fallback
|
|
39
|
+
const consumed = new Set();
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
if (consumed.has(i)) continue;
|
|
42
|
+
const arg = argv[i];
|
|
43
|
+
if (!VALID_ARGS.has(arg) && arg.startsWith("-")) {
|
|
44
|
+
throw Object.assign(
|
|
45
|
+
new Error(`Unknown flag: ${arg}`),
|
|
46
|
+
{ code: "INVALID_REPO", usage: USAGE },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (arg === "--repo") {
|
|
50
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
|
|
51
|
+
throw Object.assign(
|
|
52
|
+
new Error("--repo requires a value (owner/name)"),
|
|
53
|
+
{ code: "INVALID_REPO", usage: USAGE },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
args.repo = argv[++i];
|
|
57
|
+
consumed.add(i);
|
|
58
|
+
} else if (arg === "--project") {
|
|
59
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
|
|
60
|
+
throw Object.assign(
|
|
61
|
+
new Error("--project requires a numeric value"),
|
|
62
|
+
{ code: "INVALID_PROJECT", usage: USAGE },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const num = Number(argv[++i]);
|
|
66
|
+
if (!Number.isInteger(num) || num <= 0) {
|
|
67
|
+
throw Object.assign(
|
|
68
|
+
new Error(`--project must be a positive integer, got "${argv[i]}"`),
|
|
69
|
+
{ code: "INVALID_PROJECT", usage: USAGE },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
args.project = num;
|
|
73
|
+
} else if (arg === "--title") {
|
|
74
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
|
|
75
|
+
throw Object.assign(
|
|
76
|
+
new Error("--title requires a value"),
|
|
77
|
+
{ code: "INVALID_REPO", usage: USAGE },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
args.title = argv[++i];
|
|
81
|
+
consumed.add(i);
|
|
82
|
+
} else if (arg === "--link-repo") {
|
|
83
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
|
|
84
|
+
throw Object.assign(
|
|
85
|
+
new Error("--link-repo requires a value (owner/name)"),
|
|
86
|
+
{ code: "INVALID_REPO", usage: USAGE },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
args.linkRepo = argv[++i];
|
|
90
|
+
consumed.add(i);
|
|
91
|
+
} else if (arg === "--repair-rename") {
|
|
92
|
+
args.repairRename = true;
|
|
93
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
94
|
+
args.help = true;
|
|
95
|
+
} else {
|
|
96
|
+
throw Object.assign(
|
|
97
|
+
new Error(`Unexpected argument: ${arg}`),
|
|
98
|
+
{ code: "INVALID_REPO", usage: USAGE },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return args;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Validation ───────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
// GitHub slug rules: owner 1-39 chars (alnum/dash, no leading/trailing dash,
|
|
108
|
+
// no consecutive dashes); repo name similar but also allows dots/underscores.
|
|
109
|
+
const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
110
|
+
const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
|
|
111
|
+
|
|
112
|
+
function validateRepo(repo) {
|
|
113
|
+
if (!repo || typeof repo !== "string") {
|
|
114
|
+
throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO", usage: USAGE });
|
|
115
|
+
}
|
|
116
|
+
const trimmed = repo.trim();
|
|
117
|
+
if (trimmed !== repo) {
|
|
118
|
+
throw Object.assign(
|
|
119
|
+
new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`),
|
|
120
|
+
{ code: "INVALID_REPO", usage: USAGE },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const slashIdx = repo.indexOf("/");
|
|
124
|
+
if (slashIdx === -1) {
|
|
125
|
+
throw Object.assign(
|
|
126
|
+
new Error(`--repo must be exactly owner/name, got "${repo}"`),
|
|
127
|
+
{ code: "INVALID_REPO", usage: USAGE },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const owner = repo.slice(0, slashIdx);
|
|
131
|
+
const name = repo.slice(slashIdx + 1);
|
|
132
|
+
if (!owner || !name) {
|
|
133
|
+
throw Object.assign(
|
|
134
|
+
new Error(`--repo must be exactly owner/name, got "${repo}"`),
|
|
135
|
+
{ code: "INVALID_REPO", usage: USAGE },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (!OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
|
|
139
|
+
throw Object.assign(
|
|
140
|
+
new Error(`--repo must be exactly owner/name, got "${repo}"`),
|
|
141
|
+
{ code: "INVALID_REPO", usage: USAGE },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return repo;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Settings fallback ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function resolveSettings(cwd) {
|
|
150
|
+
// Try bare .devloops and extension variants (.yaml, .yml, .json)
|
|
151
|
+
// to match the config loader's consumer override detection.
|
|
152
|
+
// .json uses strict JSON.parse; all others use the YAML parser.
|
|
153
|
+
const basePath = path.join(cwd, ".devloops");
|
|
154
|
+
const extensions = ["", ".yaml", ".yml", ".json"];
|
|
155
|
+
for (const ext of extensions) {
|
|
156
|
+
try {
|
|
157
|
+
const settingsPath = basePath + ext;
|
|
158
|
+
const raw = readFileSync(settingsPath, "utf-8");
|
|
159
|
+
const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
160
|
+
const queue = settings?.queue;
|
|
161
|
+
if (queue) {
|
|
162
|
+
if (typeof queue.projectNumber === "number" && Number.isInteger(queue.projectNumber) && queue.projectNumber > 0) {
|
|
163
|
+
return { project: queue.projectNumber };
|
|
164
|
+
}
|
|
165
|
+
if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
|
|
166
|
+
return { title: queue.boardTitle.trim() };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
} catch {
|
|
171
|
+
// extension not present or unparseable — try next
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── API helpers ──────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
async function ghGraphql(query, vars, env, runChild = _runChild) {
|
|
180
|
+
const fieldArgs = [];
|
|
181
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
182
|
+
fieldArgs.push("--field", `${key}=${value}`);
|
|
183
|
+
}
|
|
184
|
+
const result = await runChild(
|
|
185
|
+
"gh",
|
|
186
|
+
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
187
|
+
env,
|
|
188
|
+
);
|
|
189
|
+
if (result.code !== 0) {
|
|
190
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
191
|
+
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
192
|
+
}
|
|
193
|
+
const payload = parseJsonText(result.stdout);
|
|
194
|
+
if (payload.errors && payload.errors.length > 0) {
|
|
195
|
+
throw Object.assign(
|
|
196
|
+
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
197
|
+
{ code: "GRAPHQL_ERROR" },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return payload;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Query/mutation fragments ────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
const GET_USER_ID = [
|
|
206
|
+
"query($login:String!) {",
|
|
207
|
+
" user(login:$login) {",
|
|
208
|
+
" id",
|
|
209
|
+
" }",
|
|
210
|
+
"}"
|
|
211
|
+
].join("\n");
|
|
212
|
+
|
|
213
|
+
const GET_ORG_ID = [
|
|
214
|
+
"query($login:String!) {",
|
|
215
|
+
" organization(login:$login) {",
|
|
216
|
+
" id",
|
|
217
|
+
" }",
|
|
218
|
+
"}"
|
|
219
|
+
].join("\n");
|
|
220
|
+
|
|
221
|
+
const LIST_USER_PROJECTS = [
|
|
222
|
+
"query($login:String!, $after:String) {",
|
|
223
|
+
" user(login:$login) {",
|
|
224
|
+
" projectsV2(first:50, after:$after) {",
|
|
225
|
+
" pageInfo {",
|
|
226
|
+
" hasNextPage",
|
|
227
|
+
" endCursor",
|
|
228
|
+
" }",
|
|
229
|
+
" nodes {",
|
|
230
|
+
" id",
|
|
231
|
+
" number",
|
|
232
|
+
" title",
|
|
233
|
+
" url",
|
|
234
|
+
" }",
|
|
235
|
+
" }",
|
|
236
|
+
" }",
|
|
237
|
+
"}"
|
|
238
|
+
].join("\n");
|
|
239
|
+
|
|
240
|
+
const LIST_ORG_PROJECTS = [
|
|
241
|
+
"query($login:String!, $after:String) {",
|
|
242
|
+
" organization(login:$login) {",
|
|
243
|
+
" projectsV2(first:50, after:$after) {",
|
|
244
|
+
" pageInfo {",
|
|
245
|
+
" hasNextPage",
|
|
246
|
+
" endCursor",
|
|
247
|
+
" }",
|
|
248
|
+
" nodes {",
|
|
249
|
+
" id",
|
|
250
|
+
" number",
|
|
251
|
+
" title",
|
|
252
|
+
" url",
|
|
253
|
+
" }",
|
|
254
|
+
" }",
|
|
255
|
+
" }",
|
|
256
|
+
"}"
|
|
257
|
+
].join("\n");
|
|
258
|
+
|
|
259
|
+
const CREATE_PROJECT = [
|
|
260
|
+
"mutation($ownerId:ID!, $title:String!) {",
|
|
261
|
+
" createProjectV2(input:{ownerId:$ownerId, title:$title}) {",
|
|
262
|
+
" projectV2 {",
|
|
263
|
+
" id",
|
|
264
|
+
" number",
|
|
265
|
+
" title",
|
|
266
|
+
" url",
|
|
267
|
+
" }",
|
|
268
|
+
" }",
|
|
269
|
+
"}"
|
|
270
|
+
].join("\n");
|
|
271
|
+
|
|
272
|
+
const GET_PROJECT_FIELDS = [
|
|
273
|
+
"query($projectId:ID!, $after:String) {",
|
|
274
|
+
" node(id:$projectId) {",
|
|
275
|
+
" ... on ProjectV2 {",
|
|
276
|
+
" fields(first:50, after:$after) {",
|
|
277
|
+
" pageInfo {",
|
|
278
|
+
" hasNextPage",
|
|
279
|
+
" endCursor",
|
|
280
|
+
" }",
|
|
281
|
+
" nodes {",
|
|
282
|
+
" ... on ProjectV2SingleSelectField {",
|
|
283
|
+
" id",
|
|
284
|
+
" name",
|
|
285
|
+
" options {",
|
|
286
|
+
" id",
|
|
287
|
+
" name",
|
|
288
|
+
" color",
|
|
289
|
+
" description",
|
|
290
|
+
" }",
|
|
291
|
+
" }",
|
|
292
|
+
" }",
|
|
293
|
+
" }",
|
|
294
|
+
" }",
|
|
295
|
+
" }",
|
|
296
|
+
"}"
|
|
297
|
+
].join("\n");
|
|
298
|
+
|
|
299
|
+
const CREATE_SINGLE_SELECT_FIELD = [
|
|
300
|
+
"mutation($projectId:ID!) {",
|
|
301
|
+
" createProjectV2Field(input:{projectId:$projectId, dataType:SINGLE_SELECT, name:\"Status\", singleSelectOptions:[",
|
|
302
|
+
" {name:\"Backlog\",color:GRAY,description:\"\"},",
|
|
303
|
+
" {name:\"Next Up\",color:BLUE,description:\"\"},",
|
|
304
|
+
" {name:\"In Progress\",color:YELLOW,description:\"\"},",
|
|
305
|
+
" {name:\"Done\",color:GREEN,description:\"\"}",
|
|
306
|
+
" ]}) {",
|
|
307
|
+
" projectV2Field {",
|
|
308
|
+
" ... on ProjectV2SingleSelectField {",
|
|
309
|
+
" id",
|
|
310
|
+
" name",
|
|
311
|
+
" }",
|
|
312
|
+
" }",
|
|
313
|
+
" }",
|
|
314
|
+
"}"
|
|
315
|
+
].join("\n");
|
|
316
|
+
|
|
317
|
+
const LINK_PROJECT_TO_REPO = [
|
|
318
|
+
"mutation($projectId:ID!, $repositoryId:ID!) {",
|
|
319
|
+
" linkProjectV2ToRepository(input:{projectId:$projectId, repositoryId:$repositoryId}) {",
|
|
320
|
+
" clientMutationId",
|
|
321
|
+
" }",
|
|
322
|
+
"}"
|
|
323
|
+
].join("\n");
|
|
324
|
+
|
|
325
|
+
const UPDATE_PROJECT_FIELD = [
|
|
326
|
+
"mutation($fieldId:ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {",
|
|
327
|
+
" updateProjectV2Field(input:{fieldId:$fieldId, singleSelectOptions:$options}) {",
|
|
328
|
+
" projectV2Field {",
|
|
329
|
+
" ... on ProjectV2SingleSelectField {",
|
|
330
|
+
" id",
|
|
331
|
+
" name",
|
|
332
|
+
" options {",
|
|
333
|
+
" id",
|
|
334
|
+
" name",
|
|
335
|
+
" }",
|
|
336
|
+
" }",
|
|
337
|
+
" }",
|
|
338
|
+
" }",
|
|
339
|
+
"}"
|
|
340
|
+
].join("\n");
|
|
341
|
+
|
|
342
|
+
const GET_REPO_ID = [
|
|
343
|
+
"query($owner:String!, $name:String!) {",
|
|
344
|
+
" repository(owner:$owner, name:$name) {",
|
|
345
|
+
" id",
|
|
346
|
+
" }",
|
|
347
|
+
"}"
|
|
348
|
+
].join("\n");
|
|
349
|
+
|
|
350
|
+
// ── Owner resolution ────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
async function resolveOwner(login, env, runChild) {
|
|
353
|
+
// Try user first
|
|
354
|
+
const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
|
|
355
|
+
if (userPayload?.data?.user?.id) {
|
|
356
|
+
return { id: userPayload.data.user.id, kind: "user" };
|
|
357
|
+
}
|
|
358
|
+
// Try organization (only if user returned null — not for API errors)
|
|
359
|
+
const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
|
|
360
|
+
if (orgPayload?.data?.organization?.id) {
|
|
361
|
+
return { id: orgPayload.data.organization.id, kind: "org" };
|
|
362
|
+
}
|
|
363
|
+
throw Object.assign(
|
|
364
|
+
new Error(`Could not resolve owner ID for "${login}" (not a user or organization)`),
|
|
365
|
+
{ code: "NO_USER_ID" },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Repository ID resolution ─────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
async function resolveRepoId(slug, env, runChild) {
|
|
372
|
+
const [owner, name] = slug.split("/");
|
|
373
|
+
const payload = await ghGraphql(GET_REPO_ID, { owner, name }, env, runChild);
|
|
374
|
+
if (!payload?.data?.repository?.id) {
|
|
375
|
+
throw Object.assign(
|
|
376
|
+
new Error(`Could not resolve repository ID for "${slug}"`),
|
|
377
|
+
{ code: "NO_REPO_ID" },
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return payload.data.repository.id;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Paginated project listing ────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
async function listAllProjects(login, kind, env, runChild) {
|
|
386
|
+
const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
|
|
387
|
+
const projects = [];
|
|
388
|
+
let after = null;
|
|
389
|
+
while (true) {
|
|
390
|
+
const vars = { login };
|
|
391
|
+
if (after) vars.after = after;
|
|
392
|
+
const payload = await ghGraphql(query, vars, env, runChild);
|
|
393
|
+
const connection = kind === "org"
|
|
394
|
+
? payload?.data?.organization?.projectsV2
|
|
395
|
+
: payload?.data?.user?.projectsV2;
|
|
396
|
+
const nodes = connection?.nodes ?? [];
|
|
397
|
+
projects.push(...nodes);
|
|
398
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
399
|
+
if (!pageInfo.hasNextPage) break;
|
|
400
|
+
if (!pageInfo.endCursor) {
|
|
401
|
+
throw Object.assign(
|
|
402
|
+
new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
|
|
403
|
+
{ code: "GH_API_ERROR" },
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
after = pageInfo.endCursor;
|
|
407
|
+
}
|
|
408
|
+
return projects;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── Paginated field listing ──────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
async function listAllFields(projectId, env, runChild) {
|
|
414
|
+
const fields = [];
|
|
415
|
+
let after = null;
|
|
416
|
+
while (true) {
|
|
417
|
+
const vars = { projectId };
|
|
418
|
+
if (after) vars.after = after;
|
|
419
|
+
const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
|
|
420
|
+
const connection = payload?.data?.node?.fields;
|
|
421
|
+
const nodes = connection?.nodes ?? [];
|
|
422
|
+
fields.push(...nodes);
|
|
423
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
424
|
+
if (!pageInfo.hasNextPage) break;
|
|
425
|
+
if (!pageInfo.endCursor) {
|
|
426
|
+
throw Object.assign(
|
|
427
|
+
new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
|
|
428
|
+
{ code: "GH_API_ERROR" },
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
after = pageInfo.endCursor;
|
|
432
|
+
}
|
|
433
|
+
return fields;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
// ── Column rename/reconcile helpers ──────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
const RENAME_EQUIVALENTS = {
|
|
440
|
+
"Backlog": ["Backlog", "Todo", "To do", "Pending"],
|
|
441
|
+
"Next Up": ["Next Up", "Ready", "Next", "Up next"],
|
|
442
|
+
"In Progress": ["In Progress", "Doing", "In progress", "InProgress"],
|
|
443
|
+
"Done": ["Done", "Complete", "Completed"],
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
function normalizeOptionName(name) {
|
|
447
|
+
return name.trim().toLowerCase().replace(/\s+/g, " ");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const EQUIVALENT_TO_STANDARD = new Map();
|
|
451
|
+
for (const [standard, synonyms] of Object.entries(RENAME_EQUIVALENTS)) {
|
|
452
|
+
for (const synonym of synonyms) {
|
|
453
|
+
EQUIVALENT_TO_STANDARD.set(normalizeOptionName(synonym), standard);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function buildOptionInput(option) {
|
|
458
|
+
const standard = STANDARD_COLUMNS.find((c) => c.name === option.name);
|
|
459
|
+
return {
|
|
460
|
+
name: option.name,
|
|
461
|
+
color: option.color ?? standard?.color ?? "GRAY",
|
|
462
|
+
description: option.description ?? "",
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function classifyOptions(existingOptions, repairRename) {
|
|
467
|
+
const exactPresent = new Set(existingOptions.map((o) => o.name));
|
|
468
|
+
const candidates = [];
|
|
469
|
+
const conflicts = [];
|
|
470
|
+
const seenCandidateStandard = new Map(); // standard -> option name
|
|
471
|
+
|
|
472
|
+
for (const option of existingOptions) {
|
|
473
|
+
const standard = EQUIVALENT_TO_STANDARD.get(normalizeOptionName(option.name));
|
|
474
|
+
if (!standard) continue;
|
|
475
|
+
|
|
476
|
+
// Exact standard columns are not drift; skip them.
|
|
477
|
+
if (option.name === standard) continue;
|
|
478
|
+
|
|
479
|
+
if (exactPresent.has(standard)) {
|
|
480
|
+
conflicts.push({
|
|
481
|
+
option: option.name,
|
|
482
|
+
reason: `Equivalent "${option.name}" maps to "${standard}", but the exact standard column already exists.`,
|
|
483
|
+
});
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (seenCandidateStandard.has(standard)) {
|
|
488
|
+
conflicts.push({
|
|
489
|
+
option: option.name,
|
|
490
|
+
reason: `Multiple columns map to "${standard}": "${seenCandidateStandard.get(standard)}" and "${option.name}".`,
|
|
491
|
+
});
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
seenCandidateStandard.set(standard, option.name);
|
|
496
|
+
candidates.push({
|
|
497
|
+
optionId: option.id,
|
|
498
|
+
from: option.name,
|
|
499
|
+
to: standard,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const candidateTargets = new Set(candidates.map((c) => c.to));
|
|
504
|
+
|
|
505
|
+
if (repairRename && conflicts.length === 0) {
|
|
506
|
+
const appliedRenames = [];
|
|
507
|
+
const renamedOptions = existingOptions.map((option) => {
|
|
508
|
+
const candidate = candidates.find((c) => c.optionId === option.id);
|
|
509
|
+
if (!candidate) return buildOptionInput(option);
|
|
510
|
+
appliedRenames.push({ from: candidate.from, to: candidate.to });
|
|
511
|
+
return {
|
|
512
|
+
name: candidate.to,
|
|
513
|
+
color: STANDARD_COLUMNS.find((c) => c.name === candidate.to)?.color ?? "GRAY",
|
|
514
|
+
description: option.description ?? "",
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const coveredStandards = new Set([...exactPresent, ...appliedRenames.map((r) => r.to)]);
|
|
519
|
+
const additiveMissing = STANDARD_COLUMNS.filter((c) => !coveredStandards.has(c.name));
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
options: [...renamedOptions, ...additiveMissing],
|
|
523
|
+
repairs: {
|
|
524
|
+
additive: additiveMissing.map((c) => c.name),
|
|
525
|
+
renameCandidates: [],
|
|
526
|
+
renamesApplied: appliedRenames,
|
|
527
|
+
conflicts: [],
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const coveredStandards = new Set([...exactPresent, ...candidateTargets]);
|
|
533
|
+
const additiveMissing = STANDARD_COLUMNS.filter((c) => !coveredStandards.has(c.name));
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
options: [
|
|
537
|
+
...existingOptions.map(buildOptionInput),
|
|
538
|
+
...additiveMissing,
|
|
539
|
+
],
|
|
540
|
+
repairs: {
|
|
541
|
+
additive: additiveMissing.map((c) => c.name),
|
|
542
|
+
renameCandidates: candidates.map((c) => ({ from: c.from, to: c.to })),
|
|
543
|
+
renamesApplied: [],
|
|
544
|
+
conflicts,
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function classifyAndRepairColumns(
|
|
550
|
+
fieldId,
|
|
551
|
+
existingOptions,
|
|
552
|
+
repairRename,
|
|
553
|
+
env,
|
|
554
|
+
runChild,
|
|
555
|
+
) {
|
|
556
|
+
const classification = classifyOptions(existingOptions, repairRename);
|
|
557
|
+
|
|
558
|
+
const willRename = classification.repairs.renamesApplied.length > 0;
|
|
559
|
+
const willAdd = classification.repairs.additive.length > 0;
|
|
560
|
+
|
|
561
|
+
if (!willRename && !willAdd) {
|
|
562
|
+
return { options: existingOptions, repairs: classification.repairs };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// When conflicts are present, do not mutate the field; surface the conflict.
|
|
566
|
+
if (classification.repairs.conflicts.length > 0) {
|
|
567
|
+
return { options: existingOptions, repairs: classification.repairs };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const payload = await ghGraphql(UPDATE_PROJECT_FIELD, {
|
|
571
|
+
fieldId,
|
|
572
|
+
options: JSON.stringify(classification.options),
|
|
573
|
+
}, env, runChild);
|
|
574
|
+
|
|
575
|
+
const updatedField = payload?.data?.updateProjectV2Field?.projectV2Field;
|
|
576
|
+
if (!updatedField) {
|
|
577
|
+
throw Object.assign(
|
|
578
|
+
new Error("Failed to update Status field with column repairs"),
|
|
579
|
+
{ code: "UPDATE_FIELD_FAILED" },
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return { options: updatedField.options ?? classification.options, repairs: classification.repairs };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── Column auto-repair ───────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
const STANDARD_COLUMNS = [
|
|
589
|
+
{ name: "Backlog", color: "GRAY", description: "" },
|
|
590
|
+
{ name: "Next Up", color: "BLUE", description: "" },
|
|
591
|
+
{ name: "In Progress", color: "YELLOW", description: "" },
|
|
592
|
+
{ name: "Done", color: "GREEN", description: "" },
|
|
593
|
+
];
|
|
594
|
+
|
|
595
|
+
const STANDARD_COLUMN_NAMES = STANDARD_COLUMNS.map((c) => c.name);
|
|
596
|
+
|
|
597
|
+
const EMPTY_REPAIRS = Object.freeze({ additive: [], renameCandidates: [], renamesApplied: [], conflicts: [] });
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Auto-repair a Status field that is missing standard columns.
|
|
601
|
+
*
|
|
602
|
+
* Calls updateProjectV2Field to add missing columns while preserving
|
|
603
|
+
* any existing (non-standard) columns in their current order.
|
|
604
|
+
*
|
|
605
|
+
* Returns the updated field options (with IDs from the mutation response).
|
|
606
|
+
*/
|
|
607
|
+
async function autoRepairColumns(
|
|
608
|
+
fieldId,
|
|
609
|
+
existingOptions,
|
|
610
|
+
env,
|
|
611
|
+
runChild,
|
|
612
|
+
) {
|
|
613
|
+
const existingNames = new Set(existingOptions.map((o) => o.name));
|
|
614
|
+
const missingColumns = STANDARD_COLUMNS.filter(
|
|
615
|
+
(c) => !existingNames.has(c.name),
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
if (missingColumns.length === 0) {
|
|
619
|
+
// Nothing to repair — should not be called in this case
|
|
620
|
+
return existingOptions;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Build full option list: existing options + missing standard columns appended
|
|
624
|
+
const fullOptions = [
|
|
625
|
+
...existingOptions.map((o) => ({ name: o.name, color: o.color ?? "GRAY", description: o.description ?? "" })),
|
|
626
|
+
...missingColumns,
|
|
627
|
+
];
|
|
628
|
+
|
|
629
|
+
const payload = await ghGraphql(UPDATE_PROJECT_FIELD, {
|
|
630
|
+
fieldId,
|
|
631
|
+
options: JSON.stringify(fullOptions),
|
|
632
|
+
}, env, runChild);
|
|
633
|
+
|
|
634
|
+
const updatedField = payload?.data?.updateProjectV2Field?.projectV2Field;
|
|
635
|
+
if (!updatedField) {
|
|
636
|
+
throw Object.assign(
|
|
637
|
+
new Error("Failed to update Status field with missing columns"),
|
|
638
|
+
{ code: "UPDATE_FIELD_FAILED" },
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return updatedField.options ?? fullOptions;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── Exit code classification ────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
function classifyExitCode(err) {
|
|
648
|
+
if (err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT") return 1;
|
|
649
|
+
return 2;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── Main logic ──────────────────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
async function main(args, { env = process.env, runChild } = {}) {
|
|
655
|
+
const child = runChild ?? _runChild;
|
|
656
|
+
const repo = validateRepo(args.repo);
|
|
657
|
+
const [owner] = repo.split("/");
|
|
658
|
+
const title = args.title || "Dev Loop Queue"; // explicit default after settings fallback in runCli
|
|
659
|
+
const linkRepo = args.linkRepo || null;
|
|
660
|
+
if (linkRepo) validateRepo(linkRepo); // validate format early
|
|
661
|
+
|
|
662
|
+
// 1. Resolve owner (user or org)
|
|
663
|
+
const { id: ownerId, kind: ownerKind } = await resolveOwner(owner, env, child);
|
|
664
|
+
|
|
665
|
+
// 2. Look for existing project
|
|
666
|
+
const projects = await listAllProjects(owner, ownerKind, env, child);
|
|
667
|
+
let project;
|
|
668
|
+
if (args.project) {
|
|
669
|
+
project = projects.find((p) => p.number === args.project);
|
|
670
|
+
if (!project) {
|
|
671
|
+
throw Object.assign(
|
|
672
|
+
new Error(`Project #${args.project} not found under "${owner}". Use --title to create a new board.`),
|
|
673
|
+
{ code: "PROJECT_NOT_FOUND" },
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
} else {
|
|
677
|
+
project = projects.find((p) => p.title === title);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (project) {
|
|
681
|
+
// Project exists — verify Status field (paginated)
|
|
682
|
+
const fieldNodes = await listAllFields(project.id, env, child);
|
|
683
|
+
const statusField = fieldNodes.find(
|
|
684
|
+
(f) => f.name === "Status" && f.options,
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
if (statusField) {
|
|
688
|
+
// Always classify drift for the repairs object, then mutate only when authorized.
|
|
689
|
+
const { repairs } = await classifyAndRepairColumns(
|
|
690
|
+
statusField.id,
|
|
691
|
+
statusField.options,
|
|
692
|
+
args.repairRename ?? false,
|
|
693
|
+
env,
|
|
694
|
+
child,
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
let linkedRepo = null;
|
|
698
|
+
if (linkRepo) {
|
|
699
|
+
const repoId = await resolveRepoId(linkRepo, env, child);
|
|
700
|
+
await ghGraphql(LINK_PROJECT_TO_REPO, {
|
|
701
|
+
projectId: project.id,
|
|
702
|
+
repositoryId: repoId,
|
|
703
|
+
}, env, child);
|
|
704
|
+
linkedRepo = linkRepo;
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
ok: true,
|
|
708
|
+
project: {
|
|
709
|
+
id: project.id,
|
|
710
|
+
number: project.number,
|
|
711
|
+
title: project.title,
|
|
712
|
+
url: project.url,
|
|
713
|
+
statusFieldId: statusField.id,
|
|
714
|
+
...(linkedRepo ? { linkedRepo } : {}),
|
|
715
|
+
},
|
|
716
|
+
repairs,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// No Status field — create it
|
|
721
|
+
const createFieldPayload = await ghGraphql(CREATE_SINGLE_SELECT_FIELD, {
|
|
722
|
+
projectId: project.id,
|
|
723
|
+
}, env, child);
|
|
724
|
+
const newField = createFieldPayload?.data?.createProjectV2Field?.projectV2Field;
|
|
725
|
+
if (!newField) {
|
|
726
|
+
throw Object.assign(new Error("Failed to create Status field"), { code: "CREATE_FIELD_FAILED" });
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
let linkedRepo = null;
|
|
730
|
+
if (linkRepo) {
|
|
731
|
+
const repoId = await resolveRepoId(linkRepo, env, child);
|
|
732
|
+
await ghGraphql(LINK_PROJECT_TO_REPO, {
|
|
733
|
+
projectId: project.id,
|
|
734
|
+
repositoryId: repoId,
|
|
735
|
+
}, env, child);
|
|
736
|
+
linkedRepo = linkRepo;
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
ok: true,
|
|
740
|
+
project: {
|
|
741
|
+
id: project.id,
|
|
742
|
+
number: project.number,
|
|
743
|
+
title: project.title,
|
|
744
|
+
url: project.url,
|
|
745
|
+
statusFieldId: newField.id,
|
|
746
|
+
...(linkedRepo ? { linkedRepo } : {}),
|
|
747
|
+
},
|
|
748
|
+
repairs: EMPTY_REPAIRS,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 3. Create project
|
|
753
|
+
const createPayload = await ghGraphql(CREATE_PROJECT, {
|
|
754
|
+
ownerId,
|
|
755
|
+
title,
|
|
756
|
+
}, env, child);
|
|
757
|
+
project = createPayload?.data?.createProjectV2?.projectV2;
|
|
758
|
+
if (!project) {
|
|
759
|
+
throw Object.assign(new Error("Failed to create project board"), { code: "CREATE_PROJECT_FAILED" });
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// 4. Link to repo if --link-repo provided
|
|
763
|
+
let linkedRepo = null;
|
|
764
|
+
if (linkRepo) {
|
|
765
|
+
const repoId = await resolveRepoId(linkRepo, env, child);
|
|
766
|
+
await ghGraphql(LINK_PROJECT_TO_REPO, {
|
|
767
|
+
projectId: project.id,
|
|
768
|
+
repositoryId: repoId,
|
|
769
|
+
}, env, child);
|
|
770
|
+
linkedRepo = linkRepo;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// 5. Create Status field on new project
|
|
774
|
+
const createFieldPayload = await ghGraphql(CREATE_SINGLE_SELECT_FIELD, {
|
|
775
|
+
projectId: project.id,
|
|
776
|
+
}, env, child);
|
|
777
|
+
const newField = createFieldPayload?.data?.createProjectV2Field?.projectV2Field;
|
|
778
|
+
if (!newField) {
|
|
779
|
+
throw Object.assign(new Error("Failed to create Status field on new project"), { code: "CREATE_FIELD_FAILED" });
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
ok: true,
|
|
784
|
+
project: {
|
|
785
|
+
id: project.id,
|
|
786
|
+
number: project.number,
|
|
787
|
+
title: project.title,
|
|
788
|
+
url: project.url,
|
|
789
|
+
statusFieldId: newField.id,
|
|
790
|
+
...(linkedRepo ? { linkedRepo } : {}),
|
|
791
|
+
},
|
|
792
|
+
repairs: EMPTY_REPAIRS,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ── CLI entrypoint ──────────────────────────────────────────────────────
|
|
797
|
+
|
|
798
|
+
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
|
|
799
|
+
let args;
|
|
800
|
+
try {
|
|
801
|
+
args = parseArgs(argv);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
804
|
+
process.exitCode = 1;
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (args.help) {
|
|
808
|
+
stdout.write(USAGE);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Settings-based fallback for --project and --title
|
|
813
|
+
const settings = resolveSettings(cwd);
|
|
814
|
+
if (args.project === undefined && settings?.project) {
|
|
815
|
+
args.project = settings.project;
|
|
816
|
+
}
|
|
817
|
+
if (args.title === undefined && settings?.title) {
|
|
818
|
+
args.title = settings.title;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
const result = await main(args, { env });
|
|
823
|
+
stdout.write(JSON.stringify(result) + "\n");
|
|
824
|
+
} catch (err) {
|
|
825
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
826
|
+
process.exitCode = classifyExitCode(err);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
831
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
832
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
833
|
+
process.exitCode = 2;
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export { main, autoRepairColumns, resolveSettings, STANDARD_COLUMNS, STANDARD_COLUMN_NAMES };
|