@gr8ful/spf 0.14.0 → 0.16.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.
Files changed (77) hide show
  1. package/README.md +51 -9
  2. package/assets/skill/references/config.md +12 -5
  3. package/assets/skill/references/observability.md +57 -12
  4. package/assets/templates/ts-opencode.spf.config.yaml +54 -0
  5. package/dist/chains/index.js +1 -1
  6. package/dist/chains/simple_sdlc.d.ts +2 -2
  7. package/dist/chains/simple_sdlc.js +13 -13
  8. package/dist/chains/steps.d.ts +2 -2
  9. package/dist/chains/steps.js +35 -19
  10. package/dist/cli/commands/abort.d.ts +1 -1
  11. package/dist/cli/commands/abort.js +30 -3
  12. package/dist/cli/commands/doctor.js +121 -8
  13. package/dist/cli/commands/estimate.js +3 -3
  14. package/dist/cli/commands/events.js +4 -4
  15. package/dist/cli/commands/fanout.js +93 -21
  16. package/dist/cli/commands/loop.js +31 -32
  17. package/dist/cli/commands/migrate.js +8 -1
  18. package/dist/cli/commands/phases.js +2 -2
  19. package/dist/cli/commands/sessions.js +2 -2
  20. package/dist/cli/commands/trace.d.ts +28 -8
  21. package/dist/cli/commands/trace.js +28 -15
  22. package/dist/cli/commands/ui.js +15 -5
  23. package/dist/cli/commands/watch.js +91 -30
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +95 -5
  27. package/dist/core/agent_opencode.d.ts +247 -0
  28. package/dist/core/agent_opencode.js +590 -0
  29. package/dist/core/agents.d.ts +12 -12
  30. package/dist/core/agents.js +113 -46
  31. package/dist/core/console.d.ts +12 -12
  32. package/dist/core/console.js +25 -25
  33. package/dist/core/data_types.d.ts +356 -15
  34. package/dist/core/data_types.js +180 -7
  35. package/dist/core/fanout.d.ts +1 -1
  36. package/dist/core/fanout.js +1 -1
  37. package/dist/core/gates.js +14 -1
  38. package/dist/core/issues/github_provider.d.ts +66 -2
  39. package/dist/core/issues/github_provider.js +161 -2
  40. package/dist/core/issues/jira_provider.d.ts +50 -9
  41. package/dist/core/issues/jira_provider.js +62 -2
  42. package/dist/core/paths.d.ts +41 -4
  43. package/dist/core/paths.js +32 -3
  44. package/dist/core/quality.d.ts +7 -7
  45. package/dist/core/quality.js +16 -10
  46. package/dist/core/refine.js +2 -2
  47. package/dist/core/runner.d.ts +9 -3
  48. package/dist/core/runner.js +39 -27
  49. package/dist/core/session.d.ts +2 -2
  50. package/dist/core/session.js +39 -18
  51. package/dist/core/sqlite.d.ts +14 -7
  52. package/dist/core/sqlite.js +14 -7
  53. package/dist/core/trace_db.d.ts +118 -0
  54. package/dist/core/trace_db.js +278 -0
  55. package/dist/core/tracer.d.ts +64 -34
  56. package/dist/core/tracer.js +141 -69
  57. package/dist/core/watch.d.ts +4 -4
  58. package/dist/core/watch.js +2 -2
  59. package/dist/ui/server/app.js +10 -10
  60. package/dist/ui/server/db.d.ts +89 -21
  61. package/dist/ui/server/db.js +235 -99
  62. package/dist/ui/server/serve.d.ts +5 -1
  63. package/dist/ui/server/serve.js +4 -5
  64. package/package.json +1 -1
  65. package/web/assets/index-CQ3k1Y1-.css +1 -0
  66. package/web/assets/index-CU8tom6S.js +21 -0
  67. package/web/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
  68. package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
  69. package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
  70. package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
  71. package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
  72. package/web/index.html +33 -2
  73. package/web/logo.svg +4 -4
  74. package/web/assets/index-C7nF068F.css +0 -1
  75. package/web/assets/index-mzSArcnQ.js +0 -11
  76. package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
  77. package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
@@ -475,7 +475,7 @@ export const SandboxConfigSchema = v.object({
475
475
  });
