@enrichlayer/el-linear 1.44.2 → 1.46.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.
@@ -1,9 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { loadConfig } from "../config/config.js";
3
+ import { appendConsentReceipt, consentLabelsIn, evaluateConsentReceipt, formatConsentReceipt, formatCreateConsentRefusal, formatUpdateConsentRefusal, getConsentReceiptGateConfig, } from "../config/consent-receipt.js";
3
4
  import { enrichProjectResolverError, enrichValidationErrors, } from "../config/error-enrichment.js";
4
5
  import { evaluateGoalCompletion, formatGoalCompletionBlock, getGoalCompletionGateConfig, } from "../config/goal-completion-validation.js";
5
6
  import { evaluateIntakeDecision, formatIntakeDecisionBlock, getIntakeDecisionGateConfig, } from "../config/intake-decision-validation.js";
6
7
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
8
+ import { runLabelAdvisor, } from "../config/label-advisor.js";
7
9
  import { maybeEmitPinMismatchHint } from "../config/pin-hint.js";
8
10
  import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
9
11
  import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
@@ -811,6 +813,199 @@ async function enforceIntakeDecision(description, options) {
811
813
  });
812
814
  throw new Error(`Issue creation blocked: ${message}`);
813
815
  }
816
+ /**
817
+ * DEV-10372: consent-label receipt gate on create. A consent label (default
818
+ * `bot`) needs a receipt naming the issue, which cannot exist before the issue
819
+ * does, so an explicit consent label on create is refused with the two-step
820
+ * route. Dormant unless `validation.consentReceiptGate` is true; not bypassed
821
+ * by `--skip-validation`.
822
+ */
823
+ function enforceCreateConsentLabels(options) {
824
+ const gate = getConsentReceiptGateConfig();
825
+ if (!gate.enabled || !options.labels) {
826
+ return;
827
+ }
828
+ const requested = splitList(options.labels).map((label) => labelNameForId(label) ?? label);
829
+ const consent = consentLabelsIn(requested, gate.labels);
830
+ if (consent.length > 0) {
831
+ throw new Error(`Issue creation blocked: ${formatCreateConsentRefusal(consent)}`);
832
+ }
833
+ }
834
+ /**
835
+ * DEV-10372: consult the optional label advisor and return the labels to add.
836
+ * Fail-closed on labels: an advisor failure warns and adds nothing. Labels the
837
+ * author already passed are not re-added. When the consent-receipt gate is on,
838
+ * an advisor-proposed consent label is dropped with a warning — the advisor
839
+ * cannot supply the receipt, and a create cannot carry one.
840
+ */
841
+ function adviseLabels(title, options, context) {
842
+ if (options.labelAdvisor === false) {
843
+ return null;
844
+ }
845
+ const current = options.labels ? splitList(options.labels) : [];
846
+ const result = runLabelAdvisor({
847
+ team: context.teamInput || null,
848
+ project: typeof options.project === "string" ? options.project : null,
849
+ title: title ?? null,
850
+ description: context.description ?? null,
851
+ labels: current,
852
+ state: context.status ?? null,
853
+ }, loadConfig());
854
+ if (result === null) {
855
+ return null;
856
+ }
857
+ if (!result.ok) {
858
+ outputWarning(`label advisor failed; creating without advisor labels: ${result.error}`);
859
+ return null;
860
+ }
861
+ const present = new Set(current.map((label) => label.toLowerCase()));
862
+ let added = result.labels.filter((label) => !present.has(label.toLowerCase()));
863
+ const gate = getConsentReceiptGateConfig();
864
+ const consent = consentLabelsIn(added, gate.labels);
865
+ let deferred;
866
+ if (consent.length > 0 && (gate.enabled || result.receipt)) {
867
+ const held = new Set(consent.map((label) => label.toLowerCase()));
868
+ added = added.filter((label) => !held.has(label.toLowerCase()));
869
+ if (result.receipt) {
870
+ // DEV-10455: the advisor supplied the receipt policy fields, so the
871
+ // consent label is applied right after create, in one update with a
872
+ // receipt naming the new issue (see applyAdvisorConsent).
873
+ deferred = { labels: consent, receipt: result.receipt };
874
+ }
875
+ else {
876
+ outputWarning(`label advisor proposed ${consent.join(", ")}, not applied: a consent label needs an el-intake-decision:v1 receipt naming the issue. Apply it after creation with \`el-linear issues update <ID> --labels ${consent.join(",")}\` and the receipt in the description.`);
877
+ }
878
+ }
879
+ if (added.length === 0 && !deferred) {
880
+ return null;
881
+ }
882
+ if (added.length > 0) {
883
+ outputWarning(`labels added by advisor: ${added.join(", ")}${result.reason ? ` (${result.reason})` : ""}`);
884
+ }
885
+ return {
886
+ added,
887
+ reason: result.reason,
888
+ ...(deferred ? { consent: deferred } : {}),
889
+ };
890
+ }
891
+ /**
892
+ * DEV-10455: apply advisor-proposed consent labels after create, in ONE
893
+ * update that also appends an `el-intake-decision:v1` receipt naming the new
894
+ * issue. The advisor supplied the policy fields (`repo`, `reason`); el-linear
895
+ * adds only the identifier and the acting Linear user, read from the API.
896
+ *
897
+ * Fail-closed: any problem (an app viewer, a description that already holds a
898
+ * receipt marker, a failed update, a stored receipt that does not read back
899
+ * as valid) warns and leaves the issue without the consent label — or, when
900
+ * the write landed but the read-back fails, says so loudly. Never throws: the
901
+ * issue already exists.
902
+ *
903
+ * Raw GraphQL for the read: it batches `viewer` (including the `app` flag the
904
+ * SDK's typed viewer wrapper does not expose) with the issue in one round
905
+ * trip.
906
+ */
907
+ async function applyAdvisorConsent(created, consent, services) {
908
+ const labels = consent.labels.join(", ");
909
+ const skip = (problem) => {
910
+ outputWarning(`label advisor proposed ${labels}, not applied: ${problem}. Apply it with \`el-linear issues update ${created.identifier} --labels ${consent.labels.join(",")}\` and a receipt in the description.`);
911
+ return { applied: false, problem };
912
+ };
913
+ try {
914
+ const current = await services.graphQLService.rawRequest("query($id: String!) { viewer { name email app } issue(id: $id) { identifier description } }", { id: created.id });
915
+ if (!current.issue) {
916
+ return skip(`issue ${created.identifier} could not be read back`);
917
+ }
918
+ const viewer = current.viewer;
919
+ if (!viewer || viewer.app === true) {
920
+ return skip("consent must be attributable to a person, and the authenticated Linear viewer is not one");
921
+ }
922
+ const actor = (viewer.name?.trim() || viewer.email?.trim() || "").slice(0, 160);
923
+ if (!actor) {
924
+ return skip("the authenticated Linear viewer has no name or email");
925
+ }
926
+ const existing = current.issue.description ?? "";
927
+ const description = appendConsentReceipt(existing, formatConsentReceipt({
928
+ repo: consent.receipt.repo,
929
+ reason: consent.receipt.reason,
930
+ actor,
931
+ issue: current.issue.identifier,
932
+ }));
933
+ const planned = evaluateConsentReceipt(description, current.issue.identifier);
934
+ if (!planned.ok) {
935
+ return skip(planned.problem);
936
+ }
937
+ const updated = await services.issuesService.updateIssue({ id: created.id, description, labelIds: consent.labels }, "adding");
938
+ const stored = evaluateConsentReceipt(updated.description ?? "", current.issue.identifier);
939
+ if (!stored.ok) {
940
+ outputWarning(`label advisor applied ${labels} to ${created.identifier}, but the stored receipt does not read back as valid (${stored.problem}); fix the receipt or remove the label.`);
941
+ return { applied: true, problem: stored.problem, issue: updated };
942
+ }
943
+ outputWarning(`labels added by advisor: ${labels} with an el-intake-decision:v1 receipt for ${current.issue.identifier} (repo ${consent.receipt.repo}, actor ${actor})`);
944
+ return { applied: true, issue: updated };
945
+ }
946
+ catch (error) {
947
+ return skip(error instanceof Error ? error.message : String(error));
948
+ }
949
+ }
950
+ /**
951
+ * DEV-10372: consent-label receipt gate on update. When the update newly
952
+ * applies a consent label, the resulting description must carry exactly one
953
+ * valid automatic-implementation receipt for this issue; otherwise refuse and
954
+ * name what is missing. Labels already on the issue are not re-checked.
955
+ */
956
+ async function enforceUpdateConsentLabels(issueId, options, graphQLService, linearService) {
957
+ const gate = getConsentReceiptGateConfig();
958
+ if (!gate.enabled || !options.labels) {
959
+ return;
960
+ }
961
+ const requested = splitList(options.labels).map((label) => labelNameForId(label) ?? label);
962
+ const consent = consentLabelsIn(requested, gate.labels);
963
+ if (consent.length === 0) {
964
+ return;
965
+ }
966
+ const resolved = await linearService.resolveIssueId(issueId);
967
+ const current = await graphQLService.rawRequest("query($id: String!) { issue(id: $id) { identifier description labels { nodes { name } } } }", { id: resolved });
968
+ if (!current.issue) {
969
+ throw new Error(`Issue "${issueId}" not found`);
970
+ }
971
+ const existing = new Set(current.issue.labels.nodes.map((label) => label.name.toLowerCase()));
972
+ const newlyApplied = consent.filter((label) => !existing.has(label.toLowerCase()));
973
+ if (newlyApplied.length === 0) {
974
+ return;
975
+ }
976
+ const description = typeof options.description === "string"
977
+ ? options.description
978
+ : (current.issue.description ?? "");
979
+ const evaluation = evaluateConsentReceipt(description, current.issue.identifier);
980
+ if (!evaluation.ok) {
981
+ throw new Error(`Issue update blocked: ${formatUpdateConsentRefusal(newlyApplied, current.issue.identifier, evaluation.problem)}`);
982
+ }
983
+ }
984
+ /**
985
+ * The configured team key for a resolved team UUID, so the label advisor sees
986
+ * the canonical key (`DEV`) rather than whatever alias the author typed.
987
+ */
988
+ function teamKeyForId(teamId) {
989
+ if (!teamId)
990
+ return undefined;
991
+ for (const [key, id] of Object.entries(loadConfig().teams ?? {})) {
992
+ if (id === teamId)
993
+ return key;
994
+ }
995
+ return undefined;
996
+ }
997
+ /** Reverse-map a configured label UUID to its name, so a UUID cannot bypass the gate. */
998
+ function labelNameForId(value) {
999
+ const labels = loadConfig().labels;
1000
+ const maps = [labels?.workspace ?? {}, ...Object.values(labels?.teams ?? {})];
1001
+ for (const map of maps) {
1002
+ for (const [name, id] of Object.entries(map)) {
1003
+ if (id === value)
1004
+ return name;
1005
+ }
1006
+ }
1007
+ return undefined;
1008
+ }
814
1009
  async function handleCreateIssue(title, options, command) {
815
1010
  const rootOpts = getRootOpts(command);
816
1011
  // DEV-7277: non-blocking nudge when this write is landing via the
@@ -833,7 +1028,25 @@ async function handleCreateIssue(title, options, command) {
833
1028
  // intentionally contains backslash sequences.
834
1029
  const rawDescription = resolveDescription(options);
835
1030
  await enforceIntakeDecision(rawDescription ?? "", options);
1031
+ enforceCreateConsentLabels(options);
836
1032
  const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts, rawDescription);
1033
+ // DEV-10372: optional label advisor. Runs after validation/normalization so
1034
+ // it sees the canonical labels, and before every gate that reads labels.
1035
+ const labelAdvice = adviseLabels(title, options, {
1036
+ teamInput: teamKeyForId(teamId) ?? teamInput,
1037
+ description: rawDescription,
1038
+ status,
1039
+ });
1040
+ if (labelAdvice) {
1041
+ for (const id of resolveLabels(labelAdvice.added)) {
1042
+ if (!labelIds.includes(id))
1043
+ labelIds.push(id);
1044
+ }
1045
+ options.labels = [
1046
+ ...(options.labels ? splitList(options.labels) : []),
1047
+ ...labelAdvice.added,
1048
+ ].join(",");
1049
+ }
837
1050
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
838
1051
  const descriptionWithAttachments = buildDescriptionWithAttachments(rawDescription ?? "", uploadResults);
839
1052
  // Append messageFooter (config or --footer flag) so auto-link picks up any
@@ -912,6 +1125,33 @@ async function handleCreateIssue(title, options, command) {
912
1125
  ? { templateId: options.fromTemplate }
913
1126
  : {}),
914
1127
  }), { team: options.team ?? loadConfig().defaultTeam, title }, { graphQLService, linearService });
