@h-rig/standard-plugin 0.0.6-alpha.132 → 0.0.6-alpha.134

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,421 @@
1
+ // @bun
2
+ // packages/standard-plugin/src/drift/plugin.ts
3
+ import { Schema } from "effect";
4
+ import { StageMutation as StageMutationSchema } from "@rig/contracts";
5
+
6
+ // packages/standard-plugin/src/drift/detect.ts
7
+ import { existsSync } from "fs";
8
+ import { readdir, readFile, stat } from "fs/promises";
9
+ import { basename, extname, relative, resolve } from "path";
10
+
11
+ // packages/standard-plugin/src/drift/extract-refs.ts
12
+ var INLINE_CODE = /`([^`\n]+)`/g;
13
+ var MARKDOWN_LINK = /\[[^\]]+\]\(([^)\s]+)\)/g;
14
+ var SYMBOL_REF = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/;
15
+ var PATH_REF = /^(?:\.\.?\/)?(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+$|^[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|mdx|css|scss|html|yml|yaml|toml|rs|go|py|rb|java|kt|swift|c|cc|cpp|h|hpp)$/;
16
+ function stripFenceLines(markdown) {
17
+ const lines = markdown.split(/\r?\n/);
18
+ let fenced = false;
19
+ return lines.map((line) => {
20
+ if (/^\s*(```|~~~)/.test(line)) {
21
+ fenced = !fenced;
22
+ return "";
23
+ }
24
+ return fenced ? "" : line;
25
+ });
26
+ }
27
+ function normalizeToken(raw) {
28
+ return raw.trim().replace(/^['"]|['"]$/g, "").replace(/[),.;:]+$/g, "").replace(/#L\d+(?:-L\d+)?$/i, "");
29
+ }
30
+ function classifyReference(raw) {
31
+ if (raw.startsWith("@"))
32
+ return null;
33
+ if (PATH_REF.test(raw))
34
+ return "path";
35
+ if (SYMBOL_REF.test(raw))
36
+ return "symbol";
37
+ return null;
38
+ }
39
+ function pushReference(refs, seen, raw, line) {
40
+ const value = normalizeToken(raw);
41
+ if (!value)
42
+ return;
43
+ const kind = classifyReference(value);
44
+ if (!kind)
45
+ return;
46
+ const key = `${kind}:${value}:${line}`;
47
+ if (seen.has(key))
48
+ return;
49
+ seen.add(key);
50
+ refs.push({ kind, value, line });
51
+ }
52
+ function extractDriftReferences(markdown) {
53
+ const refs = [];
54
+ const seen = new Set;
55
+ const lines = stripFenceLines(markdown);
56
+ for (const [index, line] of lines.entries()) {
57
+ const lineNumber = index + 1;
58
+ for (const match of line.matchAll(INLINE_CODE)) {
59
+ pushReference(refs, seen, match[1] ?? "", lineNumber);
60
+ }
61
+ for (const match of line.matchAll(MARKDOWN_LINK)) {
62
+ pushReference(refs, seen, match[1] ?? "", lineNumber);
63
+ }
64
+ }
65
+ return refs;
66
+ }
67
+
68
+ // packages/standard-plugin/src/drift/git-adapter.ts
69
+ import { execFile } from "child_process";
70
+ import { promisify } from "util";
71
+ var execFileAsync = promisify(execFile);
72
+ function processError(value) {
73
+ return value && typeof value === "object" ? value : null;
74
+ }
75
+ function lineCount(output) {
76
+ const trimmed = output.trim();
77
+ return trimmed ? trimmed.split(/\r?\n/).length : 0;
78
+ }
79
+ function makeDriftGit(projectRoot) {
80
+ async function git(args) {
81
+ const result = await execFileAsync("git", [...args], {
82
+ cwd: projectRoot,
83
+ encoding: "utf8",
84
+ maxBuffer: 10 * 1024 * 1024
85
+ });
86
+ return String(result.stdout);
87
+ }
88
+ async function grepCountAt(symbolOrPath, commit) {
89
+ try {
90
+ return lineCount(await git(["grep", "-F", "-n", "-e", symbolOrPath, commit, "--"]));
91
+ } catch (error) {
92
+ const detail = processError(error);
93
+ if (detail?.code === 1)
94
+ return 0;
95
+ throw error;
96
+ }
97
+ }
98
+ return {
99
+ async lastCommitTouching(path) {
100
+ const commit = (await git(["log", "-n", "1", "--format=%H", "--", path])).trim();
101
+ return commit || "HEAD";
102
+ },
103
+ async grepCount(symbolOrPath) {
104
+ return grepCountAt(symbolOrPath, "HEAD");
105
+ },
106
+ async grepCountAtCommit(symbolOrPath, commit) {
107
+ return grepCountAt(symbolOrPath, commit);
108
+ },
109
+ async wasRenamed(symbolOrPath, sinceCommit) {
110
+ if (!symbolOrPath.includes("/") && !symbolOrPath.includes("."))
111
+ return false;
112
+ try {
113
+ const output = await git(["log", "--name-status", "--format=", `${sinceCommit}..HEAD`]);
114
+ return output.split(/\r?\n/).some((line) => {
115
+ const match = line.match(/^R\d*\s+(.+?)\s+(.+)$/);
116
+ return Boolean(match && (match[1] === symbolOrPath || match[2] === symbolOrPath));
117
+ });
118
+ } catch (error) {
119
+ const detail = processError(error);
120
+ if (detail?.code === 128)
121
+ return false;
122
+ throw error;
123
+ }
124
+ }
125
+ };
126
+ }
127
+
128
+ // packages/standard-plugin/src/drift/detect.ts
129
+ var DEFAULT_IGNORED_DIRS = {
130
+ ".git": true,
131
+ node_modules: true,
132
+ dist: true,
133
+ build: true,
134
+ coverage: true,
135
+ ".next": true,
136
+ vendor: true
137
+ };
138
+ var SOURCE_EXTENSIONS = {
139
+ ".ts": true,
140
+ ".tsx": true,
141
+ ".js": true,
142
+ ".jsx": true,
143
+ ".mjs": true,
144
+ ".cjs": true,
145
+ ".rs": true,
146
+ ".go": true,
147
+ ".py": true,
148
+ ".rb": true,
149
+ ".java": true,
150
+ ".kt": true,
151
+ ".swift": true,
152
+ ".c": true,
153
+ ".cc": true,
154
+ ".cpp": true,
155
+ ".h": true,
156
+ ".hpp": true,
157
+ ".json": true,
158
+ ".toml": true,
159
+ ".yml": true,
160
+ ".yaml": true
161
+ };
162
+ function globLikeMatch(path, pattern) {
163
+ if (pattern === path)
164
+ return true;
165
+ if (pattern.startsWith("**/*"))
166
+ return path.endsWith(pattern.slice(4));
167
+ if (pattern.endsWith("/**"))
168
+ return path.startsWith(pattern.slice(0, -3));
169
+ if (pattern.includes("*")) {
170
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
171
+ return new RegExp(`^${escaped}$`).test(path);
172
+ }
173
+ return path.startsWith(pattern);
174
+ }
175
+ function isDefaultDoc(path) {
176
+ const lower = basename(path).toLowerCase();
177
+ return (path.endsWith(".md") || path.endsWith(".mdx")) && !lower.startsWith("changelog") && !lower.includes("generated");
178
+ }
179
+ function isIgnored(path, patterns) {
180
+ return (patterns ?? []).some((pattern) => globLikeMatch(path, pattern));
181
+ }
182
+ async function collectFiles(root, options) {
183
+ const files = [];
184
+ async function visit(dir) {
185
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
186
+ if (entry.isDirectory() && DEFAULT_IGNORED_DIRS[entry.name])
187
+ continue;
188
+ const absolute = resolve(dir, entry.name);
189
+ const rel = relative(root, absolute).replace(/\\/g, "/");
190
+ if (isIgnored(rel, options.ignore))
191
+ continue;
192
+ if (entry.isDirectory()) {
193
+ await visit(absolute);
194
+ continue;
195
+ }
196
+ if (!entry.isFile())
197
+ continue;
198
+ if (options.docs) {
199
+ const matchesConfigured = options.patterns && options.patterns.length > 0 ? options.patterns.some((pattern) => globLikeMatch(rel, pattern)) : isDefaultDoc(rel);
200
+ if (matchesConfigured)
201
+ files.push(rel);
202
+ continue;
203
+ }
204
+ if (SOURCE_EXTENSIONS[extname(entry.name)])
205
+ files.push(rel);
206
+ }
207
+ }
208
+ await visit(root);
209
+ return files.sort();
210
+ }
211
+ async function sourceReferenceCount(projectRoot, reference, docPath) {
212
+ if (reference.kind === "path")
213
+ return existsSync(resolve(projectRoot, reference.value)) ? 1 : 0;
214
+ let count = 0;
215
+ const sourceFiles = await collectFiles(projectRoot, { docs: false });
216
+ for (const sourceFile of sourceFiles) {
217
+ if (sourceFile === docPath)
218
+ continue;
219
+ const text = await readFile(resolve(projectRoot, sourceFile), "utf8").catch(() => "");
220
+ if (text.includes(reference.value))
221
+ count += 1;
222
+ }
223
+ return count;
224
+ }
225
+ function deletedReferenceFinding(docPath, reference) {
226
+ return {
227
+ kind: "deleted-reference",
228
+ docPath,
229
+ line: reference.line,
230
+ reference: reference.value,
231
+ detail: `Documented reference "${reference.value}" no longer exists in the source tree.`,
232
+ confidence: "high"
233
+ };
234
+ }
235
+ function staleAnchorFinding(docPath, reference) {
236
+ return {
237
+ kind: "stale-anchor",
238
+ docPath,
239
+ line: reference.line,
240
+ reference: reference.value,
241
+ detail: `Documented path "${reference.value}" changed after this doc was last updated.`,
242
+ confidence: "medium"
243
+ };
244
+ }
245
+ async function detectDeletedReferences(projectRoot, docPath, git = makeDriftGit(projectRoot)) {
246
+ const markdown = await readFile(resolve(projectRoot, docPath), "utf8");
247
+ const docCommit = await git.lastCommitTouching(docPath);
248
+ const findings = [];
249
+ for (const reference of extractDriftReferences(markdown)) {
250
+ if (await sourceReferenceCount(projectRoot, reference, docPath) > 0)
251
+ continue;
252
+ if (await git.wasRenamed(reference.value, docCommit))
253
+ continue;
254
+ findings.push(deletedReferenceFinding(docPath, reference));
255
+ }
256
+ return findings;
257
+ }
258
+ async function detectStaleAnchors(projectRoot, docPath, git = makeDriftGit(projectRoot)) {
259
+ const markdown = await readFile(resolve(projectRoot, docPath), "utf8");
260
+ const docCommit = await git.lastCommitTouching(docPath);
261
+ const findings = [];
262
+ for (const reference of extractDriftReferences(markdown).filter((ref) => ref.kind === "path")) {
263
+ if (!existsSync(resolve(projectRoot, reference.value)))
264
+ continue;
265
+ const sourceStat = await stat(resolve(projectRoot, reference.value)).catch(() => null);
266
+ if (!sourceStat?.isFile())
267
+ continue;
268
+ const sourceCommit = await git.lastCommitTouching(reference.value);
269
+ if (sourceCommit !== docCommit && !await git.wasRenamed(reference.value, docCommit)) {
270
+ findings.push(staleAnchorFinding(docPath, reference));
271
+ }
272
+ }
273
+ return findings;
274
+ }
275
+ async function detectDrift(options) {
276
+ const git = options.git ?? makeDriftGit(options.projectRoot);
277
+ const docs = await collectFiles(options.projectRoot, {
278
+ docs: true,
279
+ ...options.docsGlobs !== undefined ? { patterns: options.docsGlobs } : {},
280
+ ...options.ignoreGlobs !== undefined ? { ignore: options.ignoreGlobs } : {}
281
+ });
282
+ const findings = [];
283
+ let degraded = false;
284
+ for (const docPath of docs) {
285
+ try {
286
+ findings.push(...await detectDeletedReferences(options.projectRoot, docPath, git));
287
+ findings.push(...await detectStaleAnchors(options.projectRoot, docPath, git));
288
+ } catch {
289
+ degraded = true;
290
+ }
291
+ }
292
+ return {
293
+ generatedAt: new Date().toISOString(),
294
+ scanned: docs.length,
295
+ degraded,
296
+ findings
297
+ };
298
+ }
299
+
300
+ // packages/standard-plugin/src/drift/plugin.ts
301
+ var DOCS_DRIFT_VALIDATOR_ID = "std:docs-drift";
302
+ var DOCS_DRIFT_CLI_ID = "std:drift";
303
+ var DOCS_DRIFT_STAGE_ID = "docs-drift";
304
+ var DOCS_DRIFT_VALIDATOR = {
305
+ id: DOCS_DRIFT_VALIDATOR_ID,
306
+ category: "regression",
307
+ description: "Detect documentation references that drifted from the source tree."
308
+ };
309
+ var DOCS_DRIFT_STAGE_MUTATION = Schema.decodeUnknownSync(StageMutationSchema)({
310
+ op: "insert",
311
+ stage: {
312
+ id: DOCS_DRIFT_STAGE_ID,
313
+ kind: "gate",
314
+ before: ["merge-gate"],
315
+ after: ["verify"]
316
+ },
317
+ contributedBy: DOCS_DRIFT_STAGE_ID
318
+ });
319
+ var DOCS_DRIFT_CLI_COMMAND = `bun -e 'import { runDriftCli } from "@rig/standard-plugin/drift"; process.exitCode = await runDriftCli(process.argv.slice(1), { projectRoot: process.cwd() });' --`;
320
+ function highConfidenceDriftFindings(report) {
321
+ return report.findings.filter((finding) => finding.confidence === "high");
322
+ }
323
+ function driftGateResult(report, mode = "enforce") {
324
+ const high = highConfidenceDriftFindings(report);
325
+ if (mode === "enforce" && high.length > 0) {
326
+ return { kind: "block", reason: `${high.length} high-confidence documentation drift finding(s).` };
327
+ }
328
+ return { kind: "allow" };
329
+ }
330
+ async function runDocsDriftValidation(options) {
331
+ const report = await detectDrift(options);
332
+ const high = highConfidenceDriftFindings(report);
333
+ const passed = options.failOnDrift ? high.length === 0 : true;
334
+ const findingWord = report.findings.length === 1 ? "finding" : "findings";
335
+ return {
336
+ id: DOCS_DRIFT_VALIDATOR_ID,
337
+ passed,
338
+ summary: `docs drift scanned ${report.scanned} doc(s), ${report.findings.length} ${findingWord}`,
339
+ details: JSON.stringify(report)
340
+ };
341
+ }
342
+ function createDocsDriftValidator(options = {}) {
343
+ return {
344
+ ...DOCS_DRIFT_VALIDATOR,
345
+ async run(ctx) {
346
+ return runDocsDriftValidation({
347
+ projectRoot: ctx.workspaceRoot,
348
+ ...options.docsGlobs !== undefined ? { docsGlobs: options.docsGlobs } : {},
349
+ ...options.ignoreGlobs !== undefined ? { ignoreGlobs: options.ignoreGlobs } : {},
350
+ ...options.failOnDrift !== undefined ? { failOnDrift: options.failOnDrift } : {}
351
+ });
352
+ }
353
+ };
354
+ }
355
+ function takeOptionValue(args, index, flag) {
356
+ const value = args[index + 1];
357
+ if (!value)
358
+ throw new Error(`${flag} requires a value`);
359
+ return value;
360
+ }
361
+ async function runDriftCli(args, options = {}) {
362
+ const docsGlobs = [];
363
+ const ignoreGlobs = [];
364
+ let json = false;
365
+ let failOnDrift = false;
366
+ for (let index = 0;index < args.length; index += 1) {
367
+ const arg = args[index];
368
+ if (arg === "--json") {
369
+ json = true;
370
+ continue;
371
+ }
372
+ if (arg === "--fail-on-drift") {
373
+ failOnDrift = true;
374
+ continue;
375
+ }
376
+ if (arg === "--docs") {
377
+ docsGlobs.push(takeOptionValue(args, index, arg));
378
+ index += 1;
379
+ continue;
380
+ }
381
+ if (arg === "--ignore") {
382
+ ignoreGlobs.push(takeOptionValue(args, index, arg));
383
+ index += 1;
384
+ continue;
385
+ }
386
+ throw new Error(`Unknown rig drift argument: ${arg}`);
387
+ }
388
+ const report = await detectDrift({
389
+ projectRoot: options.projectRoot ?? process.cwd(),
390
+ ...docsGlobs.length > 0 ? { docsGlobs } : {},
391
+ ...ignoreGlobs.length > 0 ? { ignoreGlobs } : {}
392
+ });
393
+ const write = options.write ?? ((message) => console.log(message));
394
+ if (json) {
395
+ write(JSON.stringify(report));
396
+ } else {
397
+ write(`Scanned ${report.scanned} doc(s); ${report.findings.length} drift finding(s).`);
398
+ for (const finding of report.findings) {
399
+ write(`${finding.confidence.toUpperCase()} ${finding.kind} ${finding.docPath}${finding.line ? `:${finding.line}` : ""} ${finding.reference ?? ""} \u2014 ${finding.detail}`);
400
+ }
401
+ }
402
+ const high = highConfidenceDriftFindings(report);
403
+ if (failOnDrift && high.length > 0) {
404
+ options.writeError?.(`${high.length} high-confidence drift finding(s).`);
405
+ return 2;
406
+ }
407
+ return 0;
408
+ }
409
+ export {
410
+ runDriftCli,
411
+ runDocsDriftValidation,
412
+ highConfidenceDriftFindings,
413
+ driftGateResult,
414
+ createDocsDriftValidator,
415
+ DOCS_DRIFT_VALIDATOR_ID,
416
+ DOCS_DRIFT_VALIDATOR,
417
+ DOCS_DRIFT_STAGE_MUTATION,
418
+ DOCS_DRIFT_STAGE_ID,
419
+ DOCS_DRIFT_CLI_ID,
420
+ DOCS_DRIFT_CLI_COMMAND
421
+ };
@@ -47,6 +47,8 @@ export interface GitHubIssuesOptions {
47
47
  }) => void;