476
476
  export const AgentConfigSchema = v.object({
477
477
  name: v.string(),
478
- coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
478
+ coding_agent: v.optional(v.picklist(["flue", "claude_code", "opencode"]), "flue"),
479
479
  model: v.optional(v.string(), "google/gemini-3.6-flash"),
480
480
  thinking: v.optional(ThinkingLevelSchema, "medium"),
481
481
  color: v.optional(v.string(), ""), // hex swatch for this agent's lane in the UI
@@ -510,7 +510,7 @@ export const AgentConfigSchema = v.object({
510
510
  sandbox: v.optional(v.nullable(SandboxBackendSchema)),
511
511
  });
512
512
  export const ConfigDefaultsSchema = v.object({
513
- coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
513
+ coding_agent: v.optional(v.picklist(["flue", "claude_code", "opencode"]), "flue"),
514
514
  model: v.optional(v.string(), "google/gemini-3.6-flash"),
515
515
  thinking: v.optional(ThinkingLevelSchema, "medium"),
516
516
  color: v.optional(v.string(), ""),
@@ -587,8 +587,97 @@ export const OTelConfigSchema = v.object({
587
587
  headers: v.optional(v.record(v.string(), v.string()), undefined),
588
588
  service_name: v.optional(v.string(), "spf"),
589
589
  });
590
+ /**
591
+ * MIGRATION NOTE (BT-issue #66, 3 PRs): `observability.db` used to be ONLY a
592
+ * bare string (a local sqlite path, defaulting to ".spf/data/spf.db"). This
593
+ * is now a discriminated shape that additionally accepts an explicit
594
+ * `{ kind: "sqlite", path? }` object (equivalent to the string form, just
595
+ * spelled out) and a `{ kind: "d1", database_id, ... }` object that names a
596
+ * remote Cloudflare D1 database instead of a local file. The bare-string
597
+ * form is unchanged and remains the default for every existing config —
598
+ * nothing in the wild needs to change. `resolveObservabilityDb` below
599
+ * normalizes all three accepted forms to one shape.
600
+ *
601
+ * PR 1 (this schema + normalizer + `paths.resolveDataPaths` plumbing) left
602
+ * `resolveDataPaths` refusing a `kind: "d1"` config outright — no adapter
603
+ * existed yet. PR 2 (`core/trace_db.ts`'s `TraceDb` interface, `LocalTraceDb`,
604
+ * `D1TraceDb`, and `createTraceDb()`, wired through `Tracer`/`SfDb`) removes
605
+ * that refusal: `resolveDataPaths` now returns a usable descriptor for
606
+ * either kind, and both backends are real. PR 3 wires `spf doctor`/`spf
607
+ * init`'s interview flow to the new option.
608
+ */
609
+ export const SqliteDbConfigSchema = v.object({
610
+ kind: v.literal("sqlite"),
611
+ path: v.optional(v.string(), ".spf/data/spf.db"),
612
+ });
613
+ /**
614
+ * `account_id_env`/`api_token_env` default to the SAME env var names
615
+ * `core/cloudflare_provider.ts` already reads for the Cloudflare Workers AI
616
+ * provider (`CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_API_TOKEN`, see
617
+ * `core/providers.ts`'s `PROVIDER_ENV_KEYS.cloudflare`) — one Cloudflare
618
+ * account's credentials, read from the same two env vars, cover both
619
+ * Workers AI and a D1-backed trace db unless a config explicitly points
620
+ * elsewhere (e.g. a second Cloudflare account) by naming different env vars.
621
+ */
622
+ export const D1DbConfigSchema = v.object({
623
+ kind: v.literal("d1"),
624
+ database_id: v.string(),
625
+ account_id_env: v.optional(v.string(), "CLOUDFLARE_ACCOUNT_ID"),
626
+ api_token_env: v.optional(v.string(), "CLOUDFLARE_API_TOKEN"),
627
+ });
628
+ /**
629
+ * A plain `v.union([v.string(), v.variant("kind", [...])])` was tried first
630
+ * and rejected: valibot's plain union reports every failed branch with the
631
+ * same generic "Expected (string | Object) but received Object", swallowing
632
+ * `v.variant`'s own precise "kind must be sqlite or d1" / "database_id is
633
+ * required" messages. This `rawTransform` dispatches on `typeof value`
634
+ * itself — string passes through unchanged (the back-compat form), anything
635
+ * else is handed to `v.variant("kind", ...)` and ITS issues (which already
636
+ * name the offending field) are re-surfaced verbatim, prefixed with
637
+ * `observability.db:` so a nested failure is still easy to place in a large
638
+ * config file. See `src/test/data_types.test.ts` for the exact messages
639
+ * this produces for each rejected shape.
640
+ */
641
+ const ObservabilityDbVariantSchema = v.variant("kind", [SqliteDbConfigSchema, D1DbConfigSchema]);
642
+ export const ObservabilityDbSchema = v.pipe(v.unknown(), v.rawTransform(({ dataset, addIssue, NEVER }) => {
643
+ const value = dataset.value;
644
+ if (typeof value === "string")
645
+ return value;
646
+ if (value && typeof value === "object" && !Array.isArray(value)) {
647
+ const result = v.safeParse(ObservabilityDbVariantSchema, value);
648
+ if (result.success)
649
+ return result.output;
650
+ for (const issue of result.issues)
651
+ addIssue({ message: `observability.db: ${issue.message}` });
652
+ return NEVER;
653
+ }
654
+ addIssue({
655
+ message: `observability.db must be a string path, or an object with kind: "sqlite" or kind: "d1" (got ${JSON.stringify(value)})`,
656
+ });
657
+ return NEVER;
658
+ }));
659
+ /**
660
+ * Normalizes `observability.db` — bare string, `{kind:"sqlite",...}`, or
661
+ * `{kind:"d1",...}` — to one shape. Pure and side-effect-free: does NOT
662
+ * resolve `path` to an absolute path (that's `paths.resolveDataPaths`'s job,
663
+ * which needs `repo_root` to do it) and does NOT read the D1 env vars it
664
+ * names (that's PR 2's adapter's job, at the point it actually needs to
665
+ * authenticate).
666
+ */
667
+ export function resolveObservabilityDb(db) {
668
+ if (typeof db === "string")
669
+ return { kind: "sqlite", path: db };
670
+ if (db.kind === "sqlite")
671
+ return { kind: "sqlite", path: db.path ?? ".spf/data/spf.db" };
672
+ return {
673
+ kind: "d1",
674
+ database_id: db.database_id,
675
+ account_id_env: db.account_id_env ?? "CLOUDFLARE_ACCOUNT_ID",
676
+ api_token_env: db.api_token_env ?? "CLOUDFLARE_API_TOKEN",
677
+ };
678
+ }
590
679
  export const ObservabilityConfigSchema = v.object({
591
- db: v.optional(v.string(), ".spf/data/spf.db"),
680
+ db: v.optional(ObservabilityDbSchema, ".spf/data/spf.db"),
592
681
  poll_ms: v.optional(v.number(), 500),
593
682
  // Absent by default. `agents.ts`'s mergeRawConfig spreads `observability`
594
683
  // field-by-field, so this nested object merges as a WHOLE-OBJECT replace on
@@ -596,6 +685,14 @@ export const ObservabilityConfigSchema = v.object({
596
685
  // which is the semantics you want for an endpoint + its headers (a
597
686
  // half-merged pair of the two would send tokens to the wrong collector).
598
687
  // Pinned by a merge-survival test in src/test/data_types.test.ts.
688
+ //
689
+ // `db` merges the same whole-value way: `mergeRawConfig`'s `observability`
690
+ // spread is key-by-key over `observability`'s OWN keys (db/poll_ms/otel),
691
+ // never deeper — an override's `db:` (string or object, either kind)
692
+ // replaces the base's `db:` in full, so a `{kind:"d1",...}` override can
693
+ // never end up with a stray `path` key leaked in from a `{kind:"sqlite",
694
+ // path:...}` base (or vice versa). See the merge-survival test for `db` in
695
+ // src/test/data_types.test.ts.
599
696
  otel: v.optional(OTelConfigSchema),
600
697
  });
601
698
  /**
@@ -661,19 +758,94 @@ export const JiraIssueTypeMapSchema = v.object({
661
758
  */
662
759
  spec: v.optional(v.string(), "Story"),
663
760
  });
761
+ /**
762
+ * Optional, per-project `WatchState` -> Jira workflow-status-name map.
763
+ * Unlike `JiraIssueTypeMapSchema`, this has no sane universal default:
764
+ * "To Do" vs "Open" vs "Backlog" (and everything in between) is entirely
765
+ * per-project workflow configuration in Jira, so every field defaults to
766
+ * unset. A `WatchState` with no entry here keeps today's behavior exactly —
767
+ * `jira_provider.ts`'s `transition()`/`claim()` update the label only and
768
+ * never attempt a status change for it.
769
+ *
770
+ * Deliberately covers only the states a human's Jira board status would
771
+ * plausibly want to reflect (the build lane, `spec-ready` through
772
+ * `blocked`) — the refine-lane states (`refining`, `split-proposed`, ...)
773
+ * are internal bookkeeping for `core/refine.ts`, not board-visible work.
774
+ *
775
+ * `jira_provider.ts`'s `syncStatus()` is the reader; `validateStatusMap()`
776
+ * is what `spf watch init` and `spf watch`'s own startup check should call
777
+ * to confirm each configured name is a real status on the project before an
778
+ * unattended run relies on it — same shape as `issue_types`/
779
+ * `validateIssueTypes()`.
780
+ */
781
+ export const JiraStatusMapSchema = v.object({
782
+ "spec-ready": v.optional(v.string()),
783
+ ready: v.optional(v.string()),
784
+ working: v.optional(v.string()),
785
+ review: v.optional(v.string()),
786
+ done: v.optional(v.string()),
787
+ blocked: v.optional(v.string()),
788
+ });
664
789
  /**
665
790
  * Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` +
666
791
  * `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. Whole-
667
792
  * object replace on config-file-layer merge, like `refine`/
668
793
  * `observability.otel` (see `agents.ts`'s `mergeRawConfig`) — an override
669
- * file that touches `watch.jira` at all must repeat `issue_types` too if it
670
- * wants to keep a customized mapping, same caveat that already applies to
671
- * `base_url`/`project_key` today.
794
+ * file that touches `watch.jira` at all must repeat `issue_types`/
795
+ * `status_map` too if it wants to keep a customized mapping, same caveat
796
+ * that already applies to `base_url`/`project_key` today.
672
797
  */
673
798
  export const WatchJiraConfigSchema = v.object({
674
799
  base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
675
800
  project_key: v.optional(v.string(), ""), // e.g. "PROJ"
676
801
  issue_types: v.optional(JiraIssueTypeMapSchema, () => v.parse(JiraIssueTypeMapSchema, {})),
802
+ status_map: v.optional(JiraStatusMapSchema, () => v.parse(JiraStatusMapSchema, {})),
803
+ });
804
+ /**
805
+ * Optional, per-repo `WatchState` -> GitHub Projects v2 "Status" option-name
806
+ * map — the GitHub-side twin of `JiraIssueTypeMap`'s sibling on the Jira
807
+ * provider (see `jira_provider.ts`'s `status_map`). No sane universal
808
+ * default: a board's Status column options ("Todo"/"In Progress"/"Done", or
809
+ * anything else) are per-project configuration, so every field defaults to
810
+ * unset. A `WatchState` with no entry keeps today's behavior exactly —
811
+ * `github_provider.ts`'s `transition()`/`claim()` update the label only and
812
+ * never touch Projects v2 for it.
813
+ *
814
+ * Same six build-lane states as Jira's map — the refine-lane's own
815
+ * bookkeeping states (`refining`, `split-proposed`, ...) aren't board-visible
816
+ * work, same reasoning as `jira_provider.ts`'s `JiraStatusMapSchema`.
817
+ *
818
+ * `github_provider.ts`'s `syncStatus()` is the reader; `validateStatusMap()`
819
+ * is what `spf watch init` and `spf watch`'s own startup check call to
820
+ * confirm each configured name is a real Status option on the configured
821
+ * project before an unattended run relies on it.
822
+ */
823
+ export const GithubStatusMapSchema = v.object({
824
+ "spec-ready": v.optional(v.string()),
825
+ ready: v.optional(v.string()),
826
+ working: v.optional(v.string()),
827
+ review: v.optional(v.string()),
828
+ done: v.optional(v.string()),
829
+ blocked: v.optional(v.string()),
830
+ });
831
+ /**
832
+ * Only consulted when `issue_provider: github` (or `code_host: github`) AND
833
+ * `status_map` actually has an entry configured — `project_number: 0` (the
834
+ * default) disables status sync outright regardless of `status_map`, since
835
+ * there's no sane "guess the board" default: a repo can have zero, one, or
836
+ * many Projects v2 boards, and none of them is canonical. Scoped to the
837
+ * repo's OWNER (`watch.repo`'s "owner/name", the owner half) — GitHub
838
+ * Projects v2 numbers are per-owner, not per-repo, so `project_number: 3`
839
+ * means owner's project #3, which may or may not have this repo's issues on
840
+ * it yet (`github_provider.ts`'s `syncStatus()` adds the issue to the
841
+ * project itself the first time it needs to). Whole-object replace on
842
+ * config-file-layer merge, like `jira`/`refine` above — an override file
843
+ * that touches `watch.github` at all must repeat `status_map` too if it
844
+ * wants to keep a customized mapping.
845
+ */
846
+ export const WatchGithubConfigSchema = v.object({
847
+ project_number: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0)), 0),
848
+ status_map: v.optional(GithubStatusMapSchema, () => v.parse(GithubStatusMapSchema, {})),
677
849
  });
678
850
  /**
679
851
  * The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
@@ -798,6 +970,7 @@ export const WatchConfigSchema = v.object({
798
970
  */
799
971
  chain_options: v.optional(v.record(v.string(), v.string()), () => ({})),
800
972
  jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
973
+ github: v.optional(WatchGithubConfigSchema, () => v.parse(WatchGithubConfigSchema, {})),
801
974
  refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
802
975
  fanout: v.optional(WatchFanoutConfigSchema, () => v.parse(WatchFanoutConfigSchema, {})),
803
976
  });