1128
+ // DEV-10455: advisor-proposed consent label + receipt, applied now that
1129
+ // the issue (and its identifier) exists.
1130
+ let issue = result;
1131
+ let labelAdvisorOutput;
1132
+ if (labelAdvice) {
1133
+ labelAdvisorOutput = {
1134
+ added: [...labelAdvice.added],
1135
+ reason: labelAdvice.reason,
1136
+ };
1137
+ if (labelAdvice.consent) {
1138
+ const outcome = await applyAdvisorConsent(result, labelAdvice.consent, {
1139
+ graphQLService,
1140
+ issuesService,
1141
+ });
1142
+ if (outcome.issue)
1143
+ issue = { ...result, ...outcome.issue };
1144
+ if (outcome.applied) {
1145
+ labelAdvisorOutput.added.push(...labelAdvice.consent.labels);
1146
+ }
1147
+ labelAdvisorOutput.consent = {
1148
+ labels: labelAdvice.consent.labels,
1149
+ applied: outcome.applied,
1150
+ repo: labelAdvice.consent.receipt.repo,
1151
+ ...(outcome.problem ? { problem: outcome.problem } : {}),
1152
+ };
1153
+ }
1154
+ }
915
1155
  const relations = await createRelations(result.id, options, graphQLService, linearService);
