@gr8ful/spf 0.17.0 → 0.19.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/assets/skill/references/config.md +6 -3
- package/assets/skill/references/observability.md +115 -1
- package/dist/cli/index.js +6 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +39 -1
- package/dist/core/agent_flue.js +26 -0
- package/dist/core/agent_opencode.d.ts +105 -3
- package/dist/core/agent_opencode.js +169 -17
- package/dist/core/agents.d.ts +6 -0
- package/dist/core/agents.js +22 -0
- package/dist/core/data_types.d.ts +60 -0
- package/dist/core/data_types.js +69 -0
- package/dist/core/issues/jira_provider.d.ts +7 -2
- package/dist/core/issues/jira_provider.js +20 -17
- package/dist/core/issues/markdown_adf.d.ts +58 -0
- package/dist/core/issues/markdown_adf.js +705 -0
- package/dist/core/otel.d.ts +182 -48
- package/dist/core/otel.js +373 -158
- package/dist/core/otel_metrics.d.ts +127 -0
- package/dist/core/otel_metrics.js +221 -0
- package/dist/core/otel_propagation.d.ts +159 -0
- package/dist/core/otel_propagation.js +225 -0
- package/package.json +15 -2
package/dist/core/agents.d.ts
CHANGED
|
@@ -146,6 +146,12 @@ interface RunForAgents {
|
|
|
146
146
|
envelopeRow: (phase: Phase, agent: string, outputType: string, payloadJson: string, valid: boolean, attempt: number) => Promise<void>;
|
|
147
147
|
gateRow: (phase: Phase, gate: string, report: GateReport, attempt: number) => Promise<void>;
|
|
148
148
|
agentSessionRow: (adwId: string, agent: AgentConfig, sessionId: string, contextTokens?: number, contextWindow?: number) => Promise<void>;
|
|
149
|
+
otel?: {
|
|
150
|
+
agentCallTraceContext: (phaseId: string, agentName: string) => {
|
|
151
|
+
traceparent: string;
|
|
152
|
+
spanId: string;
|
|
153
|
+
} | null;
|
|
154
|
+
} | null;
|
|
149
155
|
};
|
|
150
156
|
console: {
|
|
151
157
|
agentStarted: (name: string, model: string, sessionId: string) => Promise<void>;
|
package/dist/core/agents.js
CHANGED
|
@@ -808,6 +808,19 @@ export async function execute(run, phase, call) {
|
|
|
808
808
|
// WHOLE run rather than on the first call of each phase. See
|
|
809
809
|
// `assertRunBudget` for why it is checked before, not after.
|
|
810
810
|
assertRunBudget(run);
|
|
811
|
+
// Outbound OTel propagation (SPF's otel-sdk extension): the CURRENTLY
|
|
812
|
+
// OPEN agent-call span's own trace context, when one exists — `null`
|
|
813
|
+
// whenever `observability.otel` is unconfigured for this run (the
|
|
814
|
+
// common case, and byte-identical to before this field existed) or the
|
|
815
|
+
// exporter has no agent call open (shouldn't happen here — agent_start
|
|
816
|
+
// fires before `send()` is ever reached — but a `null` is a silent
|
|
817
|
+
// no-op either way, never an error). `otelBlock` threads the SAME
|
|
818
|
+
// endpoint/headers/service_name through so `agent_flue.ts` can install
|
|
819
|
+
// its own (separate, process-scoped) http/undici propagation without
|
|
820
|
+
// needing the whole `SFConfig` — see `data_types.ts`'s `AgentRequest.
|
|
821
|
+
// otel` doc comment.
|
|
822
|
+
const otelBlock = run.cfg.observability.otel;
|
|
823
|
+
const otelCtx = otelBlock ? (run.tracer.otel?.agentCallTraceContext(phase.phase_id, agent.name) ?? null) : null;
|
|
811
824
|
const request = {
|
|
812
825
|
prompt: promptText,
|
|
813
826
|
system_prompt: systemText,
|
|
@@ -822,6 +835,15 @@ export async function execute(run, phase, call) {
|
|
|
822
835
|
flue_db_path: path.join(run.data_dir, "flue.db"),
|
|
823
836
|
env: agentEnv(agent),
|
|
824
837
|
sandbox: spec,
|
|
838
|
+
otel: otelCtx && otelBlock
|
|
839
|
+
? {
|
|
840
|
+
traceparent: otelCtx.traceparent,
|
|
841
|
+
x_request_id: otelCtx.spanId,
|
|
842
|
+
endpoint: otelBlock.endpoint,
|
|
843
|
+
headers: otelBlock.headers,
|
|
844
|
+
service_name: otelBlock.service_name,
|
|
845
|
+
}
|
|
846
|
+
: undefined,
|
|
825
847
|
};
|
|
826
848
|
const forward = eventForwarder(run, phase, agent.name, agent.coding_agent);
|
|
827
849
|
// Best-effort, fire-and-forget: these fire from a plain process-lifecycle
|
|
@@ -685,6 +685,7 @@ export declare const AgentConfigSchema: v.ObjectSchema<{
|
|
|
685
685
|
readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
|
|
686
686
|
readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
|
|
687
687
|
readonly sandbox: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["local", "opensandbox", "cloudflare"], undefined>, undefined>, undefined>;
|
|
688
|
+
readonly lora_adapter: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
688
689
|
}, undefined>;
|
|
689
690
|
export type AgentConfig = v.InferOutput<typeof AgentConfigSchema>;
|
|
690
691
|
export declare const ConfigDefaultsSchema: v.ObjectSchema<{
|
|
@@ -756,13 +757,45 @@ export type ConfigDefaults = v.InferOutput<typeof ConfigDefaultsSchema>;
|
|
|
756
757
|
* (`https://collector:4318/v1/traces`) or a bare origin (`/v1/traces` is
|
|
757
758
|
* appended — see `resolveTracesUrl`). `headers` is where a collector's auth
|
|
758
759
|
* token goes; its VALUES are treated as secrets and never logged.
|
|
760
|
+
*
|
|
761
|
+
* `metrics` (default `true`) additionally gates `otel_metrics.ts`'s
|
|
762
|
+
* process-scoped meter — set `false` to keep trace export on while opting
|
|
763
|
+
* out of the metrics pipeline entirely. It has no effect on activation
|
|
764
|
+
* either way: `endpoint`'s presence is still the sole switch for BOTH
|
|
765
|
+
* signals, this field only narrows what a configured block sends.
|
|
766
|
+
*
|
|
767
|
+
* `allow_env` (default `false`) is a narrow, opt-in exception to EXPLICIT
|
|
768
|
+
* CONFIG ONLY (see `core/otel.ts`'s header): when `true` AND this block is
|
|
769
|
+
* ALREADY active (`endpoint` set here, in the file), the standard
|
|
770
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` env vars may
|
|
771
|
+
* SUPPLEMENT it — e.g. a CI-injected collector token added to `headers`
|
|
772
|
+
* without checking it into the repo. It can NEVER activate export from
|
|
773
|
+
* nothing (there is no `endpoint` to fall back to when this block is absent
|
|
774
|
+
* at all — that case is unaffected by this flag either way) and a
|
|
775
|
+
* config-declared value always wins over the env on conflict. Defaults to
|
|
776
|
+
* `false` because this is still a deliberate loosening of an adversarially-
|
|
777
|
+
* reviewed, test-pinned invariant — an operator opts in per-repo.
|
|
759
778
|
*/
|
|
760
779
|
export declare const OTelConfigSchema: v.ObjectSchema<{
|
|
761
780
|
readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
|
|
762
781
|
readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
|
|
763
782
|
readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
|
|
783
|
+
readonly metrics: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
|
|
784
|
+
readonly allow_env: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
764
785
|
}, undefined>;
|
|
765
786
|
export type OTelConfig = v.InferOutput<typeof OTelConfigSchema>;
|
|
787
|
+
/**
|
|
788
|
+
* `allow_env`'s narrow env-supplement path (see `OTelConfigSchema`'s doc
|
|
789
|
+
* comment above): called ONLY when `cfg.observability.otel` already exists
|
|
790
|
+
* with a real `endpoint` — this function never activates anything on its
|
|
791
|
+
* own and is never called when the block is absent. `OTEL_EXPORTER_OTLP_
|
|
792
|
+
* HEADERS` follows the OTel spec's env-var shape (comma-separated
|
|
793
|
+
* `key=value` pairs, URL-decoded); config-declared headers win over the env
|
|
794
|
+
* on a key collision. `OTEL_EXPORTER_OTLP_ENDPOINT`, when set, overrides
|
|
795
|
+
* `endpoint` itself — the one field this can change, since a CI runner
|
|
796
|
+
* commonly injects a different collector per job/environment.
|
|
797
|
+
*/
|
|
798
|
+
export declare function applyOtelEnvSupplement(otel: OTelConfig, env?: NodeJS.ProcessEnv): OTelConfig;
|
|
766
799
|
/**
|
|
767
800
|
* MIGRATION NOTE (BT-issue #66, 3 PRs): `observability.db` used to be ONLY a
|
|
768
801
|
* bare string (a local sqlite path, defaulting to ".spf/data/spf.db"). This
|
|
@@ -868,6 +901,8 @@ export declare const ObservabilityConfigSchema: v.ObjectSchema<{
|
|
|
868
901
|
readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
|
|
869
902
|
readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
|
|
870
903
|
readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
|
|
904
|
+
readonly metrics: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
|
|
905
|
+
readonly allow_env: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
871
906
|
}, undefined>, undefined>;
|
|
872
907
|
}, undefined>;
|
|
873
908
|
export type ObservabilityConfig = v.InferOutput<typeof ObservabilityConfigSchema>;
|
|
@@ -1525,6 +1560,8 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1525
1560
|
readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
|
|
1526
1561
|
readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
|
|
1527
1562
|
readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
|
|
1563
|
+
readonly metrics: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
|
|
1564
|
+
readonly allow_env: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
1528
1565
|
}, undefined>, undefined>;
|
|
1529
1566
|
}, undefined>, () => {
|
|
1530
1567
|
db: string | {
|
|
@@ -1543,6 +1580,8 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1543
1580
|
[x: string]: string;
|
|
1544
1581
|
} | undefined;
|
|
1545
1582
|
service_name: string;
|
|
1583
|
+
metrics: boolean;
|
|
1584
|
+
allow_env: boolean;
|
|
1546
1585
|
} | undefined;
|
|
1547
1586
|
}>;
|
|
1548
1587
|
readonly agents: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
|
|
@@ -1561,6 +1600,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
1561
1600
|
readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
|
|
1562
1601
|
readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
|
|
1563
1602
|
readonly sandbox: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["local", "opensandbox", "cloudflare"], undefined>, undefined>, undefined>;
|
|
1603
|
+
readonly lora_adapter: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
1564
1604
|
}, undefined>, undefined>, () => never[]>;
|
|
1565
1605
|
readonly quality: v.OptionalSchema<v.ObjectSchema<{
|
|
1566
1606
|
readonly checks: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
|
|
@@ -1974,6 +2014,26 @@ export interface AgentRequest {
|
|
|
1974
2014
|
env?: Record<string, string>;
|
|
1975
2015
|
/** Absent (the default) => local(), byte-identical to before this field existed. See sandbox.ts. */
|
|
1976
2016
|
sandbox?: SandboxSpec;
|
|
2017
|
+
/**
|
|
2018
|
+
* Outbound OTel trace-context propagation — set by `agents.ts`'s `send()`
|
|
2019
|
+
* from `otel.ts`'s `OtelExporter.agentCallTraceContext()` ONLY when
|
|
2020
|
+
* `observability.otel` is configured for this run; absent otherwise, and
|
|
2021
|
+
* every backend that ignores it (`opencode` today) is byte-identical to
|
|
2022
|
+
* before this field existed. `traceparent`/`x_request_id` are this call's
|
|
2023
|
+
* own span context (`agent_cc.ts`'s single `spawn()` choke point turns
|
|
2024
|
+
* them into `TRACEPARENT`/`ANTHROPIC_CUSTOM_HEADERS`); `endpoint`/
|
|
2025
|
+
* `headers`/`service_name` are the SAME `observability.otel` block,
|
|
2026
|
+
* carried through so `agent_flue.ts` can install its own (separate,
|
|
2027
|
+
* process-scoped — see `otel_propagation.ts`) global http/undici
|
|
2028
|
+
* propagation without needing the full `SFConfig`.
|
|
2029
|
+
*/
|
|
2030
|
+
otel?: {
|
|
2031
|
+
traceparent: string;
|
|
2032
|
+
x_request_id: string;
|
|
2033
|
+
endpoint: string;
|
|
2034
|
+
headers?: Record<string, string>;
|
|
2035
|
+
service_name: string;
|
|
2036
|
+
};
|
|
1977
2037
|
}
|
|
1978
2038
|
/**
|
|
1979
2039
|
* Tokens and the dollars they cost, per component, summed over a call.
|
package/dist/core/data_types.js
CHANGED
|
@@ -508,6 +508,13 @@ export const AgentConfigSchema = v.object({
|
|
|
508
508
|
// "local" -> force local for this agent
|
|
509
509
|
// null -> same as unset (the writes/env_allowlist spelling)
|
|
510
510
|
sandbox: v.optional(v.nullable(SandboxBackendSchema)),
|
|
511
|
+
// Explicit override for `otel.ts`'s `spf.lora_adapter` span attribute —
|
|
512
|
+
// the unambiguous source of truth when set. Unset (the common case) falls
|
|
513
|
+
// back to parsing `model` itself; see `loraAdapterFor()`'s own doc comment
|
|
514
|
+
// in `core/otel.ts` for the two zero-config conventions it recognizes.
|
|
515
|
+
// Purely descriptive: it does not change routing, dispatch, or which
|
|
516
|
+
// model actually serves the call — only what an OTel backend sees.
|
|
517
|
+
lora_adapter: v.optional(v.string()),
|
|
511
518
|
});
|
|
512
519
|
export const ConfigDefaultsSchema = v.object({
|
|
513
520
|
coding_agent: v.optional(v.picklist(["flue", "claude_code", "opencode"]), "flue"),
|
|
@@ -581,12 +588,74 @@ export const ConfigDefaultsSchema = v.object({
|
|
|
581
588
|
* (`https://collector:4318/v1/traces`) or a bare origin (`/v1/traces` is
|
|
582
589
|
* appended — see `resolveTracesUrl`). `headers` is where a collector's auth
|
|
583
590
|
* token goes; its VALUES are treated as secrets and never logged.
|
|
591
|
+
*
|
|
592
|
+
* `metrics` (default `true`) additionally gates `otel_metrics.ts`'s
|
|
593
|
+
* process-scoped meter — set `false` to keep trace export on while opting
|
|
594
|
+
* out of the metrics pipeline entirely. It has no effect on activation
|
|
595
|
+
* either way: `endpoint`'s presence is still the sole switch for BOTH
|
|
596
|
+
* signals, this field only narrows what a configured block sends.
|
|
597
|
+
*
|
|
598
|
+
* `allow_env` (default `false`) is a narrow, opt-in exception to EXPLICIT
|
|
599
|
+
* CONFIG ONLY (see `core/otel.ts`'s header): when `true` AND this block is
|
|
600
|
+
* ALREADY active (`endpoint` set here, in the file), the standard
|
|
601
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` env vars may
|
|
602
|
+
* SUPPLEMENT it — e.g. a CI-injected collector token added to `headers`
|
|
603
|
+
* without checking it into the repo. It can NEVER activate export from
|
|
604
|
+
* nothing (there is no `endpoint` to fall back to when this block is absent
|
|
605
|
+
* at all — that case is unaffected by this flag either way) and a
|
|
606
|
+
* config-declared value always wins over the env on conflict. Defaults to
|
|
607
|
+
* `false` because this is still a deliberate loosening of an adversarially-
|
|
608
|
+
* reviewed, test-pinned invariant — an operator opts in per-repo.
|
|
584
609
|
*/
|
|
585
610
|
export const OTelConfigSchema = v.object({
|
|
586
611
|
endpoint: v.pipe(v.string(), v.url()),
|
|
587
612
|
headers: v.optional(v.record(v.string(), v.string()), undefined),
|
|
588
613
|
service_name: v.optional(v.string(), "spf"),
|
|
614
|
+
metrics: v.optional(v.boolean(), true),
|
|
615
|
+
allow_env: v.optional(v.boolean(), false),
|
|
589
616
|
});
|
|
617
|
+
/**
|
|
618
|
+
* `allow_env`'s narrow env-supplement path (see `OTelConfigSchema`'s doc
|
|
619
|
+
* comment above): called ONLY when `cfg.observability.otel` already exists
|
|
620
|
+
* with a real `endpoint` — this function never activates anything on its
|
|
621
|
+
* own and is never called when the block is absent. `OTEL_EXPORTER_OTLP_
|
|
622
|
+
* HEADERS` follows the OTel spec's env-var shape (comma-separated
|
|
623
|
+
* `key=value` pairs, URL-decoded); config-declared headers win over the env
|
|
624
|
+
* on a key collision. `OTEL_EXPORTER_OTLP_ENDPOINT`, when set, overrides
|
|
625
|
+
* `endpoint` itself — the one field this can change, since a CI runner
|
|
626
|
+
* commonly injects a different collector per job/environment.
|
|
627
|
+
*/
|
|
628
|
+
export function applyOtelEnvSupplement(otel, env = process.env) {
|
|
629
|
+
if (!otel.allow_env)
|
|
630
|
+
return otel;
|
|
631
|
+
const envEndpoint = env["OTEL_EXPORTER_OTLP_ENDPOINT"];
|
|
632
|
+
const envHeadersRaw = env["OTEL_EXPORTER_OTLP_HEADERS"];
|
|
633
|
+
const envHeaders = {};
|
|
634
|
+
if (envHeadersRaw) {
|
|
635
|
+
for (const pair of envHeadersRaw.split(",")) {
|
|
636
|
+
const eq = pair.indexOf("=");
|
|
637
|
+
if (eq <= 0)
|
|
638
|
+
continue;
|
|
639
|
+
const key = pair.slice(0, eq).trim();
|
|
640
|
+
const value = pair.slice(eq + 1).trim();
|
|
641
|
+
if (key) {
|
|
642
|
+
try {
|
|
643
|
+
envHeaders[key] = decodeURIComponent(value);
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
envHeaders[key] = value;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (!envEndpoint && Object.keys(envHeaders).length === 0)
|
|
652
|
+
return otel;
|
|
653
|
+
return {
|
|
654
|
+
...otel,
|
|
655
|
+
endpoint: envEndpoint || otel.endpoint,
|
|
656
|
+
headers: { ...envHeaders, ...(otel.headers ?? {}) }, // config wins on conflict
|
|
657
|
+
};
|
|
658
|
+
}
|
|
590
659
|
/**
|
|
591
660
|
* MIGRATION NOTE (BT-issue #66, 3 PRs): `observability.db` used to be ONLY a
|
|
592
661
|
* bare string (a local sqlite path, defaulting to ".spf/data/spf.db"). This
|
|
@@ -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
|
|
@@ -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;
|
|
@@ -101,7 +104,7 @@ export class JiraProvider {
|
|
|
101
104
|
return {
|
|
102
105
|
id: raw.key,
|
|
103
106
|
title: raw.fields.summary,
|
|
104
|
-
body: raw.fields.description ?
|
|
107
|
+
body: raw.fields.description ? adfToMarkdown(raw.fields.description) : "",
|
|
105
108
|
labels: raw.fields.labels,
|
|
106
109
|
};
|
|
107
110
|
}
|
|
@@ -163,7 +166,7 @@ export class JiraProvider {
|
|
|
163
166
|
fields: {
|
|
164
167
|
project: { key: this.projectKey },
|
|
165
168
|
summary: input.title,
|
|
166
|
-
description:
|
|
169
|
+
description: markdownToAdf(input.body),
|
|
167
170
|
issuetype: { name: this.issueTypes[input.kind] },
|
|
168
171
|
labels: input.labels,
|
|
169
172
|
},
|
|
@@ -296,7 +299,7 @@ export class JiraProvider {
|
|
|
296
299
|
.map(([state, jiraStatus]) => ({ state, jiraStatus, exists: available.has(jiraStatus) }));
|
|
297
300
|
}
|
|
298
301
|
async comment(issue, body) {
|
|
299
|
-
await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body:
|
|
302
|
+
await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: markdownToAdf(body) }) });
|
|
300
303
|
}
|
|
301
304
|
/** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
|
|
302
305
|
async fetchComments(issueId) {
|
|
@@ -307,7 +310,7 @@ export class JiraProvider {
|
|
|
307
310
|
const comments = await this.fetchComments(issueId);
|
|
308
311
|
let found = null;
|
|
309
312
|
for (const c of comments) {
|
|
310
|
-
const match = MARKER_RE.exec(
|
|
313
|
+
const match = MARKER_RE.exec(adfToMarkdown(c.body));
|
|
311
314
|
if (!match)
|
|
312
315
|
continue;
|
|
313
316
|
try {
|
|
@@ -323,8 +326,8 @@ export class JiraProvider {
|
|
|
323
326
|
async listComments(issue) {
|
|
324
327
|
const comments = await this.fetchComments(issue.id);
|
|
325
328
|
return comments
|
|
326
|
-
.filter((c) => !MARKER_RE.test(
|
|
327
|
-
.map((c) => ({ id: c.id, author: c.author?.displayName ?? "unknown", created_at: c.created, body:
|
|
329
|
+
.filter((c) => !MARKER_RE.test(adfToMarkdown(c.body)))
|
|
330
|
+
.map((c) => ({ id: c.id, author: c.author?.displayName ?? "unknown", created_at: c.created, body: adfToMarkdown(c.body) }));
|
|
328
331
|
}
|
|
329
332
|
async readMarker(issue) {
|
|
330
333
|
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;
|