@dev-loops/core 1.0.2-pre.0 → 1.0.2

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.
@@ -0,0 +1,202 @@
1
+ // Canonical, package-owned Projects V2 read mechanics shared by the core
2
+ // list/move/board-sync operations and the root add/ensure/reorder/archive
3
+ // scripts (issue #2038). This module owns ONLY the read mechanics that were
4
+ // byte-for-byte duplicated across those callers:
5
+ //
6
+ // - strict `owner/name` repository validation (INVALID_REPO)
7
+ // - the identical `projectsV2` discovery query + cursor traversal
8
+ // - a generic connection cursor traversal (each caller supplies its own
9
+ // query projection, page size, and traversal boundary)
10
+ // - the identical single-select `Status` field listing
11
+ // - `Status` option extraction from an item's field values
12
+ //
13
+ // It deliberately does NOT own selection policy, caching, null handling,
14
+ // error presentation, mutation policy, or public result shaping — those stay
15
+ // in each command/domain owner. It composes the already-canonical GitHub
16
+ // transport (`ghGraphql`); owner resolution (`resolveOwner`) stays canonical
17
+ // at each caller. It adds no second transport, client class, query DSL, or
18
+ // pagination framework.
19
+ //
20
+ // It imports neither queue orchestration nor repository-root scripts.
21
+ import { ghGraphql } from "../github/gh.mjs";
22
+
23
+ // ── Repository validation ──────────────────────────────────────────────────
24
+
25
+ const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
26
+ const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
27
+
28
+ /**
29
+ * Validate a `--repo` value as exactly `owner/name`. Throws INVALID_REPO on
30
+ * empty/missing input, leading/trailing whitespace, a missing slash, or an
31
+ * owner/name that fails GitHub's slug shape. Returns the input unchanged on
32
+ * success. This is the strict Projects repository acceptance rule — narrower
33
+ * than the general repo-slug parser, which is intentionally not substituted.
34
+ */
35
+ export function validateProjectsRepo(repo) {
36
+ if (!repo || typeof repo !== "string") {
37
+ throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO" });
38
+ }
39
+ const trimmed = repo.trim();
40
+ if (trimmed !== repo) {
41
+ throw Object.assign(
42
+ new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`),
43
+ { code: "INVALID_REPO" },
44
+ );
45
+ }
46
+ const slashIdx = repo.indexOf("/");
47
+ if (slashIdx === -1) {
48
+ throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
49
+ }
50
+ const owner = repo.slice(0, slashIdx);
51
+ const name = repo.slice(slashIdx + 1);
52
+ if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
53
+ throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
54
+ }
55
+ return repo;
56
+ }
57
+
58
+ // ── Generic connection cursor traversal ────────────────────────────────────
59
+
60
+ /**
61
+ * Walk a GraphQL Relay-style connection to completion, accumulating every
62
+ * page's `nodes` in order. Each caller supplies the query, the base variables
63
+ * (the `$after` cursor is injected only once a page reports one), and a
64
+ * `selectConnection(payload)` selector that returns the `{ nodes, pageInfo }`
65
+ * connection for that query. `entity` names the connection in the malformed
66
+ * -page error only.
67
+ *
68
+ * Preserved boundary semantics (identical to every migrated copy):
69
+ * - pages are concatenated in returned order (position/continuation order)
70
+ * - `after` is set from `pageInfo.endCursor` only when `hasNextPage` is true
71
+ * - a page that reports `hasNextPage` with no `endCursor` fails closed with
72
+ * code GH_API_ERROR (an unpaginatable continuation is never silently
73
+ * truncated)
74
+ *
75
+ * @param {object} opts
76
+ * @param {string} opts.query
77
+ * @param {object} [opts.variables]
78
+ * @param {(payload:any)=>any} opts.selectConnection
79
+ * @param {object} opts.env
80
+ * @param {Function} opts.runChild
81
+ * @param {string} [opts.entity]
82
+ * @returns {Promise<any[]>}
83
+ */
84
+ export async function paginateNodes({ query, variables = {}, selectConnection, env, runChild, entity = "connection" }) {
85
+ const acc = [];
86
+ let after = null;
87
+ while (true) {
88
+ const vars = { ...variables };
89
+ if (after) vars.after = after;
90
+ const payload = await ghGraphql(query, vars, env, runChild);
91
+ const connection = selectConnection(payload);
92
+ const nodes = connection?.nodes ?? [];
93
+ acc.push(...nodes);
94
+ const pageInfo = connection?.pageInfo ?? {};
95
+ if (!pageInfo.hasNextPage) break;
96
+ if (!pageInfo.endCursor) {
97
+ throw Object.assign(
98
+ new Error(`Invalid ${entity} payload: hasNextPage is true but endCursor is missing`),
99
+ { code: "GH_API_ERROR" },
100
+ );
101
+ }
102
+ after = pageInfo.endCursor;
103
+ }
104
+ return acc;
105
+ }
106
+
107
+ // ── Project discovery ──────────────────────────────────────────────────────
108
+
109
+ const LIST_USER_PROJECTS = [
110
+ "query($login:String!, $after:String) {",
111
+ " user(login:$login) {",
112
+ " projectsV2(first:50, after:$after) {",
113
+ " pageInfo { hasNextPage endCursor }",
114
+ " nodes { id number title url }",
115
+ " }",
116
+ " }",
117
+ "}",
118
+ ].join("\n");
119
+
120
+ const LIST_ORG_PROJECTS = [
121
+ "query($login:String!, $after:String) {",
122
+ " organization(login:$login) {",
123
+ " projectsV2(first:50, after:$after) {",
124
+ " pageInfo { hasNextPage endCursor }",
125
+ " nodes { id number title url }",
126
+ " }",
127
+ " }",
128
+ "}",
129
+ ].join("\n");
130
+
131
+ /**
132
+ * Discover every ProjectV2 (id/number/title/url) owned by `login`, resolving
133
+ * the user vs organization query from `kind` ("org" selects the organization
134
+ * root, anything else the user root). Traverses all pages. Returns raw nodes;
135
+ * callers that need null filtering (board-sync) filter the result themselves.
136
+ */
137
+ export function discoverProjects(login, kind, env, runChild) {
138
+ const isOrg = kind === "org";
139
+ return paginateNodes({
140
+ query: isOrg ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS,
141
+ variables: { login },
142
+ selectConnection: (payload) =>
143
+ isOrg ? payload?.data?.organization?.projectsV2 : payload?.data?.user?.projectsV2,
144
+ env,
145
+ runChild,
146
+ entity: "projects list",
147
+ });
148
+ }
149
+
150
+ // ── Status field listing ───────────────────────────────────────────────────
151
+
152
+ const GET_PROJECT_FIELDS = [
153
+ "query($projectId:ID!, $after:String) {",
154
+ " node(id:$projectId) {",
155
+ " ... on ProjectV2 {",
156
+ " fields(first:50, after:$after) {",
157
+ " pageInfo { hasNextPage endCursor }",
158
+ " nodes {",
159
+ " ... on ProjectV2SingleSelectField {",
160
+ " id name",
161
+ " options { id name }",
162
+ " }",
163
+ " }",
164
+ " }",
165
+ " }",
166
+ " }",
167
+ "}",
168
+ ].join("\n");
169
+
170
+ /**
171
+ * List every single-select field (id/name + `options{id name}`) on a project,
172
+ * traversing all pages. This is the identical field projection used by the
173
+ * list/move/add callers. Operations that need a wider option projection (e.g.
174
+ * ensure-queue-board, which also reads option color/description for repair)
175
+ * call `paginateNodes` with their own field query instead.
176
+ */
177
+ export function listProjectFields(projectId, env, runChild) {
178
+ return paginateNodes({
179
+ query: GET_PROJECT_FIELDS,
180
+ variables: { projectId },
181
+ selectConnection: (payload) => payload?.data?.node?.fields,
182
+ env,
183
+ runChild,
184
+ entity: "fields",
185
+ });
186
+ }
187
+
188
+ // ── Status extraction ──────────────────────────────────────────────────────
189
+
190
+ /**
191
+ * Extract an item's current `Status` single-select option name from its
192
+ * `fieldValues`, or null when the item has no Status value. Preserves the
193
+ * exact matching used by every caller: the first field value whose owning
194
+ * single-select field is named "Status".
195
+ */
196
+ export function extractStatus(node) {
197
+ const fvs = node?.fieldValues?.nodes ?? [];
198
+ for (const fv of fvs) {
199
+ if (fv && fv.field && fv.field.name === "Status") return fv.name;
200
+ }
201
+ return null;
202
+ }