916
1156
  // Pass the ORIGINAL description (pre-wrap) so the extractor's prose-keyword inference
917
1157
  // ("blocked by", "duplicates", etc.) sees `keyword DEV-100` instead of `keyword [DEV-100](url)`.
@@ -966,7 +1206,8 @@ async function handleCreateIssue(title, options, command) {
966
1206
  }
967
1207
  }
968
1208
  const output = {
969
- ...result,
1209
+ ...issue,
1210
+ ...(labelAdvisorOutput ? { labelAdvisor: labelAdvisorOutput } : {}),
970
1211
  ...(branch ? { branch } : {}),
971
1212
  ...(claim ? { claim } : {}),
972
1213
  ...(relations.length > 0 ? { relations } : {}),
@@ -1153,6 +1394,7 @@ async function handleUpdateIssue(issueId, options, command) {
1153
1394
  strict: options.strict,
1154
1395
  });
1155
1396
  }
1397
+ await enforceUpdateConsentLabels(issueId, options, graphQLService, linearService);
1156
1398
  // Save the original (pre-wrap) description so we can pass it to maybeAutoLink later.
1157
1399
  // The wrapped form breaks prose-keyword inference because the inserted `[` defeats the
1158
1400
  // trailing-whitespace anchor in patterns like /\bblocked by\s*$/.