@@ -886,7 +1059,7 @@ export const ReviewConfigSchema = v.object({
886
1059
  */
887
1060
  export const TierSchema = v.object({
888
1061
  name: v.pipe(v.string(), v.minLength(1)),
889
- coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
1062
+ coding_agent: v.optional(v.picklist(["flue", "claude_code", "opencode"]), "flue"),
890
1063
  model: v.pipe(v.string(), v.minLength(1)),
891
1064
  });
892
1065
  /**
@@ -204,7 +204,7 @@ export interface FanoutDeps {
204
204
  /** Run one attempt's chain in `cwd`, returning its exit code. */
205
205
  runAttempt: (dispatch: AttemptDispatch) => Promise<number>;
206
206
  /** Read this adw_id's gate/usage rows from the SHARED db. Must not throw — return `ZERO_METRICS` on any hiccup. */
207
- readMetrics: (adwId: string) => AttemptMetrics;
207
+ readMetrics: (adwId: string) => Promise<AttemptMetrics>;
208
208
  log: (message: string) => void;
209
209
  /**
210
210
  * Opt-in first-past-the-post. Default (unset/false): every attempt that
@@ -239,7 +239,7 @@ export async function runBestOf(deps) {
239
239
  decided = true;
240
240
  let metrics = ZERO_METRICS;
241
241
  try {
242
- metrics = deps.readMetrics(adwId);
242
+ metrics = await deps.readMetrics(adwId);
243
243
  }
244
244
  catch (caught) {
245
245
  // Metrics are for RANKING, never for correctness — an attempt whose
@@ -96,7 +96,20 @@ export function diffMatchesClaims(envelope, run) {
96
96
  continue;
97
97
  }
98
98
  const exists = existsSync(resolved);
99
- report.check(f, exists, exists ? `exists, ${size(resolved)}` : "claimed changed file does not exist");
99
+ // A bulk/mechanical change (a repo-wide formatter, a codemod) tempts an
100
+ // agent to compress many identical edits into one descriptive sentence
101
+ // ("139 files reformatted (app/, components/, ...)") instead of listing
102
+ // each path — that string obviously never resolves, but the plain
103
+ // "does not exist" message gives no hint why, so a retry repeats the
104
+ // same mistake. Whitespace inside an otherwise-nonexistent claim is the
105
+ // cheap, cheap-to-check signal: a real repo-relative path is never a
106
+ // prose fragment. Never suppresses the failure itself, only clarifies it.
107
+ const looksLikeProse = !exists && /\s/.test(f);
108
+ report.check(f, exists, exists
109
+ ? `exists, ${size(resolved)}`
110
+ : looksLikeProse
111
+ ? "claimed changed file does not exist — this looks like a summary sentence, not a path; list every changed file individually, even for a bulk/mechanical change"
112
+ : "claimed changed file does not exist");
100
113
  }
101
114
  return report;
102
115
  }
@@ -8,7 +8,8 @@
8
8
  * a couple dozen REST calls, none of them exotic. `spf`'s own package stays
9
9
  * dependency-free either way.
10
10
  *
11
- * Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
11
+ * Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope plus `project` if
12
+ * `watch.github.status_map` is configured, see below), read once at
12
13
  * construction — matching the reference implementation's pattern and this
13
14
  * project's existing env-var-for-credentials philosophy. `listByLabel`
14
15
  * paginates up to `MAX_LIST_PAGES` (500 issues per label query) — no longer
@@ -17,7 +18,21 @@
17
18
  * page 1 would silently lose to a new low-priority one), not just a missed
18
19
  * issue. A repo past even that cap gets a loud warning, never a silent
19
20
  * truncation — see `listByLabel`'s own doc comment.
21
+ *
22
+ * State is modeled as labels (`<prefix>:ready`, etc.) — labels are spf's
23
+ * ACTUAL state machine and always get written, unconditionally. Native
24
+ * GitHub Projects v2 board status is a separate, OPTIONAL, best-effort layer
25
+ * on top (`syncStatus()`), driven entirely by `watch.github.status_map` —
26
+ * empty by default, so an existing config's behavior is unchanged. It's
27
+ * opt-in, and GraphQL-only (Projects v2 has no REST API), because not every
28
+ * repo has a board wired up, and a board's Status option names are per-
29
+ * project configuration `spf` can't assume; a misconfigured or unreachable
30
+ * entry degrades to a logged warning, never a thrown error — same rule
31
+ * `jira_provider.ts`'s own `syncStatus()` follows, for the same reason: a
32
+ * status-sync miss must never block the label update `spf watch` actually
33
+ * depends on.
20
34
  */
35
+ import type { GithubStatusMap } from "../data_types.ts";
21
36
  import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringKind, IssueAuthoringProvider, IssueComment, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
22
37
  /**
23
38
  * The refine lane's leaf/container taxonomy — see `data_types.ts`'s
@@ -32,9 +47,23 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
32
47
  private readonly repo;
33
48
  private readonly labelPrefix;
34
49
  private readonly token;
50
+ private readonly projectNumber;
51
+ private readonly statusMap;
52
+ /** Resolved lazily by `resolveProjectStatusField()` — cached only on SUCCESS, so a transient GraphQL hiccup gets retried the next call rather than disabling status sync for this instance's entire (potentially daemon-long) lifetime. */
53
+ private projectMeta?;
35
54
  constructor(repo: string, // "owner/name"
36
- labelPrefix: string, token: string);
55
+ labelPrefix: string, token: string, projectNumber?: number, // 0 = status sync disabled, regardless of statusMap
56
+ statusMap?: GithubStatusMap);
37
57
  private gh;
58
+ /**
59
+ * Projects v2 has no REST surface at all — this is the one place this
60
+ * file talks GraphQL instead of REST. A GraphQL "not found" (bad login,
61
+ * bad project number, missing `project` scope) comes back as a 200 with a
62
+ * null data field plus an `errors` array, not a non-2xx — callers read
63
+ * `data` being falsy as "couldn't resolve," same as a 404 elsewhere in
64
+ * this file.
65
+ */
66
+ private ghGraphql;
38
67
  private label;
39
68
  private typeLabel;
40
69
  /** Mirrors `core/refine.ts`'s own module-level `priorityLabel()` — that one stays provider-agnostic (a plain string, no `this`); this one is `ensureLabels()`'s seeding half. */
@@ -82,6 +111,41 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
82
111
  to?: WatchState;
83
112
  }): Promise<boolean>;
