pm-github 2026.10.25 → 2026.10.26

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/dist/index.js CHANGED
@@ -1,19 +1,7 @@
1
- // pm-github — GitHub Issues + Projects v2 sync for pm-cli
2
- //
3
- // Capabilities (see manifest.json):
4
- // commands — `pm gh-issues import` (legacy) + `pm github sync` +
5
- // `pm github gate` (fail-closed pre-push privacy gate) +
6
- // `pm github project list|fields|import|sync` (Projects v2)
7
- // importers — `pm github import <owner/repo>` (idempotent native import)
8
- // exporters — `pm github export` (render pm items as a GitHub-issues payload)
9
- // schema — declares github_url / github_number / github_state /
10
- // github_author / github_created_at / github_updated_at item fields
11
- // hooks — afterCommand: actionable sync hint for github-linked items
12
- // preflight — local guard for mutating github commands (token presence)
13
- //
14
- // Issues use the REST API; Projects v2 is GraphQL-only (see the Projects v2
15
- // section below and the pure plan/mapping logic in ./projects.ts).
1
+ import { ownValue } from "./own-record.js";
16
2
  import { CloseoutUsageError, compactCloseoutReport, runCloseout } from "./closeout.js";
3
+ import { compileIssueForm, completeIssueForm, readRepositoryIssueForm, refuseIssueForm } from "./issue-forms.js";
4
+ export { compileIssueForm, completeIssueForm, readRepositoryIssueForm } from "./issue-forms.js";
17
5
  import { AmbiguousCitationError, buildImportFidelity, fetchImportTimeline, importFidelityFields, matchImportedIssue, persistImportFidelity, readImportReleases, selectImportContextIndex, validateImportLifetime, } from "./import-fidelity.js";
18
6
  import { refreshChanges, runRefresh } from "./refresh.js";
