@enrichlayer/el-linear 1.9.0 → 1.15.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 +139 -10
- package/claude-skills/linear-operations/SKILL.md +41 -1
- package/dist/auth/linear-credential.d.ts +27 -0
- package/dist/auth/linear-credential.js +1 -0
- package/dist/auth/oauth-app-config.d.ts +4 -3
- package/dist/auth/oauth-app-config.js +13 -2
- package/dist/auth/oauth-callback.d.ts +2 -3
- package/dist/auth/oauth-callback.js +2 -2
- package/dist/auth/oauth-client.d.ts +8 -2
- package/dist/auth/oauth-client.js +26 -0
- package/dist/auth/oauth-fs.d.ts +2 -1
- package/dist/auth/oauth-headless.d.ts +2 -1
- package/dist/auth/oauth-storage.d.ts +5 -1
- package/dist/auth/oauth-storage.js +1 -1
- package/dist/auth/oauth-token.d.ts +4 -3
- package/dist/auth/oauth-token.js +16 -4
- package/dist/auth/token-resolver.d.ts +14 -5
- package/dist/auth/token-resolver.js +6 -1
- package/dist/commands/attachments.js +2 -1
- package/dist/commands/batch.js +18 -21
- package/dist/commands/comments.js +22 -33
- package/dist/commands/config.js +178 -5
- package/dist/commands/cycles.js +2 -1
- package/dist/commands/documents.js +2 -1
- package/dist/commands/graphql.js +4 -6
- package/dist/commands/init/aliases.js +1 -1
- package/dist/commands/init/defaults.d.ts +2 -1
- package/dist/commands/init/index.js +45 -35
- package/dist/commands/init/oauth.d.ts +4 -1
- package/dist/commands/init/oauth.js +22 -4
- package/dist/commands/init/shared.d.ts +24 -2
- package/dist/commands/init/shared.js +35 -4
- package/dist/commands/init/token.d.ts +3 -3
- package/dist/commands/init/token.js +5 -24
- package/dist/commands/init/workspace.d.ts +2 -1
- package/dist/commands/init/workspace.js +1 -1
- package/dist/commands/introspect.d.ts +27 -0
- package/dist/commands/introspect.js +178 -0
- package/dist/commands/issue-id.js +1 -3
- package/dist/commands/issues/branch.js +9 -1
- package/dist/commands/issues/description.js +2 -6
- package/dist/commands/issues/link-references.d.ts +21 -0
- package/dist/commands/issues/link-references.js +171 -0
- package/dist/commands/issues/relations.d.ts +44 -0
- package/dist/commands/issues/relations.js +132 -0
- package/dist/commands/issues.js +269 -309
- package/dist/commands/labels.js +15 -24
- package/dist/commands/profile.js +1 -0
- package/dist/commands/project-milestones.js +13 -20
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +157 -44
- package/dist/commands/read-shortcut.d.ts +1 -1
- package/dist/commands/read-shortcut.js +28 -8
- package/dist/commands/refs.js +75 -8
- package/dist/commands/releases.js +26 -30
- package/dist/commands/search.js +49 -33
- package/dist/commands/teams.js +2 -1
- package/dist/commands/templates.js +9 -14
- package/dist/commands/users.js +5 -2
- package/dist/config/config.d.ts +99 -1
- package/dist/config/config.js +264 -52
- package/dist/config/error-enrichment.d.ts +62 -0
- package/dist/config/error-enrichment.js +417 -0
- package/dist/config/issue-validation.d.ts +37 -0
- package/dist/config/issue-validation.js +63 -1
- package/dist/config/paths.d.ts +2 -8
- package/dist/config/paths.js +4 -2
- package/dist/config/resolver.d.ts +8 -1
- package/dist/config/resolver.js +11 -5
- package/dist/main.js +13 -1
- package/dist/queries/attachments-types.d.ts +30 -0
- package/dist/queries/attachments-types.js +5 -0
- package/dist/queries/comments-types.d.ts +55 -0
- package/dist/queries/comments-types.js +5 -0
- package/dist/queries/common.d.ts +2 -2
- package/dist/queries/common.js +8 -0
- package/dist/queries/documents-types.d.ts +62 -0
- package/dist/queries/documents-types.js +9 -0
- package/dist/queries/introspect-types.d.ts +58 -0
- package/dist/queries/introspect-types.js +10 -0
- package/dist/queries/issues-types.d.ts +481 -0
- package/dist/queries/issues-types.js +23 -0
- package/dist/queries/issues.d.ts +51 -10
- package/dist/queries/issues.js +147 -5
- package/dist/queries/labels-types.d.ts +65 -0
- package/dist/queries/labels-types.js +5 -0
- package/dist/queries/project-milestones-types.d.ts +92 -0
- package/dist/queries/project-milestones-types.js +10 -0
- package/dist/queries/project-milestones.d.ts +1 -1
- package/dist/queries/projects-types.d.ts +76 -0
- package/dist/queries/projects-types.js +5 -0
- package/dist/queries/projects.d.ts +2 -0
- package/dist/queries/projects.js +22 -0
- package/dist/queries/releases-types.d.ts +85 -0
- package/dist/queries/releases-types.js +5 -0
- package/dist/queries/search-types.d.ts +102 -0
- package/dist/queries/search-types.js +6 -0
- package/dist/queries/templates-types.d.ts +62 -0
- package/dist/queries/templates-types.js +9 -0
- package/dist/types/linear.d.ts +21 -3
- package/dist/utils/auto-link-references.d.ts +3 -3
- package/dist/utils/auto-link-references.js +30 -34
- package/dist/utils/extract-field.d.ts +19 -0
- package/dist/utils/extract-field.js +99 -0
- package/dist/utils/file-service.d.ts +6 -13
- package/dist/utils/file-service.js +0 -2
- package/dist/utils/formatters/summary.js +6 -1
- package/dist/utils/graphql-attachments-service.js +6 -9
- package/dist/utils/graphql-documents-service.js +19 -25
- package/dist/utils/graphql-issues-service.d.ts +112 -46
- package/dist/utils/graphql-issues-service.js +398 -206
- package/dist/utils/graphql-service.d.ts +10 -12
- package/dist/utils/graphql-service.js +0 -3
- package/dist/utils/issue-reference-extractor.d.ts +7 -0
- package/dist/utils/issue-reference-extractor.js +5 -3
- package/dist/utils/issues-service-bootstrap.d.ts +28 -0
- package/dist/utils/issues-service-bootstrap.js +27 -0
- package/dist/utils/linear-service.d.ts +21 -14
- package/dist/utils/linear-service.js +73 -11
- package/dist/utils/markdown-prosemirror.js +12 -12
- package/dist/utils/mention-resolver.js +1 -1
- package/dist/utils/output.d.ts +82 -2
- package/dist/utils/output.js +76 -11
- package/dist/utils/project-slug.d.ts +21 -0
- package/dist/utils/project-slug.js +45 -0
- package/dist/utils/protected-ranges.d.ts +14 -0
- package/dist/utils/protected-ranges.js +88 -2
- package/dist/utils/sanitize-for-log.d.ts +24 -0
- package/dist/utils/sanitize-for-log.js +38 -0
- package/dist/utils/table-formatter.js +24 -0
- package/dist/utils/validators.d.ts +7 -2
- package/dist/utils/validators.js +6 -0
- package/dist/utils/workspace-url.d.ts +5 -1
- package/dist/utils/workspace-url.js +53 -7
- package/package.json +2 -2
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation error enrichment.
|
|
3
|
+
*
|
|
4
|
+
* When `validateIssueCreation` flags a missing required field AND `--team`
|
|
5
|
+
* is set, this module fetches concrete team-scoped suggestions (active
|
|
6
|
+
* projects, active members, valid labels) and appends a "Suggestions:" block
|
|
7
|
+
* to each relevant error message.
|
|
8
|
+
*
|
|
9
|
+
* Goal: reduce the agent's loop time. A single error should carry enough
|
|
10
|
+
* concrete options that the agent can rebuild a complete retry command
|
|
11
|
+
* without follow-up `projects list` / `users list` / `labels list` calls.
|
|
12
|
+
*
|
|
13
|
+
* Latency budget: enrichment only runs on the validation-fail path. The
|
|
14
|
+
* success path is unchanged. Fetches run in parallel and only for the fields
|
|
15
|
+
* that are actually missing.
|
|
16
|
+
*/
|
|
17
|
+
import { isUuid } from "../utils/uuid.js";
|
|
18
|
+
import { loadConfig } from "./config.js";
|
|
19
|
+
import { getCanonicalTypeLabels, inferTypeFromTitle, } from "./issue-validation.js";
|
|
20
|
+
import { resolveTeam } from "./resolver.js";
|
|
21
|
+
/**
|
|
22
|
+
* Raw GraphQL is used here instead of `LinearService` / `client.team(id)`
|
|
23
|
+
* because we need three connections (projects, members, labels) in a
|
|
24
|
+
* single round-trip, gated on which fields the validator actually flagged
|
|
25
|
+
* as missing. Going through the SDK would mean three sequential
|
|
26
|
+
* `.projects()` / `.members()` / `.labels()` calls (the SDK's
|
|
27
|
+
* connection-resolver promises don't batch), which doubles the
|
|
28
|
+
* worst-case latency of an already-on-the-error-path enrichment.
|
|
29
|
+
*
|
|
30
|
+
* Per CLAUDE.md "prefer @linear/sdk over raw GraphQL" rule: this is the
|
|
31
|
+
* "batching with @include directives" exception. Revisit if `@linear/sdk`
|
|
32
|
+
* ever exposes a multi-connection batch helper.
|
|
33
|
+
*/
|
|
34
|
+
const TEAM_SUGGESTIONS_QUERY = `
|
|
35
|
+
query TeamSuggestions(
|
|
36
|
+
$teamId: String!
|
|
37
|
+
$includeProjects: Boolean!
|
|
38
|
+
$includeMembers: Boolean!
|
|
39
|
+
$includeLabels: Boolean!
|
|
40
|
+
) {
|
|
41
|
+
team(id: $teamId) {
|
|
42
|
+
id
|
|
43
|
+
key
|
|
44
|
+
name
|
|
45
|
+
projects(
|
|
46
|
+
filter: { state: { in: ["started", "backlog", "planned"] } }
|
|
47
|
+
orderBy: updatedAt
|
|
48
|
+
first: 5
|
|
49
|
+
) @include(if: $includeProjects) {
|
|
50
|
+
nodes { id name state }
|
|
51
|
+
}
|
|
52
|
+
members(
|
|
53
|
+
filter: { active: { eq: true } }
|
|
54
|
+
first: 8
|
|
55
|
+
) @include(if: $includeMembers) {
|
|
56
|
+
nodes { id name displayName email active }
|
|
57
|
+
}
|
|
58
|
+
labels(first: 50) @include(if: $includeLabels) {
|
|
59
|
+
nodes { id name isGroup }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
/**
|
|
65
|
+
* What kind of "missing field" each error represents, derived from the
|
|
66
|
+
* error message prefix. Returns null for errors we don't enrich.
|
|
67
|
+
*/
|
|
68
|
+
function classifyError(error) {
|
|
69
|
+
if (error.startsWith("Missing --project")) {
|
|
70
|
+
return "project";
|
|
71
|
+
}
|
|
72
|
+
if (error.startsWith("Missing --assignee")) {
|
|
73
|
+
return "assignee";
|
|
74
|
+
}
|
|
75
|
+
if (error.startsWith("Missing --labels")) {
|
|
76
|
+
return "labels";
|
|
77
|
+
}
|
|
78
|
+
if (error.startsWith("Missing type label")) {
|
|
79
|
+
return "type-label";
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the team input to a Linear UUID.
|
|
85
|
+
* Tries the synchronous config-based resolver first (no API call). Falls back
|
|
86
|
+
* to LinearService.resolveTeamId for inputs not in config.
|
|
87
|
+
*/
|
|
88
|
+
async function resolveTeamUuid(team, linearService) {
|
|
89
|
+
const resolved = resolveTeam(team);
|
|
90
|
+
if (isUuid(resolved)) {
|
|
91
|
+
return resolved;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return await linearService.resolveTeamId(team);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Best shell-safe token to identify this user on the command line.
|
|
102
|
+
*
|
|
103
|
+
* Preference order:
|
|
104
|
+
* 1. Config alias (always single-token, e.g. "dima")
|
|
105
|
+
* 2. Email (stable, no spaces, no shell metacharacters)
|
|
106
|
+
* 3. POSIX-quoted displayName (last resort — single-quoted via
|
|
107
|
+
* `shellQuote` so a name like `Kamal M`, `O'Brien`, or one
|
|
108
|
+
* containing `$`/backtick/`\\` doesn't get split or expanded by
|
|
109
|
+
* the shell on retry).
|
|
110
|
+
*
|
|
111
|
+
* The retryability is the point: the suggestion must paste back into the
|
|
112
|
+
* same `el-linear issues create ... --assignee <token>` without further
|
|
113
|
+
* escaping by the caller. The displayName branch uses the same POSIX
|
|
114
|
+
* single-quote helper as the rest of the retry command so the escaping
|
|
115
|
+
* contract is uniform — no asymmetric "quote with double quotes here,
|
|
116
|
+
* single quotes there" trap for future maintainers.
|
|
117
|
+
*/
|
|
118
|
+
function bestAssigneeToken(name, displayName, email) {
|
|
119
|
+
const config = loadConfig();
|
|
120
|
+
for (const [alias, configName] of Object.entries(config.members.aliases)) {
|
|
121
|
+
if (configName.toLowerCase() === name.toLowerCase()) {
|
|
122
|
+
return alias;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (email && email.trim().length > 0) {
|
|
126
|
+
return email;
|
|
127
|
+
}
|
|
128
|
+
const label = displayName || name;
|
|
129
|
+
if (/\s/.test(label) || /['"`$\\]/.test(label)) {
|
|
130
|
+
return shellQuote(label);
|
|
131
|
+
}
|
|
132
|
+
return label;
|
|
133
|
+
}
|
|
134
|
+
function formatProjectSuggestions(projects) {
|
|
135
|
+
if (projects.length === 0) {
|
|
136
|
+
return " Suggestions: no active projects found on this team.";
|
|
137
|
+
}
|
|
138
|
+
const lines = projects
|
|
139
|
+
.filter((p) => Boolean(p.name))
|
|
140
|
+
.map((p) => ` --project "${p.name}"`);
|
|
141
|
+
return ` Suggestions (top active projects on this team):\n${lines.join("\n")}`;
|
|
142
|
+
}
|
|
143
|
+
function formatAssigneeSuggestions(members) {
|
|
144
|
+
if (members.length === 0) {
|
|
145
|
+
return " Suggestions: no active members found on this team.";
|
|
146
|
+
}
|
|
147
|
+
const lines = members
|
|
148
|
+
.filter((m) => Boolean(m.name))
|
|
149
|
+
.map((m) => ` --assignee ${bestAssigneeToken(m.name, m.displayName, m.email)}`);
|
|
150
|
+
return ` Suggestions (active team members):\n${lines.join("\n")}`;
|
|
151
|
+
}
|
|
152
|
+
function formatLabelSuggestions(ctx) {
|
|
153
|
+
const { labels, typeLabels, inferred } = ctx;
|
|
154
|
+
// Domain labels = team labels that are NOT type labels and NOT groups.
|
|
155
|
+
const typeSet = new Set(typeLabels.map((t) => t.toLowerCase()));
|
|
156
|
+
const domainLabels = labels
|
|
157
|
+
.filter((l) => !l.isGroup && l.name)
|
|
158
|
+
.filter((l) => !typeSet.has(l.name.toLowerCase()))
|
|
159
|
+
.slice(0, 5)
|
|
160
|
+
.map((l) => l.name);
|
|
161
|
+
// Order type labels: inferred type first if it matches one of typeLabels.
|
|
162
|
+
let orderedTypes = [...typeLabels];
|
|
163
|
+
if (inferred && typeLabels.includes(inferred.type)) {
|
|
164
|
+
orderedTypes = [
|
|
165
|
+
inferred.type,
|
|
166
|
+
...typeLabels.filter((t) => t !== inferred.type),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
const typeLines = orderedTypes
|
|
170
|
+
.slice(0, 5)
|
|
171
|
+
.map((t) => inferred && t === inferred.type
|
|
172
|
+
? ` ${t} # inferred from title verb "${inferred.verb}"`
|
|
173
|
+
: ` ${t}`);
|
|
174
|
+
const out = [];
|
|
175
|
+
out.push(" Suggestions:");
|
|
176
|
+
out.push(" Valid type labels (pick one):");
|
|
177
|
+
out.push(...typeLines.map((l) => ` ${l}`));
|
|
178
|
+
if (domainLabels.length > 0) {
|
|
179
|
+
out.push(` Common domain labels on this team: ${domainLabels.join(", ")}`);
|
|
180
|
+
}
|
|
181
|
+
return out.join("\n");
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Enrich validation errors with team-scoped suggestions.
|
|
185
|
+
*
|
|
186
|
+
* Mutates `result.errors` in place — each enrichable error gets a
|
|
187
|
+
* "Suggestions:" block appended. Verb→type inference is added as a prefixed
|
|
188
|
+
* "Inferred from title:" line on the `--labels` missing error.
|
|
189
|
+
*
|
|
190
|
+
* No-op when `--team` is not set or when there are no errors to enrich.
|
|
191
|
+
* Safe to call on the failure path only — never invoked when validation
|
|
192
|
+
* passes, so the success path's latency is unchanged.
|
|
193
|
+
*
|
|
194
|
+
* Failures (network, unknown team) are swallowed: enrichment is a best-effort
|
|
195
|
+
* UX hint, not part of the validation contract.
|
|
196
|
+
*/
|
|
197
|
+
export async function enrichValidationErrors(result, options, services) {
|
|
198
|
+
if (!options.team || result.errors.length === 0) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Figure out which suggestion types we actually need.
|
|
202
|
+
const classifications = result.errors.map(classifyError);
|
|
203
|
+
const needProjects = classifications.includes("project");
|
|
204
|
+
const needMembers = classifications.includes("assignee");
|
|
205
|
+
const needLabels = classifications.includes("labels") ||
|
|
206
|
+
classifications.includes("type-label");
|
|
207
|
+
if (!(needProjects || needMembers || needLabels)) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Resolve team to UUID. Bail if we can't — no enrichment possible.
|
|
211
|
+
const teamId = await resolveTeamUuid(options.team, services.linearService);
|
|
212
|
+
if (!teamId) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Single batched query, only fetching the connections we need.
|
|
216
|
+
let teamData;
|
|
217
|
+
try {
|
|
218
|
+
const res = await services.graphQLService.rawRequest(TEAM_SUGGESTIONS_QUERY, {
|
|
219
|
+
teamId,
|
|
220
|
+
includeProjects: needProjects,
|
|
221
|
+
includeMembers: needMembers,
|
|
222
|
+
includeLabels: needLabels,
|
|
223
|
+
});
|
|
224
|
+
teamData = res.team;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return; // best-effort
|
|
228
|
+
}
|
|
229
|
+
if (!teamData) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const projects = teamData.projects?.nodes ?? [];
|
|
233
|
+
const members = teamData.members?.nodes ?? [];
|
|
234
|
+
const labels = teamData.labels?.nodes ?? [];
|
|
235
|
+
const typeLabels = getCanonicalTypeLabels();
|
|
236
|
+
const inferred = options.title ? inferTypeFromTitle(options.title) : null;
|
|
237
|
+
for (let i = 0; i < result.errors.length; i++) {
|
|
238
|
+
result.errors[i] = decorateError(result.errors[i], classifications[i], {
|
|
239
|
+
projects,
|
|
240
|
+
members,
|
|
241
|
+
labels,
|
|
242
|
+
typeLabels,
|
|
243
|
+
inferred,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
// Append a single copy-pasteable retry command after the last enriched
|
|
247
|
+
// error, so the agent has one concrete line to run instead of having to
|
|
248
|
+
// stitch together fragments from each error's suggestion block.
|
|
249
|
+
const retry = formatRetryCommand({
|
|
250
|
+
title: options.title,
|
|
251
|
+
team: options.team,
|
|
252
|
+
classifications,
|
|
253
|
+
projects,
|
|
254
|
+
members,
|
|
255
|
+
labels,
|
|
256
|
+
typeLabels,
|
|
257
|
+
inferred,
|
|
258
|
+
});
|
|
259
|
+
if (retry) {
|
|
260
|
+
const lastIdx = result.errors.length - 1;
|
|
261
|
+
result.errors[lastIdx] = `${result.errors[lastIdx]}\n\n${retry}`;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Pattern for the `Project "X" not found` error shape thrown by
|
|
266
|
+
* `LinearService.resolveProjectId` (both name and slug-id paths) and the
|
|
267
|
+
* batched issues-service project resolver. Matches the message body and
|
|
268
|
+
* captures the user's original input so the synthesized enrichment error
|
|
269
|
+
* can reference it.
|
|
270
|
+
*
|
|
271
|
+
* Start-anchored to avoid a false-positive when the substring appears
|
|
272
|
+
* inside an unrelated error. Greedy `.+` with `\.?` tail handles project
|
|
273
|
+
* names that themselves contain quotes — the regex backtracks from the
|
|
274
|
+
* final `" not found.` to find the longest valid identifier (`notFoundError`
|
|
275
|
+
* always ends with a period, so the tail is a stable terminator).
|
|
276
|
+
*/
|
|
277
|
+
const PROJECT_NOT_FOUND_PATTERN = /^Project "(.+)" not found\.?/;
|
|
278
|
+
/**
|
|
279
|
+
* Enrich a project resolver failure ({@link PROJECT_NOT_FOUND_PATTERN})
|
|
280
|
+
* with the same team-scoped suggestions that {@link enrichValidationErrors}
|
|
281
|
+
* appends to `Missing --project` validation errors. Returns the original
|
|
282
|
+
* message unchanged if the error doesn't match or `--team` is unknown.
|
|
283
|
+
*
|
|
284
|
+
* The validation path covers "user forgot `--project`"; this path covers
|
|
285
|
+
* "user pasted a URL/slug/name that didn't resolve" — same recovery hints
|
|
286
|
+
* apply in both cases. Synthesizes a `Missing --project: …` error string
|
|
287
|
+
* so the existing classifier and suggestion code can be reused without
|
|
288
|
+
* special-casing the resolver shape.
|
|
289
|
+
*
|
|
290
|
+
* Best-effort like `enrichValidationErrors`: any failure (network, unknown
|
|
291
|
+
* team, GraphQL error) returns the original message unchanged. The caller
|
|
292
|
+
* is expected to rethrow with the returned message.
|
|
293
|
+
*/
|
|
294
|
+
export async function enrichProjectResolverError(originalMessage, options, services) {
|
|
295
|
+
const match = originalMessage.match(PROJECT_NOT_FOUND_PATTERN);
|
|
296
|
+
if (!match || !options.team) {
|
|
297
|
+
return originalMessage;
|
|
298
|
+
}
|
|
299
|
+
const projectInput = match[1];
|
|
300
|
+
const synthetic = {
|
|
301
|
+
errors: [
|
|
302
|
+
`Missing --project: "${projectInput}" did not resolve to any project on team ${options.team}.`,
|
|
303
|
+
],
|
|
304
|
+
warnings: [],
|
|
305
|
+
normalizedLabels: null,
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
await enrichValidationErrors(synthetic, options, services);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return originalMessage;
|
|
312
|
+
}
|
|
313
|
+
// If enrichment didn't actually append a suggestions block, fall back
|
|
314
|
+
// to the original — better a clean error than a stuttering one with
|
|
315
|
+
// just the synthetic preamble and no concrete options.
|
|
316
|
+
if (synthetic.errors.length === 0 ||
|
|
317
|
+
!synthetic.errors[0].includes("Suggestions")) {
|
|
318
|
+
return originalMessage;
|
|
319
|
+
}
|
|
320
|
+
return synthetic.errors[0];
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Build one copy-pasteable `el-linear issues create ...` command using the
|
|
324
|
+
* top-ranked suggestion for each missing field. Returns null when there's
|
|
325
|
+
* nothing useful to retry with (no team, or every suggestion source was empty).
|
|
326
|
+
*
|
|
327
|
+
* All values are shell-quoted with single quotes (POSIX), so titles, project
|
|
328
|
+
* names, and display names with spaces or special characters paste safely.
|
|
329
|
+
*/
|
|
330
|
+
function formatRetryCommand(ctx) {
|
|
331
|
+
if (!ctx.team) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const parts = ["el-linear issues create"];
|
|
335
|
+
parts.push(shellQuote(ctx.title ?? "<title>"));
|
|
336
|
+
parts.push(`--team ${shellQuote(ctx.team)}`);
|
|
337
|
+
const missing = new Set(ctx.classifications.filter((c) => c !== null));
|
|
338
|
+
if (missing.has("project")) {
|
|
339
|
+
const top = ctx.projects.find((p) => p.name);
|
|
340
|
+
if (!top?.name) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
parts.push(`--project ${shellQuote(top.name)}`);
|
|
344
|
+
}
|
|
345
|
+
if (missing.has("assignee")) {
|
|
346
|
+
const top = ctx.members.find((m) => m.name);
|
|
347
|
+
if (!top?.name) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
// Token is already shell-safe (alias, email, or pre-quoted display name).
|
|
351
|
+
parts.push(`--assignee ${bestAssigneeToken(top.name, top.displayName, top.email)}`);
|
|
352
|
+
}
|
|
353
|
+
if (missing.has("labels") || missing.has("type-label")) {
|
|
354
|
+
const typeLabel = ctx.inferred && ctx.typeLabels.includes(ctx.inferred.type)
|
|
355
|
+
? ctx.inferred.type
|
|
356
|
+
: ctx.typeLabels[0];
|
|
357
|
+
if (!typeLabel) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
const domain = pickDomainHint(ctx.labels, ctx.typeLabels);
|
|
361
|
+
const labelArg = domain === "<domain>" ? typeLabel : `${typeLabel},${domain}`;
|
|
362
|
+
parts.push(`--labels ${shellQuote(labelArg)}`);
|
|
363
|
+
}
|
|
364
|
+
parts.push("--description '<describe the change and the motivation>'");
|
|
365
|
+
return ` Retry with:\n ${parts.join(" ")}`;
|
|
366
|
+
}
|
|
367
|
+
/** POSIX single-quote escape: 'foo' → 'foo', it's → 'it'\''s'. */
|
|
368
|
+
function shellQuote(value) {
|
|
369
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Apply suggestion blocks to a single error message based on its classification.
|
|
373
|
+
* Extracted from enrichValidationErrors to keep that orchestrator small.
|
|
374
|
+
*/
|
|
375
|
+
function decorateError(message, kind, ctx) {
|
|
376
|
+
if (kind === "project") {
|
|
377
|
+
return `${message}\n${formatProjectSuggestions(ctx.projects)}`;
|
|
378
|
+
}
|
|
379
|
+
if (kind === "assignee") {
|
|
380
|
+
return `${message}\n${formatAssigneeSuggestions(ctx.members)}`;
|
|
381
|
+
}
|
|
382
|
+
if (kind === "labels" || kind === "type-label") {
|
|
383
|
+
// Both branches benefit from the verb→type inference hint plus
|
|
384
|
+
// the canonical-types + domain-labels suggestion block. Sharing
|
|
385
|
+
// the decorator keeps them in lockstep: a user who passed a
|
|
386
|
+
// non-type label (type-label error) sees the same "title verb
|
|
387
|
+
// suggests type X" guidance as one who passed no labels at all.
|
|
388
|
+
return decorateLabelsError(message, ctx);
|
|
389
|
+
}
|
|
390
|
+
return message;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Decorate a "Missing --labels" error with a verb-inference hint (when
|
|
394
|
+
* applicable) plus the standard label suggestions block.
|
|
395
|
+
*
|
|
396
|
+
* Inference is a hint only — the agent must still pass --labels explicitly.
|
|
397
|
+
* We do not auto-apply labels.
|
|
398
|
+
*/
|
|
399
|
+
function decorateLabelsError(message, ctx) {
|
|
400
|
+
let body = message;
|
|
401
|
+
if (ctx.inferred && ctx.typeLabels.includes(ctx.inferred.type)) {
|
|
402
|
+
const domainHint = pickDomainHint(ctx.labels, ctx.typeLabels);
|
|
403
|
+
const hint = `Inferred from title: type label "${ctx.inferred.type}" (title starts with "${ctx.inferred.verb}") — suggested: --labels "${ctx.inferred.type},${domainHint}"`;
|
|
404
|
+
body = `${hint}\n${body}`;
|
|
405
|
+
}
|
|
406
|
+
return `${body}\n${formatLabelSuggestions(ctx)}`;
|
|
407
|
+
}
|
|
408
|
+
/** First non-type, non-team-key label as a placeholder, or "<domain>". */
|
|
409
|
+
function pickDomainHint(labels, typeLabels) {
|
|
410
|
+
const typeSet = new Set(typeLabels.map((t) => t.toLowerCase()));
|
|
411
|
+
const teamLikeKeys = new Set(["dev", "fe", "be"]);
|
|
412
|
+
const candidate = labels
|
|
413
|
+
.filter((l) => !l.isGroup && l.name)
|
|
414
|
+
.map((l) => l.name)
|
|
415
|
+
.find((n) => !(typeSet.has(n.toLowerCase()) || teamLikeKeys.has(n.toLowerCase())));
|
|
416
|
+
return candidate ?? "<domain>";
|
|
417
|
+
}
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* Gated behind `validation.enabled` in config so it can be rolled out per-user
|
|
8
8
|
* before becoming the default for the team.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Recommended leading verbs for each type label.
|
|
12
|
+
* Title verb and type label should express the same intent.
|
|
13
|
+
*
|
|
14
|
+
* Exported so error-enrichment can reuse the same mapping when inferring a
|
|
15
|
+
* type label from a title's first word.
|
|
16
|
+
*/
|
|
17
|
+
export declare const TYPE_VERB_MAP: Record<string, string[]>;
|
|
10
18
|
export interface ValidationResult {
|
|
11
19
|
errors: string[];
|
|
12
20
|
warnings: string[];
|
|
@@ -20,6 +28,35 @@ export interface ValidationInput {
|
|
|
20
28
|
assignee: string | undefined;
|
|
21
29
|
project: string | undefined;
|
|
22
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Public accessor for the canonical type labels.
|
|
33
|
+
* Used by error-enrichment when suggesting labels.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getCanonicalTypeLabels(): string[];
|
|
36
|
+
/**
|
|
37
|
+
* Find the canonical type label for a title's first word, if it matches a
|
|
38
|
+
* known verb in TYPE_VERB_MAP. Returns the matched verb and inferred type,
|
|
39
|
+
* or null if no match.
|
|
40
|
+
*
|
|
41
|
+
* Checks multi-word verbs first (e.g. "Set up"), then single-word verbs.
|
|
42
|
+
*
|
|
43
|
+
* Why this lives next to `checkTitleVerbAlignment` but does NOT share its
|
|
44
|
+
* matching loop: the two functions answer different questions despite
|
|
45
|
+
* walking the same map.
|
|
46
|
+
* - `checkTitleVerbAlignment` runs only when a type label is already
|
|
47
|
+
* supplied; it warns when the verb belongs to a *different* type set
|
|
48
|
+
* than the one the user picked.
|
|
49
|
+
* - `inferTypeFromTitle` runs only when no type label is supplied; it
|
|
50
|
+
* wants the *positive* match — "this verb belongs to this type" —
|
|
51
|
+
* so it can suggest a default in the enrichment block.
|
|
52
|
+
* Sharing TYPE_VERB_MAP keeps them in sync; sharing the matcher would
|
|
53
|
+
* conflate "warn about mismatch" with "suggest a default" and produce
|
|
54
|
+
* subtle bugs (e.g. inferring a type the alignment check just rejected).
|
|
55
|
+
*/
|
|
56
|
+
export declare function inferTypeFromTitle(title: string): {
|
|
57
|
+
verb: string;
|
|
58
|
+
type: string;
|
|
59
|
+
} | null;
|
|
23
60
|
/**
|
|
24
61
|
* Normalize a label name: resolve known aliases and fix casing.
|
|
25
62
|
* Returns the canonical form if an alias exists, otherwise the original.
|
|
@@ -14,8 +14,11 @@ const DEFAULT_TYPE_LABELS = ["bug", "feature", "refactor", "chore", "spike"];
|
|
|
14
14
|
/**
|
|
15
15
|
* Recommended leading verbs for each type label.
|
|
16
16
|
* Title verb and type label should express the same intent.
|
|
17
|
+
*
|
|
18
|
+
* Exported so error-enrichment can reuse the same mapping when inferring a
|
|
19
|
+
* type label from a title's first word.
|
|
17
20
|
*/
|
|
18
|
-
const TYPE_VERB_MAP = {
|
|
21
|
+
export const TYPE_VERB_MAP = {
|
|
19
22
|
bug: ["Fix", "Resolve", "Patch", "Handle", "Address", "Correct"],
|
|
20
23
|
feature: [
|
|
21
24
|
"Add",
|
|
@@ -93,6 +96,65 @@ function getValidationConfig() {
|
|
|
93
96
|
typeLabels: validation?.typeLabels ?? DEFAULT_TYPE_LABELS,
|
|
94
97
|
};
|
|
95
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Public accessor for the canonical type labels.
|
|
101
|
+
* Used by error-enrichment when suggesting labels.
|
|
102
|
+
*/
|
|
103
|
+
export function getCanonicalTypeLabels() {
|
|
104
|
+
return getValidationConfig().typeLabels;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Find the canonical type label for a title's first word, if it matches a
|
|
108
|
+
* known verb in TYPE_VERB_MAP. Returns the matched verb and inferred type,
|
|
109
|
+
* or null if no match.
|
|
110
|
+
*
|
|
111
|
+
* Checks multi-word verbs first (e.g. "Set up"), then single-word verbs.
|
|
112
|
+
*
|
|
113
|
+
* Why this lives next to `checkTitleVerbAlignment` but does NOT share its
|
|
114
|
+
* matching loop: the two functions answer different questions despite
|
|
115
|
+
* walking the same map.
|
|
116
|
+
* - `checkTitleVerbAlignment` runs only when a type label is already
|
|
117
|
+
* supplied; it warns when the verb belongs to a *different* type set
|
|
118
|
+
* than the one the user picked.
|
|
119
|
+
* - `inferTypeFromTitle` runs only when no type label is supplied; it
|
|
120
|
+
* wants the *positive* match — "this verb belongs to this type" —
|
|
121
|
+
* so it can suggest a default in the enrichment block.
|
|
122
|
+
* Sharing TYPE_VERB_MAP keeps them in sync; sharing the matcher would
|
|
123
|
+
* conflate "warn about mismatch" with "suggest a default" and produce
|
|
124
|
+
* subtle bugs (e.g. inferring a type the alignment check just rejected).
|
|
125
|
+
*/
|
|
126
|
+
export function inferTypeFromTitle(title) {
|
|
127
|
+
const lowered = title.toLowerCase();
|
|
128
|
+
// Multi-word verbs first ("Set up")
|
|
129
|
+
for (const [type, verbs] of Object.entries(TYPE_VERB_MAP)) {
|
|
130
|
+
for (const verb of verbs) {
|
|
131
|
+
if (!verb.includes(" ")) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const verbLower = verb.toLowerCase();
|
|
135
|
+
if (lowered.startsWith(`${verbLower} `) || lowered === verbLower) {
|
|
136
|
+
return { verb, type };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Single-word verb
|
|
141
|
+
const firstWord = title.split(/\s/)[0];
|
|
142
|
+
if (!firstWord) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const firstLower = firstWord.toLowerCase();
|
|
146
|
+
for (const [type, verbs] of Object.entries(TYPE_VERB_MAP)) {
|
|
147
|
+
for (const verb of verbs) {
|
|
148
|
+
if (verb.includes(" ")) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (verb.toLowerCase() === firstLower) {
|
|
152
|
+
return { verb, type };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
96
158
|
/**
|
|
97
159
|
* Normalize a label name: resolve known aliases and fix casing.
|
|
98
160
|
* Returns the canonical form if an alias exists, otherwise the original.
|
package/dist/config/paths.d.ts
CHANGED
|
@@ -5,17 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export declare const CONFIG_DIR: string;
|
|
7
7
|
export declare const CONFIG_PATH: string;
|
|
8
|
+
export declare const LOCAL_CONFIG_PATH: string;
|
|
8
9
|
export declare const TOKEN_PATH: string;
|
|
9
10
|
export declare const TEAM_OAUTH_CONFIG_PATH: string;
|
|
10
11
|
export declare const ALIASES_PROGRESS_PATH: string;
|
|
11
|
-
/**
|
|
12
|
-
* Legacy fallback paths kept for backward compatibility. The CLI was briefly
|
|
13
|
-
* published as `@enrichlayer/linctl` (binary `linctl`); reverted to el-linear
|
|
14
|
-
* because of an npm collision with `dorkitude/linctl`. Reads check the new
|
|
15
|
-
* path first and fall back to these. Writes always go to the new path.
|
|
16
|
-
*/
|
|
17
|
-
export declare const LEGACY_LINCTL_CONFIG_DIR: string;
|
|
18
|
-
export declare const LEGACY_LINCTL_CONFIG_PATH: string;
|
|
19
12
|
export declare const LEGACY_LINCTL_TOKEN_PATH: string;
|
|
20
13
|
/** Even older fallback from before the `~/.config/...` move (one release). */
|
|
21
14
|
export declare const LEGACY_TOKEN_PATH: string;
|
|
@@ -25,6 +18,7 @@ export interface ProfilePaths {
|
|
|
25
18
|
/** Profile name; null when using the legacy single-file layout. */
|
|
26
19
|
name: string | null;
|
|
27
20
|
configPath: string;
|
|
21
|
+
localConfigPath: string;
|
|
28
22
|
tokenPath: string;
|
|
29
23
|
}
|
|
30
24
|
export interface ProfileFsOps {
|
package/dist/config/paths.js
CHANGED
|
@@ -8,6 +8,7 @@ import os from "node:os";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
export const CONFIG_DIR = path.join(os.homedir(), ".config", "el-linear");
|
|
10
10
|
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
11
|
+
export const LOCAL_CONFIG_PATH = path.join(CONFIG_DIR, "local.json");
|
|
11
12
|
export const TOKEN_PATH = path.join(CONFIG_DIR, "token");
|
|
12
13
|
export const TEAM_OAUTH_CONFIG_PATH = path.join(CONFIG_DIR, "team-oauth.json");
|
|
13
14
|
export const ALIASES_PROGRESS_PATH = path.join(CONFIG_DIR, ".init-aliases-progress");
|
|
@@ -17,8 +18,7 @@ export const ALIASES_PROGRESS_PATH = path.join(CONFIG_DIR, ".init-aliases-progre
|
|
|
17
18
|
* because of an npm collision with `dorkitude/linctl`. Reads check the new
|
|
18
19
|
* path first and fall back to these. Writes always go to the new path.
|
|
19
20
|
*/
|
|
20
|
-
|
|
21
|
-
export const LEGACY_LINCTL_CONFIG_PATH = path.join(LEGACY_LINCTL_CONFIG_DIR, "config.json");
|
|
21
|
+
const LEGACY_LINCTL_CONFIG_DIR = path.join(os.homedir(), ".config", "linctl");
|
|
22
22
|
export const LEGACY_LINCTL_TOKEN_PATH = path.join(LEGACY_LINCTL_CONFIG_DIR, "token");
|
|
23
23
|
/** Even older fallback from before the `~/.config/...` move (one release). */
|
|
24
24
|
export const LEGACY_TOKEN_PATH = path.join(os.homedir(), ".linear_api_token");
|
|
@@ -113,6 +113,7 @@ export function resolveActiveProfile(env = process.env, fsImpl = DEFAULT_FS_OPS)
|
|
|
113
113
|
return {
|
|
114
114
|
name: null,
|
|
115
115
|
configPath: CONFIG_PATH,
|
|
116
|
+
localConfigPath: LOCAL_CONFIG_PATH,
|
|
116
117
|
tokenPath: TOKEN_PATH,
|
|
117
118
|
};
|
|
118
119
|
}
|
|
@@ -122,6 +123,7 @@ export function profilePaths(name) {
|
|
|
122
123
|
return {
|
|
123
124
|
name,
|
|
124
125
|
configPath: path.join(dir, "config.json"),
|
|
126
|
+
localConfigPath: path.join(dir, "local.json"),
|
|
125
127
|
tokenPath: path.join(dir, "token"),
|
|
126
128
|
};
|
|
127
129
|
}
|
|
@@ -20,6 +20,13 @@ export declare function resolveAssignee(input: string, rootOpts: Record<string,
|
|
|
20
20
|
export declare function resolveUserDisplayName(id: string, name: string): string;
|
|
21
21
|
/**
|
|
22
22
|
* Resolve labels for a team, returning UUIDs for known labels.
|
|
23
|
-
*
|
|
23
|
+
*
|
|
24
|
+
* Labels that don't map to a config UUID are returned as their canonical
|
|
25
|
+
* (alias-expanded) name — e.g. `docs` becomes `documentation` — so the
|
|
26
|
+
* API-side resolver matches the right label rather than auto-creating one
|
|
27
|
+
* under the abbreviation. Pass no `teamKey` to defer team-scoped labels to
|
|
28
|
+
* API resolution: a team-scoped config UUID is only valid for that team,
|
|
29
|
+
* so resolving it here is wrong whenever the team is decided later (e.g.
|
|
30
|
+
* the create flow auto-switches the team to match the project).
|
|
24
31
|
*/
|
|
25
32
|
export declare function resolveLabels(names: string[], teamKey?: string): string[];
|
package/dist/config/resolver.js
CHANGED
|
@@ -93,11 +93,10 @@ export async function resolveAssignee(input, rootOpts) {
|
|
|
93
93
|
if (input.toLowerCase() === "me") {
|
|
94
94
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
95
95
|
const result = await graphQLService.rawRequest("{ viewer { id } }");
|
|
96
|
-
|
|
97
|
-
if (!viewer?.id) {
|
|
96
|
+
if (!result.viewer?.id) {
|
|
98
97
|
throw new Error('Could not resolve "me" — viewer query returned no user. Check your API token.');
|
|
99
98
|
}
|
|
100
|
-
return viewer.id;
|
|
99
|
+
return result.viewer.id;
|
|
101
100
|
}
|
|
102
101
|
return resolveMember(input);
|
|
103
102
|
}
|
|
@@ -173,11 +172,18 @@ function resolveLabel(name, teamKey) {
|
|
|
173
172
|
}
|
|
174
173
|
/**
|
|
175
174
|
* Resolve labels for a team, returning UUIDs for known labels.
|
|
176
|
-
*
|
|
175
|
+
*
|
|
176
|
+
* Labels that don't map to a config UUID are returned as their canonical
|
|
177
|
+
* (alias-expanded) name — e.g. `docs` becomes `documentation` — so the
|
|
178
|
+
* API-side resolver matches the right label rather than auto-creating one
|
|
179
|
+
* under the abbreviation. Pass no `teamKey` to defer team-scoped labels to
|
|
180
|
+
* API resolution: a team-scoped config UUID is only valid for that team,
|
|
181
|
+
* so resolving it here is wrong whenever the team is decided later (e.g.
|
|
182
|
+
* the create flow auto-switches the team to match the project).
|
|
177
183
|
*/
|
|
178
184
|
export function resolveLabels(names, teamKey) {
|
|
179
185
|
return names.map((name) => {
|
|
180
186
|
const resolved = resolveLabel(name, teamKey);
|
|
181
|
-
return resolved || name;
|
|
187
|
+
return resolved || LABEL_ALIASES[name.toLowerCase()] || name;
|
|
182
188
|
});
|
|
183
189
|
}
|