@@ -1483,6 +1725,7 @@ export function setupIssuesCommands(program) {
1483
1725
  .option("--allow-vague-goal", 'skip the goal-completion gate and create even without a falsifiable "Done when" / acceptance-criteria section')
1484
1726
  .option("--allow-missing-intake-decision", "create without a complete intake decision when an accountable human approved the exception (recorded as a gate override)")
1485
1727
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1728
+ .option("--no-label-advisor", "skip the configured label advisor (config.labelAdvisor / EL_LINEAR_LABEL_ADVISOR) for this issue")
1486
1729
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
1487
1730
  .option("--no-footer", "skip the configured messageFooter for this issue")
1488
1731
  .option("-q, --quiet", "print one confirmation line (IDENTIFIER STATE URL) instead of the full JSON")
@@ -61,6 +61,23 @@ export interface ElLinearConfig {
61
61
  /** Milliseconds before the resolver is treated as a miss (default 8000). */
62
62
  resolverTimeoutMs?: number;
63
63
  };
64
+ /**
65
+ * Optional label-advisor hook (DEV-10372). `command` is an argv array that
66
+ * `issues create` runs with the proposed issue as JSON on stdin
67
+ * (`{team, project, title, description, labels, state}`); any labels it
68
+ * prints are added to the issue and reported. `--no-label-advisor` skips it
69
+ * for one create. Fail-closed on labels: any advisor failure warns and
70
+ * creates the issue without extra labels. See `label-advisor.ts`.
71
+ *
72
+ * Like `identity.resolver`, this names a binary el-linear spawns, so it is
73
+ * honored only from personal config or `EL_LINEAR_LABEL_ADVISOR` — never
74
+ * from the shared team layer.
75
+ */
76
+ labelAdvisor?: {
77
+ command?: string[];
78
+ /** Milliseconds before the advisor is treated as failed (default 5000). */
79
+ timeoutMs?: number;
80
+ };
64
81
  /**
65
82
  * Term-enforcement rules. Each rule has a canonical form and a list of
66
83
  * rejected forms; rejected forms in issue titles/descriptions are flagged
@@ -152,6 +169,19 @@ export interface ElLinearConfig {
152
169
  intakeDecisionGate?: false | "warn" | "block";
153
170
  /** Headers accepted for the intake section. Defaults to `["Intake decision"]`. */
154
171
  intakeSectionHeaders?: string[];
172
+ /**
173
+ * OPT-IN consent-label receipt gate (DEV-10372). When `true`, applying a
174
+ * consent label (see `consentLabels`) is refused unless the issue
175
+ * description carries exactly one valid `el-intake-decision:v1`
176
+ * automatic-implementation receipt for that issue. `issues create`
177
+ * always refuses an explicit consent label (the receipt must name an
178
+ * issue that does not exist yet) and drops an advisor-proposed one with
179
+ * a warning. Independent of `enabled` and `--skip-validation`. See
180
+ * `consent-receipt.ts`.
181
+ */
182
+ consentReceiptGate?: boolean;
183
+ /** Labels treated as consent for the receipt gate. Defaults to `["bot"]`. */
184
+ consentLabels?: string[];
155
185
  };
