@enrichlayer/el-linear 1.43.0 → 1.44.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 +45 -3
- package/claude-skills/linear-operations/SKILL.md +8 -0
- package/dist/commands/comments.js +43 -1
- package/dist/commands/issues.js +14 -0
- package/dist/commands/profile.d.ts +20 -0
- package/dist/commands/profile.js +150 -2
- package/dist/commands/projects.js +22 -3
- package/dist/config/intake-decision-validation.d.ts +22 -0
- package/dist/config/intake-decision-validation.js +174 -49
- package/dist/config/pin-hint.d.ts +36 -0
- package/dist/config/pin-hint.js +64 -0
- package/dist/config/repo-profile.d.ts +100 -0
- package/dist/config/repo-profile.js +274 -0
- package/dist/main.js +24 -2
- package/dist/queries/projects-types.d.ts +6 -0
- package/dist/queries/projects.d.ts +1 -1
- package/dist/queries/projects.js +6 -0
- package/dist/utils/formatters/summary.js +7 -0
- package/dist/utils/graphql-issues-service.js +1 -1
- package/dist/utils/graphql-service.d.ts +20 -2
- package/dist/utils/graphql-service.js +40 -4
- package/dist/utils/linear-service.d.ts +17 -0
- package/dist/utils/linear-service.js +28 -0
- package/dist/utils/protected-ranges.d.ts +2 -2
- package/dist/utils/protected-ranges.js +9 -2
- package/package.json +4 -4
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* OPT-IN by design. el-linear is open source, so the gate is dormant unless a
|
|
11
11
|
* workspace config sets `validation.intakeDecisionGate` to `"warn"` or
|
|
12
12
|
* `"block"`.
|
|
13
|
+
*
|
|
14
|
+
* DEV-7074: the parser accepts the markdown an author actually types — any
|
|
15
|
+
* list marker (`-`, `*`, `+`, `1.`) and bolded or italicized labels with the
|
|
16
|
+
* colon inside (`**Needed:**`) or outside (`**Needed**:`) the emphasis run.
|
|
17
|
+
* When a field genuinely fails, the diagnostic names WHICH failure it is:
|
|
18
|
+
* an unreadable line, an absent field, an empty value, a placeholder, or a
|
|
19
|
+
* present-but-unjudged value. Reporting a formatting mismatch as empty content
|
|
20
|
+
* sent authors to rewrite prose that was already correct.
|
|
13
21
|
*/
|
|
14
22
|
import { extractField, stripFencedCodeBlocks } from "../utils/extract-field.js";
|
|
15
23
|
import { loadConfig } from "./config.js";
|
|
@@ -34,25 +42,116 @@ const FIELD_DEFINITIONS = [
|
|
|
34
42
|
{ key: "placement", label: "Placement" },
|
|
35
43
|
{ key: "decision", label: "Decision" },
|
|
36
44
|
];
|
|
37
|
-
|
|
45
|
+
/**
|
|
46
|
+
* A field line, split at its FIRST colon: an optional list marker or ordinal,
|
|
47
|
+
* the label part, then the value part. Emphasis markers are deliberately not
|
|
48
|
+
* described here — the label and value are normalized separately, because
|
|
49
|
+
* markdown lets the colon fall either inside (`**Needed:**`) or outside
|
|
50
|
+
* (`**Needed**:`) the emphasis run (DEV-7074).
|
|
51
|
+
*/
|
|
52
|
+
const FIELD_LINE = /^[ \t]*(?:[-*+][ \t]+|\d+[.)][ \t]+)?([^:\n]*):(.*)$/;
|
|
53
|
+
/** Leading / trailing markdown emphasis run (`**`, `__`, `*`, `_`). */
|
|
54
|
+
const LEADING_EMPHASIS = /^[*_]{1,3}\s*/;
|
|
55
|
+
const TRAILING_EMPHASIS = /\s*[*_]{1,3}$/;
|
|
56
|
+
/** A value entirely wrapped in one emphasis run, e.g. `**PROCEED**`. */
|
|
57
|
+
const WRAPPED_IN_EMPHASIS = /^([*_]{1,3})([^*_].*?)\1$/;
|
|
58
|
+
/**
|
|
59
|
+
* A paired inline emphasis run at a token boundary. The boundaries are
|
|
60
|
+
* load-bearing: a literal underscore in `PRO_CEED` or `customer_support` is
|
|
61
|
+
* content, not markdown, and must not disappear during semantic validation.
|
|
62
|
+
*/
|
|
63
|
+
const INLINE_EMPHASIS = /(^|[^\p{L}\p{N}])([*_]{1,3})(?=\S)(.+?\S)\2(?=$|[^\p{L}\p{N}])/gu;
|
|
38
64
|
const PLACEHOLDER = /^(?:tbd|todo|unknown|n\/?a|none|unsure|not decided|-)\.?$/i;
|
|
39
65
|
const NON_SPECIFIC = /^(?:yes|no)$/i;
|
|
40
66
|
const AFFIRMATIVE_WITH_REASON = /^yes\s*(?:[-—:;,]|because)\s*(\S.{2,})$/i;
|
|
67
|
+
function labelOf(key) {
|
|
68
|
+
return FIELD_DEFINITIONS.find((field) => field.key === key)?.label ?? key;
|
|
69
|
+
}
|
|
41
70
|
function normalizedKey(label) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
const normalized = label.toLowerCase().trim().replace(/\s+/g, " ");
|
|
72
|
+
return (FIELD_DEFINITIONS.find((field) => field.label.toLowerCase() === normalized)
|
|
73
|
+
?.key ?? null);
|
|
74
|
+
}
|
|
75
|
+
/** Strip the emphasis markers wrapping a label, e.g. `**Needed**` -> `Needed`. */
|
|
76
|
+
function stripLabelEmphasis(raw) {
|
|
77
|
+
const trimmed = raw.trim();
|
|
78
|
+
const opened = LEADING_EMPHASIS.test(trimmed);
|
|
79
|
+
const closed = TRAILING_EMPHASIS.test(trimmed);
|
|
80
|
+
return {
|
|
81
|
+
label: trimmed.replace(LEADING_EMPHASIS, "").replace(TRAILING_EMPHASIS, ""),
|
|
82
|
+
opensEmphasis: opened && !closed,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function normalizeFieldValue(raw, labelOpensEmphasis) {
|
|
86
|
+
let value = raw.trim();
|
|
87
|
+
if (labelOpensEmphasis) {
|
|
88
|
+
// The emphasis run heading the value closes the label's bold, it is not
|
|
89
|
+
// part of the value: `* **Needed:** Yes — …`.
|
|
90
|
+
value = value.replace(LEADING_EMPHASIS, "").trim();
|
|
91
|
+
}
|
|
92
|
+
const wrapped = value.match(WRAPPED_IN_EMPHASIS);
|
|
93
|
+
return (wrapped?.[2] ?? value).trim();
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The value with inline emphasis removed, used for the SEMANTIC checks only.
|
|
97
|
+
* `- Decision: **PROCEED**` and `- Needed: **Yes** — …` are the same judgments
|
|
98
|
+
* as their unemphasized forms. Diagnostics report the parsed value, which
|
|
99
|
+
* normalizes a whole-value wrapper but preserves emphasis within longer prose.
|
|
100
|
+
*/
|
|
101
|
+
function withoutEmphasis(value) {
|
|
102
|
+
let normalized = value;
|
|
103
|
+
let previous;
|
|
104
|
+
do {
|
|
105
|
+
previous = normalized;
|
|
106
|
+
normalized = normalized.replace(INLINE_EMPHASIS, "$1$3");
|
|
107
|
+
} while (normalized !== previous);
|
|
108
|
+
return normalized.trim();
|
|
109
|
+
}
|
|
110
|
+
function parseFieldLine(line) {
|
|
111
|
+
const match = line.match(FIELD_LINE);
|
|
112
|
+
if (!match)
|
|
113
|
+
return null;
|
|
114
|
+
const { label, opensEmphasis } = stripLabelEmphasis(match[1] ?? "");
|
|
115
|
+
const key = normalizedKey(label);
|
|
116
|
+
if (key === null)
|
|
117
|
+
return null;
|
|
118
|
+
return { key, value: normalizeFieldValue(match[2] ?? "", opensEmphasis) };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Find a line that names `label` but did not parse as `Label: value` — a
|
|
122
|
+
* formatting failure, not absent content. Reported as `unparsed-field` so the
|
|
123
|
+
* author fixes the line instead of writing a field they already wrote.
|
|
124
|
+
*/
|
|
125
|
+
function findUnparsedLine(lines, label) {
|
|
126
|
+
// Built from FIELD_DEFINITIONS' fixed labels (no regex metacharacters).
|
|
127
|
+
const names = new RegExp(`^[ \\t]*(?:[-*+][ \\t]+|\\d+[.)][ \\t]+)?[*_]{0,3}${label}\\b`, "i");
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
if (names.test(line) && parseFieldLine(line) === null)
|
|
130
|
+
return line.trim();
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
function classifyValue(key, value) {
|
|
135
|
+
if (value.length === 0)
|
|
136
|
+
return "empty";
|
|
137
|
+
const probe = withoutEmphasis(value);
|
|
138
|
+
if (probe.length === 0)
|
|
139
|
+
return "empty";
|
|
140
|
+
if (PLACEHOLDER.test(probe))
|
|
141
|
+
return "placeholder";
|
|
142
|
+
if (key === "needed" || key === "worth") {
|
|
143
|
+
const match = probe.match(AFFIRMATIVE_WITH_REASON);
|
|
144
|
+
if (!match)
|
|
145
|
+
return "no-judgment";
|
|
146
|
+
return PLACEHOLDER.test(match[1]?.trim() ?? "")
|
|
147
|
+
? "placeholder-reason"
|
|
148
|
+
: null;
|
|
55
149
|
}
|
|
150
|
+
// Measure the parsed value rather than the semantic probe so removing inline
|
|
151
|
+
// emphasis markers cannot make substantive content look artificially short.
|
|
152
|
+
if (value.length < 3 || NON_SPECIFIC.test(probe))
|
|
153
|
+
return "non-specific";
|
|
154
|
+
return null;
|
|
56
155
|
}
|
|
57
156
|
export function evaluateIntakeDecision(description, headers = DEFAULT_INTAKE_SECTION_HEADERS) {
|
|
58
157
|
let section = null;
|
|
@@ -69,28 +168,36 @@ export function evaluateIntakeDecision(description, headers = DEFAULT_INTAKE_SEC
|
|
|
69
168
|
}
|
|
70
169
|
// A template/example fence is not an operative decision record. Remove all
|
|
71
170
|
// fenced examples before matching so copied guidance cannot satisfy intake.
|
|
72
|
-
const
|
|
171
|
+
const lines = stripFencedCodeBlocks(section).split("\n");
|
|
73
172
|
const values = new Map();
|
|
74
|
-
for (const
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
173
|
+
for (const [index, line] of lines.entries()) {
|
|
174
|
+
const parsed = parseFieldLine(line);
|
|
175
|
+
if (!parsed)
|
|
176
|
+
continue;
|
|
177
|
+
if (values.has(parsed.key)) {
|
|
77
178
|
return {
|
|
78
179
|
ok: false,
|
|
79
180
|
reason: "duplicate-field",
|
|
80
|
-
field:
|
|
181
|
+
field: labelOf(parsed.key),
|
|
81
182
|
};
|
|
82
183
|
}
|
|
83
|
-
values.set(key, { value:
|
|
184
|
+
values.set(parsed.key, { value: parsed.value, index });
|
|
84
185
|
}
|
|
85
186
|
let previousIndex = -1;
|
|
86
187
|
for (const definition of FIELD_DEFINITIONS) {
|
|
87
188
|
const entry = values.get(definition.key);
|
|
88
189
|
if (!entry) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
190
|
+
// Distinguish "the field is absent" from "the field is there but its
|
|
191
|
+
// line does not parse" — different authoring fixes (DEV-7074).
|
|
192
|
+
const unparsed = findUnparsedLine(lines, definition.label);
|
|
193
|
+
return unparsed === null
|
|
194
|
+
? { ok: false, reason: "missing-field", field: definition.label }
|
|
195
|
+
: {
|
|
196
|
+
ok: false,
|
|
197
|
+
reason: "unparsed-field",
|
|
198
|
+
field: definition.label,
|
|
199
|
+
line: unparsed,
|
|
200
|
+
};
|
|
94
201
|
}
|
|
95
202
|
if (entry.index < previousIndex) {
|
|
96
203
|
return {
|
|
@@ -101,40 +208,55 @@ export function evaluateIntakeDecision(description, headers = DEFAULT_INTAKE_SEC
|
|
|
101
208
|
}
|
|
102
209
|
previousIndex = entry.index;
|
|
103
210
|
}
|
|
104
|
-
for (const key of [
|
|
211
|
+
for (const key of [
|
|
212
|
+
"needed",
|
|
213
|
+
"worth",
|
|
214
|
+
"existing",
|
|
215
|
+
"owner",
|
|
216
|
+
"placement",
|
|
217
|
+
]) {
|
|
105
218
|
const value = values.get(key)?.value ?? "";
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
if (!match || PLACEHOLDER.test(reason)) {
|
|
219
|
+
const problem = classifyValue(key, value);
|
|
220
|
+
if (problem !== null) {
|
|
109
221
|
return {
|
|
110
222
|
ok: false,
|
|
111
223
|
reason: "invalid-field",
|
|
112
|
-
field: key
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
for (const key of ["existing", "owner", "placement"]) {
|
|
117
|
-
const value = values.get(key)?.value ?? "";
|
|
118
|
-
if (value.length < 3 ||
|
|
119
|
-
PLACEHOLDER.test(value) ||
|
|
120
|
-
NON_SPECIFIC.test(value)) {
|
|
121
|
-
return {
|
|
122
|
-
ok: false,
|
|
123
|
-
reason: "invalid-field",
|
|
124
|
-
field: key === "existing"
|
|
125
|
-
? "Existing work"
|
|
126
|
-
: key === "owner"
|
|
127
|
-
? "Owner"
|
|
128
|
-
: "Placement",
|
|
224
|
+
field: labelOf(key),
|
|
225
|
+
problem,
|
|
226
|
+
value,
|
|
129
227
|
};
|
|
130
228
|
}
|
|
131
229
|
}
|
|
132
230
|
const decision = values.get("decision")?.value ?? "";
|
|
133
|
-
if (!/^proceed$/i.test(decision)) {
|
|
231
|
+
if (!/^proceed$/i.test(withoutEmphasis(decision))) {
|
|
134
232
|
return { ok: false, reason: "not-proceeding", decision };
|
|
135
233
|
}
|
|
136
234
|
return { ok: true, header: matchedHeader };
|
|
137
235
|
}
|
|
236
|
+
/** Quote what the author actually wrote, bounded so a long line stays readable. */
|
|
237
|
+
function quote(text, limit = 80) {
|
|
238
|
+
const single = text.replace(/\s+/g, " ").trim();
|
|
239
|
+
return single.length > limit ? `${single.slice(0, limit - 1)}…` : single;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Name the ACTUAL failure. A message that blames content for a formatting
|
|
243
|
+
* mismatch sends the author to rewrite prose that was already correct
|
|
244
|
+
* (DEV-7074), so each problem gets its own remedy.
|
|
245
|
+
*/
|
|
246
|
+
function describeFieldProblem(field, problem, value) {
|
|
247
|
+
switch (problem) {
|
|
248
|
+
case "empty":
|
|
249
|
+
return `The intake field "${field}" parsed, but has no content after the label.`;
|
|
250
|
+
case "placeholder":
|
|
251
|
+
return `The intake field "${field}" is the placeholder "${quote(value)}"; record a real value.`;
|
|
252
|
+
case "non-specific":
|
|
253
|
+
return `The intake field "${field}" reads "${quote(value)}", which is too short or too generic to be a specific answer.`;
|
|
254
|
+
case "no-judgment":
|
|
255
|
+
return `The intake field "${field}" reads "${quote(value)}" — the content is there, but it is not an explicit "Yes — <reason>" judgment.`;
|
|
256
|
+
case "placeholder-reason":
|
|
257
|
+
return `The intake field "${field}" says Yes but gives the placeholder reason "${quote(value)}"; record the real reason.`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
138
260
|
export function formatIntakeDecisionBlock(opts) {
|
|
139
261
|
const { evaluation } = opts;
|
|
140
262
|
let reason;
|
|
@@ -143,7 +265,10 @@ export function formatIntakeDecisionBlock(opts) {
|
|
|
143
265
|
reason = `Issue description has no intake section (looked for: ${opts.headers.join(", ")}).`;
|
|
144
266
|
break;
|
|
145
267
|
case "missing-field":
|
|
146
|
-
reason = `The intake decision is missing the "${evaluation.field}" field.`;
|
|
268
|
+
reason = `The intake decision is missing the "${evaluation.field}" field — no line records it.`;
|
|
269
|
+
break;
|
|
270
|
+
case "unparsed-field":
|
|
271
|
+
reason = `The intake decision's "${evaluation.field}" line is not in "${evaluation.field}: <value>" form, so it was not read as a field: "${quote(evaluation.line)}". This is a formatting mismatch, not missing content — the label must be followed by a colon. Bolded labels are accepted in either form (\`- **${evaluation.field}:** <value>\` or \`- **${evaluation.field}**: <value>\`).`;
|
|
147
272
|
break;
|
|
148
273
|
case "duplicate-field":
|
|
149
274
|
reason = `The intake decision repeats the "${evaluation.field}" field; record one unambiguous value.`;
|
|
@@ -152,7 +277,7 @@ export function formatIntakeDecisionBlock(opts) {
|
|
|
152
277
|
reason = `The intake fields are out of order at "${evaluation.field}".`;
|
|
153
278
|
break;
|
|
154
279
|
case "invalid-field":
|
|
155
|
-
reason =
|
|
280
|
+
reason = describeFieldProblem(evaluation.field, evaluation.problem, evaluation.value);
|
|
156
281
|
break;
|
|
157
282
|
case "not-proceeding":
|
|
158
283
|
reason = `The intake decision is "${evaluation.decision || "empty"}", not PROCEED.`;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-blocking repo/profile mismatch hint (DEV-7277).
|
|
3
|
+
*
|
|
4
|
+
* On write commands (`issues create`, `comments create`) a user who works
|
|
5
|
+
* across multiple Linear workspaces can silently write into the wrong one: the
|
|
6
|
+
* machine-global `active-profile` marker is whatever they last `profile use`d,
|
|
7
|
+
* and it applies to every repo that hasn't pinned a profile. This prints a
|
|
8
|
+
* ONE-LINE hint to stderr when — and only when — the active profile came from
|
|
9
|
+
* that bare global marker AND the current repo has no pin, nudging the user to
|
|
10
|
+
* `el-linear profile pin`.
|
|
11
|
+
*
|
|
12
|
+
* It NEVER blocks the command and NEVER writes to stdout (that stays reserved
|
|
13
|
+
* for the machine-parseable JSON envelope). It is suppressed by `--quiet` and
|
|
14
|
+
* by the `EL_LINEAR_NO_PIN_HINT` opt-out.
|
|
15
|
+
*/
|
|
16
|
+
import { type RepoLinearProfile } from "./repo-profile.js";
|
|
17
|
+
export interface PinHintDeps {
|
|
18
|
+
env: NodeJS.ProcessEnv;
|
|
19
|
+
cwd: string;
|
|
20
|
+
/** True when `--profile` was passed for this invocation. */
|
|
21
|
+
hasProfileFlag: boolean;
|
|
22
|
+
/** Whether `--quiet` is active. */
|
|
23
|
+
isQuiet: () => boolean;
|
|
24
|
+
/** Raw `active-profile` marker content (bypasses the flag/env layers). */
|
|
25
|
+
readMarker: () => string | null;
|
|
26
|
+
/** Repo→profile pin lookup for `cwd`. */
|
|
27
|
+
resolveRepoPin: (cwd: string) => RepoLinearProfile | null;
|
|
28
|
+
/** Sink for the one-line hint (stderr in production). */
|
|
29
|
+
write: (message: string) => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Emit the mismatch hint when applicable. Returns true when a hint was written
|
|
33
|
+
* (for tests). All decision inputs are injectable so the trigger/suppression
|
|
34
|
+
* matrix can be unit-tested without git, env, or process state.
|
|
35
|
+
*/
|
|
36
|
+
export declare function maybeEmitPinMismatchHint(deps: Pick<PinHintDeps, "hasProfileFlag"> & Partial<PinHintDeps>): boolean;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-blocking repo/profile mismatch hint (DEV-7277).
|
|
3
|
+
*
|
|
4
|
+
* On write commands (`issues create`, `comments create`) a user who works
|
|
5
|
+
* across multiple Linear workspaces can silently write into the wrong one: the
|
|
6
|
+
* machine-global `active-profile` marker is whatever they last `profile use`d,
|
|
7
|
+
* and it applies to every repo that hasn't pinned a profile. This prints a
|
|
8
|
+
* ONE-LINE hint to stderr when — and only when — the active profile came from
|
|
9
|
+
* that bare global marker AND the current repo has no pin, nudging the user to
|
|
10
|
+
* `el-linear profile pin`.
|
|
11
|
+
*
|
|
12
|
+
* It NEVER blocks the command and NEVER writes to stdout (that stays reserved
|
|
13
|
+
* for the machine-parseable JSON envelope). It is suppressed by `--quiet` and
|
|
14
|
+
* by the `EL_LINEAR_NO_PIN_HINT` opt-out.
|
|
15
|
+
*/
|
|
16
|
+
import { getQuietMode } from "../utils/output.js";
|
|
17
|
+
import { isSafeProfileName, readActiveProfileMarker } from "./paths.js";
|
|
18
|
+
import { resolveRepoLinearProfile, } from "./repo-profile.js";
|
|
19
|
+
const ENV_OPT_IN_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
20
|
+
/** True only when `env[name]` is an explicit opt-in value (not `0`/`false`). */
|
|
21
|
+
function isEnvOptIn(name, env) {
|
|
22
|
+
const raw = env[name]?.trim().toLowerCase();
|
|
23
|
+
return raw !== undefined && ENV_OPT_IN_VALUES.has(raw);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Emit the mismatch hint when applicable. Returns true when a hint was written
|
|
27
|
+
* (for tests). All decision inputs are injectable so the trigger/suppression
|
|
28
|
+
* matrix can be unit-tested without git, env, or process state.
|
|
29
|
+
*/
|
|
30
|
+
export function maybeEmitPinMismatchHint(deps) {
|
|
31
|
+
const env = deps.env ?? process.env;
|
|
32
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
33
|
+
const isQuiet = deps.isQuiet ?? getQuietMode;
|
|
34
|
+
const readMarker = deps.readMarker ?? (() => readActiveProfileMarker());
|
|
35
|
+
const resolveRepoPin = deps.resolveRepoPin ?? ((c) => resolveRepoLinearProfile(c));
|
|
36
|
+
const write = deps.write ?? ((m) => process.stderr.write(`${m}\n`));
|
|
37
|
+
// Suppressed contexts — every one of these means the hint would be noise.
|
|
38
|
+
if (isQuiet())
|
|
39
|
+
return false;
|
|
40
|
+
if (isEnvOptIn("EL_LINEAR_NO_PIN_HINT", env))
|
|
41
|
+
return false;
|
|
42
|
+
// Came from `--profile` — the user was explicit.
|
|
43
|
+
if (deps.hasProfileFlag)
|
|
44
|
+
return false;
|
|
45
|
+
// Came from `$EL_LINEAR_PROFILE` — also explicit.
|
|
46
|
+
if ((env.EL_LINEAR_PROFILE ?? "").trim())
|
|
47
|
+
return false;
|
|
48
|
+
// Repo already has an effective pin — nothing to nudge. Startup ignores
|
|
49
|
+
// unsafe names, so the hint must apply the same predicate or a bad pin would
|
|
50
|
+
// suppress the warning while the write falls through to the global marker.
|
|
51
|
+
const pin = resolveRepoPin(cwd);
|
|
52
|
+
if (pin && isSafeProfileName(pin.profile))
|
|
53
|
+
return false;
|
|
54
|
+
// Only fire when the profile came from the BARE global marker. No marker =
|
|
55
|
+
// legacy single-profile default; that user hasn't opted into workspaces at
|
|
56
|
+
// all, so the nudge would be premature.
|
|
57
|
+
const marker = readMarker();
|
|
58
|
+
if (!marker)
|
|
59
|
+
return false;
|
|
60
|
+
write(`el-linear: writing with the machine-global active profile "${marker}" — this repo has no pinned workspace. ` +
|
|
61
|
+
"If it belongs to a specific workspace, pin it: `el-linear profile pin`. " +
|
|
62
|
+
"(silence this: EL_LINEAR_NO_PIN_HINT=1)");
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-aware Linear-profile resolution (DEV-7277).
|
|
3
|
+
*
|
|
4
|
+
* el-linear supports multiple named profiles (one Linear workspace each). When
|
|
5
|
+
* a user works across several workspaces the machine-global `active-profile`
|
|
6
|
+
* marker is a foot-gun: whichever workspace was last `profile use`d becomes the
|
|
7
|
+
* default for EVERY repo, so a write lands in the wrong workspace.
|
|
8
|
+
*
|
|
9
|
+
* This module lets a repo PIN the profile it belongs to, read from two on-disk
|
|
10
|
+
* sources in precedence order:
|
|
11
|
+
* 1. Repo-root `.el-git.json`: `{ "linearProfile": "<name>" }`.
|
|
12
|
+
* 2. `${XDG_CONFIG_HOME:-~/.config}/el-git/linear-profiles.json`:
|
|
13
|
+
* `{ "profiles": { "owner/repo": "<name>", "owner/*": "<name>" } }` — keys
|
|
14
|
+
* are the origin remote's `owner/repo`; an exact key wins over the
|
|
15
|
+
* `owner/*` wildcard.
|
|
16
|
+
*
|
|
17
|
+
* ── SHARED CROSS-TOOL CONTRACT — DO NOT DRIFT ────────────────────────────────
|
|
18
|
+
* The file names, shapes, and precedence here are the SAME contract el-git
|
|
19
|
+
* (DEV-6465) and el-session (DEV-7131) already read/write via the internal
|
|
20
|
+
* `@enrichlayer/cli-package-info` module (`resolveRepoLinearProfile`). el-linear
|
|
21
|
+
* is a standalone MIT package published to public npm; cli-package-info is
|
|
22
|
+
* published only to an internal GitLab registry, so depending on it would break
|
|
23
|
+
* public installs. Instead this file re-implements the EXACT same formats and
|
|
24
|
+
* precedence. The filename stays `.el-git.json` (NOT a new `.el-linear` file) on
|
|
25
|
+
* purpose — it is the established shared location el-session already reads. If
|
|
26
|
+
* you change anything here, change the canonical impl at
|
|
27
|
+
* `tools/cli/cli-package-info/src/index.cjs` in lock-step or the tools silently
|
|
28
|
+
* disagree about which workspace a repo belongs to.
|
|
29
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* Trust note (inherited from the shared contract): the repo-local file is read
|
|
32
|
+
* from whatever repo you `cd` into, including an untrusted clone. Blast radius
|
|
33
|
+
* is bounded to selecting a profile NAME — worst case is a wrong-workspace
|
|
34
|
+
* lookup or a rejected name, never code execution. Callers still validate the
|
|
35
|
+
* resolved name with `isSafeProfileName` before it reaches any path join.
|
|
36
|
+
*/
|
|
37
|
+
/** Repo-local pin file, at the git repo root. Shared with el-git/el-session. */
|
|
38
|
+
export declare const LINEAR_PROFILE_REPO_FILE = ".el-git.json";
|
|
39
|
+
/** Global pin table filename, under `<config>/el-git/`. */
|
|
40
|
+
export declare const LINEAR_PROFILE_USER_CONFIG = "linear-profiles.json";
|
|
41
|
+
/** Where the resolved pin came from. Mirrors the shared contract's `source`. */
|
|
42
|
+
export type RepoProfileSource = "repo-file" | "user-config";
|
|
43
|
+
export interface RepoLinearProfile {
|
|
44
|
+
profile: string;
|
|
45
|
+
source: RepoProfileSource;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Injected I/O seam so resolution is unit-testable without a real git repo or
|
|
49
|
+
* tmpdirs — mirrors the `ProfileFsOps` pattern in `paths.ts` and the `ops`
|
|
50
|
+
* injection in the canonical cli-package-info impl.
|
|
51
|
+
*/
|
|
52
|
+
export interface RepoProfileOps {
|
|
53
|
+
existsSync: (p: string) => boolean;
|
|
54
|
+
readFileSync: (p: string) => string;
|
|
55
|
+
/** Git repo root for `cwd`, or null when not inside a repo. */
|
|
56
|
+
repoRoot: (cwd: string) => string | null;
|
|
57
|
+
/** Origin remote parsed to `owner/repo`, or null when unavailable. */
|
|
58
|
+
originFullpath: (cwd: string) => string | null;
|
|
59
|
+
}
|
|
60
|
+
export interface ResolveRepoProfileOptions {
|
|
61
|
+
ops?: Partial<RepoProfileOps>;
|
|
62
|
+
env?: NodeJS.ProcessEnv;
|
|
63
|
+
/** Called with the offending file path when a config JSON is malformed. */
|
|
64
|
+
onMalformedConfig?: (filePath: string) => void;
|
|
65
|
+
}
|
|
66
|
+
/** `${XDG_CONFIG_HOME:-~/.config}/el-git`. Env-injectable for tests. */
|
|
67
|
+
export declare function linearProfileConfigDir(env?: NodeJS.ProcessEnv): string;
|
|
68
|
+
export declare function globalPinTablePath(env?: NodeJS.ProcessEnv): string;
|
|
69
|
+
export declare function parseGitRemoteFullpath(url: string | null | undefined): string | null;
|
|
70
|
+
/** Real git/fs implementations. Overridden per-field in tests. */
|
|
71
|
+
export declare function defaultRepoProfileOps(): RepoProfileOps;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the profile a repo is pinned to, or null when it has no pin.
|
|
74
|
+
* Repo-local `.el-git.json` wins over the global `owner/repo` table; within the
|
|
75
|
+
* table an exact key wins over the `owner/*` wildcard.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveRepoLinearProfile(cwd?: string, opts?: ResolveRepoProfileOptions): RepoLinearProfile | null;
|
|
78
|
+
/**
|
|
79
|
+
* Merge `{ linearProfile: name }` into an existing `.el-git.json` body,
|
|
80
|
+
* preserving every other key. `existing` is the current file text (or null when
|
|
81
|
+
* the file is absent). Returns the new file text (2-space, trailing newline).
|
|
82
|
+
*/
|
|
83
|
+
export declare function applyRepoLocalPin(existing: string | null, name: string): string;
|
|
84
|
+
/**
|
|
85
|
+
* Remove the `linearProfile` key from an existing `.el-git.json` body,
|
|
86
|
+
* preserving other keys. Returns the new file text, or null when the file
|
|
87
|
+
* should be deleted (it held only the pin / was absent / becomes empty).
|
|
88
|
+
*/
|
|
89
|
+
export declare function removeRepoLocalPin(existing: string | null): string | null;
|
|
90
|
+
/**
|
|
91
|
+
* Merge `profiles[fullpath] = name` into the global pin table body, preserving
|
|
92
|
+
* every other entry. Returns the new file text (2-space, trailing newline).
|
|
93
|
+
*/
|
|
94
|
+
export declare function applyGlobalPin(existing: string | null, fullpath: string, name: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Remove an exact `owner/repo` entry from the global pin table. Preserves
|
|
97
|
+
* wildcard entries and unrelated top-level keys. Returns null only when the
|
|
98
|
+
* file is absent or becomes empty after removal.
|
|
99
|
+
*/
|
|
100
|
+
export declare function removeGlobalPin(existing: string | null, fullpath: string): string | null;
|