19
7
  import { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
@@ -216,7 +204,7 @@ function requestOnce(method, url, token, payload, redirectsLeft = 5) {
216
204
  */
217
205
  export function computeBackoffMs(headers, attempt, nowMs = Date.now()) {
218
206
  const get = (k) => {
219
- const v = headers[k] ?? headers[k.toLowerCase()];
207
+ const v = ownValue(headers, k) ?? ownValue(headers, k.toLowerCase());
220
208
  return Array.isArray(v) ? v[0] : v;
221
209
  };
222
210
  const cap = 60_000;
@@ -245,11 +233,11 @@ export function parseRateLimit(headers, lowThreshold = 10) {
245
233
  // Node lowercases response header names, but be defensive: scan
246
234
  // case-insensitively so a mixed-case key (e.g. from a different runtime or a
247
235
  // mocked response) is still found.
248
- const lower = {};
236
+ const lower = new Map();
249
237
  for (const [hk, hv] of Object.entries(headers))
250
- lower[hk.toLowerCase()] = hv;
238
+ lower.set(hk.toLowerCase(), hv);
251
239
  const get = (k) => {
252
- const v = lower[k.toLowerCase()];
240
+ const v = lower.get(k.toLowerCase());
253
241
  return Array.isArray(v) ? v[0] : v;
254
242
  };
255
243
  const num = (s) => {
@@ -310,11 +298,11 @@ export function isRetryableStatus(status, headers) {
310
298
  return true;
311
299
  // Secondary/primary rate limit surfaces as 403 with remaining=0.
312
300
  if (status === 403) {
313
- const v = headers["x-ratelimit-remaining"] ?? headers["X-RateLimit-Remaining"];
301
+ const v = ownValue(headers, "x-ratelimit-remaining") ?? ownValue(headers, "X-RateLimit-Remaining");
314
302
  const remaining = Array.isArray(v) ? v[0] : v;
315
303
  if (remaining === "0")
316
304
  return true;
317
- if (headers["retry-after"] ?? headers["Retry-After"])
305
+ if (ownValue(headers, "retry-after") ?? ownValue(headers, "Retry-After"))
318
306
  return true;
319
307
  }
320
308
  return false;
@@ -373,7 +361,7 @@ export function mapState(state, stateReason) {
373
361
  // key depending on runtime normalization, so check every candidate.
374
362
  export function optionEnabled(options, ...keys) {
375
363
  return keys.some((k) => {
376
- const v = options[k];
364
+ const v = ownValue(options, k);
377
365
  return v === true || v === "true" || v === "1";
378
366
  });
379
367
  }
@@ -381,7 +369,7 @@ export function optionEnabled(options, ...keys) {
381
369
  // (kebab- or camel-case) keys; returns undefined when none are set.
382
370
  export function optionString(options, ...keys) {
383
371
  for (const k of keys) {
384
- const v = options[k];
372
+ const v = ownValue(options, k);
385
373
  if (typeof v === "string" && v.trim().length > 0)
386
374
  return v.trim();
387
375
  }
@@ -453,7 +441,7 @@ export function applyLabelMap(labels, labelMap) {
453
441
  export function optionCsv(options, ...keys) {
454
442
  const rawChunks = [];
455
443
  for (const k of keys) {
456
- const v = options[k];
444
+ const v = ownValue(options, k);
457
445
  if (typeof v === "string") {
458
446
  rawChunks.push(v);
459
447
  continue;
@@ -679,7 +667,7 @@ export function decodeCompletePmItems(parsed) {
679
667
  || !readOutput.requested_dimensions.includes("cost")) {
680
668
  throw new CommandError("Refusing unverifiable pm list --all output: read_output.requested_dimensions must include include, amount, and cost.");
681
669
  }
682
- if ("output_budget_truncation" in parsed || "output_budget_exceeded" in parsed) {
670
+ if (Object.hasOwn(parsed, "output_budget_truncation") || Object.hasOwn(parsed, "output_budget_exceeded")) {
683
671
  throw new CommandError("Refusing unverifiable pm list --all output: a budget truncation or omission disclosure was present.");
684
672
  }
685
673
  if (!Number.isSafeInteger(parsed.count) || parsed.count < 0) {
@@ -832,7 +820,7 @@ export function searchDocumentToItem(document) {
832
820
  const wrapped = document.metadata;
833
821
  if (wrapped && typeof wrapped === "object")
834
822
  return wrapped;
835
- return "id" in document ? document : undefined;
823
+ return Object.hasOwn(document, "id") ? document : undefined;
836
824
  }
837
825
  /**
838
826
  * Resolve the corpus the search provider matches remote hits against.
@@ -3453,14 +3441,12 @@ export function applyOutcomeError(plan, result, repo) {
3453
3441
  // stdout in JSON mode (pm renders the return value). Used by both the
3454
3442
  // `registerExporter("github", ...)` entry point and the `pm github export`
3455
3443
  // command so the surface stays consistent.
3456
- /**
3457
- * Shared handler for the `pm github export` exporter and command.
3458
- *
3459
- * Export is SAFE BY DEFAULT: it previews the create/update plan and writes
3460
- * nothing unless the user opts in with `--apply` (plus a token and `--repo`).
3461
- * Items already linked to an issue in `--repo` are upserted rather than
3462
- * duplicated; `--label-map` translates pm tags to GitHub labels.
3463
- */
3444
+ /** Resolve explicit CLI selection and files through the package's read-only REST client. */
3445
+ async function selectedIssueForm(ctx) {
3446
+ const token = resolveGitHubToken();
3447
+ return readRepositoryIssueForm({ repo: optionString(ctx.options, "repo") ?? "", ref: optionString(ctx.options, "form-ref", "formRef") ?? "", path: optionString(ctx.options, "form") ?? "" }, url => fetchJSON(url, token), githubApiBase(), { answers: optionString(ctx.options, "form-answers", "formAnswers"), bindings: optionString(ctx.options, "form-bindings", "formBindings") });
3448
+ }
3449
+ /** Re-read all selected form answers before constructing or applying the export. */
3464
3450
  async function runExport(ctx) {
3465
3451
  const options = ctx.options;
3466
3452
  const jsonMode = ctx.global.json === true;
@@ -3473,8 +3459,28 @@ async function runExport(ctx) {
3473
3459
  if (idsProvided && scopedIds.length === 0) {
3474
3460
  throw new CommandError("--ids requires at least one pm item id (comma-separated), e.g. --ids pm-123,pm-456.", EXIT_CODE.USAGE);
3475
3461
  }
3476
- const selected = requireRequestedItems(readPmItems(ctx.pm_root), scopedIds);
3462
+ const items = optionProvided(options, "form")
3463
+ ? decodeCompletePmItems(certifyCompleteListResult(await listAllComplete({ includeBody: true }, { pmRoot: ctx.pm_root })))
3464
+ : readPmItems(ctx.pm_root);
3465
+ const selected = requireRequestedItems(items, scopedIds);
3466
+ const completions = [];
3467
+ if (optionProvided(options, "form")) {
3468
+ const source = await selectedIssueForm(ctx);
3469
+ compileIssueForm(source);
3470
+ for (const item of selected)
3471
+ completions.push(await completeIssueForm(ctx.pm_root, { ...source, item: item.id }));
3472
+ const repairs = completions.flatMap(completion => completion.report.checklist.map(repair => ({ ...repair, field: `${completion.report.item}:${repair.field}` })));
3473
+ if (repairs.length)
3474
+ refuseIssueForm("incomplete_answers", repairs, completions.reduce((sum, completion) => sum + completion.report.omitted, 0));
3475
+ }
3476
+ else if (["form-ref", "formRef", "form-answers", "formAnswers", "form-bindings", "formBindings"].some(flag => optionProvided(options, flag))) {
3477
+ refuseIssueForm("invalid_form_input", [{ field: "form", reason: "selection", remedy: "Pass --form with --repo and --form-ref to enforce an issue-form contract." }]);
3478
+ }
3477
3479
  const plan = buildExportPlan(selected, repo, labelMap);
3480
+ for (const [index, completion] of completions.entries()) {
3481
+ plan[index].payload.body = completion.body;
3482
+ plan[index].payload.labels = [...new Set([...plan[index].payload.labels, ...completion.report.contract.labels])];
3483
+ }
3478
3484
  const creates = plan.filter((e) => e.action === "create").length;
3479
3485
  const updates = plan.filter((e) => e.action === "update").length;
3480
3486
  if (!apply) {
@@ -3980,12 +3986,12 @@ export async function fetchBlockerReferenceStates(refs, transport) {
3980
3986
  states.set(entry.ref.key, { ...entry.ref, state: "error", error: "GitHub rejected blocker state access." });
3981
3987
  continue;
3982
3988
  }
3983
- const repoBlock = response.data?.[entry.repoAlias];
3984
- if (repoBlock === undefined || (repoBlock !== null && (repoBlock[entry.issueAlias] === undefined || repoBlock[entry.pullAlias] === undefined))) {
3989
+ const repoBlock = ownValue(response.data, entry.repoAlias);
3990
+ if (repoBlock === undefined || (repoBlock !== null && (ownValue(repoBlock, entry.issueAlias) === undefined || ownValue(repoBlock, entry.pullAlias) === undefined))) {
3985
3991
  states.set(entry.ref.key, { ...entry.ref, state: "error", error: "Incomplete GitHub blocker state response." });
3986
3992
  continue;
3987
3993
  }
3988
- states.set(entry.ref.key, mapBlockerReferenceNode(entry.ref, repoBlock?.[entry.issueAlias], repoBlock?.[entry.pullAlias]));
3994
+ states.set(entry.ref.key, mapBlockerReferenceNode(entry.ref, ownValue(repoBlock, entry.issueAlias), ownValue(repoBlock, entry.pullAlias)));
3989
3995
  }
3990
3996
  }
3991
3997
  return states;
@@ -5246,6 +5252,10 @@ const IMPORT_FLAGS = [
5246
5252
  { long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" },
5247
5253
  ];
5248
5254
  const EXPORT_FLAGS = [
5255
+ { long: "--form", value_name: "path", description: "Enforce a selected repository issue form before any export writes" },
5256
+ { long: "--form-ref", value_name: "revision", description: "Explicit repository revision containing the selected form" },
5257
+ { long: "--form-answers", value_name: "json", description: "JSON map of form answer IDs to strings or string arrays" },
5258
+ { long: "--form-bindings", value_name: "json", description: "JSON map of form answer IDs to native pm field names" },
5249
5259
  { long: "--repo", value_name: "owner/repo", description: "Target GitHub repo (required for --apply; enables upsert of linked issues)" },
5250
5260
  { long: "--ids", value_name: "pm-1,pm-2", description: "Only export these pm item IDs (comma-separated)" },
5251
5261
  { long: "--apply", description: "Write to GitHub (default is a safe dry-run preview)" },
@@ -5307,7 +5317,7 @@ const PROJECT_SYNC_FLAGS = [
5307
5317
  const defineExtension = (module) => module;
5308
5318
  export default defineExtension({
5309
5319
  name: "pm-github",
5310
- version: "2026.10.25",
5320
+ version: "2026.10.26",
5311
5321
  activate(api) {
5312
5322
  // -----------------------------------------------------------------------
5313
5323
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -5416,6 +5426,23 @@ export default defineExtension({
5416
5426
  "Use --ids <pm-1,pm-2> to scope export; unknown IDs fail fast.",
5417
5427
  ],
5418
5428
  });
5429
+ api.registerCommand({
5430
+ name: "github form",
5431
+ description: "Compile and check an explicitly selected repository issue form against current SDK item state. Read-only; returns a compact contract and bounded repair checklist.",
5432
+ flags: [...EXPORT_FLAGS.filter(flag => ["--repo", "--form", "--form-ref", "--form-answers", "--form-bindings"].includes(flag.long)),
5433
+ { long: "--compile", description: "Only compile requirements, validators and bindings; do not check completion" },
5434
+ { long: "--item", value_name: "id", description: "Read current native pm fields through the SDK for explicit bindings" }],
5435
+ examples: ["pm github form --repo owner/repo --form .github/ISSUE_TEMPLATE/bug.yml --form-ref main --item pm-1 --form-bindings bindings.json --json"],
5436
+ async run(ctx) {
5437
+ const source = await selectedIssueForm(ctx);
5438
+ if (ctx.options.compile === true)
5439
+ return compileIssueForm(source);
5440
+ const completion = await completeIssueForm(ctx.pm_root, { ...source, item: optionString(ctx.options, "item") });
5441
+ if (!completion.report.ok)
5442
+ refuseIssueForm("incomplete_answers", completion.report.checklist, completion.report.omitted);
5443
+ return completion.report;
5444
+ },
5445
+ });
5419
5446
  // -----------------------------------------------------------------------
5420
5447
  // search — reach GitHub from `pm search` for imported items.
5421
5448
  // Guarded by a capability check so it is a no-op on runtimes that predate