linear-ls 1.1.1 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +10 -16
  2. package/dist/server.js +176 -58
  3. package/package.json +28 -28
package/README.md CHANGED
@@ -10,22 +10,14 @@ npm i -g linear-ls
10
10
 
11
11
  ### neovim
12
12
 
13
- If you use `lspconfig`, add the following:
14
-
15
13
  ```lua
16
- if not lspconfigconfigs.linear_ls then
17
- lspconfigconfigs.linear_ls = {
18
- default_config = {
19
- cmd = { "linear-ls", "--stdio" },
20
- filetypes = { "typescript" },
21
- root_dir = function(fname)
22
- return lspconfig.util.find_git_ancestor(fname)
23
- end,
24
- },
25
- }
26
- end
27
- lspconfig.lls.setup({
28
- capabilities = capabilities,
14
+ vim.lsp.config("linear-ls", {
15
+ cmd = { "linear-ls", "--stdio" },
16
+ filetypes = { "typescript" },
17
+ })
18
+
19
+ vim.lsp.enable({
20
+ "linear-ls",
29
21
  })
30
22
  ```
31
23
 
@@ -47,4 +39,6 @@ Typing team key, hyphen and search term (e.g. `EUC-thing`) triggers issue search
47
39
 
48
40
  ### Create Ticket from Text Selection
49
41
 
50
- TODO
42
+ Select text in your editor and trigger the code action (e.g., `ga` in Neovim or `Cmd+.` in VS Code) to create a Linear issue. The selected text will be used as the issue title, and it will be replaced with a link to the created ticket in the format `[KEY-123](https://linear.app/...)`.
43
+
44
+ _Note: The server currently uses the first available team it has access to._
package/dist/server.js CHANGED
@@ -3,17 +3,51 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const sdk_1 = require("@linear/sdk");
4
4
  const node_1 = require("vscode-languageserver/node");
5
5
  const vscode_languageserver_textdocument_1 = require("vscode-languageserver-textdocument");
6
- // ENG-100
7
- const connection = (0, node_1.createConnection)(node_1.ProposedFeatures.all);
8
- const documents = new node_1.TextDocuments(vscode_languageserver_textdocument_1.TextDocument);
6
+ const conn = (0, node_1.createConnection)(node_1.ProposedFeatures.all);
7
+ const docs = new node_1.TextDocuments(vscode_languageserver_textdocument_1.TextDocument);
9
8
  const teams = new Map();
10
9
  const issues = new Map();
11
- const issuePositions = new Map();
10
+ const positions = new Map();
12
11
  let client;