48
48
  /** Optional GitHub Projects (v2) status-field sync mapped from Rig task status. */
49
49
  projects?: GitHubProjectsOptions;
50
+ /** Opt into GitHub-native issue dependency reads; body parsing remains the fallback. */
51
+ useNativeDependencies?: boolean;
50
52
  }
51
53
  export interface GitHubIssueCreateInput {
52
54
  title: string;
@@ -89,17 +89,42 @@ function statusFor(issue) {
89
89
  return "cancelled";
90
90
  return "open";
91
91
  }
92
+ function parseIssueRefs(raw) {
93
+ return raw.split(",").map((s) => s.trim()).map((s) => s.replace(/^#/, "").match(/^(\d+)/)?.[1] ?? "").filter((s) => s.length > 0);
94
+ }
95
+ function parseMetadataList(body, key) {
96
+ const block = body.match(/<!-- rig:metadata:start -->\s*([\s\S]*?)\s*<!-- rig:metadata:end -->/);
97
+ if (!block)
98
+ return [];
99
+ const lines = block[1].split(/\r?\n/);
100
+ const values = [];
101
+ for (let index = 0;index < lines.length; index += 1) {
102
+ const line = lines[index];
103
+ const sameLine = line.match(new RegExp(`^${key}:\\s*(.+)$`, "i"));
104
+ if (sameLine) {
105
+ values.push(...parseIssueRefs(sameLine[1]));
106
+ continue;
107
+ }
108
+ if (!new RegExp(`^${key}:\\s*$`, "i").test(line))
109
+ continue;
110
+ for (let cursor = index + 1;cursor < lines.length; cursor += 1) {
111
+ const item = lines[cursor].match(/^\s*-\s*(.+)$/);
112
+ if (!item)
113
+ break;
114
+ values.push(...parseIssueRefs(item[1]));
115
+ }
116
+ }
117
+ return [...new Set(values)];
118
+ }
92
119
  function parseDeps(body) {
93
120
  const match = body.match(/^depends-on:\s*([^\n]+)/im);
94
- if (!match)
95
- return [];
96
- return match[1].split(",").map((s) => s.trim()).map((s) => s.replace(/^#/, "").match(/^(\d+)/)?.[1] ?? "").filter((s) => s.length > 0);
121
+ const bodyRefs = match ? parseIssueRefs(match[1]) : [];
122
+ return [...new Set([...bodyRefs, ...parseMetadataList(body, "depends-on")])];
97
123
  }
98
124
  function parseParents(body) {
99
125
  const match = body.match(/^parents?:\s*([^\n]+)/im);
100
- if (!match)
101
- return [];
102
- return match[1].split(",").map((s) => s.trim()).map((s) => s.replace(/^#/, "").match(/^(\d+)/)?.[1] ?? "").filter((s) => s.length > 0);
126
+ const bodyRefs = match ? parseIssueRefs(match[1]) : [];
127
+ return [...new Set([...bodyRefs, ...parseMetadataList(body, "parents")])];
103
128
  }
104
129
  function issueTypeFor(issue) {
105
130
  const labels = labelNamesFor(issue);
@@ -110,7 +135,7 @@ function issueTypeFor(issue) {
110
135
  return "epic";
111
136
  return "task";
112
137
  }
113
- function issueToTask(issue, repo) {
138
+ function issueToTask(issue, repo, nativeDependencies) {
114
139
  const labelNames = labelNamesFor(issue);
115
140
  const scope = labelNames.filter((l) => l.startsWith("scope:")).map((l) => l.slice("scope:".length));
116
141
  const roleLabel = labelNames.find((l) => l.startsWith("role:"));
@@ -118,10 +143,12 @@ function issueToTask(issue, repo) {
118
143
  const validators = labelNames.filter((l) => l.startsWith("validator:")).map((l) => l.slice("validator:".length));
119
144
  const body = issue.body ?? "";
120
145
  const issueNodeId = issue.id ?? issue.nodeId ?? issue.node_id;
146
+ const parsedDeps = parseDeps(body);
147
+ const deps = nativeDependencies?.deps ? [...new Set([...parsedDeps, ...nativeDependencies.deps])] : parsedDeps;
121
148
  return {
122
149
  id: String(issue.number),
123
150
  ...typeof issueNodeId === "string" && issueNodeId.trim() ? { issueNodeId: issueNodeId.trim() } : {},
124
- deps: parseDeps(body),
151
+ deps,
125
152
  status: statusFor(issue),
126
153
  title: issue.title,
127
154
  body,
@@ -133,6 +160,7 @@ function issueToTask(issue, repo) {
133
160
  sourceIssueId: `${repo}#${issue.number}`,
134
161
  parentChildDeps: parseParents(body),
135
162
  labels: labelNames,
163
+ ...nativeDependencies?.degraded ? { nativeDependenciesDegraded: true, nativeDependenciesError: nativeDependencies.degraded } : {},
136
164
  raw: issue
137
165
  };
138
166
  }
@@ -306,6 +334,86 @@ function ghGraphQLFetch(bin, spawnFn, extraEnv, timeoutMs) {
306
334
  return asProjectRecord(response)?.data ?? response;
307
335
  };
308
336
  }
337
+ function issueNodeIdFor(issue) {
338
+ const id = issue.id ?? issue.nodeId ?? issue.node_id;
339
+ return typeof id === "string" && id.trim().length > 0 ? id.trim() : null;
340
+ }
341
+ function nativeIssueDependencyRef(value, currentRepo) {
342
+ const record = asProjectRecord(value);
343
+ const number = typeof record?.number === "number" ? String(record.number) : projectString(record?.number);
344
+ if (!number)
345
+ return null;
346
+ const repository = asProjectRecord(record?.repository);
347
+ const owner = projectString(asProjectRecord(repository?.owner)?.login);
348
+ const name = projectString(repository?.name);
349
+ if (!owner || !name || `${owner}/${name}` === currentRepo)
350
+ return number;
351
+ return `${owner}/${name}#${number}`;
352
+ }
353
+ function nativeDependencyRefsFrom(data, currentRepo) {
354
+ const issue = asProjectRecord(asProjectRecord(data)?.node);
355
+ const blockedBy = asProjectRecord(issue?.blockedBy);
356
+ const nodes = Array.isArray(blockedBy?.nodes) ? blockedBy.nodes : [];
357
+ return [...new Set(nodes.flatMap((node) => {
358
+ const ref = nativeIssueDependencyRef(node, currentRepo);
359
+ return ref ? [ref] : [];
360
+ }))];
361
+ }
362
+ async function readNativeDependenciesForIssue(input) {
363
+ const issueId = issueNodeIdFor(input.issue);
364
+ if (!issueId)
365
+ return { deps: [], degraded: "GitHub issue node id is unavailable." };
366
+ const query = `
367
+ query RigIssueNativeDependencies($issueId: ID!) {
368
+ node(id: $issueId) {
369
+ ... on Issue {
370
+ blockedBy(first: 100) {
371
+ nodes {
372
+ number
373
+ repository { name owner { login } }
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ `;
380
+ try {
381
+ return {
382
+ deps: nativeDependencyRefsFrom(await input.fetchGraphQL(query, { issueId }, "gh-cli"), input.repo)
383
+ };
384
+ } catch (error) {
385
+ const detail = error instanceof Error ? error.message : String(error);
386
+ return { deps: [], degraded: detail };
387
+ }
388
+ }
389
+ function formatIssueReference(ref) {
390
+ const clean = ref.trim().replace(/^#/, "");
391
+ return /^\d+$/.test(clean) ? `#${clean}` : clean;
392
+ }
393
+ function appendReferenceLines(body, deps, parents) {
394
+ const lines = [];
395
+ const cleanDeps = (deps ?? []).map(formatIssueReference).filter((ref) => ref.length > 0);
396
+ const cleanParents = (parents ?? []).map(formatIssueReference).filter((ref) => ref.length > 0);
397
+ if (cleanDeps.length > 0)
398
+ lines.push(`depends-on: ${cleanDeps.join(", ")}`);
399
+ if (cleanParents.length > 0)
400
+ lines.push(`parents: ${cleanParents.join(", ")}`);
401
+ if (lines.length === 0)
402
+ return body;
403
+ return body.trim().length > 0 ? `${body.trimEnd()}
404
+
405
+ ${lines.join(`
406
+ `)}` : lines.join(`
407
+ `);
408
+ }
409
+ function bodyForCreatedTask(input) {
410
+ const metadata = { ...input.metadata ?? {} };
411
+ if (input.deps && input.deps.length > 0)
412
+ metadata["depends-on"] = input.deps.map(formatIssueReference);
413
+ if (input.parents && input.parents.length > 0)
414
+ metadata.parents = input.parents.map(formatIssueReference);
415
+ return updateRigOwnedMetadataBlock(appendReferenceLines(input.body, input.deps, input.parents), metadata);
416
+ }
309
417
  function projectStatusFieldFrom(data, projectId) {
310
418
  const fields = asProjectRecord(asProjectRecord(asProjectRecord(data)?.node)?.fields)?.nodes;
311
419
  for (const node of Array.isArray(fields) ? fields : []) {
@@ -641,6 +749,16 @@ function createGitHubIssuesTaskSource(opts) {
641
749
  const timeoutMs = Math.max(1000, Math.trunc(opts.timeoutMs ?? DEFAULT_GH_TIMEOUT_MS));
642
750
  const listLimit = Math.max(1, Math.trunc(opts.listLimit ?? DEFAULT_GITHUB_ISSUE_LIST_LIMIT));
643
751
  const issueUpdates = issueUpdatesMode(opts.issueUpdates);
752
+ async function issueToTaskWithOptionalNativeDependencies(issue, env) {
753
+ if (!opts.useNativeDependencies)
754
+ return issueToTask(issue, repo);
755
+ const nativeDependencies = await readNativeDependenciesForIssue({
756
+ issue,
757
+ repo,
758
+ fetchGraphQL: ghGraphQLFetch(bin, spawnFn, env, timeoutMs)
759
+ });
760
+ return issueToTask(issue, repo, nativeDependencies);
761
+ }
644
762
  return {
645
763
  id: "std:github-issues",
646
764
  kind: "github-issues",
@@ -667,7 +785,7 @@ function createGitHubIssuesTaskSource(opts) {
667
785
  throw new Error(`GitHub issue list for ${repo} reached the configured limit (${listLimit}); refusing to silently truncate matching issues. Increase taskSource.options.listLimit or narrow labels/state/assignee.`);
668
786
  }
669
787
  const issues = rawIssues.filter((issue) => !issue.pull_request);
670
- return issues.map((i) => issueToTask(i, repo));
788
+ return Promise.all(issues.map((issue) => issueToTaskWithOptionalNativeDependencies(issue, env)));
671
789
  },
672
790
  async get(id) {
673
791
  const env = await resolveCredentialEnv(opts, "selected-repo");
@@ -684,12 +802,12 @@ function createGitHubIssuesTaskSource(opts) {
684
802
  ], spawnFn, env, timeoutMs);
685
803
  } catch (error) {
686
804
  const detail = error instanceof Error ? error.message : String(error);
687
- if (/could not resolve to (an? )?(issue|pullrequest)|no issues? (found|matched)|404 not found|gh: not found/i.test(detail)) {
805
+ if (/could not resolve to (an? )?(issue|pullrequest)|no issues? (found|matched)|404 not found|gh: not found|gh issue view\b[\s\S]*failed \(exit \d+\): not found\b/i.test(detail)) {
688
806
  return;
689
807
  }
690
808
  throw new Error(`Failed to read task ${id} from GitHub repo ${repo}: ${detail}`);
691
809
  }
692
- return issueToTask(issue, repo);
810
+ return issueToTaskWithOptionalNativeDependencies(issue, env);
693
811
  },
694
812
  async updateStatus(id, status) {
695
813
  const env = await resolveCredentialEnv(opts, "selected-repo");
@@ -713,6 +831,7 @@ function createGitHubIssuesTaskSource(opts) {
713
831
  },
714
832
  async createIssue(input) {
715
833
  const env = await resolveCredentialEnv(opts, "selected-repo");
834
+ const body = input.body ?? "";
716
835
  const args = [
717
836
  "api",
718
837
  "-X",
@@ -721,12 +840,31 @@ function createGitHubIssuesTaskSource(opts) {
721
840
  "-f",
722
841
  `title=${input.title}`,
723
842
  "-f",
724
- `body=${input.body ?? ""}`,
843
+ `body=${body}`,
725
844
  ...(input.labels ?? []).flatMap((label) => ["-f", `labels[]=${label}`])
726
845
  ];
727
846
  const issue = runGh(bin, args, spawnFn, env, timeoutMs);
728
847
  notifyTaskChanged(opts.onTaskChanged, repo, String(issue.number));
729
- return issueToTask(issue, repo);
848
+ return issueToTask({ ...issue, body: issue.body ?? body }, repo);
849
+ },
850
+ async create(input) {
851
+ const env = await resolveCredentialEnv(opts, "selected-repo");
852
+ const body = bodyForCreatedTask(input);
853
+ const args = [
854
+ "api",
855
+ "-X",
856
+ "POST",
857
+ `repos/${repo}/issues`,
858
+ "-f",
859
+ `title=${input.title}`,
860
+ "-f",
861
+ `body=${body}`,
862
+ "-f",
863
+ "labels[]=rig:generated"
864
+ ];
865
+ const issue = runGh(bin, args, spawnFn, env, timeoutMs);
866
+ notifyTaskChanged(opts.onTaskChanged, repo, String(issue.number));
867
+ return issueToTask({ ...issue, body: issue.body ?? body }, repo);
730
868
  },
731
869
  async getIssueBody(id) {
732
870
  const env = await resolveCredentialEnv(opts, "selected-repo");