@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.
- package/README.md +51 -9
- package/assets/skill/references/config.md +12 -5
- package/assets/skill/references/observability.md +57 -12
- package/assets/templates/ts-opencode.spf.config.yaml +54 -0
- package/dist/chains/index.js +1 -1
- package/dist/chains/simple_sdlc.d.ts +2 -2
- package/dist/chains/simple_sdlc.js +13 -13
- package/dist/chains/steps.d.ts +2 -2
- package/dist/chains/steps.js +35 -19
- package/dist/cli/commands/abort.d.ts +1 -1
- package/dist/cli/commands/abort.js +30 -3
- package/dist/cli/commands/doctor.js +121 -8
- package/dist/cli/commands/estimate.js +3 -3
- package/dist/cli/commands/events.js +4 -4
- package/dist/cli/commands/fanout.js +93 -21
- package/dist/cli/commands/loop.js +31 -32
- package/dist/cli/commands/migrate.js +8 -1
- package/dist/cli/commands/phases.js +2 -2
- package/dist/cli/commands/sessions.js +2 -2
- package/dist/cli/commands/trace.d.ts +28 -8
- package/dist/cli/commands/trace.js +28 -15
- package/dist/cli/commands/ui.js +15 -5
- package/dist/cli/commands/watch.js +91 -30
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +95 -5
- package/dist/core/agent_opencode.d.ts +247 -0
- package/dist/core/agent_opencode.js +590 -0
- package/dist/core/agents.d.ts +12 -12
- package/dist/core/agents.js +113 -46
- package/dist/core/console.d.ts +12 -12
- package/dist/core/console.js +25 -25
- package/dist/core/data_types.d.ts +356 -15
- package/dist/core/data_types.js +180 -7
- package/dist/core/fanout.d.ts +1 -1
- package/dist/core/fanout.js +1 -1
- package/dist/core/gates.js +14 -1
- package/dist/core/issues/github_provider.d.ts +66 -2
- package/dist/core/issues/github_provider.js +161 -2
- package/dist/core/issues/jira_provider.d.ts +50 -9
- package/dist/core/issues/jira_provider.js +62 -2
- package/dist/core/paths.d.ts +41 -4
- package/dist/core/paths.js +32 -3
- package/dist/core/quality.d.ts +7 -7
- package/dist/core/quality.js +16 -10
- package/dist/core/refine.js +2 -2
- package/dist/core/runner.d.ts +9 -3
- package/dist/core/runner.js +39 -27
- package/dist/core/session.d.ts +2 -2
- package/dist/core/session.js +39 -18
- package/dist/core/sqlite.d.ts +14 -7
- package/dist/core/sqlite.js +14 -7
- package/dist/core/trace_db.d.ts +118 -0
- package/dist/core/trace_db.js +278 -0
- package/dist/core/tracer.d.ts +64 -34
- package/dist/core/tracer.js +141 -69
- package/dist/core/watch.d.ts +4 -4
- package/dist/core/watch.js +2 -2
- package/dist/ui/server/app.js +10 -10
- package/dist/ui/server/db.d.ts +89 -21
- package/dist/ui/server/db.js +235 -99
- package/dist/ui/server/serve.d.ts +5 -1
- package/dist/ui/server/serve.js +4 -5
- package/package.json +1 -1
- package/web/assets/index-CQ3k1Y1-.css +1 -0
- package/web/assets/index-CU8tom6S.js +21 -0
- package/web/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
- package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
- package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
- package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
- package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
- package/web/index.html +33 -2
- package/web/logo.svg +4 -4
- package/web/assets/index-C7nF068F.css +0 -1
- package/web/assets/index-mzSArcnQ.js +0 -11
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
package/dist/core/data_types.js
CHANGED
|
@@ -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(
|
|
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
|
|
670
|
-
* wants to keep a customized mapping, same caveat
|
|
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
|
/**
|
package/dist/core/fanout.d.ts
CHANGED
|
@@ -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
|
package/dist/core/fanout.js
CHANGED
|
@@ -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
|
package/dist/core/gates.js
CHANGED
|
@@ -96,7 +96,20 @@ export function diffMatchesClaims(envelope, run) {
|
|
|
96
96
|
continue;
|
|
97
97
|
}
|
|
98
98
|
const exists = existsSync(resolved);
|
|
99
|
-
|
|
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
|
|
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(
|
|
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",
|