13
- connection.onInitialize(async () => {
12
+ async function diagnostics(textDocument) {
13
+ if (!client) {
14
+ return;
15
+ }
16
+ const docPositions = positions.get(textDocument.uri);
17
+ if (!docPositions) {
18
+ return;
19
+ }
20
+ const diagnostics = [];
21
+ for (const p of docPositions) {
22
+ let issue = issues.get(p.issueKey);
23
+ if (!issue) {
24
+ issue = await client.issue(p.issueKey).catch(() => undefined);
25
+ if (issue)
26
+ issues.set(p.issueKey, issue);
27
+ }
28
+ if (issue?.completedAt) {
29
+ diagnostics.push({
30
+ severity: node_1.DiagnosticSeverity.Information,
31
+ range: { start: p.positionStart, end: p.positionEnd },
32
+ message: `${p.issueKey} is completed`,
33
+ source: "Linear",
34
+ });
35
+ }
36
+ else if (issue?.archivedAt) {
37
+ diagnostics.push({
38
+ severity: node_1.DiagnosticSeverity.Information,
39
+ range: { start: p.positionStart, end: p.positionEnd },
40
+ message: `${p.issueKey} is archived`,
41
+ source: "Linear",
42
+ });
43
+ }
44
+ }
45
+ conn.sendDiagnostics({ uri: textDocument.uri, diagnostics });
46
+ }
47
+ conn.onInitialize(async () => {
14
48
  const apiKey = process.env.LINEAR_API_KEY;
15
49
  if (!apiKey) {
16
- connection.window.showWarningMessage("Linear API Key not found. Please set the LINEAR_API_KEY environment variable.");
50
+ conn.window.showWarningMessage("Linear API Key not found. Please set the LINEAR_API_KEY environment variable.");
17
51
  return { capabilities: {} };
18
52
  }
19
53
  client = new sdk_1.LinearClient({ apiKey });
@@ -22,10 +56,13 @@ connection.onInitialize(async () => {
22
56
  teams.set(t.key, t.id);
23
57
  });
24
58
  });
25
- const result = {
59
+ return {
26
60
  capabilities: {
27
61
  textDocumentSync: node_1.TextDocumentSyncKind.Incremental,
28
62
  codeActionProvider: {},
63
+ executeCommandProvider: {
64
+ commands: ["linear-ls.createIssue", "linear-ls.openIssue"],
65
+ },
29
66
  hoverProvider: {},
30
67
  completionProvider: { resolveProvider: true },
31
68
  semanticTokensProvider: {
@@ -37,33 +74,124 @@ connection.onInitialize(async () => {
37
74
  },
38
75
  },
39
76
  };
40
- return result;
41
77
  });
42
- connection.onCodeAction((params) => {
78
+ conn.onCodeAction(async (params) => {
79
+ const actions = [];
43
80
  if (params.context.triggerKind === node_1.CodeActionTriggerKind.Invoked) {
44
- const textDocument = documents.get(params.textDocument.uri);
45
- if (!textDocument) {
81
+ const doc = docs.get(params.textDocument.uri);
82
+ if (doc) {
83
+ const text = doc.getText(params.range);
84
+ if (text) {
85
+ actions.push({
86
+ title: "Create Issue from Selection",
87
+ kind: node_1.CodeActionKind.RefactorRewrite,
88
+ command: {
89
+ command: "linear-ls.createIssue",
90
+ title: "Create Issue",
91
+ arguments: [params.textDocument.uri, params.range, text.trim()],
92
+ },
93
+ });
94
+ }
95
+ }
96
+ }
97
+ const docPositions = positions.get(params.textDocument.uri);
98
+ for (const p of docPositions ?? []) {
99
+ if (p.positionStart.line === params.range.start.line) {
100
+ if (params.range.start.character >= p.positionStart.character &&
101
+ params.range.start.character <= p.positionEnd.character) {
102
+ let issue = issues.get(p.issueKey);
103
+ if (!issue) {
104
+ issue = await client.issue(p.issueKey).catch(() => undefined);
105
+ if (issue)
106
+ issues.set(p.issueKey, issue);
107
+ }
108
+ if (issue?.url) {
109
+ actions.push({
110
+ title: `Open ${p.issueKey} in Linear`,
111
+ kind: node_1.CodeActionKind.Empty,
112
+ command: {
113
+ command: "linear-ls.openIssue",
114
+ title: "Open in Linear",
115
+ arguments: [issue.url],
116
+ },
117
+ });
118
+ }
119
+ }
120
+ }
121
+ }
122
+ return actions;
123
+ });
124
+ conn.onExecuteCommand(async (params) => {
125
+ if (params.command === "linear-ls.openIssue") {
126
+ const [url] = params.arguments || [];
127
+ if (url) {
128
+ conn.window.showDocument({ uri: url, external: true });
129
+ }
130
+ }
131
+ if (params.command === "linear-ls.createTicket") {
132
+ const [uri, range, title] = params.arguments || [];
133
+ if (!uri || !range || !title) {
46
134
  return;
47
135
  }
48
- const text = textDocument.getText(params.range);
49
- if (!text) {
136
+ if (teams.size === 0) {
137
+ conn.window.showErrorMessage("No teams found. Please ensure you have access to at least one Linear team.");
50
138
  return;
51
139
  }
52
- // TODO: Implement command to create ticket
53
- return [{ title: "Create Ticket" }];
140
+ let teamId;
141
+ if (teams.size === 1) {
142
+ teamId = teams.values().next().value;
143
+ }
144
+ else {
145
+ const teamItems = Array.from(teams.keys()).map((key) => ({
146
+ title: key,
147
+ }));
148
+ const selected = await conn.window.showInformationMessage("Select a team for the new ticket:", ...teamItems);
149
+ if (selected) {
150
+ teamId = teams.get(selected.title);
151
+ }
152
+ }
153
+ if (!teamId) {
154
+ return;
155
+ }
156
+ try {
157
+ const issue = await client.createIssue({ teamId, title });
158
+ const createdIssue = await issue.issue;
159
+ if (createdIssue) {
160
+ const identifier = createdIssue.identifier;
161
+ const url = createdIssue.url;
162
+ const link = `[${identifier}](${url})`;
163
+ const edit = {
164
+ changes: {
165
+ [uri]: [
166
+ {
167
+ range: range,
168
+ newText: link,
169
+ },
170
+ ],
171
+ },
172
+ };
173
+ await conn.workspace.applyEdit(edit);
174
+ }
175
+ }
176
+ catch (e) {
177
+ conn.console.error(`Error creating issue: ${e}`);
178
+ conn.window.showErrorMessage(`Failed to create Linear issue: ${e}`);
179
+ }
54
180
  }
55
181
  });
56
182
  let documentChangeTimeout;
57
- documents.onDidChangeContent((change) => {
183
+ docs.onDidChangeContent((change) => {
58
184
  clearTimeout(documentChangeTimeout);
59
- documentChangeTimeout = setTimeout(() => {
185
+ documentChangeTimeout = setTimeout(async () => {
60
186
  const text = change.document.getText();
61
187
  const documentPositions = [];
62
- Array.from(teams.keys())
63
- .flatMap((prefix) => {
64
- return Array.from(text.matchAll(new RegExp(`${prefix}-[0-9]*`, "g")));
65
- })
66
- .forEach((m) => {
188
+ const teamKeys = Array.from(teams.keys());
189
+ if (teamKeys.length === 0) {
190
+ positions.set(change.document.uri, []);
191
+ return;
192
+ }
193
+ const regexp = new RegExp(`(${teamKeys.join("|")})-[0-9]*`, "g");
194
+ Array.from(text.matchAll(regexp)).forEach((m) => {
67
195
  const issueKey = m[0];
68
196
  const positionStart = change.document.positionAt(m.index ?? 0);
69
197
  const positionEnd = change.document.positionAt(issueKey.length + (m.index ?? 0));
@@ -80,43 +208,42 @@ documents.onDidChangeContent((change) => {
80
208
  offsetEnd: change.document.offsetAt(positionEnd),
81
209
  });
82
210
  });
83
- issuePositions.set(change.document.uri, documentPositions);
84
- }, 200);
211
+ positions.set(change.document.uri, documentPositions);
212
+ await diagnostics(change.document);
213
+ }, 1000);
85
214
  });
86
- connection.onHover(async (params) => {
87
- const documentPositions = issuePositions.get(params.textDocument.uri);
88
- if (!documentPositions) {
215
+ conn.onHover(async (params) => {
216
+ const docPositions = positions.get(params.textDocument.uri);
217
+ if (!docPositions) {
89
218
  return;
90
219
  }
91
- const textDocument = documents.get(params.textDocument.uri);
92
- if (!textDocument) {
220
+ const doc = docs.get(params.textDocument.uri);
221
+ if (!doc) {
93
222
  return;
94
223
  }
95
- const cursorOffset = textDocument.offsetAt(params.position);
96
- const targetIssue = documentPositions.find((dp) => dp.offsetStart <= cursorOffset && dp.offsetEnd > cursorOffset);
97
- if (!targetIssue) {
224
+ const cursorOffset = doc.offsetAt(params.position);
225
+ const pos = docPositions.find((dp) => dp.offsetStart <= cursorOffset && dp.offsetEnd > cursorOffset);
226
+ if (!pos) {
98
227
  return;
99
228
  }
100
- const issueFromCache = issues.get(targetIssue.issueKey);
101
- if (issueFromCache) {
102
- return {
103
- contents: issueFromCache.description ?? "Not available",
104
- };
229
+ let issue = issues.get(pos.issueKey);
230
+ if (!issue) {
231
+ issue = await client.issue(pos.issueKey).catch(() => undefined);
232
+ if (issue)
233
+ issues.set(pos.issueKey, issue);
105
234
  }
106
- const issue = await client.issue(targetIssue.issueKey);
107
235
  if (!issue) {
108
236
  return;
109
237
  }
110
- issues.set(targetIssue.issueKey, issue);
111
238
  return {
112
239
  contents: issue.description ?? "Not available",
113
240
  };
114
241
  });
115
- connection.onCompletion(async (params) => {
242
+ conn.onCompletion(async (params) => {
116
243
  if (teams.size === 0) {
117
244
  return [];
118
245
  }
119
- const document = documents.get(params.textDocument.uri);
246
+ const document = docs.get(params.textDocument.uri);
120
247
  if (!document) {
121
248
  return [];
122
249
  }
@@ -141,7 +268,7 @@ connection.onCompletion(async (params) => {
141
268
  });
142
269
  }
143
270
  catch (e) {
144
- connection.console.error(`LLS: Error searching issues: ${e}`);
271
+ conn.console.error(`LLS: Error searching issues: ${e}`);
145
272
  return [];
146
273
  }
147
274
  return issues.nodes.map((i) => {
@@ -158,30 +285,21 @@ connection.onCompletion(async (params) => {
158
285
  };
159
286
  });
160
287
  });
161
- connection.languages.semanticTokens.on((params) => {
162
- const positions = issuePositions.get(params.textDocument.uri);
163
- if (!positions) {
288
+ conn.languages.semanticTokens.on((params) => {
289
+ const docPositions = positions.get(params.textDocument.uri);
290
+ if (!docPositions) {
164
291
  return { data: [] };
165
292
  }
166
293
  const builder = new node_1.SemanticTokensBuilder();
167
- positions.sort((a, b) => {
168
- if (a.positionStart.line !== b.positionStart.line) {
169
- return a.positionStart.line - b.positionStart.line;
170
- }
171
- return a.positionStart.character - b.positionStart.character;
172
- });
173
- for (const p of positions) {
174
- // Semantic tokens are assumed to be single-line. If a token ever spans multiple
175
- // lines, its length must be calculated differently. For now, we ignore such ranges.
294
+ for (const p of docPositions) {
176
295
  if (p.positionStart.line !== p.positionEnd.line) {
177
296
  continue;
178
297
  }
179
298
  const length = p.positionEnd.character - p.positionStart.character;
180
299
  builder.push(p.positionStart.line, p.positionStart.character, length, 0, // token type index (0 = variable, as defined in legend)
181
- 0 // token modifiers (none)
182
- );
300
+ 0);
183
301
  }
184
302
  return builder.build();
185
303
  });
186
- documents.listen(connection);
187
- connection.listen();
304
+ docs.listen(conn);
305
+ conn.listen();
package/package.json CHANGED
@@ -1,30 +1,30 @@
1
1
  {
2
- "name": "linear-ls",
3
- "version": "1.1.1",
4
- "description": "Linear Language Server",
5
- "bin": "index.js",
6
- "main": "index.js",
7
- "keywords": [
8
- "lsp",
9
- "linear",
10
- "language",
11
- "productivity"
12
- ],
13
- "repository": "https://github.com/wilhelmeek/linear-ls",
14
- "author": "wilhelm <helm@hey.com>",
15
- "license": "MIT",
16
- "files": [
17
- "dist/*.js",
18
- "index.js"
19
- ],
20
- "dependencies": {
21
- "@linear/sdk": "^68.1.0",
22
- "vscode-languageserver": "^9.0.1",
23
- "vscode-languageserver-textdocument": "^1.0.12"
24
- },
25
- "devDependencies": {
26
- "@biomejs/biome": "^2.3.10",
27
- "@types/node": "^23",
28
- "typescript": "^5.9.3"
29
- }
2
+ "name": "linear-ls",
3
+ "version": "1.3.0",
4
+ "description": "Linear Language Server",
5
+ "bin": "index.js",
6
+ "main": "index.js",
7
+ "keywords": [
8
+ "lsp",
9
+ "linear",
10
+ "language",
11
+ "productivity"
12
+ ],
13
+ "repository": "https://github.com/wilhelmeek/linear-ls",
14
+ "author": "wilhelm <helm@hey.com>",
15
+ "license": "MIT",
16
+ "files": [
17
+ "dist/*.js",
18
+ "index.js"
19
+ ],
20
+ "dependencies": {
21
+ "@linear/sdk": "^68.1.0",
22
+ "vscode-languageserver": "^9.0.1",
23
+ "vscode-languageserver-textdocument": "^1.0.12"
24
+ },
25
+ "devDependencies": {
26
+ "@biomejs/biome": "^2.3.10",
27
+ "@types/node": "^23",
28
+ "typescript": "^5.9.3"
29
+ }
30
30
  }