@enrichlayer/el-linear 1.45.1 → 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/claude-skills/linear-operations/SKILL.md +17 -0
- 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/package.json +1 -1
|
@@ -468,6 +468,23 @@ The label is plain config — set it to anything you want, or skip it entirely.
|
|
|
468
468
|
|
|
469
469
|
---
|
|
470
470
|
|
|
471
|
+
## Label advisor defaults and consent labels (`bot`)
|
|
472
|
+
|
|
473
|
+
A workspace can configure a **label advisor** (`labelAdvisor.command` in personal config, or `EL_LINEAR_LABEL_ADVISOR`): a command `issues create` consults with the proposed issue. Whatever labels it returns are added and reported — a `labels added by advisor: … (<reason>)` warning on stderr and a `labelAdvisor` field in the JSON output. In Enrich Layer's Tools setup the advisor is `el-bot linear-rubric --advise`, the bot-suitability rubric: an issue it marks BOT gets `bot` **by default**, and an EXCLUDE issue is unchanged.
|
|
474
|
+
|
|
475
|
+
- **Opt out for one create** with `--no-label-advisor` (for example, work you intend to do yourself, or an issue whose consent you are not in a position to give).
|
|
476
|
+
- **Read the create output.** `labelAdvisor.added` lists what the advisor added; `labelAdvisor.consent` reports a consent label (`{labels, applied, repo, problem?}`). `applied: false` means the issue exists without that label, and `problem` says why.
|
|
477
|
+
|
|
478
|
+
**Consent labels need a receipt.** Where `validation.consentReceiptGate` is on (Enrich Layer's shared config turns it on), a consent label (`validation.consentLabels`, default `bot`) is only valid with exactly one `<!-- el-intake-decision:v1 {...} -->` receipt in the description that names the issue — bot-layer intake silently skips a `bot` issue without one. So:
|
|
479
|
+
|
|
480
|
+
- **Default route: let the advisor apply it.** When the advisor returns `bot` with its receipt fields, el-linear creates the issue and then, in one update, applies `bot` with a receipt naming the new issue and you (the acting Linear user) as `actor`.
|
|
481
|
+
- **Never pass `--labels bot` on create.** It is refused: the receipt must name an issue that does not exist yet.
|
|
482
|
+
- **Adding `bot` to an existing issue** needs the receipt in the same update, or the update is refused and the error names what is missing. Enrich Layer: run `el-bot linear-consent <ID> --automatic-implementation --reason "<why>"`, which writes the label and a valid receipt together. Elsewhere: `el-linear issues update <ID> --labels bot --description-file <body-ending-with-the-receipt>`.
|
|
483
|
+
|
|
484
|
+
The gate has no override flag and ignores `--skip-validation`: it protects consent, not field hygiene.
|
|
485
|
+
|
|
486
|
+
---
|
|
487
|
+
|
|
471
488
|
## User @Mentions
|
|
472
489
|
|
|
473
490
|
Reference team members by name in comments. el-linear resolves both explicit `@name` tokens and bare capitalized references to proper Linear mentions.
|
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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ElLinearConfig } from "./config.js";
|
|
2
|
+
export interface LabelAdvisorInput {
|
|
3
|
+
team: string | null;
|
|
4
|
+
project: string | null;
|
|
5
|
+
title: string | null;
|
|
6
|
+
description: string | null;
|
|
7
|
+
labels: string[];
|
|
8
|
+
state: string | null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Receipt policy fields an advisor may return with a consent label
|
|
12
|
+
* (DEV-10455). el-linear never guesses these: `repo` is the repository the
|
|
13
|
+
* organization's intake policy maps this issue to, and `reason` is why that
|
|
14
|
+
* policy consents to unattended work. el-linear adds only what it knows
|
|
15
|
+
* itself (the new issue's identifier and the acting Linear user) and writes
|
|
16
|
+
* the receipt after the issue exists. See `consent-receipt.ts`.
|
|
17
|
+
*/
|
|
18
|
+
export interface LabelAdvisorReceipt {
|
|
19
|
+
repo: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}
|
|
22
|
+
export type LabelAdvisorResult = {
|
|
23
|
+
ok: true;
|
|
24
|
+
labels: string[];
|
|
25
|
+
reason: string | null;
|
|
26
|
+
receipt: LabelAdvisorReceipt | null;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The configured advisor argv, or `null` when the hook is off. Env wins over
|
|
33
|
+
* config so one invocation can point elsewhere or disable it without editing
|
|
34
|
+
* files.
|
|
35
|
+
*/
|
|
36
|
+
export declare function labelAdvisorCommand(config: Pick<ElLinearConfig, "labelAdvisor">, env?: NodeJS.ProcessEnv): string[] | null;
|
|
37
|
+
/**
|
|
38
|
+
* Parse what the advisor printed. Returns an error string for anything that is
|
|
39
|
+
* not exactly a well-formed answer — a malformed answer is a failure, and a
|
|
40
|
+
* failure adds no labels. Partial acceptance (keep the valid elements, drop the
|
|
41
|
+
* rest) is deliberately not offered: an advisor emitting garbage is broken, and
|
|
42
|
+
* trusting half of its output is how a wrong label slips in.
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseLabelAdvisorOutput(stdout: string): LabelAdvisorResult;
|
|
45
|
+
/**
|
|
46
|
+
* Run the configured advisor. Returns `null` when no advisor is configured,
|
|
47
|
+
* otherwise the parsed advice or a failure. Never throws.
|
|
48
|
+
*
|
|
49
|
+
* Synchronous for the same reason as the identity resolver: it sits on the
|
|
50
|
+
* critical path of a short-lived CLI, and a single failure site is easier to
|
|
51
|
+
* reason about than a promise race.
|
|
52
|
+
*/
|
|
53
|
+
export declare function runLabelAdvisor(input: LabelAdvisorInput, config: Pick<ElLinearConfig, "labelAdvisor">, env?: NodeJS.ProcessEnv): LabelAdvisorResult | null;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Optional **label advisor hook** (DEV-10372).
|
|
4
|
+
*
|
|
5
|
+
* An organization may have a rubric that knows which labels a new issue should
|
|
6
|
+
* carry — for example "this Tools issue is small and well specified, so it
|
|
7
|
+
* belongs in the unattended-automation lane". el-linear can consult it on
|
|
8
|
+
* `issues create`, but it must never learn *what* the rubric is or how to run
|
|
9
|
+
* it. So the hook is a **command**, modelled on the identity resolver
|
|
10
|
+
* (DEV-5628, `identity-resolver.ts`):
|
|
11
|
+
*
|
|
12
|
+
* "labelAdvisor": { "command": ["my-rubric", "--advise"] }
|
|
13
|
+
*
|
|
14
|
+
* Contract:
|
|
15
|
+
*
|
|
16
|
+
* - stdin: one JSON object describing the proposed issue —
|
|
17
|
+
* `{team, project, title, description, labels, state}`.
|
|
18
|
+
* - stdout: JSON. Either a bare array of label names (`["bot"]`) or an
|
|
19
|
+
* object `{"labels": ["bot"], "reason": "…"}`; one level of `{data: …}`
|
|
20
|
+
* envelope is unwrapped (the el-* CLI shape). `{"labels": []}` means
|
|
21
|
+
* "nothing to add". With a consent label (see `consent-receipt.ts`) the
|
|
22
|
+
* object may also carry `"receipt": {"repo": "…", "reason": "…"}`; then
|
|
23
|
+
* el-linear applies the consent label after create together with an
|
|
24
|
+
* `el-intake-decision:v1` receipt naming the new issue.
|
|
25
|
+
* - exit 0 on success.
|
|
26
|
+
*
|
|
27
|
+
* **Fail-closed on labels.** A label may carry meaning — in some workspaces a
|
|
28
|
+
* label is consent for unattended work — so a broken advisor must never add
|
|
29
|
+
* one. Non-zero exit, timeout, missing binary, unparseable or malformed output
|
|
30
|
+
* all return a failure; the caller warns and creates the issue with exactly
|
|
31
|
+
* the labels the author asked for. The hook never throws.
|
|
32
|
+
*
|
|
33
|
+
* The issue text is untrusted input (agents create issues from arbitrary
|
|
34
|
+
* prose), so it travels on stdin, never in argv, and the command runs with
|
|
35
|
+
* `shell: false`.
|
|
36
|
+
*/
|
|
37
|
+
/** Env override — a whitespace-separated command; `""` is an explicit OFF. */
|
|
38
|
+
const ADVISOR_ENV = "EL_LINEAR_LABEL_ADVISOR";
|
|
39
|
+
/** An advisor that hasn't answered in this long is not going to. */
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
41
|
+
/** Advice for more labels than this is a confused advisor, not a rubric. */
|
|
42
|
+
const MAX_LABELS = 10;
|
|
43
|
+
const MAX_LABEL_LENGTH = 80;
|
|
44
|
+
const MAX_REASON_LENGTH = 300;
|
|
45
|
+
const MAX_RECEIPT_REPO_LENGTH = 200;
|
|
46
|
+
const MAX_RECEIPT_REASON_LENGTH = 500;
|
|
47
|
+
/** True when `text` has no ASCII control character (U+0000–U+001F, U+007F). */
|
|
48
|
+
function isSafeText(text) {
|
|
49
|
+
for (let i = 0; i < text.length; i++) {
|
|
50
|
+
const code = text.charCodeAt(i);
|
|
51
|
+
if (code < 0x20 || code === 0x7f)
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return text.length > 0;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The configured advisor argv, or `null` when the hook is off. Env wins over
|
|
58
|
+
* config so one invocation can point elsewhere or disable it without editing
|
|
59
|
+
* files.
|
|
60
|
+
*/
|
|
61
|
+
export function labelAdvisorCommand(config, env = process.env) {
|
|
62
|
+
const fromEnv = env[ADVISOR_ENV];
|
|
63
|
+
if (fromEnv !== undefined) {
|
|
64
|
+
const argv = fromEnv.trim().split(/\s+/).filter(Boolean);
|
|
65
|
+
return argv.length > 0 ? argv : null;
|
|
66
|
+
}
|
|
67
|
+
const configured = config.labelAdvisor?.command;
|
|
68
|
+
if (!Array.isArray(configured) || configured.length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return configured;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the effective timeout. Node treats `timeout <= 0` as *no timeout*,
|
|
75
|
+
* which would turn a hung advisor into a hung CLI — so non-positive values
|
|
76
|
+
* fall back to the default, exactly like the identity resolver.
|
|
77
|
+
*/
|
|
78
|
+
function resolveTimeoutMs(config) {
|
|
79
|
+
const configured = config.labelAdvisor?.timeoutMs;
|
|
80
|
+
return typeof configured === "number" && configured > 0
|
|
81
|
+
? configured
|
|
82
|
+
: DEFAULT_TIMEOUT_MS;
|
|
83
|
+
}
|
|
84
|
+
/** The advisor classifies issue text; it never needs Linear's token. */
|
|
85
|
+
function advisorEnv(env) {
|
|
86
|
+
const { LINEAR_API_TOKEN: _dropped, ...rest } = env;
|
|
87
|
+
return rest;
|
|
88
|
+
}
|
|
89
|
+
function isRecord(value) {
|
|
90
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse what the advisor printed. Returns an error string for anything that is
|
|
94
|
+
* not exactly a well-formed answer — a malformed answer is a failure, and a
|
|
95
|
+
* failure adds no labels. Partial acceptance (keep the valid elements, drop the
|
|
96
|
+
* rest) is deliberately not offered: an advisor emitting garbage is broken, and
|
|
97
|
+
* trusting half of its output is how a wrong label slips in.
|
|
98
|
+
*/
|
|
99
|
+
export function parseLabelAdvisorOutput(stdout) {
|
|
100
|
+
const trimmed = stdout.trim();
|
|
101
|
+
if (!trimmed) {
|
|
102
|
+
return { ok: false, error: "advisor printed nothing (expected JSON)" };
|
|
103
|
+
}
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(trimmed);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { ok: false, error: "advisor output is not valid JSON" };
|
|
110
|
+
}
|
|
111
|
+
if (isRecord(parsed) && isRecord(parsed.data)) {
|
|
112
|
+
parsed = parsed.data;
|
|
113
|
+
}
|
|
114
|
+
let rawLabels;
|
|
115
|
+
let rawReason = null;
|
|
116
|
+
let rawReceipt = null;
|
|
117
|
+
if (Array.isArray(parsed)) {
|
|
118
|
+
rawLabels = parsed;
|
|
119
|
+
}
|
|
120
|
+
else if (isRecord(parsed)) {
|
|
121
|
+
rawLabels = parsed.labels;
|
|
122
|
+
rawReason = parsed.reason ?? null;
|
|
123
|
+
rawReceipt = parsed.receipt ?? null;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: "advisor output must be a label array or an object",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(rawLabels)) {
|
|
132
|
+
return { ok: false, error: 'advisor output has no "labels" array' };
|
|
133
|
+
}
|
|
134
|
+
if (rawLabels.length > MAX_LABELS) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: `advisor returned ${rawLabels.length} labels (limit ${MAX_LABELS})`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const labels = [];
|
|
141
|
+
for (const label of rawLabels) {
|
|
142
|
+
if (typeof label !== "string" ||
|
|
143
|
+
label.trim().length === 0 ||
|
|
144
|
+
label.length > MAX_LABEL_LENGTH ||
|
|
145
|
+
!isSafeText(label) ||
|
|
146
|
+
label.includes(",")) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
error: "advisor returned a label that is not a plain label name",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const name = label.trim();
|
|
153
|
+
if (!labels.some((seen) => seen.toLowerCase() === name.toLowerCase())) {
|
|
154
|
+
labels.push(name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (rawReason !== null && typeof rawReason !== "string") {
|
|
158
|
+
return { ok: false, error: "advisor reason must be a string" };
|
|
159
|
+
}
|
|
160
|
+
const reason = typeof rawReason === "string" && rawReason.trim()
|
|
161
|
+
? rawReason.replace(/\s+/g, " ").trim().slice(0, MAX_REASON_LENGTH)
|
|
162
|
+
: null;
|
|
163
|
+
const receipt = parseReceiptFields(rawReceipt);
|
|
164
|
+
if (typeof receipt === "string") {
|
|
165
|
+
return { ok: false, error: receipt };
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, labels, reason, receipt };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Validate the optional `receipt` object. A malformed one fails the whole
|
|
171
|
+
* answer (returned as an error string): a receipt is consent, so half of one
|
|
172
|
+
* is never used.
|
|
173
|
+
*/
|
|
174
|
+
function parseReceiptFields(raw) {
|
|
175
|
+
if (raw === null) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
if (!isRecord(raw)) {
|
|
179
|
+
return "advisor receipt must be an object";
|
|
180
|
+
}
|
|
181
|
+
const { repo, reason } = raw;
|
|
182
|
+
if (typeof repo !== "string" ||
|
|
183
|
+
repo.trim().length === 0 ||
|
|
184
|
+
repo.length > MAX_RECEIPT_REPO_LENGTH ||
|
|
185
|
+
!isSafeText(repo)) {
|
|
186
|
+
return "advisor receipt.repo must be a non-empty plain string";
|
|
187
|
+
}
|
|
188
|
+
if (typeof reason !== "string" ||
|
|
189
|
+
reason.trim().length === 0 ||
|
|
190
|
+
reason.length > MAX_RECEIPT_REASON_LENGTH ||
|
|
191
|
+
!isSafeText(reason)) {
|
|
192
|
+
return `advisor receipt.reason must be a non-empty plain string of at most ${MAX_RECEIPT_REASON_LENGTH} characters`;
|
|
193
|
+
}
|
|
194
|
+
return { repo: repo.trim(), reason: reason.trim() };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Run the configured advisor. Returns `null` when no advisor is configured,
|
|
198
|
+
* otherwise the parsed advice or a failure. Never throws.
|
|
199
|
+
*
|
|
200
|
+
* Synchronous for the same reason as the identity resolver: it sits on the
|
|
201
|
+
* critical path of a short-lived CLI, and a single failure site is easier to
|
|
202
|
+
* reason about than a promise race.
|
|
203
|
+
*/
|
|
204
|
+
export function runLabelAdvisor(input, config, env = process.env) {
|
|
205
|
+
const argv = labelAdvisorCommand(config, env);
|
|
206
|
+
if (!argv) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const [command, ...args] = argv;
|
|
210
|
+
if (!command) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const result = spawnSync(command, args, {
|
|
215
|
+
encoding: "utf8",
|
|
216
|
+
input: JSON.stringify(input),
|
|
217
|
+
timeout: resolveTimeoutMs(config),
|
|
218
|
+
// A trapped SIGTERM would let a broken advisor wedge the CLI past
|
|
219
|
+
// its time budget; see the identical note in identity-resolver.ts.
|
|
220
|
+
killSignal: "SIGKILL",
|
|
221
|
+
shell: false,
|
|
222
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
223
|
+
env: advisorEnv(env),
|
|
224
|
+
});
|
|
225
|
+
// Check `error` before `status`: a timeout that leaves a grandchild
|
|
226
|
+
// holding the pipe reports ETIMEDOUT with status 0 (identity-resolver.ts).
|
|
227
|
+
if (result.error) {
|
|
228
|
+
return { ok: false, error: `${command}: ${result.error.message}` };
|
|
229
|
+
}
|
|
230
|
+
if (result.status !== 0) {
|
|
231
|
+
const stderr = (result.stderr ?? "").trim().split("\n")[0] ?? "";
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
error: `${command} exited ${result.status ?? `on ${result.signal}`}${stderr ? `: ${stderr}` : ""}`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return parseLabelAdvisorOutput(result.stdout ?? "");
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
error: err instanceof Error ? err.message : String(err),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.46.0",
|
|
4
4
|
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|