@gr8ful/spf 0.18.0 → 0.19.1
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 +8 -0
- package/assets/skill/references/config.md +1 -0
- package/dist/cli/commands/doctor.js +25 -3
- package/dist/cli/commands/estimate.d.ts +22 -6
- package/dist/cli/commands/estimate.js +32 -10
- package/dist/cli/commands/loop.d.ts +20 -0
- package/dist/cli/commands/loop.js +20 -1
- package/dist/cli/commands/ui.js +2 -1
- package/dist/cli/commands/watch.js +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/interview.js +13 -0
- package/dist/cli/ui/run_dashboard.js +13 -7
- package/dist/core/agent_cc.d.ts +19 -3
- package/dist/core/agent_cc.js +38 -18
- package/dist/core/agent_flue.js +51 -14
- package/dist/core/agent_opencode.d.ts +62 -25
- package/dist/core/agent_opencode.js +71 -30
- package/dist/core/agents.d.ts +51 -4
- package/dist/core/agents.js +79 -4
- package/dist/core/console.d.ts +24 -4
- package/dist/core/console.js +20 -7
- package/dist/core/data_types.d.ts +300 -19
- package/dist/core/data_types.js +134 -5
- package/dist/core/issues/jira_provider.d.ts +58 -3
- package/dist/core/issues/jira_provider.js +89 -18
- package/dist/core/issues/markdown_adf.d.ts +58 -0
- package/dist/core/issues/markdown_adf.js +705 -0
- package/dist/core/issues/provider.d.ts +23 -0
- package/dist/core/loop.d.ts +39 -1
- package/dist/core/loop.js +33 -2
- package/dist/core/ollama_provider.d.ts +96 -13
- package/dist/core/ollama_provider.js +172 -26
- package/dist/core/otel.js +10 -1
- package/dist/core/otel_propagation.d.ts +168 -24
- package/dist/core/otel_propagation.js +219 -43
- package/dist/core/permissions.d.ts +16 -1
- package/dist/core/permissions.js +91 -3
- package/dist/core/providers.js +8 -3
- package/dist/core/refine.js +13 -1
- package/dist/core/runner.d.ts +33 -2
- package/dist/core/runner.js +40 -5
- package/dist/core/tiering.js +7 -3
- package/dist/core/tracer.d.ts +7 -1
- package/dist/core/tracer.js +15 -3
- package/dist/ui/server/db.d.ts +8 -1
- package/dist/ui/server/db.js +21 -4
- package/dist/ui/server/serve.d.ts +7 -0
- package/dist/ui/server/serve.js +10 -7
- package/dist/ui/shared/types.d.ts +16 -0
- package/package.json +3 -3
package/dist/core/data_types.js
CHANGED
|
@@ -528,6 +528,46 @@ export const ConfigDefaultsSchema = v.object({
|
|
|
528
528
|
// the machinery that decides whether its work passed.
|
|
529
529
|
// .spf/ is the whole per-repo footprint now — no adws/ tree to protect.
|
|
530
530
|
protected_files: v.optional(v.array(v.string()), () => [".spf/", "spf.config.yaml"]),
|
|
531
|
+
/**
|
|
532
|
+
* Paths a READ-ONLY (or write-restricted) agent may touch WITHOUT failing
|
|
533
|
+
* the phase — `core/permissions.ts`'s `enforce()` still rolls every one of
|
|
534
|
+
* them back (an agent's claimed report must never rest on a change that
|
|
535
|
+
* didn't survive), it just does not count that rollback as a breach.
|
|
536
|
+
*
|
|
537
|
+
* WHY THIS EXISTS: a lockfile is dependency-manager BOOKKEEPING, not the
|
|
538
|
+
* repo's intent — an agent that ran `npm install` (to read a package's
|
|
539
|
+
* real shape, say) rewrites `package-lock.json` as a side effect of a
|
|
540
|
+
* read, not an edit. Observed live: a read-only scout phase failed with
|
|
541
|
+
* "scout is read-only but modified 1 path(s): factory/content/
|
|
542
|
+
* package-lock.json — rolled back" over exactly this, for work that
|
|
543
|
+
* changed nothing an operator would call "the code."
|
|
544
|
+
*
|
|
545
|
+
* The four defaults are the lockfiles of every package manager this repo
|
|
546
|
+
* already builds against (npm, pnpm, yarn, bun) — additive, not
|
|
547
|
+
* exhaustive; a repo using another one adds its own pattern here. Same
|
|
548
|
+
* glob syntax as `protected_files`/`agents[].writes` (`permissions.ts`'s
|
|
549
|
+
* `globToRegex`), where a leading "**" followed by a path separator
|
|
550
|
+
* matches at any depth INCLUDING the repo root — so the packaged
|
|
551
|
+
* defaults below match a lockfile whether it sits at the top of the repo
|
|
552
|
+
* or nested under a subdirectory.
|
|
553
|
+
*
|
|
554
|
+
* Emptying this list (`read_only_ignore: []`) restores today's strict
|
|
555
|
+
* behavior exactly — every touched path outside an agent's own allowlist
|
|
556
|
+
* fails the phase, lockfiles included.
|
|
557
|
+
*
|
|
558
|
+
* PRECEDENCE: `protected_files` always wins. A path matching
|
|
559
|
+
* `protected_files` is never ignorable via `read_only_ignore`, no matter
|
|
560
|
+
* how narrowly write-restricted the agent is — a pattern here that
|
|
561
|
+
* happens to also match a protected path is not read as "exempt this
|
|
562
|
+
* from protected_files too"; it stays a real breach. See
|
|
563
|
+
* `permissions.ts`'s `isSafeToIgnore` for the enforcement.
|
|
564
|
+
*/
|
|
565
|
+
read_only_ignore: v.optional(v.array(v.string()), () => [
|
|
566
|
+
"**/package-lock.json",
|
|
567
|
+
"**/pnpm-lock.yaml",
|
|
568
|
+
"**/yarn.lock",
|
|
569
|
+
"**/bun.lockb",
|
|
570
|
+
]),
|
|
531
571
|
data_dir: v.optional(v.string(), ".spf/data"),
|
|
532
572
|
/**
|
|
533
573
|
* RUN BUDGET CEILINGS — the two knobs that bound what one adw_id may spend.
|
|
@@ -550,11 +590,20 @@ export const ConfigDefaultsSchema = v.object({
|
|
|
550
590
|
* `agents.ts`'s `BudgetExceeded`.
|
|
551
591
|
*
|
|
552
592
|
* `max_run_cost` is USD (the same unit the provider's own usage.cost
|
|
553
|
-
* arrives in, summed by `UsageBreakdown`); `max_run_tokens` is
|
|
554
|
-
* tokens
|
|
555
|
-
*
|
|
556
|
-
* `
|
|
557
|
-
*
|
|
593
|
+
* arrives in, summed by `UsageBreakdown`); `max_run_tokens` is BILLABLE
|
|
594
|
+
* tokens — `UsageBreakdown.billable_tokens` (input + cache-write + output),
|
|
595
|
+
* checked against `Run.billable_tokens`, NOT the `total_tokens` column
|
|
596
|
+
* `sessions` also carries for display. A prompt-caching backend (Ollama
|
|
597
|
+
* Cloud's kimi models, Anthropic's own caching) re-sends the whole
|
|
598
|
+
* conversation every turn as CACHE READS, which `total_tokens` counts and
|
|
599
|
+
* this ceiling does not: cache reads are billed (when billed at all) at a
|
|
600
|
+
* small fraction of input price, sometimes free, so a ceiling measured
|
|
601
|
+
* against the bigger number trips on bulk that cost nothing — observed
|
|
602
|
+
* live, one scout phase alone reported 1,311,740 total_tokens against a
|
|
603
|
+
* gateway that billed 189,321 uncached input + 17,908 output for it.
|
|
604
|
+
* `total_tokens` is kept exactly as before for anything display-only
|
|
605
|
+
* (the sessions-panel "tokens" line, the UI) — only the budget check
|
|
606
|
+
* changed which number it reads.
|
|
558
607
|
*
|
|
559
608
|
* Both are `> 0`, not `>= 0`: a zero ceiling would mean "no agent may ever
|
|
560
609
|
* run", which is a config mistake, not a budget — it would fail the first
|
|
@@ -573,6 +622,43 @@ export const ConfigDefaultsSchema = v.object({
|
|
|
573
622
|
*/
|
|
574
623
|
max_run_cost: v.optional(v.pipe(v.number(), v.gtValue(0))),
|
|
575
624
|
max_run_tokens: v.optional(v.pipe(v.number(), v.integer(), v.gtValue(0))),
|
|
625
|
+
/**
|
|
626
|
+
* REQUEST TIMEOUT — how long a single agent dispatch may run before it is
|
|
627
|
+
* aborted and settled as failed, rather than hanging on a connection that
|
|
628
|
+
* silently died mid-call with nothing to notice.
|
|
629
|
+
*
|
|
630
|
+
* FLUE-SPECIFIC, unlike every other key in this schema: it maps straight
|
|
631
|
+
* onto `AgentStatics.durability.timeoutMs` (see `@flue/runtime`'s own
|
|
632
|
+
* docs), a `flue`-backend-only mechanism. `claude_code`/`opencode` are
|
|
633
|
+
* subprocess backends with no such knob today — this field is silently
|
|
634
|
+
* ignored for them, the same way `flue_db_path` on `AgentRequest` already
|
|
635
|
+
* is. Not a bug to fix here: a subprocess backend needs its own separate
|
|
636
|
+
* process-level timeout story, which is out of scope for this key.
|
|
637
|
+
*
|
|
638
|
+
* ABSENT BY DEFAULT, and absence is a total no-op: Flue's own default
|
|
639
|
+
* applies unchanged (1 hour, 10 attempts) — the same "surprise mid-run
|
|
640
|
+
* failure on a ceiling nobody chose is worse than the spend" reasoning as
|
|
641
|
+
* `max_run_cost`/`max_run_tokens` above. Set this when a hung connection
|
|
642
|
+
* should surface as an attributable failure (and feed the normal
|
|
643
|
+
* gate-correction / `spf watch` retry loop) in minutes, not however long
|
|
644
|
+
* Flue's own default takes — e.g. `300_000` for a five-minute ceiling.
|
|
645
|
+
* NOT a precise deadline, though: manual verification against a socket
|
|
646
|
+
* that accepts a connection and then sends nothing (see
|
|
647
|
+
* `request_timeout.test.ts`'s header comment) saw Flue's own timeout check
|
|
648
|
+
* fire on a coarser periodic sweep — a 3s ceiling settled at ~15s, not 3s.
|
|
649
|
+
* Bounded-but-imprecise is still a firm improvement over unbounded.
|
|
650
|
+
*
|
|
651
|
+
* SCOPE IS ONE SUBMISSION (one agent dispatch — the first prompt, one
|
|
652
|
+
* JSON-repair retry, one gate correction), NOT the accumulated run, unlike
|
|
653
|
+
* `max_run_cost`/`max_run_tokens` above. It is still process-scoped, not
|
|
654
|
+
* per-agent: Flue's `durability` is a static on the single shared agent
|
|
655
|
+
* function `agent_flue.ts` dispatches everything through, set once before
|
|
656
|
+
* the first dispatch of the process — see that file's `ensureRuntime()`.
|
|
657
|
+
* Deliberately NOT in `loadConfig`'s per-agent back-fill list for the same
|
|
658
|
+
* reason `max_run_cost`/`max_run_tokens` aren't: a per-agent copy would
|
|
659
|
+
* read as "this agent gets its own timeout", which nothing enforces.
|
|
660
|
+
*/
|
|
661
|
+
request_timeout_ms: v.optional(v.pipe(v.number(), v.integer(), v.gtValue(0))),
|
|
576
662
|
});
|
|
577
663
|
/**
|
|
578
664
|
* OpenTelemetry span export — OFF unless this block exists, and `endpoint` is
|
|
@@ -869,6 +955,30 @@ export const WatchJiraConfigSchema = v.object({
|
|
|
869
955
|
project_key: v.optional(v.string(), ""), // e.g. "PROJ"
|
|
870
956
|
issue_types: v.optional(JiraIssueTypeMapSchema, () => v.parse(JiraIssueTypeMapSchema, {})),
|
|
871
957
|
status_map: v.optional(JiraStatusMapSchema, () => v.parse(JiraStatusMapSchema, {})),
|
|
958
|
+
/**
|
|
959
|
+
* The Jira issue-link `type` name `refine.ts`'s `publish()` uses to
|
|
960
|
+
* connect a freshly-published tree's ROOT issue(s) back to the spec they
|
|
961
|
+
* were refined from (`JiraProvider.linkToSpec`) — a plain, symmetric
|
|
962
|
+
* "issue link" (Jira's generic relate-two-issues mechanism), never the
|
|
963
|
+
* hierarchical `parent` field `linkChild` sets: the spec's own issue type
|
|
964
|
+
* defaults to Story (`issue_types.spec`), and a root node is often an
|
|
965
|
+
* Epic/Task — Jira's issue-type hierarchy frequently refuses a Story as
|
|
966
|
+
* one of those types' PARENT, so the hierarchy field is not a safe choice
|
|
967
|
+
* here regardless of which type actually published. "Relates" is a
|
|
968
|
+
* built-in link type on every Jira Cloud project; override this only if a
|
|
969
|
+
* project's admin has renamed or restricted it.
|
|
970
|
+
*
|
|
971
|
+
* MUST NAME A SYMMETRIC LINK TYPE. `JiraProvider.linkToSpec` fixes which
|
|
972
|
+
* side is `inwardIssue`/`outwardIssue` (the published root is always
|
|
973
|
+
* inward, the spec always outward) and does not expose direction as a
|
|
974
|
+
* separate knob — harmless for a symmetric type like "Relates" (Jira's UI
|
|
975
|
+
* does not even surface a direction for one), but pointing this at a
|
|
976
|
+
* DIRECTIONAL type (e.g. "blocks"/"is blocked by") would silently record
|
|
977
|
+
* the opposite relationship from the one intended. Only rename this to
|
|
978
|
+
* another symmetric type; a directional one needs code changes, not just
|
|
979
|
+
* config.
|
|
980
|
+
*/
|
|
981
|
+
link_type: v.optional(v.string(), "Relates"),
|
|
872
982
|
});
|
|
873
983
|
/**
|
|
874
984
|
* Optional, per-repo `WatchState` -> GitHub Projects v2 "Status" option-name
|
|
@@ -1230,6 +1340,24 @@ export class UsageBreakdown {
|
|
|
1230
1340
|
// at the output rate. Report it nested under output, never added to it.
|
|
1231
1341
|
reasoning_tokens = 0;
|
|
1232
1342
|
total_tokens = 0;
|
|
1343
|
+
/**
|
|
1344
|
+
* The SPEND number, as distinct from `total_tokens` (the SIZE number).
|
|
1345
|
+
* `input_tokens + output_tokens + cache_write_tokens` — `cache_read_tokens`
|
|
1346
|
+
* excluded on purpose: a cache read is Anthropic's own prompt-caching
|
|
1347
|
+
* discount (billed at a small fraction of the input rate, sometimes free
|
|
1348
|
+
* on some gateways) for context the conversation already sent, not new
|
|
1349
|
+
* material moved. `total_tokens` re-sends (and re-counts) the whole
|
|
1350
|
+
* conversation every turn, so a long-running scout/build session's cache
|
|
1351
|
+
* reads dwarf everything else in it (observed live: 1.31M total_tokens in
|
|
1352
|
+
* one phase, of which 1.15M were cache reads the gateway did not bill as
|
|
1353
|
+
* input) — a run-budget ceiling measured against `total_tokens` trips on
|
|
1354
|
+
* cache-driven bulk that cost nothing, not on real spend. `assertRunBudget`
|
|
1355
|
+
* (`agents.ts`) checks THIS field against `defaults.max_run_tokens`;
|
|
1356
|
+
* `total_tokens` is kept, unchanged, for display (the sessions-panel
|
|
1357
|
+
* "tokens" line, the UI) because an operator sizing context occupancy
|
|
1358
|
+
* still needs the real re-send count, not the billable one.
|
|
1359
|
+
*/
|
|
1360
|
+
billable_tokens = 0;
|
|
1233
1361
|
input_cost = 0.0;
|
|
1234
1362
|
output_cost = 0.0;
|
|
1235
1363
|
cache_read_cost = 0.0;
|
|
@@ -1249,6 +1377,7 @@ export class UsageBreakdown {
|
|
|
1249
1377
|
this.cache_write_tokens += usage.cacheWrite || 0;
|
|
1250
1378
|
this.reasoning_tokens += usage.reasoning || 0;
|
|
1251
1379
|
this.total_tokens += totalTokens;
|
|
1380
|
+
this.billable_tokens += (usage.input || 0) + (usage.output || 0) + (usage.cacheWrite || 0);
|
|
1252
1381
|
this.input_cost += cost.input || 0.0;
|
|
1253
1382
|
this.output_cost += cost.output || 0.0;
|
|
1254
1383
|
this.cache_read_cost += cost.cacheRead || 0.0;
|
|
@@ -14,8 +14,13 @@
|
|
|
14
14
|
* now is `/rest/api/3/search/jql`.
|
|
15
15
|
* - Comment/description bodies in API v3 are Atlassian Document Format
|
|
16
16
|
* (ADF) JSON, not plain strings — `{"body": "text"}` is rejected
|
|
17
|
-
* outright.
|
|
18
|
-
*
|
|
17
|
+
* outright. Human/LLM-authored content (`comment()`, `createIssue()`)
|
|
18
|
+
* goes through `markdownToAdf()`/`adfToMarkdown()` (`./markdown_adf.ts`)
|
|
19
|
+
* for real Jira formatting; the local `toAdf()` minimal round-trip (one
|
|
20
|
+
* paragraph of plain text, nothing richer) survives ONLY for
|
|
21
|
+
* `writeMarker()`'s hidden `[spf-watch-marker]` JSON payload, which must
|
|
22
|
+
* never pass through a real Markdown parser — see `toAdf`'s own doc
|
|
23
|
+
* comment.
|
|
19
24
|
*
|
|
20
25
|
* State is modeled as Jira labels (`<prefix>:ready`, etc.), mirroring
|
|
21
26
|
* `github_provider.ts` exactly — labels are spf's ACTUAL state machine and
|
|
@@ -66,6 +71,15 @@
|
|
|
66
71
|
* `feature` (both mapping to Jira's Epic type by default) surfaces a real
|
|
67
72
|
* Jira API error at publish time — a genuine platform difference, not
|
|
68
73
|
* something this file tries to paper over.
|
|
74
|
+
*
|
|
75
|
+
* `linkToSpec` is the OTHER half of `IssueAuthoringProvider`'s hierarchy —
|
|
76
|
+
* a published tree's ROOT connected back to the spec it was refined FROM,
|
|
77
|
+
* which `linkChild` cannot express (the spec's own issue type, a Story by
|
|
78
|
+
* default, frequently cannot legally PARENT a root node's type). Uses
|
|
79
|
+
* Jira's plain issue-link API instead (`type` configurable via
|
|
80
|
+
* `watch.jira.link_type`, "Relates" by default), with a plain comment as
|
|
81
|
+
* its own fallback if that API is unavailable — see the method's own doc
|
|
82
|
+
* comment.
|
|
69
83
|
*/
|
|
70
84
|
import type { JiraIssueTypeMap, JiraStatusMap } from "../data_types.ts";
|
|
71
85
|
import type { EnsureLabelsResult, Issue, IssueAuthoringKind, IssueAuthoringProvider, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
|
|
@@ -77,8 +91,9 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
|
|
|
77
91
|
private readonly apiToken;
|
|
78
92
|
private readonly issueTypes;
|
|
79
93
|
private readonly statusMap;
|
|
94
|
+
private readonly linkType;
|
|
80
95
|
constructor(baseUrl: string, // e.g. "https://your-domain.atlassian.net", no trailing slash
|
|
81
|
-
projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap, statusMap?: JiraStatusMap);
|
|
96
|
+
projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap, statusMap?: JiraStatusMap, linkType?: string);
|
|
82
97
|
private authHeader;
|
|
83
98
|
private jira;
|
|
84
99
|
private label;
|
|
@@ -129,6 +144,46 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
|
|
|
129
144
|
* Epic-under-Epic limitation this implies.
|
|
130
145
|
*/
|
|
131
146
|
linkChild(parent: Issue, child: Issue): Promise<void>;
|
|
147
|
+
/**
|
|
148
|
+
* `IssueAuthoringProvider.linkToSpec` — see its own doc comment
|
|
149
|
+
* (`issues/provider.ts`) for why this is a plain Jira "issue link"
|
|
150
|
+
* (`/rest/api/3/issueLink`) rather than `linkChild`'s hierarchy `parent`
|
|
151
|
+
* field: the spec's issue type (Story by default) frequently cannot
|
|
152
|
+
* legally PARENT a root node's type under Jira's issue-type hierarchy,
|
|
153
|
+
* while a generic issue link has no such restriction.
|
|
154
|
+
*
|
|
155
|
+
* `inwardIssue`/`outwardIssue` direction is arbitrary for a symmetric
|
|
156
|
+
* type like "Relates" — Jira does not surface it differently in the UI —
|
|
157
|
+
* so `issue` (the freshly published root) is the inward side and the
|
|
158
|
+
* spec is the outward side, consistently.
|
|
159
|
+
*
|
|
160
|
+
* BEST-EFFORT: this project's Jira instance may not have `linkType`
|
|
161
|
+
* enabled/named exactly this way (a renamed or removed link type, a
|
|
162
|
+
* permission scheme that disallows issue links for this project, ...) —
|
|
163
|
+
* a failure here must never fail the whole publish over what is, at
|
|
164
|
+
* bottom, a cosmetic cross-reference. Falls back to a plain comment
|
|
165
|
+
* naming the spec, so the relationship is visible SOMEWHERE even when
|
|
166
|
+
* the link API itself is unavailable.
|
|
167
|
+
*
|
|
168
|
+
* The fallback comment gets the SAME "never fail publish() over this"
|
|
169
|
+
* treatment as the link call itself: a rate limit or network blip on the
|
|
170
|
+
* comment call is just as cosmetic a failure as one on the link call, so
|
|
171
|
+
* it is caught and logged here rather than left to propagate out of
|
|
172
|
+
* `publish()` (`refine.ts`), which awaits this unguarded.
|
|
173
|
+
*
|
|
174
|
+
* `this.linkType` MUST name a SYMMETRIC link type ("Relates" and its
|
|
175
|
+
* project-renamed equivalents) — `inwardIssue`/`outwardIssue` above are
|
|
176
|
+
* fixed (the published root is always inward, the spec always outward)
|
|
177
|
+
* and not independently configurable, which is fine for a symmetric type
|
|
178
|
+
* (Jira does not surface the direction differently in the UI) but WRONG
|
|
179
|
+
* for a directional one (e.g. "blocks"/"is blocked by"): configuring
|
|
180
|
+
* `watch.jira.link_type` to a directional type would silently assert the
|
|
181
|
+
* opposite relationship from the one intended. See that field's own doc
|
|
182
|
+
* comment (`data_types.ts`) — direction is not exposed as a separate knob
|
|
183
|
+
* on purpose, to avoid a second config field only meaningful alongside a
|
|
184
|
+
* link type most projects never change from the "Relates" default.
|
|
185
|
+
*/
|
|
186
|
+
linkToSpec(specId: string, issue: Issue): Promise<void>;
|
|
132
187
|
/** The read-back half of `linkChild` — same JQL-in-body pattern as `searchByLabel`, since a GET with query params silently returns nothing on this endpoint (see the module comment). What makes container roll-up (`rollUp` in `watch.ts`) work on Jira too. */
|
|
133
188
|
listChildren(parent: Issue): Promise<Issue[]>;
|
|
134
189
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchRetryTransient } from "../utils.js";
|
|
2
|
+
import { adfToMarkdown, markdownToAdf } from "./markdown_adf.js";
|
|
2
3
|
const STATES = [
|
|
3
4
|
"ready",
|
|
4
5
|
"working",
|
|
@@ -27,6 +28,19 @@ const STATES = [
|
|
|
27
28
|
* here because nothing follows the JSON in this format at all.
|
|
28
29
|
*/
|
|
29
30
|
const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*\})/s;
|
|
31
|
+
/**
|
|
32
|
+
* The ONLY remaining caller is `writeMarker()`'s hidden `[spf-watch-marker]`
|
|
33
|
+
* JSON payload — deliberately kept as this minimal, un-parsed shape (see
|
|
34
|
+
* this file's module comment) rather than routed through
|
|
35
|
+
* `markdownToAdf()`, because that payload must round-trip byte-for-byte
|
|
36
|
+
* through `MARKER_RE` and must never have its JSON characters (underscores,
|
|
37
|
+
* brackets, backticks) reinterpreted as Markdown syntax. Do not change this
|
|
38
|
+
* call site or add other callers — human/LLM-authored content
|
|
39
|
+
* (`comment()`, `createIssue()`) goes through `markdownToAdf()` instead, and
|
|
40
|
+
* reading ADF back to text goes through `adfToMarkdown()` (both imported
|
|
41
|
+
* from `./markdown_adf.ts`) — there is exactly one ADF-to-text
|
|
42
|
+
* implementation in this codebase now, not two divergent ones.
|
|
43
|
+
*/
|
|
30
44
|
function toAdf(text) {
|
|
31
45
|
return {
|
|
32
46
|
type: "doc",
|
|
@@ -34,17 +48,6 @@ function toAdf(text) {
|
|
|
34
48
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
35
49
|
};
|
|
36
50
|
}
|
|
37
|
-
/** Walks an ADF document's `text` nodes and joins them — the minimal inverse of `toAdf`, not a full ADF renderer. */
|
|
38
|
-
function adfToText(adf) {
|
|
39
|
-
if (!adf || typeof adf !== "object")
|
|
40
|
-
return "";
|
|
41
|
-
const node = adf;
|
|
42
|
-
if (node.type === "text" && typeof node.text === "string")
|
|
43
|
-
return node.text;
|
|
44
|
-
if (Array.isArray(node.content))
|
|
45
|
-
return node.content.map(adfToText).join("");
|
|
46
|
-
return "";
|
|
47
|
-
}
|
|
48
51
|
export class JiraProvider {
|
|
49
52
|
baseUrl;
|
|
50
53
|
projectKey;
|
|
@@ -53,8 +56,12 @@ export class JiraProvider {
|
|
|
53
56
|
apiToken;
|
|
54
57
|
issueTypes;
|
|
55
58
|
statusMap;
|
|
59
|
+
linkType;
|
|
56
60
|
constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
|
|
57
|
-
projectKey, labelPrefix, email, apiToken, issueTypes, statusMap = {}
|
|
61
|
+
projectKey, labelPrefix, email, apiToken, issueTypes, statusMap = {},
|
|
62
|
+
// See `WatchJiraConfigSchema.link_type`'s own doc comment (`data_types.ts`)
|
|
63
|
+
// — the issue-link `type` name `linkToSpec` below creates.
|
|
64
|
+
linkType = "Relates") {
|
|
58
65
|
this.baseUrl = baseUrl;
|
|
59
66
|
this.projectKey = projectKey;
|
|
60
67
|
this.labelPrefix = labelPrefix;
|
|
@@ -62,6 +69,7 @@ export class JiraProvider {
|
|
|
62
69
|
this.apiToken = apiToken;
|
|
63
70
|
this.issueTypes = issueTypes;
|
|
64
71
|
this.statusMap = statusMap;
|
|
72
|
+
this.linkType = linkType;
|
|
65
73
|
}
|
|
66
74
|
authHeader() {
|
|
67
75
|
return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
|
|
@@ -101,7 +109,7 @@ export class JiraProvider {
|
|
|
101
109
|
return {
|
|
102
110
|
id: raw.key,
|
|
103
111
|
title: raw.fields.summary,
|
|
104
|
-
body: raw.fields.description ?
|
|
112
|
+
body: raw.fields.description ? adfToMarkdown(raw.fields.description) : "",
|
|
105
113
|
labels: raw.fields.labels,
|
|
106
114
|
};
|
|
107
115
|
}
|
|
@@ -163,7 +171,7 @@ export class JiraProvider {
|
|
|
163
171
|
fields: {
|
|
164
172
|
project: { key: this.projectKey },
|
|
165
173
|
summary: input.title,
|
|
166
|
-
description:
|
|
174
|
+
description: markdownToAdf(input.body),
|
|
167
175
|
issuetype: { name: this.issueTypes[input.kind] },
|
|
168
176
|
labels: input.labels,
|
|
169
177
|
},
|
|
@@ -182,6 +190,69 @@ export class JiraProvider {
|
|
|
182
190
|
async linkChild(parent, child) {
|
|
183
191
|
await this.jira(`/rest/api/3/issue/${child.id}`, { method: "PUT", body: JSON.stringify({ fields: { parent: { key: parent.id } } }) });
|
|
184
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* `IssueAuthoringProvider.linkToSpec` — see its own doc comment
|
|
195
|
+
* (`issues/provider.ts`) for why this is a plain Jira "issue link"
|
|
196
|
+
* (`/rest/api/3/issueLink`) rather than `linkChild`'s hierarchy `parent`
|
|
197
|
+
* field: the spec's issue type (Story by default) frequently cannot
|
|
198
|
+
* legally PARENT a root node's type under Jira's issue-type hierarchy,
|
|
199
|
+
* while a generic issue link has no such restriction.
|
|
200
|
+
*
|
|
201
|
+
* `inwardIssue`/`outwardIssue` direction is arbitrary for a symmetric
|
|
202
|
+
* type like "Relates" — Jira does not surface it differently in the UI —
|
|
203
|
+
* so `issue` (the freshly published root) is the inward side and the
|
|
204
|
+
* spec is the outward side, consistently.
|
|
205
|
+
*
|
|
206
|
+
* BEST-EFFORT: this project's Jira instance may not have `linkType`
|
|
207
|
+
* enabled/named exactly this way (a renamed or removed link type, a
|
|
208
|
+
* permission scheme that disallows issue links for this project, ...) —
|
|
209
|
+
* a failure here must never fail the whole publish over what is, at
|
|
210
|
+
* bottom, a cosmetic cross-reference. Falls back to a plain comment
|
|
211
|
+
* naming the spec, so the relationship is visible SOMEWHERE even when
|
|
212
|
+
* the link API itself is unavailable.
|
|
213
|
+
*
|
|
214
|
+
* The fallback comment gets the SAME "never fail publish() over this"
|
|
215
|
+
* treatment as the link call itself: a rate limit or network blip on the
|
|
216
|
+
* comment call is just as cosmetic a failure as one on the link call, so
|
|
217
|
+
* it is caught and logged here rather than left to propagate out of
|
|
218
|
+
* `publish()` (`refine.ts`), which awaits this unguarded.
|
|
219
|
+
*
|
|
220
|
+
* `this.linkType` MUST name a SYMMETRIC link type ("Relates" and its
|
|
221
|
+
* project-renamed equivalents) — `inwardIssue`/`outwardIssue` above are
|
|
222
|
+
* fixed (the published root is always inward, the spec always outward)
|
|
223
|
+
* and not independently configurable, which is fine for a symmetric type
|
|
224
|
+
* (Jira does not surface the direction differently in the UI) but WRONG
|
|
225
|
+
* for a directional one (e.g. "blocks"/"is blocked by"): configuring
|
|
226
|
+
* `watch.jira.link_type` to a directional type would silently assert the
|
|
227
|
+
* opposite relationship from the one intended. See that field's own doc
|
|
228
|
+
* comment (`data_types.ts`) — direction is not exposed as a separate knob
|
|
229
|
+
* on purpose, to avoid a second config field only meaningful alongside a
|
|
230
|
+
* link type most projects never change from the "Relates" default.
|
|
231
|
+
*/
|
|
232
|
+
async linkToSpec(specId, issue) {
|
|
233
|
+
try {
|
|
234
|
+
await this.jira("/rest/api/3/issueLink", {
|
|
235
|
+
method: "POST",
|
|
236
|
+
body: JSON.stringify({
|
|
237
|
+
type: { name: this.linkType },
|
|
238
|
+
inwardIssue: { key: issue.id },
|
|
239
|
+
outwardIssue: { key: specId },
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch (linkError) {
|
|
244
|
+
try {
|
|
245
|
+
await this.comment(issue, `Refined from ${specId}.`);
|
|
246
|
+
}
|
|
247
|
+
catch (commentError) {
|
|
248
|
+
// Both the issue-link API and the comment fallback failed — the
|
|
249
|
+
// spec/root relationship is not recorded ANYWHERE on Jira this run,
|
|
250
|
+
// but that is still a cosmetic loss, not a reason to fail the whole
|
|
251
|
+
// publish (see this method's own doc comment).
|
|
252
|
+
console.error(`spf watch: ${issue.id} — could not link to spec ${specId} (${linkError instanceof Error ? linkError.message : String(linkError)}) and the fallback comment also failed — ${commentError instanceof Error ? commentError.message : String(commentError)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
185
256
|
/** The read-back half of `linkChild` — same JQL-in-body pattern as `searchByLabel`, since a GET with query params silently returns nothing on this endpoint (see the module comment). What makes container roll-up (`rollUp` in `watch.ts`) work on Jira too. */
|
|
186
257
|
async listChildren(parent) {
|
|
187
258
|
const jql = `parent = ${JSON.stringify(parent.id)}`;
|
|
@@ -296,7 +367,7 @@ export class JiraProvider {
|
|
|
296
367
|
.map(([state, jiraStatus]) => ({ state, jiraStatus, exists: available.has(jiraStatus) }));
|
|
297
368
|
}
|
|
298
369
|
async comment(issue, body) {
|
|
299
|
-
await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body:
|
|
370
|
+
await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: markdownToAdf(body) }) });
|
|
300
371
|
}
|
|
301
372
|
/** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
|
|
302
373
|
async fetchComments(issueId) {
|
|
@@ -307,7 +378,7 @@ export class JiraProvider {
|
|
|
307
378
|
const comments = await this.fetchComments(issueId);
|
|
308
379
|
let found = null;
|
|
309
380
|
for (const c of comments) {
|
|
310
|
-
const match = MARKER_RE.exec(
|
|
381
|
+
const match = MARKER_RE.exec(adfToMarkdown(c.body));
|
|
311
382
|
if (!match)
|
|
312
383
|
continue;
|
|
313
384
|
try {
|
|
@@ -323,8 +394,8 @@ export class JiraProvider {
|
|
|
323
394
|
async listComments(issue) {
|
|
324
395
|
const comments = await this.fetchComments(issue.id);
|
|
325
396
|
return comments
|
|
326
|
-
.filter((c) => !MARKER_RE.test(
|
|
327
|
-
.map((c) => ({ id: c.id, author: c.author?.displayName ?? "unknown", created_at: c.created, body:
|
|
397
|
+
.filter((c) => !MARKER_RE.test(adfToMarkdown(c.body)))
|
|
398
|
+
.map((c) => ({ id: c.id, author: c.author?.displayName ?? "unknown", created_at: c.created, body: adfToMarkdown(c.body) }));
|
|
328
399
|
}
|
|
329
400
|
async readMarker(issue) {
|
|
330
401
|
const found = await this.findMarkerComment(issue.id);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hand-rolled Markdown <-> Atlassian Document Format (ADF) converter for
|
|
3
|
+
* Jira comment/description bodies — deliberately NOT built on a markdown
|
|
4
|
+
* parsing library. This project has zero markdown dependencies today and
|
|
5
|
+
* that's a deliberate choice (see `package.json`): `jira_provider.ts`'s own
|
|
6
|
+
* `toAdf`/`adfToText` pair is the existing precedent (one paragraph of
|
|
7
|
+
* plain text, nothing richer), and this module is the same philosophy
|
|
8
|
+
* extended to a small, explicitly-bounded Markdown subset, not a general
|
|
9
|
+
* CommonMark implementation.
|
|
10
|
+
*
|
|
11
|
+
* SUPPORTED (confirmed scope, see the module's originating task — do not
|
|
12
|
+
* silently grow this list): ATX headings (`#`..`######`), bold
|
|
13
|
+
* (`**x**`/`__x__`), italic (`*x*`/`_x_`), strikethrough (`~~x~~`), inline
|
|
14
|
+
* code (`` `x` ``), fenced code blocks with optional language, bullet
|
|
15
|
+
* (`-`/`*`/`+`) and ordered (`1.`) lists with ONE level of nesting,
|
|
16
|
+
* blockquotes (consecutive `>` lines as one block), horizontal rules
|
|
17
|
+
* (`---`/`***`/`___` alone on a line), links (`[text](url)`), paragraphs
|
|
18
|
+
* separated by blank lines, and hard line breaks (a line ending in two-plus
|
|
19
|
+
* trailing spaces, or a lone trailing backslash).
|
|
20
|
+
*
|
|
21
|
+
* OUT OF SCOPE (tables, images, @mentions, raw HTML, nested blockquotes-in-
|
|
22
|
+
* lists, >1 level of list nesting): never crashes and never corrupts
|
|
23
|
+
* structure on these — `markdownToAdf` falls through to literal text in a
|
|
24
|
+
* plain paragraph (matching `toAdf`'s existing behavior for "everything"
|
|
25
|
+
* before this module existed), and `adfToMarkdown` degrades any node/mark
|
|
26
|
+
* type it doesn't recognize to its nested text content. `markdownToAdf`
|
|
27
|
+
* must NEVER throw on any input string — a malformed fence, an unmatched
|
|
28
|
+
* `**`, an empty link target, all degrade to literal text rather than
|
|
29
|
+
* erroring, because this runs unattended inside `spf watch`.
|
|
30
|
+
*
|
|
31
|
+
* ADF node/mark shapes below are taken from Atlassian's published document
|
|
32
|
+
* structure (developer.atlassian.com/cloud/jira/platform/apis/document/
|
|
33
|
+
* structure/), not guessed — in particular the strikethrough mark's real
|
|
34
|
+
* type name is `"strike"`, NOT `"strikethrough"` (an easy guess to get
|
|
35
|
+
* wrong), and ADF text nodes must never carry an empty `text` string (the
|
|
36
|
+
* schema requires non-empty), which is why every text-emitting path here
|
|
37
|
+
* checks for empty content before emitting a node instead of always
|
|
38
|
+
* emitting one unconditionally the way `jira_provider.ts`'s `toAdf` does
|
|
39
|
+
* for its single fixed paragraph.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Parses the constrained Markdown subset (module comment) into an ADF
|
|
43
|
+
* document node. Never throws: every unrecognized or malformed construct
|
|
44
|
+
* degrades to literal text inside a plain paragraph rather than raising,
|
|
45
|
+
* because this feeds `spf watch`'s unattended comment/description writes.
|
|
46
|
+
*/
|
|
47
|
+
export declare function markdownToAdf(text: string): unknown;
|
|
48
|
+
/**
|
|
49
|
+
* The inverse of `markdownToAdf`: renders an ADF document back into the
|
|
50
|
+
* same Markdown subset. NOT required to byte-match the original input
|
|
51
|
+
* (canonical forms are fine — e.g. always `-` for bullets even if the
|
|
52
|
+
* source used `*`), only to be semantically recognizable and to converge
|
|
53
|
+
* after one more round trip through `markdownToAdf`. Handles ADF built by
|
|
54
|
+
* this module OR hand-built elsewhere (e.g. Jira's own rich-text editor):
|
|
55
|
+
* any node or mark type it doesn't recognize degrades to its nested text
|
|
56
|
+
* content via `renderFallbackText` rather than throwing or dropping it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function adfToMarkdown(adf: unknown): string;
|