@enrichlayer/el-linear 1.17.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.
@@ -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
  }
@@ -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 gitCheckoutBranch(branchName: string): void;
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
  }
@@ -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";
@@ -472,8 +472,25 @@ async function handleCreateIssue(title, options, command) {
472
472
  }
473
473
  let branch;
474
474
  if (options.checkout && result.branchName) {
475
- branch = toBranchName(result.branchName);
476
- gitCheckoutBranch(branch);
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
+ }
477
494
  }
478
495
  const output = {
479
496
  ...result,
@@ -794,6 +811,14 @@ async function handleRetrolink(options, command) {
794
811
  execFileSync("git", ["branch", "-m", "--", oldBranch, newBranch], {
795
812
  stdio: "pipe",
796
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
+ }
797
822
  }
798
823
  outputSuccess({
799
824
  ...result,
@@ -801,6 +826,43 @@ async function handleRetrolink(options, command) {
801
826
  ...(newBranch ? { branch: newBranch } : {}),
802
827
  });
803
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
+ }
804
866
  export function setupIssuesCommands(program) {
805
867
  const issues = program
806
868
  .command("issues")
@@ -1001,4 +1063,11 @@ export function setupIssuesCommands(program) {
1001
1063
  .option("--title <title>", "override auto-generated title (default: first commit message)")
1002
1064
  .option("--base <branch>", "base branch for log comparison", "main")
1003
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));
1004
1073
  }
@@ -52,4 +52,9 @@ export interface UpdateCommentResponse {
52
52
  comment: UpdatedCommentResourceNode | null;
53
53
  };
54
54
  }
55
+ export interface DeleteCommentResponse {
56
+ commentDelete: {
57
+ success: boolean;
58
+ };
59
+ }
55
60
  export {};
@@ -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";
@@ -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
+ `;
@@ -359,7 +359,7 @@ export class GraphQLIssuesService {
359
359
  const finalProjectId = projectIdArg
360
360
  ? this.resolveProjectId(projectIdArg, resolveResult)
361
361
  : undefined;
362
- const finalAssigneeId = this.resolveAssigneeId(args.assigneeId, resolveResult);
362
+ const finalAssigneeId = await this.resolveAssigneeId(args.assigneeId, resolveResult);
363
363
  const finalDelegateId = await this.resolveDelegateId(args.delegateId, resolveResult);
364
364
  // Map the public discriminant back to the internal filter shape:
365
365
  // `{ kind: "id", id }` → `{ kind: "id", id: <resolvedUuid> }`
@@ -877,10 +877,20 @@ export class GraphQLIssuesService {
877
877
  }
878
878
  return filter;
879
879
  }
880
- resolveAssigneeId(assigneeId, resolveResult) {
881
- if (!assigneeId || isUuid(assigneeId) || !assigneeId.includes("@")) {
880
+ async resolveAssigneeId(assigneeId, resolveResult) {
881
+ if (!assigneeId || isUuid(assigneeId)) {
882
882
  return assigneeId;
883
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
+ }
884
894
  const userNodes = resolveResult.users?.nodes;
885
895
  if (!userNodes?.length) {
886
896
  throw notFoundError("User", assigneeId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.17.0",
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",