84
113
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
114
+ /**
115
+ * Best-effort native Projects v2 status sync — a no-op unless
116
+ * `project_number` AND `status_map` both configure something for `to`.
117
+ * Every failure mode (unconfigured, project/field not found, no matching
118
+ * option, a rejected mutation) is logged and swallowed, never thrown —
119
+ * see this file's module comment on why a status-sync miss must never
120
+ * break the label update callers depend on.
121
+ */
122
+ private syncStatus;
123
+ /**
124
+ * Resolves (and caches — see `projectMeta`'s own doc comment) `watch.github.project_number`'s
125
+ * "Status" single-select field against the repo OWNER's Projects v2 board
126
+ * (Projects v2 numbers are per-owner, not per-repo — see
127
+ * `WatchGithubConfigSchema`'s doc comment). Tries `organization(login:)`
128
+ * first, then `user(login:)`: an owner is exactly one of the two, and
129
+ * GraphQL returns that field as `null` (not a hard error) when it's the
130
+ * wrong kind, so falling through is safe.
131
+ */
132
+ private resolveProjectStatusField;
133
+ /** The item-id half of `syncStatus()`: an issue already on the project has one; otherwise this adds it, since a `status_map` entry is an implicit "yes, put this on the board" — the same way a Jira issue is already assumed to be on its project. `null` (logged) on any lookup/add failure. */
134
+ private resolveProjectItemId;
135
+ /**
136
+ * Read-only validation of the configured `status_map` against the real
137
+ * project's Status options — what `spf watch init` and `spf watch`'s own
138
+ * startup check call to catch a misnamed option before an unattended run
139
+ * silently no-ops its status sync every time, the same role
140
+ * `validateIssueTypes()`/`validateStatusMap()` play on the Jira side.
141
+ * Empty when `status_map` has no entries configured at all — nothing to
142
+ * report, not a mismatch.
143
+ */
144
+ validateStatusMap(): Promise<Array<{
145
+ state: string;
146
+ githubStatus: string;
147
+ exists: boolean;
148
+ }>>;
85
149
  comment(issue: Issue, body: string): Promise<void>;
