@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.
- package/README.md +159 -0
- package/claude-skills/linear-operations/SKILL.md +17 -0
- package/dist/auth/oauth-storage.d.ts +5 -3
- package/dist/auth/oauth-token.d.ts +7 -0
- package/dist/auth/oauth-token.js +13 -0
- package/dist/auth/token-resolver.d.ts +2 -0
- package/dist/auth/token-resolver.js +25 -10
- package/dist/commands/init/index.js +17 -1
- package/dist/commands/init/oauth.d.ts +9 -1
- package/dist/commands/init/oauth.js +62 -3
- package/dist/commands/issues.js +244 -1
- package/dist/config/config.d.ts +30 -0
- package/dist/config/config.js +3 -0
- package/dist/config/consent-receipt.d.ts +76 -0
- package/dist/config/consent-receipt.js +219 -0
- package/dist/config/label-advisor.d.ts +53 -0
- package/dist/config/label-advisor.js +245 -0
- package/dist/queries/catalog-types.d.ts +129 -0
- package/dist/queries/catalog-types.js +1 -0
- package/dist/queries/catalog.d.ts +20 -0
- package/dist/queries/catalog.js +140 -0
- package/dist/utils/disk-cache.js +48 -14
- package/dist/utils/graphql-service.d.ts +42 -1
- package/dist/utils/graphql-service.js +267 -14
- package/dist/utils/linear-graphql-error.d.ts +100 -0
- package/dist/utils/linear-graphql-error.js +182 -0
- package/dist/utils/linear-service.d.ts +19 -2
- package/dist/utils/linear-service.js +194 -214
- package/dist/utils/output.d.ts +39 -0
- package/dist/utils/output.js +152 -1
- package/dist/utils/rate-limit-admission.d.ts +33 -0
- package/dist/utils/rate-limit-admission.js +240 -0
- package/package.json +79 -79
package/dist/commands/issues.js
CHANGED
|
@@ -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
|
-
...
|
|
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")
|
package/dist/config/config.d.ts
CHANGED
|
@@ -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
|
package/dist/config/config.js
CHANGED
|
@@ -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
|
+
}
|