@enrichlayer/el-linear 1.16.0 → 1.18.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/dist/commands/comments.js +17 -1
- package/dist/commands/documents.js +1 -1
- package/dist/commands/issues/branch.d.ts +48 -1
- package/dist/commands/issues/branch.js +81 -1
- package/dist/commands/issues.js +76 -3
- package/dist/config/config.d.ts +8 -0
- package/dist/config/error-enrichment.js +7 -2
- package/dist/config/issue-validation.d.ts +23 -4
- package/dist/config/issue-validation.js +107 -20
- package/dist/queries/comments-types.d.ts +5 -0
- package/dist/queries/comments.d.ts +1 -0
- package/dist/queries/comments.js +13 -0
- package/dist/queries/issues.d.ts +2 -2
- package/dist/queries/issues.js +5 -2
- package/dist/utils/graphql-issues-service.d.ts +13 -0
- package/dist/utils/graphql-issues-service.js +56 -8
- package/dist/utils/linear-service.d.ts +15 -1
- package/dist/utils/linear-service.js +50 -5
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
3
|
-
import { CREATE_COMMENT_MUTATION, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
|
|
3
|
+
import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
|
|
4
4
|
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
5
5
|
import { applyFooter } from "../utils/footer.js";
|
|
6
6
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
@@ -260,6 +260,15 @@ async function handleListComments(issueId, options, command) {
|
|
|
260
260
|
},
|
|
261
261
|
});
|
|
262
262
|
}
|
|
263
|
+
async function handleDeleteComment(commentId, _options, command) {
|
|
264
|
+
const rootOpts = getRootOpts(command);
|
|
265
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
266
|
+
const result = await graphQLService.rawRequest(DELETE_COMMENT_MUTATION, { id: commentId });
|
|
267
|
+
if (!result.commentDelete.success) {
|
|
268
|
+
throw new Error(`Failed to delete comment "${commentId}"`);
|
|
269
|
+
}
|
|
270
|
+
outputSuccess({ id: commentId, deleted: true });
|
|
271
|
+
}
|
|
263
272
|
export function setupCommentsCommands(program) {
|
|
264
273
|
const comments = program
|
|
265
274
|
.command("comments")
|
|
@@ -291,4 +300,11 @@ export function setupCommentsCommands(program) {
|
|
|
291
300
|
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
|
|
292
301
|
.option("-l, --limit <number>", "limit results", "25")
|
|
293
302
|
.action(handleAsyncCommand(handleListComments));
|
|
303
|
+
comments
|
|
304
|
+
.command("delete <commentId>")
|
|
305
|
+
.alias("remove")
|
|
306
|
+
.alias("rm")
|
|
307
|
+
.description("Delete a comment by its id.")
|
|
308
|
+
.addHelpText("after", "\nTakes a comment id (the `id` field from `comments list` / `comments create`), not an issue identifier.")
|
|
309
|
+
.action(handleAsyncCommand(handleDeleteComment));
|
|
294
310
|
}
|
|
@@ -34,7 +34,7 @@ async function handleCreateDocument(options, command) {
|
|
|
34
34
|
title: options.title,
|
|
35
35
|
content: options.content,
|
|
36
36
|
projectId: options.project
|
|
37
|
-
? await linearService.resolveProjectId(options.project)
|
|
37
|
+
? await linearService.resolveProjectId(options.project, options.team)
|
|
38
38
|
: undefined,
|
|
39
39
|
teamId: options.team
|
|
40
40
|
? await linearService.resolveTeamId(options.team)
|
|
@@ -13,5 +13,52 @@ export declare function toBranchName(linearBranchName: string, prefix?: string):
|
|
|
13
13
|
/**
|
|
14
14
|
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
15
15
|
* Throws if the branch already exists.
|
|
16
|
+
*
|
|
17
|
+
* Returns `true` if the branch was actually checked out, `false` if the
|
|
18
|
+
* checkout was skipped (not in a git repo). Callers gate follow-on work
|
|
19
|
+
* (e.g. writing the `branch.<branch>.linearIssue` marker) on the result so
|
|
20
|
+
* they don't emit a second, contradictory warning about a branch that was
|
|
21
|
+
* never created.
|
|
22
|
+
*/
|
|
23
|
+
export declare function gitCheckoutBranch(branchName: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Extract the canonical (uppercase) Linear identifier embedded in a branch
|
|
26
|
+
* name — `feature/DEV-4293-slug` → `DEV-4293`, `dev-4293-slug` → `DEV-4293`.
|
|
27
|
+
* Returns null when the branch carries no identifier token.
|
|
28
|
+
*/
|
|
29
|
+
export declare function extractIssueIdentifierFromBranch(branch: string): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Record the Linear issue a branch implements as durable git metadata:
|
|
32
|
+
* `git config branch.<branch>.linearIssue <identifier>`. This is the signal
|
|
33
|
+
* DEV-4241's PreToolUse hook reads to confirm a workflow was established
|
|
34
|
+
* before allowing tracked-file edits — far more robust than parsing the
|
|
35
|
+
* branch name shape (which legitimately varies: `dev-4083-slug`,
|
|
36
|
+
* `fe-630-slug`, `emw-280-slug`), and it stays fast + offline (a local
|
|
37
|
+
* git config read, no API call).
|
|
38
|
+
*
|
|
39
|
+
* Best-effort by design: a failure to write the marker must never abort the
|
|
40
|
+
* issue-creation flow that triggered it. Callers in a known-good repo
|
|
41
|
+
* context wrap this and warn on failure; the standalone `mark-branch`
|
|
42
|
+
* command does its own repo/branch validation first so it can surface a
|
|
43
|
+
* clear error instead.
|
|
44
|
+
*
|
|
45
|
+
* No `--` ref-terminator (unlike the checkout/rename calls above): `git
|
|
46
|
+
* config <key> <value>` takes exactly two positional operands, the key is
|
|
47
|
+
* always our `branch.`-prefixed literal, and a value beginning with `-` is
|
|
48
|
+
* stored verbatim rather than parsed as a flag — so there's no
|
|
49
|
+
* flag-injection surface to defend here.
|
|
50
|
+
*/
|
|
51
|
+
export declare function setBranchLinearIssue(branch: string, identifier: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Read back the `branch.<branch>.linearIssue` marker, or null when unset.
|
|
54
|
+
*/
|
|
55
|
+
export declare function getBranchLinearIssue(branch: string): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Current branch's short name, or null when detached / not in a repo.
|
|
58
|
+
*
|
|
59
|
+
* Uses `symbolic-ref` rather than `rev-parse --abbrev-ref`: the former
|
|
60
|
+
* resolves the name even on an unborn branch (fresh repo, no commits yet)
|
|
61
|
+
* and still fails — yielding null — on a detached HEAD, which is exactly
|
|
62
|
+
* the "no named branch" signal callers want.
|
|
16
63
|
*/
|
|
17
|
-
export declare function
|
|
64
|
+
export declare function currentGitBranch(): string | null;
|
|
@@ -25,6 +25,12 @@ export function toBranchName(linearBranchName, prefix = "feature/") {
|
|
|
25
25
|
/**
|
|
26
26
|
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
27
27
|
* Throws if the branch already exists.
|
|
28
|
+
*
|
|
29
|
+
* Returns `true` if the branch was actually checked out, `false` if the
|
|
30
|
+
* checkout was skipped (not in a git repo). Callers gate follow-on work
|
|
31
|
+
* (e.g. writing the `branch.<branch>.linearIssue` marker) on the result so
|
|
32
|
+
* they don't emit a second, contradictory warning about a branch that was
|
|
33
|
+
* never created.
|
|
28
34
|
*/
|
|
29
35
|
export function gitCheckoutBranch(branchName) {
|
|
30
36
|
try {
|
|
@@ -34,7 +40,7 @@ export function gitCheckoutBranch(branchName) {
|
|
|
34
40
|
}
|
|
35
41
|
catch {
|
|
36
42
|
outputWarning("Not inside a git repository — skipping branch checkout.");
|
|
37
|
-
return;
|
|
43
|
+
return false;
|
|
38
44
|
}
|
|
39
45
|
// `--` separates the new-branch name from any ref. Without it, a
|
|
40
46
|
// branch name starting with `-` (server bug, malicious team member
|
|
@@ -45,4 +51,78 @@ export function gitCheckoutBranch(branchName) {
|
|
|
45
51
|
// prefix callers would lose the defense without this terminator.
|
|
46
52
|
// Defense-in-depth (DEV-4064).
|
|
47
53
|
execFileSync("git", ["checkout", "-b", branchName, "--"], { stdio: "pipe" });
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
// First Linear-style identifier token in a branch name, in any position, so
|
|
57
|
+
// it survives a configurable prefix (`feature/DEV-123-slug`) and the bare
|
|
58
|
+
// Linear shape (`dev-123-slug`). Anchored on a non-word boundary so it
|
|
59
|
+
// doesn't match mid-token.
|
|
60
|
+
const BRANCH_IDENTIFIER_REGEX = /(?:^|[^A-Za-z0-9])([A-Za-z]+-\d+)(?![A-Za-z0-9])/;
|
|
61
|
+
/**
|
|
62
|
+
* Extract the canonical (uppercase) Linear identifier embedded in a branch
|
|
63
|
+
* name — `feature/DEV-4293-slug` → `DEV-4293`, `dev-4293-slug` → `DEV-4293`.
|
|
64
|
+
* Returns null when the branch carries no identifier token.
|
|
65
|
+
*/
|
|
66
|
+
export function extractIssueIdentifierFromBranch(branch) {
|
|
67
|
+
const match = branch.match(BRANCH_IDENTIFIER_REGEX);
|
|
68
|
+
return match ? match[1].toUpperCase() : null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Record the Linear issue a branch implements as durable git metadata:
|
|
72
|
+
* `git config branch.<branch>.linearIssue <identifier>`. This is the signal
|
|
73
|
+
* DEV-4241's PreToolUse hook reads to confirm a workflow was established
|
|
74
|
+
* before allowing tracked-file edits — far more robust than parsing the
|
|
75
|
+
* branch name shape (which legitimately varies: `dev-4083-slug`,
|
|
76
|
+
* `fe-630-slug`, `emw-280-slug`), and it stays fast + offline (a local
|
|
77
|
+
* git config read, no API call).
|
|
78
|
+
*
|
|
79
|
+
* Best-effort by design: a failure to write the marker must never abort the
|
|
80
|
+
* issue-creation flow that triggered it. Callers in a known-good repo
|
|
81
|
+
* context wrap this and warn on failure; the standalone `mark-branch`
|
|
82
|
+
* command does its own repo/branch validation first so it can surface a
|
|
83
|
+
* clear error instead.
|
|
84
|
+
*
|
|
85
|
+
* No `--` ref-terminator (unlike the checkout/rename calls above): `git
|
|
86
|
+
* config <key> <value>` takes exactly two positional operands, the key is
|
|
87
|
+
* always our `branch.`-prefixed literal, and a value beginning with `-` is
|
|
88
|
+
* stored verbatim rather than parsed as a flag — so there's no
|
|
89
|
+
* flag-injection surface to defend here.
|
|
90
|
+
*/
|
|
91
|
+
export function setBranchLinearIssue(branch, identifier) {
|
|
92
|
+
execFileSync("git", ["config", `branch.${branch}.linearIssue`, identifier], {
|
|
93
|
+
stdio: "pipe",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Read back the `branch.<branch>.linearIssue` marker, or null when unset.
|
|
98
|
+
*/
|
|
99
|
+
export function getBranchLinearIssue(branch) {
|
|
100
|
+
try {
|
|
101
|
+
return execFileSync("git", ["config", "--get", `branch.${branch}.linearIssue`], { stdio: "pipe" })
|
|
102
|
+
.toString()
|
|
103
|
+
.trim();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Current branch's short name, or null when detached / not in a repo.
|
|
111
|
+
*
|
|
112
|
+
* Uses `symbolic-ref` rather than `rev-parse --abbrev-ref`: the former
|
|
113
|
+
* resolves the name even on an unborn branch (fresh repo, no commits yet)
|
|
114
|
+
* and still fails — yielding null — on a detached HEAD, which is exactly
|
|
115
|
+
* the "no named branch" signal callers want.
|
|
116
|
+
*/
|
|
117
|
+
export function currentGitBranch() {
|
|
118
|
+
try {
|
|
119
|
+
return execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
|
|
120
|
+
stdio: "pipe",
|
|
121
|
+
})
|
|
122
|
+
.toString()
|
|
123
|
+
.trim();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
48
128
|
}
|
package/dist/commands/issues.js
CHANGED
|
@@ -17,7 +17,7 @@ import { handleAsyncCommand, outputSuccess, outputWarning, warnIfTruncated, } fr
|
|
|
17
17
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
18
18
|
import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
|
|
19
19
|
import { parsePositiveInt, parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
|
|
20
|
-
import { gitCheckoutBranch, toBranchName } from "./issues/branch.js";
|
|
20
|
+
import { currentGitBranch, extractIssueIdentifierFromBranch, getBranchLinearIssue, gitCheckoutBranch, setBranchLinearIssue, toBranchName, } from "./issues/branch.js";
|
|
21
21
|
import { maybeAutoLink, prepareAutoLinkedDescription, readDescriptionFile, resolveDescription, } from "./issues/description.js";
|
|
22
22
|
import { handleLinkReferencesIssue } from "./issues/link-references.js";
|
|
23
23
|
import { buildIncomingRelationEntries, buildOutgoingRelationEntries, createRelations, } from "./issues/relations.js";
|
|
@@ -292,6 +292,10 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
292
292
|
title,
|
|
293
293
|
assignee: effectiveAssignee,
|
|
294
294
|
project: options.project,
|
|
295
|
+
// DEV-4084: scope the accepted type-label set to the team so the
|
|
296
|
+
// DEV team's `research` label validates without `--skip-validation`.
|
|
297
|
+
// Falls back to the workspace default when no team is provided.
|
|
298
|
+
team: options.team || config.defaultTeam || undefined,
|
|
295
299
|
});
|
|
296
300
|
// Apply normalized labels back so resolution uses the canonical names
|
|
297
301
|
if (validationResult.normalizedLabels) {
|
|
@@ -468,8 +472,25 @@ async function handleCreateIssue(title, options, command) {
|
|
|
468
472
|
}
|
|
469
473
|
let branch;
|
|
470
474
|
if (options.checkout && result.branchName) {
|
|
471
|
-
|
|
472
|
-
|
|
475
|
+
const branchName = toBranchName(result.branchName);
|
|
476
|
+
// Only treat the branch as created (and surface it in the output) when
|
|
477
|
+
// the checkout actually happened — outside a git repo gitCheckoutBranch
|
|
478
|
+
// warns and skips, and claiming a branch / a missing marker afterward
|
|
479
|
+
// would be a contradictory second warning (DEV-4293 cycle-1).
|
|
480
|
+
if (gitCheckoutBranch(branchName)) {
|
|
481
|
+
branch = branchName;
|
|
482
|
+
// Record the issue→branch link as git metadata (DEV-4293) so the
|
|
483
|
+
// DEV-4241 hook can confirm a workflow was established without parsing
|
|
484
|
+
// the branch name. Best-effort: never let a marker-write failure abort
|
|
485
|
+
// a successful issue creation.
|
|
486
|
+
try {
|
|
487
|
+
setBranchLinearIssue(branch, result.identifier);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
outputWarning(`Created branch ${branch} but could not record its Linear issue marker. ` +
|
|
491
|
+
`Run 'el-linear issues mark-branch ${result.identifier}' from the branch to set it.`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
473
494
|
}
|
|
474
495
|
const output = {
|
|
475
496
|
...result,
|
|
@@ -790,6 +811,14 @@ async function handleRetrolink(options, command) {
|
|
|
790
811
|
execFileSync("git", ["branch", "-m", "--", oldBranch, newBranch], {
|
|
791
812
|
stdio: "pipe",
|
|
792
813
|
});
|
|
814
|
+
// Record the issue→branch link (DEV-4293), same as `create --checkout`.
|
|
815
|
+
try {
|
|
816
|
+
setBranchLinearIssue(newBranch, result.identifier);
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
outputWarning(`Renamed branch to ${newBranch} but could not record its Linear issue marker. ` +
|
|
820
|
+
`Run 'el-linear issues mark-branch ${result.identifier}' from the branch to set it.`);
|
|
821
|
+
}
|
|
793
822
|
}
|
|
794
823
|
outputSuccess({
|
|
795
824
|
...result,
|
|
@@ -797,6 +826,43 @@ async function handleRetrolink(options, command) {
|
|
|
797
826
|
...(newBranch ? { branch: newBranch } : {}),
|
|
798
827
|
});
|
|
799
828
|
}
|
|
829
|
+
async function handleMarkBranch(issueId, _options, _command) {
|
|
830
|
+
const branch = currentGitBranch();
|
|
831
|
+
if (!branch) {
|
|
832
|
+
throw new Error("Not on a named git branch (detached HEAD or not in a repository).");
|
|
833
|
+
}
|
|
834
|
+
if (branch === "main" || branch === "master") {
|
|
835
|
+
throw new Error(`Refusing to mark the '${branch}' branch.`);
|
|
836
|
+
}
|
|
837
|
+
// Resolve the identifier to record. An explicit arg wins; otherwise infer
|
|
838
|
+
// it from the branch name. Both paths are offline — the marker's whole
|
|
839
|
+
// value is letting the hook verify a workflow without a network call, so
|
|
840
|
+
// we don't round-trip to the API just to normalize an identifier.
|
|
841
|
+
let identifier;
|
|
842
|
+
if (issueId) {
|
|
843
|
+
// Accepts a bare identifier (DEV-123), a branch-style token
|
|
844
|
+
// (dev-123-slug), or a Linear URL — extract normalizes all three.
|
|
845
|
+
identifier = extractIssueIdentifierFromBranch(issueId);
|
|
846
|
+
if (!identifier) {
|
|
847
|
+
throw new Error(`'${issueId}' is not a Linear issue identifier (expected a shape like DEV-123).`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
identifier = extractIssueIdentifierFromBranch(branch);
|
|
852
|
+
if (!identifier) {
|
|
853
|
+
throw new Error(`Branch '${branch}' has no Linear identifier to infer. ` +
|
|
854
|
+
`Pass one explicitly: 'el-linear issues mark-branch DEV-123'.`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const previous = getBranchLinearIssue(branch);
|
|
858
|
+
setBranchLinearIssue(branch, identifier);
|
|
859
|
+
outputSuccess({
|
|
860
|
+
branch,
|
|
861
|
+
linearIssue: identifier,
|
|
862
|
+
...(previous && previous !== identifier ? { previous } : {}),
|
|
863
|
+
marked: true,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
800
866
|
export function setupIssuesCommands(program) {
|
|
801
867
|
const issues = program
|
|
802
868
|
.command("issues")
|
|
@@ -997,4 +1063,11 @@ export function setupIssuesCommands(program) {
|
|
|
997
1063
|
.option("--title <title>", "override auto-generated title (default: first commit message)")
|
|
998
1064
|
.option("--base <branch>", "base branch for log comparison", "main")
|
|
999
1065
|
.action(handleAsyncCommand(handleRetrolink));
|
|
1066
|
+
issues
|
|
1067
|
+
.command("mark-branch [issueId]")
|
|
1068
|
+
.description("Record the current branch's Linear issue as git metadata (branch.<branch>.linearIssue).")
|
|
1069
|
+
.addHelpText("after", "\nManual recovery for branches not created via 'issues create --checkout' / 'retrolink'.\n" +
|
|
1070
|
+
"With no argument, infers the identifier from the branch name (e.g. dev-4293-slug -> DEV-4293).\n" +
|
|
1071
|
+
"Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.")
|
|
1072
|
+
.action(handleAsyncCommand(handleMarkBranch));
|
|
1000
1073
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface ElLinearConfig {
|
|
|
47
47
|
validation?: {
|
|
48
48
|
enabled: boolean;
|
|
49
49
|
typeLabels?: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Per-team overrides of the canonical type-label set. Keys are Linear
|
|
52
|
+
* team keys (uppercase, e.g. `DEV`). When set, an `issues create
|
|
53
|
+
* --team X` call validates labels against the team-scoped set rather
|
|
54
|
+
* than the workspace default — see `issue-validation.ts` for the
|
|
55
|
+
* built-in overrides (DEV-4084).
|
|
56
|
+
*/
|
|
57
|
+
teamTypeLabels?: Record<string, string[]>;
|
|
50
58
|
};
|
|
51
59
|
/**
|
|
52
60
|
* Optional override for the Linear workspace URL key (the part after
|
|
@@ -232,8 +232,13 @@ export async function enrichValidationErrors(result, options, services) {
|
|
|
232
232
|
const projects = teamData.projects?.nodes ?? [];
|
|
233
233
|
const members = teamData.members?.nodes ?? [];
|
|
234
234
|
const labels = teamData.labels?.nodes ?? [];
|
|
235
|
-
|
|
236
|
-
|
|
235
|
+
// DEV-4084: scope the suggested type-label set to the team when one is
|
|
236
|
+
// known so the retry hint surfaces (e.g.) `research` instead of `spike`
|
|
237
|
+
// on the Dev team.
|
|
238
|
+
const typeLabels = getCanonicalTypeLabels(options.team);
|
|
239
|
+
const inferred = options.title
|
|
240
|
+
? inferTypeFromTitle(options.title, options.team)
|
|
241
|
+
: null;
|
|
237
242
|
for (let i = 0; i < result.errors.length; i++) {
|
|
238
243
|
result.errors[i] = decorateError(result.errors[i], classifications[i], {
|
|
239
244
|
projects,
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Exported so error-enrichment can reuse the same mapping when inferring a
|
|
15
15
|
* type label from a title's first word.
|
|
16
|
+
*
|
|
17
|
+
* **Ambiguous-verb resolution.** When a verb belongs to multiple type sets
|
|
18
|
+
* (e.g. `Research` appears under both `spike` and `research`), the first
|
|
19
|
+
* declared type wins — `Object.entries` preserves insertion order, and
|
|
20
|
+
* `inferTypeFromTitle` filters by the team's accepted set before iterating.
|
|
21
|
+
* In practice no team accepts both `spike` and `research` (the synonyms),
|
|
22
|
+
* so the first-declared-wins rule never bites; if a future team accepts
|
|
23
|
+
* both, list the preferred one earlier in this map.
|
|
16
24
|
*/
|
|
17
25
|
export declare const TYPE_VERB_MAP: Record<string, string[]>;
|
|
18
26
|
export interface ValidationResult {
|
|
@@ -27,12 +35,23 @@ export interface ValidationInput {
|
|
|
27
35
|
title: string;
|
|
28
36
|
assignee: string | undefined;
|
|
29
37
|
project: string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Team key (e.g. `DEV`) the issue is being created on. When set, the
|
|
40
|
+
* validator consults the team-scoped `typeLabels` override (DEV-4084) so
|
|
41
|
+
* a team whose taxonomy doesn't include the workspace default (`spike`)
|
|
42
|
+
* can validate its own equivalent (`research`) without `--skip-validation`.
|
|
43
|
+
*
|
|
44
|
+
* `team` is otherwise advisory: it does not affect description/title/
|
|
45
|
+
* assignee/project requirements.
|
|
46
|
+
*/
|
|
47
|
+
team?: string;
|
|
30
48
|
}
|
|
31
49
|
/**
|
|
32
|
-
* Public accessor for the canonical type labels.
|
|
33
|
-
*
|
|
50
|
+
* Public accessor for the canonical type labels. Pass the team key to get
|
|
51
|
+
* the team-scoped set (DEV-4084); omit for the workspace default. Used by
|
|
52
|
+
* error-enrichment when suggesting labels.
|
|
34
53
|
*/
|
|
35
|
-
export declare function getCanonicalTypeLabels(): string[];
|
|
54
|
+
export declare function getCanonicalTypeLabels(team?: string): string[];
|
|
36
55
|
/**
|
|
37
56
|
* Find the canonical type label for a title's first word, if it matches a
|
|
38
57
|
* known verb in TYPE_VERB_MAP. Returns the matched verb and inferred type,
|
|
@@ -53,7 +72,7 @@ export declare function getCanonicalTypeLabels(): string[];
|
|
|
53
72
|
* conflate "warn about mismatch" with "suggest a default" and produce
|
|
54
73
|
* subtle bugs (e.g. inferring a type the alignment check just rejected).
|
|
55
74
|
*/
|
|
56
|
-
export declare function inferTypeFromTitle(title: string): {
|
|
75
|
+
export declare function inferTypeFromTitle(title: string, team?: string): {
|
|
57
76
|
verb: string;
|
|
58
77
|
type: string;
|
|
59
78
|
} | null;
|
|
@@ -11,12 +11,54 @@ import { outputWarning } from "../utils/output.js";
|
|
|
11
11
|
import { loadConfig } from "./config.js";
|
|
12
12
|
/** Canonical type labels. Stored in config so they can be updated without code changes. */
|
|
13
13
|
const DEFAULT_TYPE_LABELS = ["bug", "feature", "refactor", "chore", "spike"];
|
|
14
|
+
/**
|
|
15
|
+
* Built-in per-team overrides for the canonical type-label set. Teams whose
|
|
16
|
+
* taxonomy diverges from the workspace default ship here so the CLI accepts
|
|
17
|
+
* their labels out of the box without forcing every operator to maintain a
|
|
18
|
+
* personal config override.
|
|
19
|
+
*
|
|
20
|
+
* DEV-4084: the Dev team uses `research` instead of `spike` (and has no
|
|
21
|
+
* `spike` label) — the DEV-3768 fix removed the silent `research → spike`
|
|
22
|
+
* alias correctly, but left the validator's accepted set workspace-wide,
|
|
23
|
+
* so DEV creators were forced into `--skip-validation` (which also drops
|
|
24
|
+
* title-verb, label-count, and description checks).
|
|
25
|
+
*
|
|
26
|
+
* This widens the accepted set for DEV (no rewrite — DEV-3768's no-silent-
|
|
27
|
+
* alias guarantee is preserved) so `--labels "research,tools"` validates
|
|
28
|
+
* cleanly on DEV while every other team keeps `spike`.
|
|
29
|
+
*/
|
|
30
|
+
const TEAM_TYPE_LABELS = {
|
|
31
|
+
DEV: ["bug", "feature", "refactor", "chore", "research"],
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Verbs that indicate a `spike`-equivalent type. Shared between `spike` and
|
|
35
|
+
* its team-local synonym `research` so adding a new investigation verb only
|
|
36
|
+
* has to land in one place — without this constant the two arrays drift the
|
|
37
|
+
* next time someone adds e.g. `Probe`.
|
|
38
|
+
*/
|
|
39
|
+
const SPIKE_VERBS = [
|
|
40
|
+
"Research",
|
|
41
|
+
"Investigate",
|
|
42
|
+
"Explore",
|
|
43
|
+
"Evaluate",
|
|
44
|
+
"Audit",
|
|
45
|
+
"Benchmark",
|
|
46
|
+
"Test",
|
|
47
|
+
];
|
|
14
48
|
/**
|
|
15
49
|
* Recommended leading verbs for each type label.
|
|
16
50
|
* Title verb and type label should express the same intent.
|
|
17
51
|
*
|
|
18
52
|
* Exported so error-enrichment can reuse the same mapping when inferring a
|
|
19
53
|
* type label from a title's first word.
|
|
54
|
+
*
|
|
55
|
+
* **Ambiguous-verb resolution.** When a verb belongs to multiple type sets
|
|
56
|
+
* (e.g. `Research` appears under both `spike` and `research`), the first
|
|
57
|
+
* declared type wins — `Object.entries` preserves insertion order, and
|
|
58
|
+
* `inferTypeFromTitle` filters by the team's accepted set before iterating.
|
|
59
|
+
* In practice no team accepts both `spike` and `research` (the synonyms),
|
|
60
|
+
* so the first-declared-wins rule never bites; if a future team accepts
|
|
61
|
+
* both, list the preferred one earlier in this map.
|
|
20
62
|
*/
|
|
21
63
|
export const TYPE_VERB_MAP = {
|
|
22
64
|
bug: ["Fix", "Resolve", "Patch", "Handle", "Address", "Correct"],
|
|
@@ -56,15 +98,13 @@ export const TYPE_VERB_MAP = {
|
|
|
56
98
|
"Teardown",
|
|
57
99
|
"Upgrade",
|
|
58
100
|
],
|
|
59
|
-
spike:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"Test",
|
|
67
|
-
],
|
|
101
|
+
spike: SPIKE_VERBS,
|
|
102
|
+
// Team-local synonym for `spike`. Active when the validator is scoped to
|
|
103
|
+
// a team whose `typeLabels` includes `research` (see `TEAM_TYPE_LABELS` /
|
|
104
|
+
// `validation.teamTypeLabels`). Shares `SPIKE_VERBS` so the two stay in
|
|
105
|
+
// lockstep — drift would silently produce different inferences depending
|
|
106
|
+
// on which team you're on.
|
|
107
|
+
research: SPIKE_VERBS,
|
|
68
108
|
refactor: [
|
|
69
109
|
"Refactor",
|
|
70
110
|
"Restructure",
|
|
@@ -91,17 +131,50 @@ const LABEL_ALIASES = {
|
|
|
91
131
|
function getValidationConfig() {
|
|
92
132
|
const config = loadConfig();
|
|
93
133
|
const validation = config.validation;
|
|
134
|
+
// User-supplied `teamTypeLabels` are layered on top of the built-in
|
|
135
|
+
// `TEAM_TYPE_LABELS` so an operator can add a new team's override
|
|
136
|
+
// without having to re-declare DEV's. A user override for an existing
|
|
137
|
+
// team key replaces the built-in entry for that key.
|
|
138
|
+
//
|
|
139
|
+
// Normalize user-config keys to uppercase before merging — Linear team
|
|
140
|
+
// keys are uppercase canonical, but an operator who writes
|
|
141
|
+
// `{ "dev": [...] }` (lowercase) should still hit DEV's override. Doing
|
|
142
|
+
// it here means `resolveTypeLabels` can rely on a uniformly uppercased
|
|
143
|
+
// map without re-walking on every lookup.
|
|
144
|
+
const userOverrides = {};
|
|
145
|
+
for (const [key, value] of Object.entries(validation?.teamTypeLabels ?? {})) {
|
|
146
|
+
userOverrides[key.toUpperCase()] = value;
|
|
147
|
+
}
|
|
148
|
+
const teamTypeLabels = {
|
|
149
|
+
...TEAM_TYPE_LABELS,
|
|
150
|
+
...userOverrides,
|
|
151
|
+
};
|
|
94
152
|
return {
|
|
95
153
|
enabled: validation?.enabled ?? true,
|
|
96
154
|
typeLabels: validation?.typeLabels ?? DEFAULT_TYPE_LABELS,
|
|
155
|
+
teamTypeLabels,
|
|
97
156
|
};
|
|
98
157
|
}
|
|
99
158
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
159
|
+
* Resolve the canonical type-label set for a team. DEV-4084: when the team
|
|
160
|
+
* has a per-team override (built-in or config-declared) that set is returned;
|
|
161
|
+
* otherwise the workspace default. The team key is normalized to uppercase
|
|
162
|
+
* because Linear team keys are case-insensitive but stored uppercase.
|
|
163
|
+
*/
|
|
164
|
+
function resolveTypeLabels(team) {
|
|
165
|
+
const cfg = getValidationConfig();
|
|
166
|
+
if (!team)
|
|
167
|
+
return cfg.typeLabels;
|
|
168
|
+
const teamKey = team.toUpperCase();
|
|
169
|
+
return cfg.teamTypeLabels[teamKey] ?? cfg.typeLabels;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Public accessor for the canonical type labels. Pass the team key to get
|
|
173
|
+
* the team-scoped set (DEV-4084); omit for the workspace default. Used by
|
|
174
|
+
* error-enrichment when suggesting labels.
|
|
102
175
|
*/
|
|
103
|
-
export function getCanonicalTypeLabels() {
|
|
104
|
-
return
|
|
176
|
+
export function getCanonicalTypeLabels(team) {
|
|
177
|
+
return resolveTypeLabels(team);
|
|
105
178
|
}
|
|
106
179
|
/**
|
|
107
180
|
* Find the canonical type label for a title's first word, if it matches a
|
|
@@ -123,10 +196,18 @@ export function getCanonicalTypeLabels() {
|
|
|
123
196
|
* conflate "warn about mismatch" with "suggest a default" and produce
|
|
124
197
|
* subtle bugs (e.g. inferring a type the alignment check just rejected).
|
|
125
198
|
*/
|
|
126
|
-
export function inferTypeFromTitle(title) {
|
|
199
|
+
export function inferTypeFromTitle(title, team) {
|
|
127
200
|
const lowered = title.toLowerCase();
|
|
201
|
+
// DEV-4084: when scoping to a team, only suggest a type that the team
|
|
202
|
+
// actually accepts. This makes "Research GraphQL caching options" on a
|
|
203
|
+
// DEV team infer `research` instead of `spike` (which DEV doesn't have)
|
|
204
|
+
// without disrupting other teams whose taxonomy still uses `spike`.
|
|
205
|
+
const allowedTypes = new Set(resolveTypeLabels(team));
|
|
206
|
+
const isAllowed = (type) => allowedTypes.has(type);
|
|
128
207
|
// Multi-word verbs first ("Set up")
|
|
129
208
|
for (const [type, verbs] of Object.entries(TYPE_VERB_MAP)) {
|
|
209
|
+
if (!isAllowed(type))
|
|
210
|
+
continue;
|
|
130
211
|
for (const verb of verbs) {
|
|
131
212
|
if (!verb.includes(" ")) {
|
|
132
213
|
continue;
|
|
@@ -144,6 +225,8 @@ export function inferTypeFromTitle(title) {
|
|
|
144
225
|
}
|
|
145
226
|
const firstLower = firstWord.toLowerCase();
|
|
146
227
|
for (const [type, verbs] of Object.entries(TYPE_VERB_MAP)) {
|
|
228
|
+
if (!isAllowed(type))
|
|
229
|
+
continue;
|
|
147
230
|
for (const verb of verbs) {
|
|
148
231
|
if (verb.includes(" ")) {
|
|
149
232
|
continue;
|
|
@@ -182,6 +265,10 @@ export function validateIssueCreation(input) {
|
|
|
182
265
|
if (!vConfig.enabled) {
|
|
183
266
|
return result;
|
|
184
267
|
}
|
|
268
|
+
// DEV-4084: scope the accepted type-label set to the team when one was
|
|
269
|
+
// passed. Falls back to the workspace default when no team is supplied
|
|
270
|
+
// or no override exists.
|
|
271
|
+
const typeLabels = resolveTypeLabels(input.team);
|
|
185
272
|
// --- Label normalization (always runs when validation is on) ---
|
|
186
273
|
if (input.labels && input.labels.length > 0) {
|
|
187
274
|
result.normalizedLabels = input.labels.map((label) => {
|
|
@@ -196,21 +283,21 @@ export function validateIssueCreation(input) {
|
|
|
196
283
|
// --- Required: labels must be provided ---
|
|
197
284
|
if (effectiveLabels.length === 0) {
|
|
198
285
|
result.errors.push("Missing --labels. At least one label is required, including a type label.\n" +
|
|
199
|
-
` Valid type labels: ${
|
|
286
|
+
` Valid type labels: ${typeLabels.join(", ")}\n` +
|
|
200
287
|
' Example: --labels "bug,backend"');
|
|
201
288
|
}
|
|
202
289
|
else {
|
|
203
290
|
// --- Required: exactly one type label ---
|
|
204
|
-
const typeLabelsFound = effectiveLabels.filter((l) =>
|
|
291
|
+
const typeLabelsFound = effectiveLabels.filter((l) => typeLabels.includes(l.toLowerCase()));
|
|
205
292
|
if (typeLabelsFound.length === 0) {
|
|
206
293
|
result.errors.push("Missing type label. Exactly one required.\n" +
|
|
207
|
-
` Valid type labels: ${
|
|
294
|
+
` Valid type labels: ${typeLabels.join(", ")}\n` +
|
|
208
295
|
` Provided labels: ${effectiveLabels.join(", ")}\n` +
|
|
209
|
-
` Example: --labels "${
|
|
296
|
+
` Example: --labels "${typeLabels[0]},${effectiveLabels[0]}"`);
|
|
210
297
|
}
|
|
211
298
|
else if (typeLabelsFound.length > 1) {
|
|
212
299
|
result.errors.push(`Multiple type labels found: ${typeLabelsFound.join(", ")}. Exactly one required.\n` +
|
|
213
|
-
` Valid type labels: ${
|
|
300
|
+
` Valid type labels: ${typeLabels.join(", ")}`);
|
|
214
301
|
}
|
|
215
302
|
}
|
|
216
303
|
// --- Required: description must be provided ---
|
|
@@ -242,7 +329,7 @@ export function validateIssueCreation(input) {
|
|
|
242
329
|
result.warnings.push("Consider starting the title with an action verb instead of an article.");
|
|
243
330
|
}
|
|
244
331
|
// --- Warning: title-verb / type-label alignment ---
|
|
245
|
-
const typeLabelsFound = effectiveLabels.filter((l) =>
|
|
332
|
+
const typeLabelsFound = effectiveLabels.filter((l) => typeLabels.includes(l.toLowerCase()));
|
|
246
333
|
if (typeLabelsFound.length === 1) {
|
|
247
334
|
checkTitleVerbAlignment(input.title, typeLabelsFound[0].toLowerCase(), result);
|
|
248
335
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare const LIST_COMMENTS_QUERY = "\n query ListComments($issueId: String!, $first: Int) {\n issue(id: $issueId) {\n id\n identifier\n comments(first: $first, orderBy: createdAt) {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n }\n }\n }\n }\n";
|
|
2
2
|
export declare const CREATE_COMMENT_MUTATION = "\n mutation CreateComment($input: CommentCreateInput!) {\n commentCreate(input: $input) {\n success\n comment {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n }\n }\n }\n";
|
|
3
3
|
export declare const UPDATE_COMMENT_MUTATION = "\n mutation UpdateComment($id: String!, $input: CommentUpdateInput!) {\n commentUpdate(id: $id, input: $input) {\n success\n comment {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n issue {\n id\n identifier\n }\n }\n }\n }\n";
|
|
4
|
+
export declare const DELETE_COMMENT_MUTATION = "\n mutation DeleteComment($id: String!) {\n commentDelete(id: $id) {\n success\n }\n }\n";
|
package/dist/queries/comments.js
CHANGED
|
@@ -62,3 +62,16 @@ export const UPDATE_COMMENT_MUTATION = `
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
`;
|
|
65
|
+
// commentDelete returns a DeletePayload (`{ success }`) — no `comment` node,
|
|
66
|
+
// since the entity is gone. The @linear/sdk exposes this as
|
|
67
|
+
// `client.deleteComment(id)`, but the standalone comment commands all go
|
|
68
|
+
// through raw mutations on `graphQLService` (create/update need `bodyData`,
|
|
69
|
+
// which the SDK's typed input wraps awkwardly), so delete stays on the same
|
|
70
|
+
// raw path for a consistent, single-mock test surface.
|
|
71
|
+
export const DELETE_COMMENT_MUTATION = `
|
|
72
|
+
mutation DeleteComment($id: String!) {
|
|
73
|
+
commentDelete(id: $id) {
|
|
74
|
+
success
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
`;
|
package/dist/queries/issues.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export declare const GET_ISSUE_BY_IDENTIFIER_QUERY = "\n query GetIssueByIdenti
|
|
|
22
22
|
* by `id` (`projectsById`) so its milestones are still fetched for
|
|
23
23
|
* `--project-milestone` name resolution; a name uses `projectsByName`.
|
|
24
24
|
*/
|
|
25
|
-
export declare const BATCH_RESOLVE_FOR_UPDATE_QUERY = "\n query BatchResolveForUpdate(\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $teamKey: String\n $issueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first:
|
|
25
|
+
export declare const BATCH_RESOLVE_FOR_UPDATE_QUERY = "\n query BatchResolveForUpdate(\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $teamKey: String\n $issueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 5\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n projectsById: projects(\n filter: { id: { eq: $projectId } }\n first: 1\n ) @include(if: $hasProjectId) {\n nodes {\n id\n name\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n milestones: projectMilestones(\n filter: { name: { eq: $milestoneName } }\n first: 1\n ) @include(if: $hasMilestoneName) {\n nodes {\n id\n name\n }\n }\n\n issues(\n filter: {\n and: [\n { team: { key: { eq: $teamKey } } }\n { number: { eq: $issueNumber } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n identifier\n team {\n id\n key\n }\n labels {\n nodes {\n id\n name\n }\n }\n project {\n id\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n }\n }\n";
|
|
26
26
|
export declare const CREATE_ISSUE_MUTATION = "\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
27
27
|
export declare const UPDATE_ISSUE_MUTATION = "\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
28
28
|
export declare const ARCHIVE_ISSUE_MUTATION = "\n mutation ArchiveIssue($id: String!) {\n issueArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
|
@@ -46,7 +46,7 @@ export declare const DELETE_ISSUE_MUTATION = "\n mutation DeleteIssue($id: Stri
|
|
|
46
46
|
* so they carry distinct aliases and the service folds whichever ran
|
|
47
47
|
* into `resolveResult.projects`.
|
|
48
48
|
*/
|
|
49
|
-
export declare const BATCH_RESOLVE_FOR_CREATE_QUERY = "\n query BatchResolveForCreate(\n $teamKey: String\n $teamName: String\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $parentTeamKey: String\n $parentIssueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n teams(\n filter: {\n or: [\n { key: { eq: $teamKey } }\n { name: { eqIgnoreCase: $teamName } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n key\n name\n }\n }\n\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first:
|
|
49
|
+
export declare const BATCH_RESOLVE_FOR_CREATE_QUERY = "\n query BatchResolveForCreate(\n $teamKey: String\n $teamName: String\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $parentTeamKey: String\n $parentIssueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n teams(\n filter: {\n or: [\n { key: { eq: $teamKey } }\n { name: { eqIgnoreCase: $teamName } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n key\n name\n }\n }\n\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 5\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes { id name }\n }\n }\n }\n\n projectsById: projects(\n filter: { id: { eq: $projectId } }\n first: 1\n ) @include(if: $hasProjectId) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes { id name }\n }\n }\n }\n\n milestones: projectMilestones(\n filter: { name: { eq: $milestoneName } }\n first: 1\n ) @include(if: $hasMilestoneName) {\n nodes {\n id\n name\n }\n }\n\n parentIssues: issues(\n filter: {\n and: [\n { team: { key: { eq: $parentTeamKey } } }\n { number: { eq: $parentIssueNumber } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n identifier\n }\n }\n }\n";
|
|
50
50
|
/**
|
|
51
51
|
* Build a label-resolution query filtered by names (case-insensitive).
|
|
52
52
|
* Uses `or` + `eqIgnoreCase` since Linear's `in` filter may be case-sensitive.
|
package/dist/queries/issues.js
CHANGED
|
@@ -146,11 +146,14 @@ export const BATCH_RESOLVE_FOR_UPDATE_QUERY = `
|
|
|
146
146
|
) {
|
|
147
147
|
projectsByName: projects(
|
|
148
148
|
filter: { name: { eqIgnoreCase: $projectName } }
|
|
149
|
-
first:
|
|
149
|
+
first: 5
|
|
150
150
|
) @include(if: $hasProjectName) {
|
|
151
151
|
nodes {
|
|
152
152
|
id
|
|
153
153
|
name
|
|
154
|
+
teams {
|
|
155
|
+
nodes { id key }
|
|
156
|
+
}
|
|
154
157
|
projectMilestones {
|
|
155
158
|
nodes {
|
|
156
159
|
id
|
|
@@ -313,7 +316,7 @@ export const BATCH_RESOLVE_FOR_CREATE_QUERY = `
|
|
|
313
316
|
|
|
314
317
|
projectsByName: projects(
|
|
315
318
|
filter: { name: { eqIgnoreCase: $projectName } }
|
|
316
|
-
first:
|
|
319
|
+
first: 5
|
|
317
320
|
) @include(if: $hasProjectName) {
|
|
318
321
|
nodes {
|
|
319
322
|
id
|
|
@@ -175,6 +175,19 @@ export declare class GraphQLIssuesService {
|
|
|
175
175
|
private executeCreateMutation;
|
|
176
176
|
searchIssues(args: SearchIssueArgs): Promise<LinearIssue[]>;
|
|
177
177
|
private resolveTeamId;
|
|
178
|
+
/**
|
|
179
|
+
* Pick a project ID from the batched name-lookup result.
|
|
180
|
+
*
|
|
181
|
+
* The batch query fetches up to 5 candidates by name (DEV-4103) so that
|
|
182
|
+
* a name shared across teams doesn't silently bind to whichever project
|
|
183
|
+
* Linear returned first. When a `--team` was supplied, prefer the
|
|
184
|
+
* candidate whose team set includes it; the downstream
|
|
185
|
+
* `validateProjectTeam` will then no-op (the team already matches).
|
|
186
|
+
* When multiple candidates exist and none of them are on the requested
|
|
187
|
+
* team (or no team was supplied), throw an explicit ambiguous-project
|
|
188
|
+
* error listing the candidate teams — far better than the prior silent
|
|
189
|
+
* cross-team match.
|
|
190
|
+
*/
|
|
178
191
|
private resolveProjectId;
|
|
179
192
|
/**
|
|
180
193
|
* Check that the resolved team is associated with the project.
|
|
@@ -3,7 +3,7 @@ import { ARCHIVE_ISSUE_MUTATION, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_F
|
|
|
3
3
|
import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
|
|
4
4
|
import { toISOStringOrNow } from "./date-format.js";
|
|
5
5
|
import { extractEmbeds } from "./embed-parser.js";
|
|
6
|
-
import { notFoundError } from "./error-messages.js";
|
|
6
|
+
import { multipleMatchesError, notFoundError } from "./error-messages.js";
|
|
7
7
|
import { parseIssueIdentifier, tryParseIssueIdentifier, } from "./identifier-parser.js";
|
|
8
8
|
import { logger } from "./logger.js";
|
|
9
9
|
import { isUuid } from "./uuid.js";
|
|
@@ -111,8 +111,15 @@ export class GraphQLIssuesService {
|
|
|
111
111
|
teamIdForLabels = await this.fetchIssueTeamId(resolvedIssueId);
|
|
112
112
|
}
|
|
113
113
|
const finalLabelIds = await this.resolveLabelsWithMode(normalizedArgs.labelIds, resolveResult, labelMode, currentIssueLabels, teamIdForLabels, normalizedArgs.teamId);
|
|
114
|
+
// DEV-4103: scope project disambiguation by the issue's existing team
|
|
115
|
+
// so a `--project` name shared across teams binds to the issue's team
|
|
116
|
+
// rather than whichever project Linear happened to return first.
|
|
117
|
+
// `issueTeamId` is the right scope here — it's always a UUID and
|
|
118
|
+
// represents the team the issue currently lives on. `--team` is rare
|
|
119
|
+
// on update (and would imply a cross-team move that goes through
|
|
120
|
+
// separate validation paths anyway).
|
|
114
121
|
const finalProjectId = normalizedArgs.projectId
|
|
115
|
-
? this.resolveProjectId(normalizedArgs.projectId, resolveResult)
|
|
122
|
+
? this.resolveProjectId(normalizedArgs.projectId, resolveResult, issueTeamId)
|
|
116
123
|
: undefined;
|
|
117
124
|
const { projectMilestoneNodes, issueProjectMilestoneNodes } = this.extractMilestoneNodes(normalizedArgs, resolveResult);
|
|
118
125
|
const finalMilestoneId = this.resolveMilestoneId(normalizedArgs.milestoneId, resolveResult, projectMilestoneNodes, issueProjectMilestoneNodes);
|
|
@@ -272,7 +279,7 @@ export class GraphQLIssuesService {
|
|
|
272
279
|
? await this.resolveTeamId(args.teamId, resolveResult)
|
|
273
280
|
: undefined;
|
|
274
281
|
const projectId = args.projectId
|
|
275
|
-
? this.resolveProjectId(args.projectId, resolveResult)
|
|
282
|
+
? this.resolveProjectId(args.projectId, resolveResult, teamId)
|
|
276
283
|
: undefined;
|
|
277
284
|
// Validate team-project compatibility and auto-correct when possible
|
|
278
285
|
if (projectId && teamId && args.projectId) {
|
|
@@ -352,7 +359,7 @@ export class GraphQLIssuesService {
|
|
|
352
359
|
const finalProjectId = projectIdArg
|
|
353
360
|
? this.resolveProjectId(projectIdArg, resolveResult)
|
|
354
361
|
: undefined;
|
|
355
|
-
const finalAssigneeId = this.resolveAssigneeId(args.assigneeId, resolveResult);
|
|
362
|
+
const finalAssigneeId = await this.resolveAssigneeId(args.assigneeId, resolveResult);
|
|
356
363
|
const finalDelegateId = await this.resolveDelegateId(args.delegateId, resolveResult);
|
|
357
364
|
// Map the public discriminant back to the internal filter shape:
|
|
358
365
|
// `{ kind: "id", id }` → `{ kind: "id", id: <resolvedUuid> }`
|
|
@@ -423,7 +430,20 @@ export class GraphQLIssuesService {
|
|
|
423
430
|
// Exact GraphQL match failed — fall back to prefix matching via LinearService
|
|
424
431
|
return this.linearService.resolveTeamId(teamId);
|
|
425
432
|
}
|
|
426
|
-
|
|
433
|
+
/**
|
|
434
|
+
* Pick a project ID from the batched name-lookup result.
|
|
435
|
+
*
|
|
436
|
+
* The batch query fetches up to 5 candidates by name (DEV-4103) so that
|
|
437
|
+
* a name shared across teams doesn't silently bind to whichever project
|
|
438
|
+
* Linear returned first. When a `--team` was supplied, prefer the
|
|
439
|
+
* candidate whose team set includes it; the downstream
|
|
440
|
+
* `validateProjectTeam` will then no-op (the team already matches).
|
|
441
|
+
* When multiple candidates exist and none of them are on the requested
|
|
442
|
+
* team (or no team was supplied), throw an explicit ambiguous-project
|
|
443
|
+
* error listing the candidate teams — far better than the prior silent
|
|
444
|
+
* cross-team match.
|
|
445
|
+
*/
|
|
446
|
+
resolveProjectId(projectId, resolveResult, teamId) {
|
|
427
447
|
if (isUuid(projectId)) {
|
|
428
448
|
// The create/update batch queries fetch a `projectsById` block for a
|
|
429
449
|
// UUID `--project` (folded into `resolveResult.projects`). An empty
|
|
@@ -443,7 +463,25 @@ export class GraphQLIssuesService {
|
|
|
443
463
|
if (!projectNodes?.length) {
|
|
444
464
|
throw notFoundError("Project", projectId);
|
|
445
465
|
}
|
|
446
|
-
|
|
466
|
+
if (projectNodes.length === 1) {
|
|
467
|
+
return projectNodes[0].id;
|
|
468
|
+
}
|
|
469
|
+
// Multiple matches — disambiguate by team when possible.
|
|
470
|
+
if (teamId) {
|
|
471
|
+
const teamMatches = projectNodes.filter((n) => n.teams?.nodes.some((t) => t.id === teamId));
|
|
472
|
+
if (teamMatches.length === 1) {
|
|
473
|
+
return teamMatches[0].id;
|
|
474
|
+
}
|
|
475
|
+
if (teamMatches.length > 1) {
|
|
476
|
+
// Same name, same team — extremely rare but Linear permits it.
|
|
477
|
+
// Fall through to ambiguity error so the user picks by URL/slug.
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const descriptions = projectNodes.map((n) => {
|
|
481
|
+
const teamKeys = n.teams?.nodes.map((t) => t.key) ?? [];
|
|
482
|
+
return `${n.id} (teams: ${teamKeys.join(", ") || "none"})`;
|
|
483
|
+
});
|
|
484
|
+
throw multipleMatchesError("project", projectId, descriptions, "scope with --team, or pass the project URL/slug-id");
|
|
447
485
|
}
|
|
448
486
|
/**
|
|
449
487
|
* Check that the resolved team is associated with the project.
|
|
@@ -839,10 +877,20 @@ export class GraphQLIssuesService {
|
|
|
839
877
|
}
|
|
840
878
|
return filter;
|
|
841
879
|
}
|
|
842
|
-
resolveAssigneeId(assigneeId, resolveResult) {
|
|
843
|
-
if (!assigneeId || isUuid(assigneeId)
|
|
880
|
+
async resolveAssigneeId(assigneeId, resolveResult) {
|
|
881
|
+
if (!assigneeId || isUuid(assigneeId)) {
|
|
844
882
|
return assigneeId;
|
|
845
883
|
}
|
|
884
|
+
// A plain name (no `@`) resolves via the user lookup — same as
|
|
885
|
+
// `resolveDelegateId`. Without this, a non-config full name like
|
|
886
|
+
// "Yury Tsukerman" fell through unchanged and was sent to the
|
|
887
|
+
// GraphQL filter as a bogus UUID, producing an opaque "Argument
|
|
888
|
+
// Validation Error" instead of resolving (or a clean not-found).
|
|
889
|
+
// `resolveUserId` matches displayName then name and throws a
|
|
890
|
+
// structured notFound / multiple-matches error (DEV-4312).
|
|
891
|
+
if (!assigneeId.includes("@")) {
|
|
892
|
+
return this.linearService.resolveUserId(assigneeId);
|
|
893
|
+
}
|
|
846
894
|
const userNodes = resolveResult.users?.nodes;
|
|
847
895
|
if (!userNodes?.length) {
|
|
848
896
|
throw notFoundError("User", assigneeId);
|
|
@@ -35,7 +35,21 @@ export declare class LinearService {
|
|
|
35
35
|
getCycles(teamFilter?: string, activeOnly?: boolean, limit?: number): Promise<LinearCycleSummary[]>;
|
|
36
36
|
getCycleById(cycleId: string, issuesLimit?: number): Promise<LinearCycleDetail>;
|
|
37
37
|
resolveCycleId(cycleNameOrId: string, teamFilter?: string): Promise<string>;
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a user-supplied project identifier to a UUID.
|
|
40
|
+
*
|
|
41
|
+
* Inputs (in order): UUID (pass-through), Linear URL or slug-id pair
|
|
42
|
+
* (unique by slug, resolved via `slugId` filter), or a plain name (case
|
|
43
|
+
* insensitive). DEV-4103: when a name resolution returns multiple matches
|
|
44
|
+
* across teams, the resolver disambiguates by `--team` when provided and
|
|
45
|
+
* throws an `ambiguous project` error otherwise — replacing the prior
|
|
46
|
+
* silent `first: 1` pick that could cross team boundaries.
|
|
47
|
+
*
|
|
48
|
+
* `teamInput` is the same string a user would pass to `--team`
|
|
49
|
+
* (key / name / UUID); it is resolved here when present so callers don't
|
|
50
|
+
* have to double-resolve.
|
|
51
|
+
*/
|
|
52
|
+
resolveProjectId(projectInput: string, teamInput?: string): Promise<string>;
|
|
39
53
|
/**
|
|
40
54
|
* Normalize a user-supplied project input to a UUID when the input is
|
|
41
55
|
* a URL or slug-id form. Pass-through for UUIDs and plain names — the
|
|
@@ -471,7 +471,21 @@ export class LinearService {
|
|
|
471
471
|
}
|
|
472
472
|
return chosen.id;
|
|
473
473
|
}
|
|
474
|
-
|
|
474
|
+
/**
|
|
475
|
+
* Resolve a user-supplied project identifier to a UUID.
|
|
476
|
+
*
|
|
477
|
+
* Inputs (in order): UUID (pass-through), Linear URL or slug-id pair
|
|
478
|
+
* (unique by slug, resolved via `slugId` filter), or a plain name (case
|
|
479
|
+
* insensitive). DEV-4103: when a name resolution returns multiple matches
|
|
480
|
+
* across teams, the resolver disambiguates by `--team` when provided and
|
|
481
|
+
* throws an `ambiguous project` error otherwise — replacing the prior
|
|
482
|
+
* silent `first: 1` pick that could cross team boundaries.
|
|
483
|
+
*
|
|
484
|
+
* `teamInput` is the same string a user would pass to `--team`
|
|
485
|
+
* (key / name / UUID); it is resolved here when present so callers don't
|
|
486
|
+
* have to double-resolve.
|
|
487
|
+
*/
|
|
488
|
+
async resolveProjectId(projectInput, teamInput) {
|
|
475
489
|
if (isUuid(projectInput)) {
|
|
476
490
|
return projectInput;
|
|
477
491
|
}
|
|
@@ -501,15 +515,46 @@ export class LinearService {
|
|
|
501
515
|
// not-found error as the name path.
|
|
502
516
|
throw notFoundError("Project", projectInput);
|
|
503
517
|
}
|
|
504
|
-
|
|
518
|
+
// DEV-4103: scope by team when provided so a name shared across teams
|
|
519
|
+
// doesn't resolve to a different team's project. Same SDK filter shape
|
|
520
|
+
// `getProjects` already uses for `--team` filtering.
|
|
521
|
+
const teamId = teamInput ? await this.resolveTeamId(teamInput) : undefined;
|
|
522
|
+
const filter = {
|
|
523
|
+
name: { eqIgnoreCase: projectInput },
|
|
524
|
+
};
|
|
525
|
+
if (teamId) {
|
|
526
|
+
// Matches the filter shape `getProjects` uses for `--team`.
|
|
527
|
+
filter.teams = { some: { id: { eq: teamId } } };
|
|
528
|
+
}
|
|
529
|
+
// `first: 5` is wide enough to detect ambiguity (same name across
|
|
530
|
+
// multiple teams) without paying for a deeper page. Linear's UI caps
|
|
531
|
+
// effective project-name collisions at a handful in practice; if a
|
|
532
|
+
// workspace ever exceeds this we'll see it as a still-ambiguous error
|
|
533
|
+
// listing the first 5 teams — better than a silent wrong pick.
|
|
505
534
|
const projectsConnection = await this.client.projects({
|
|
506
535
|
filter,
|
|
507
|
-
first:
|
|
536
|
+
first: 5,
|
|
508
537
|
});
|
|
509
538
|
if (projectsConnection.nodes.length === 0) {
|
|
510
|
-
|
|
539
|
+
const context = teamInput ? `on team "${teamInput}"` : undefined;
|
|
540
|
+
throw notFoundError("Project", projectInput, context);
|
|
541
|
+
}
|
|
542
|
+
if (projectsConnection.nodes.length === 1) {
|
|
543
|
+
return projectsConnection.nodes[0].id;
|
|
511
544
|
}
|
|
512
|
-
|
|
545
|
+
// Multiple candidates — collect team keys per candidate so the error
|
|
546
|
+
// names them concretely. Bounded N ≤ 5 keeps the per-edge resolver
|
|
547
|
+
// promise cheap (handful of round-trips on the rare ambiguous path).
|
|
548
|
+
const candidates = await Promise.all(projectsConnection.nodes.map(async (p) => {
|
|
549
|
+
const teams = await p.teams();
|
|
550
|
+
return {
|
|
551
|
+
id: p.id,
|
|
552
|
+
name: p.name,
|
|
553
|
+
teamKeys: teams.nodes.map((t) => t.key),
|
|
554
|
+
};
|
|
555
|
+
}));
|
|
556
|
+
const descriptions = candidates.map((c) => `${c.id} (teams: ${c.teamKeys.join(", ") || "none"})`);
|
|
557
|
+
throw multipleMatchesError("project", projectInput, descriptions, "scope with --team, or pass the project URL/slug-id");
|
|
513
558
|
}
|
|
514
559
|
/**
|
|
515
560
|
* Normalize a user-supplied project input to a UUID when the input is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.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",
|