86
150
  openPr(opts: {
87
151
  branch: string;
@@ -86,11 +86,18 @@ export class GitHubProvider {
86
86
  repo;
87
87
  labelPrefix;
88
88
  token;
89
+ projectNumber;
90
+ statusMap;
91
+ /** Resolved lazily by `resolveProjectStatusField()` — cached only on SUCCESS, so a transient GraphQL hiccup gets retried the next call rather than disabling status sync for this instance's entire (potentially daemon-long) lifetime. */
92
+ projectMeta;
89
93
  constructor(repo, // "owner/name"
90
- labelPrefix, token) {
94
+ labelPrefix, token, projectNumber = 0, // 0 = status sync disabled, regardless of statusMap
95
+ statusMap = {}) {
91
96
  this.repo = repo;
92
97
  this.labelPrefix = labelPrefix;
93
98
  this.token = token;
99
+ this.projectNumber = projectNumber;
100
+ this.statusMap = statusMap;
94
101
  }
95
102
  async gh(path, init) {
96
103
  const response = await fetch(`${API}${path}`, {
@@ -111,6 +118,34 @@ export class GitHubProvider {
111
118
  return undefined;
112
119
  return (await response.json());
113
120
  }
121
+ /**
122
+ * Projects v2 has no REST surface at all — this is the one place this
123
+ * file talks GraphQL instead of REST. A GraphQL "not found" (bad login,
124
+ * bad project number, missing `project` scope) comes back as a 200 with a
125
+ * null data field plus an `errors` array, not a non-2xx — callers read
126
+ * `data` being falsy as "couldn't resolve," same as a 404 elsewhere in
127
+ * this file.
128
+ */
129
+ async ghGraphql(query, variables) {
130
+ const response = await fetch(`${API}/graphql`, {
131
+ method: "POST",
132
+ headers: {
133
+ Authorization: `Bearer ${this.token}`,
134
+ Accept: "application/vnd.github+json",
135
+ "Content-Type": "application/json",
136
+ },
137
+ body: JSON.stringify({ query, variables }),
138
+ });
139
+ if (!response.ok) {
140
+ const detail = await response.text().catch(() => "");
141
+ throw new Error(`GitHub GraphQL -> ${response.status}: ${detail.slice(0, 500)}`);
142
+ }
143
+ const json = (await response.json());
144
+ if (!json.data) {
145
+ throw new Error(`GitHub GraphQL returned no data${json.errors ? `: ${json.errors.map((e) => e.message).join("; ")}` : ""}`);
146
+ }
147
+ return json.data;
148
+ }
114
149
  label(state) {
115
150
  return `${this.labelPrefix}:${state}`;
116
151
  }
@@ -242,8 +277,9 @@ export class GitHubProvider {
242
277
  return raw.filter((i) => !i.pull_request).map((i) => this.toIssue(i));
243
278
  }
244
279
  async claim(issue, opts) {
280
+ const toState = opts?.to ?? "working";
245
281
  const from = this.label(opts?.from ?? "ready");
246
- const to = this.label(opts?.to ?? "working");
282
+ const to = this.label(toState);
247
283
  await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels/${encodeURIComponent(from)}`, {
248
284
  method: "DELETE",
249
285
  }).catch(() => undefined); // already gone is fine
@@ -261,6 +297,9 @@ export class GitHubProvider {
261
297
  body: JSON.stringify({ labels: [from] }),
262
298
  }).catch(() => undefined);
263
299
  }
300
+ else {
301
+ await this.syncStatus(issue, toState);
302
+ }
264
303
  return claimed;
265
304
  }
266
305
  async transition(issue, to, detail) {
@@ -274,9 +313,129 @@ export class GitHubProvider {
274
313
  method: "POST",
275
314
  body: JSON.stringify({ labels: [this.label(to)] }),
276
315
  });
316
+ await this.syncStatus(issue, to);
277
317
  if (detail)
278
318
  await this.comment(issue, detail);
279
319
  }
320
+ /**
321
+ * Best-effort native Projects v2 status sync — a no-op unless
322
+ * `project_number` AND `status_map` both configure something for `to`.
323
+ * Every failure mode (unconfigured, project/field not found, no matching
324
+ * option, a rejected mutation) is logged and swallowed, never thrown —
325
+ * see this file's module comment on why a status-sync miss must never
326
+ * break the label update callers depend on.
327
+ */
328
+ async syncStatus(issue, to) {
329
+ const statusName = this.statusMap[to];
330
+ if (!statusName)
331
+ return;
332
+ const meta = await this.resolveProjectStatusField();
333
+ if (!meta)
334
+ return; // already logged inside resolveProjectStatusField, or simply unconfigured (project_number: 0)
335
+ const optionId = meta.options.get(statusName);
336
+ if (!optionId) {
337
+ console.error(`spf watch: issue #${issue.id} — GitHub Projects #${this.projectNumber} has no Status option named ${JSON.stringify(statusName)} (watch.github.status_map.${to}) — skipping status sync, label already updated`);
338
+ return;
339
+ }
340
+ try {
341
+ const itemId = await this.resolveProjectItemId(issue, meta.projectId);
342
+ if (!itemId)
343
+ return; // already logged inside resolveProjectItemId
344
+ await this.ghGraphql(`mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
345
+ updateProjectV2ItemFieldValue(input: {projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: {singleSelectOptionId: $optionId}}) {
346
+ clientMutationId
347
+ }
348
+ }`, { projectId: meta.projectId, itemId, fieldId: meta.statusFieldId, optionId });
349
+ }
350
+ catch (err) {
351
+ console.error(`spf watch: issue #${issue.id} — GitHub Projects status sync to ${JSON.stringify(statusName)} failed; label already updated — ${err instanceof Error ? err.message : String(err)}`);
352
+ }
353
+ }
354
+ /**
355
+ * Resolves (and caches — see `projectMeta`'s own doc comment) `watch.github.project_number`'s
356
+ * "Status" single-select field against the repo OWNER's Projects v2 board
357
+ * (Projects v2 numbers are per-owner, not per-repo — see
358
+ * `WatchGithubConfigSchema`'s doc comment). Tries `organization(login:)`
359
+ * first, then `user(login:)`: an owner is exactly one of the two, and
360
+ * GraphQL returns that field as `null` (not a hard error) when it's the
361
+ * wrong kind, so falling through is safe.
362
+ */
363
+ async resolveProjectStatusField() {
364
+ if (!this.projectNumber)
365
+ return null;
366
+ if (this.projectMeta)
367
+ return this.projectMeta;
368
+ const owner = this.repo.split("/")[0];
369
+ let data;
370
+ try {
371
+ data = await this.ghGraphql(`query($login: String!, $number: Int!) {
372
+ organization(login: $login) { projectV2(number: $number) { id fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } } }
373
+ user(login: $login) { projectV2(number: $number) { id fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } } }
374
+ }`, { login: owner, number: this.projectNumber });
375
+ }
376
+ catch (err) {
377
+ console.error(`spf watch: couldn't resolve GitHub Projects v2 #${this.projectNumber} for ${owner} — status sync skipped this run — ${err instanceof Error ? err.message : String(err)}`);
378
+ return null;
379
+ }
380
+ const project = data.organization?.projectV2 ?? data.user?.projectV2;
381
+ if (!project) {
382
+ console.error(`spf watch: GitHub Projects v2 #${this.projectNumber} not found for ${owner} (or GITHUB_TOKEN lacks "project" scope) — status sync skipped this run`);
383
+ return null;
384
+ }
385
+ const statusField = project.fields.nodes.find((f) => f !== null && f.name === "Status");
386
+ if (!statusField) {
387
+ console.error(`spf watch: GitHub Projects v2 #${this.projectNumber} has no "Status" single-select field — status sync skipped this run`);
388
+ return null;
389
+ }
390
+ this.projectMeta = { projectId: project.id, statusFieldId: statusField.id, options: new Map(statusField.options.map((o) => [o.name, o.id])) };
391
+ return this.projectMeta;
392
+ }
393
+ /** The item-id half of `syncStatus()`: an issue already on the project has one; otherwise this adds it, since a `status_map` entry is an implicit "yes, put this on the board" — the same way a Jira issue is already assumed to be on its project. `null` (logged) on any lookup/add failure. */
394
+ async resolveProjectItemId(issue, projectId) {
395
+ const [owner, name] = this.repo.split("/");
396
+ let data;
397
+ try {
398
+ data = await this.ghGraphql(`query($owner: String!, $name: String!, $number: Int!) {
399
+ repository(owner: $owner, name: $name) {
400
+ issue(number: $number) { id projectItems(first: 20) { nodes { id project { id } } } }
401
+ }
402
+ }`, { owner, name, number: Number(issue.id) });
403
+ }
404
+ catch (err) {
405
+ console.error(`spf watch: issue #${issue.id} — couldn't look up its GitHub Projects item; status sync skipped, label already updated — ${err instanceof Error ? err.message : String(err)}`);
406
+ return null;
407
+ }
408
+ const ghIssue = data.repository?.issue;
409
+ if (!ghIssue)
410
+ return null; // deleted between the label update and here — nothing left to sync
411
+ const existing = ghIssue.projectItems.nodes.find((n) => n.project.id === projectId);
412
+ if (existing)
413
+ return existing.id;
414
+ try {
415
+ const added = await this.ghGraphql(`mutation($projectId: ID!, $contentId: ID!) { addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } } }`, { projectId, contentId: ghIssue.id });
416
+ return added.addProjectV2ItemById.item.id;
417
+ }
418
+ catch (err) {
419
+ console.error(`spf watch: issue #${issue.id} — couldn't add it to GitHub Projects #${this.projectNumber}; status sync skipped, label already updated — ${err instanceof Error ? err.message : String(err)}`);
420
+ return null;
421
+ }
422
+ }
423
+ /**
424
+ * Read-only validation of the configured `status_map` against the real
425
+ * project's Status options — what `spf watch init` and `spf watch`'s own
426
+ * startup check call to catch a misnamed option before an unattended run
427
+ * silently no-ops its status sync every time, the same role
428
+ * `validateIssueTypes()`/`validateStatusMap()` play on the Jira side.
429
+ * Empty when `status_map` has no entries configured at all — nothing to
430
+ * report, not a mismatch.
431
+ */
432
+ async validateStatusMap() {
433
+ const entries = Object.entries(this.statusMap).filter((entry) => Boolean(entry[1]));
434
+ if (entries.length === 0)
435
+ return [];
436
+ const meta = await this.resolveProjectStatusField();
437
+ return entries.map(([state, githubStatus]) => ({ state, githubStatus, exists: meta ? meta.options.has(githubStatus) : false }));
438
+ }
280
439
  async comment(issue, body) {
281
440
  await this.gh(`/repos/${this.repo}/issues/${issue.id}/comments`, {
282
441
  method: "POST",