156
186
  /**
157
187
  * Optional override for the Linear workspace URL key (the part after
@@ -154,6 +154,9 @@ export function loadConfig() {
154
154
  // into their personal config at setup time; that keeps the decision with the
155
155
  // machine's owner instead of with whoever can land a commit upstream.
156
156
  delete teamRaw.identity;
157
+ // Same reasoning for the label advisor (DEV-10372): it is a binary el-linear
158
+ // spawns on every `issues create`, so only the operator's own files choose it.
159
+ delete teamRaw.labelAdvisor;
157
160
  // Merge order: defaults → team config → personal config.
158
161
  // Arrays (terms, defaultLabels, etc.) are concatenated so personal entries
159
162
  // extend team entries rather than replace them.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Consent-label receipt gate (DEV-10372).
3
+ *
4
+ * Some workspaces treat a label as **consent** for unattended work: an
5
+ * automation picks up any issue carrying it. Consent there is not the label
6
+ * alone — the automation also requires exactly one machine-readable intake
7
+ * receipt in the issue description:
8
+ *
9
+ * <!-- el-intake-decision:v1 {"policyVersion":"intake-policy/v1", …} -->
10
+ *
11
+ * An issue that carries the label without a valid receipt is silently skipped
12
+ * by that automation, which is the failure this gate removes: el-linear refuses
13
+ * to apply a consent label unless the resulting description carries a receipt
14
+ * for that issue, and names what is missing.
15
+ *
16
+ * Why el-linear refuses rather than writes the receipt: the receipt names the
17
+ * target repository, and whether a (team, project) maps to an admissible
18
+ * repository — and whether that repository is review-only — is policy owned
19
+ * by the automation, not by a generic Linear CLI. Writing a receipt with a
20
+ * guessed repository would manufacture consent the policy never granted. The
21
+ * receipt must also name the issue identifier, which does not exist before
22
+ * `issues create`, so a create can never carry a valid receipt: the consent
23
+ * label is applied with `issues update` once the receipt is written.
24
+ *
25
+ * The receipt shape mirrors the canonical `intake-policy/v1` wire format
26
+ * (`validateIntakeDecisionReceipt` in the automation's shared contracts). This
27
+ * is a strict structural check; the automation's own evaluator remains the
28
+ * admission authority and additionally checks the repository mapping.
29
+ *
30
+ * OPT-IN: dormant unless `validation.consentReceiptGate` is `true`. It is
31
+ * deliberately independent of `validation.enabled` and `--skip-validation`:
32
+ * it guards consent integrity, not field hygiene.
33
+ */
34
+ export declare const DEFAULT_CONSENT_LABELS: string[];
35
+ export declare const INTAKE_POLICY_VERSION = "intake-policy/v1";
36
+ export interface ConsentReceiptGateConfig {
37
+ enabled: boolean;
38
+ labels: string[];
39
+ }
40
+ export declare function getConsentReceiptGateConfig(): ConsentReceiptGateConfig;
41
+ /** Consent labels present in `names`, matched case-insensitively. */
42
+ export declare function consentLabelsIn(names: readonly string[], consentLabels: readonly string[]): string[];
43
+ export type ConsentReceiptEvaluation = {
44
+ ok: true;
45
+ } | {
46
+ ok: false;
47
+ problem: string;
48
+ };
49
+ /**
50
+ * Does `description` carry exactly one valid automatic-implementation receipt
51
+ * for `identifier`? An `issue-triage` receipt must name this issue; a receipt
52
+ * copied from another issue is not consent for this one.
53
+ */
54
+ export declare function evaluateConsentReceipt(description: string, identifier: string): ConsentReceiptEvaluation;
55
+ /** Refusal for `issues create` carrying a consent label. */
56
+ export declare function formatCreateConsentRefusal(labels: readonly string[]): string;
57
+ /** Refusal for `issues update` applying a consent label without a receipt. */
58
+ export declare function formatUpdateConsentRefusal(labels: readonly string[], identifier: string, problem: string): string;
59
+ export interface ConsentReceiptFields {
60
+ /** Repository the organization's intake policy maps the issue to. */
61
+ repo: string;
62
+ /** Why that policy consents to unattended work. */
63
+ reason: string;
64
+ /** The acting Linear user, as named by the API. */
65
+ actor: string;
66
+ /** The issue identifier the receipt is for. */
67
+ issue: string;
68
+ }
69
+ /**
70
+ * The canonical `issue-triage` receipt marker for `fields` (DEV-10455). The
71
+ * caller supplies every policy field; this only serializes them in the
72
+ * `intake-policy/v1` shape that `evaluateConsentReceipt` accepts.
73
+ */
74
+ export declare function formatConsentReceipt(fields: ConsentReceiptFields): string;
75
+ /** `description` with `receipt` appended as its final paragraph. */
76
+ export declare function appendConsentReceipt(description: string, receipt: string): string;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Consent-label receipt gate (DEV-10372).
3
+ *
4
+ * Some workspaces treat a label as **consent** for unattended work: an
5
+ * automation picks up any issue carrying it. Consent there is not the label
6
+ * alone — the automation also requires exactly one machine-readable intake
7
+ * receipt in the issue description:
8
+ *
9
+ * <!-- el-intake-decision:v1 {"policyVersion":"intake-policy/v1", …} -->
10
+ *
11
+ * An issue that carries the label without a valid receipt is silently skipped
12
+ * by that automation, which is the failure this gate removes: el-linear refuses
13
+ * to apply a consent label unless the resulting description carries a receipt
14
+ * for that issue, and names what is missing.
15
+ *
16
+ * Why el-linear refuses rather than writes the receipt: the receipt names the
17
+ * target repository, and whether a (team, project) maps to an admissible
18
+ * repository — and whether that repository is review-only — is policy owned
19
+ * by the automation, not by a generic Linear CLI. Writing a receipt with a
20
+ * guessed repository would manufacture consent the policy never granted. The
21
+ * receipt must also name the issue identifier, which does not exist before
22
+ * `issues create`, so a create can never carry a valid receipt: the consent
23
+ * label is applied with `issues update` once the receipt is written.
24
+ *
25
+ * The receipt shape mirrors the canonical `intake-policy/v1` wire format
26
+ * (`validateIntakeDecisionReceipt` in the automation's shared contracts). This
27
+ * is a strict structural check; the automation's own evaluator remains the
28
+ * admission authority and additionally checks the repository mapping.
29
+ *
30
+ * OPT-IN: dormant unless `validation.consentReceiptGate` is `true`. It is
31
+ * deliberately independent of `validation.enabled` and `--skip-validation`:
32
+ * it guards consent integrity, not field hygiene.
33
+ */
34
+ import { loadConfig } from "./config.js";
35
+ export const DEFAULT_CONSENT_LABELS = ["bot"];
36
+ export const INTAKE_POLICY_VERSION = "intake-policy/v1";
37
+ const RECEIPT_RE = /<!--\s*el-intake-decision:v1\s+([^\r\n]+?)\s*-->/g;
38
+ const LINEAR_ISSUE_RE = /^[A-Z][A-Z0-9]*-\d+$/;
39
+ const SENTRY_SOURCE_ISSUE_RE = /^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+$/;
40
+ /** True when `text` has no ASCII control character (U+0000–U+001F, U+007F). */
41
+ function isSafeText(text) {
42
+ for (let i = 0; i < text.length; i++) {
43
+ const code = text.charCodeAt(i);
44
+ if (code < 0x20 || code === 0x7f)
45
+ return false;
46
+ }
47
+ return text.length > 0;
48
+ }
49
+ /**
50
+ * Linear stores descriptions as normalized markdown and backslash-escapes
51
+ * characters such as `[` and `]`, so a receipt read back from an issue can
52
+ * contain `\[`. No JSON escape starts with those characters, so dropping a
53
+ * backslash before a non-JSON-escape character recovers the written receipt
54
+ * and leaves `\"` and `\\` intact (mirrors the canonical parser).
55
+ */
56
+ const JSON_ESCAPE_CHARACTERS = new Set([
57
+ '"',
58
+ "\\",
59
+ "/",
60
+ "b",
61
+ "f",
62
+ "n",
63
+ "r",
64
+ "t",
65
+ "u",
66
+ ]);
67
+ export function getConsentReceiptGateConfig() {
68
+ const validation = loadConfig().validation;
69
+ const labels = validation?.consentLabels && validation.consentLabels.length > 0
70
+ ? validation.consentLabels
71
+ : DEFAULT_CONSENT_LABELS;
72
+ return { enabled: validation?.consentReceiptGate === true, labels };
73
+ }
74
+ /** Consent labels present in `names`, matched case-insensitively. */
75
+ export function consentLabelsIn(names, consentLabels) {
76
+ const wanted = new Set(consentLabels.map((label) => label.toLowerCase()));
77
+ return names.filter((name) => wanted.has(name.trim().toLowerCase()));
78
+ }
79
+ function isRecord(value) {
80
+ return value !== null && typeof value === "object" && !Array.isArray(value);
81
+ }
82
+ function parseReceiptJson(raw) {
83
+ return JSON.parse(raw.replace(/\\(.)/g, (sequence, character) => JSON_ESCAPE_CHARACTERS.has(character) ? sequence : character));
84
+ }
85
+ /** First structural problem with a parsed receipt, or `null` when valid. */
86
+ function receiptError(value) {
87
+ if (!isRecord(value))
88
+ return "receipt is not a JSON object";
89
+ if (value.policyVersion !== INTAKE_POLICY_VERSION) {
90
+ return "unsupported policy version";
91
+ }
92
+ if (value.decision !== "automatic-implementation") {
93
+ return `receipt decision is ${JSON.stringify(value.decision)}, not "automatic-implementation"`;
94
+ }
95
+ const capabilities = value.capabilities;
96
+ if (!Array.isArray(capabilities) || capabilities.length === 0) {
97
+ return "capabilities must be a non-empty array";
98
+ }
99
+ const allowed = new Set(["manual-review", "automatic-implementation"]);
100
+ if (capabilities.some((capability) => typeof capability !== "string" || !allowed.has(capability)) ||
101
+ new Set(capabilities).size !== capabilities.length) {
102
+ return "capabilities must be unique known values";
103
+ }
104
+ if (!capabilities.includes("manual-review") ||
105
+ !capabilities.includes("automatic-implementation")) {
106
+ return "capabilities must include manual-review and automatic-implementation";
107
+ }
108
+ if (typeof value.reason !== "string" ||
109
+ value.reason.trim().length === 0 ||
110
+ value.reason.length > 500) {
111
+ return "reason must be a non-empty string of at most 500 characters";
112
+ }
113
+ const provenance = value.provenance;
114
+ if (!isRecord(provenance))
115
+ return "provenance must be an object";
116
+ if (provenance.source !== "issue-triage" &&
117
+ provenance.source !== "sentry-registry") {
118
+ return "provenance.source is not recognized";
119
+ }
120
+ if (typeof provenance.actor !== "string" ||
121
+ provenance.actor.trim().length === 0 ||
122
+ provenance.actor.length > 160) {
123
+ return "provenance.actor must be a non-empty string of at most 160 characters";
124
+ }
125
+ if (typeof provenance.issue !== "string" ||
126
+ provenance.issue.length > 200 ||
127
+ !isSafeText(provenance.issue) ||
128
+ (provenance.source === "issue-triage" &&
129
+ !LINEAR_ISSUE_RE.test(provenance.issue)) ||
130
+ (provenance.source === "sentry-registry" &&
131
+ !SENTRY_SOURCE_ISSUE_RE.test(provenance.issue))) {
132
+ return "provenance.issue has an invalid shape";
133
+ }
134
+ if (typeof provenance.repo !== "string" ||
135
+ provenance.repo.trim().length === 0 ||
136
+ provenance.repo.length > 200 ||
137
+ !isSafeText(provenance.repo)) {
138
+ return "provenance.repo must be a non-empty safe string of at most 200 characters";
139
+ }
140
+ return null;
141
+ }
142
+ /**
143
+ * Does `description` carry exactly one valid automatic-implementation receipt
144
+ * for `identifier`? An `issue-triage` receipt must name this issue; a receipt
145
+ * copied from another issue is not consent for this one.
146
+ */
147
+ export function evaluateConsentReceipt(description, identifier) {
148
+ const matches = [...description.matchAll(RECEIPT_RE)];
149
+ if (matches.length === 0) {
150
+ return {
151
+ ok: false,
152
+ problem: "the description has no el-intake-decision:v1 receipt",
153
+ };
154
+ }
155
+ if (matches.length > 1) {
156
+ return {
157
+ ok: false,
158
+ problem: "the description has more than one el-intake-decision:v1 receipt",
159
+ };
160
+ }
161
+ let parsed;
162
+ try {
163
+ parsed = parseReceiptJson(matches[0]?.[1] ?? "");
164
+ }
165
+ catch {
166
+ return { ok: false, problem: "the receipt is not valid JSON" };
167
+ }
168
+ const error = receiptError(parsed);
169
+ if (error) {
170
+ return { ok: false, problem: `the receipt is invalid: ${error}` };
171
+ }
172
+ const provenance = parsed
173
+ .provenance;
174
+ if (provenance.source === "issue-triage" &&
175
+ provenance.issue.toUpperCase() !== identifier.toUpperCase()) {
176
+ return {
177
+ ok: false,
178
+ problem: `the receipt names ${provenance.issue}, not ${identifier}`,
179
+ };
180
+ }
181
+ return { ok: true };
182
+ }
183
+ function receiptTemplate(identifier) {
184
+ return `<!-- el-intake-decision:v1 {"policyVersion":"${INTAKE_POLICY_VERSION}","decision":"automatic-implementation","capabilities":["manual-review","automatic-implementation"],"reason":"<why unattended work is consented>","provenance":{"source":"issue-triage","actor":"<your name>","issue":"${identifier}","repo":"<namespace/repo>"}} -->`;
185
+ }
186
+ /** Refusal for `issues create` carrying a consent label. */
187
+ export function formatCreateConsentRefusal(labels) {
188
+ const names = labels.map((label) => `"${label}"`).join(", ");
189
+ return `Label ${names} is consent for unattended work and requires an el-intake-decision:v1 receipt naming the issue, which cannot exist before the issue does. Create the issue without ${names}, then apply it with the receipt in one update:\n\n el-linear issues update <ID> --labels ${labels.join(",")} --description-file <body-with-receipt>\n\nwhere the body ends with:\n\n${receiptTemplate("<ID>")}`;
190
+ }
191
+ /** Refusal for `issues update` applying a consent label without a receipt. */
192
+ export function formatUpdateConsentRefusal(labels, identifier, problem) {
193
+ const names = labels.map((label) => `"${label}"`).join(", ");
194
+ return `Label ${names} is consent for unattended work and requires an el-intake-decision:v1 receipt for ${identifier}, but ${problem}. Include the receipt in the same update (--description / --description-file / --append-description), e.g.:\n\n${receiptTemplate(identifier)}`;
195
+ }
196
+ /**
197
+ * The canonical `issue-triage` receipt marker for `fields` (DEV-10455). The
198
+ * caller supplies every policy field; this only serializes them in the
199
+ * `intake-policy/v1` shape that `evaluateConsentReceipt` accepts.
200
+ */
201
+ export function formatConsentReceipt(fields) {
202
+ return `<!-- el-intake-decision:v1 ${JSON.stringify({
203
+ policyVersion: INTAKE_POLICY_VERSION,
204
+ decision: "automatic-implementation",
205
+ capabilities: ["manual-review", "automatic-implementation"],
206
+ reason: fields.reason,
207
+ provenance: {
208
+ source: "issue-triage",
209
+ actor: fields.actor,
210
+ issue: fields.issue,
211
+ repo: fields.repo,
212
+ },
213
+ })} -->`;
214
+ }
215
+ /** `description` with `receipt` appended as its final paragraph. */
216
+ export function appendConsentReceipt(description, receipt) {
217
+ const body = description.trimEnd();
218
+ return body ? `${body}\n\n${receipt}` : receipt;
219
+ }