@alasano/pi-linear 0.0.1
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 +181 -0
- package/assets/screenshot.png +0 -0
- package/extensions/client.ts +291 -0
- package/extensions/index.ts +214 -0
- package/extensions/params.ts +44 -0
- package/extensions/selections.ts +327 -0
- package/extensions/settings.ts +415 -0
- package/extensions/tools/comments.ts +237 -0
- package/extensions/tools/documents.ts +357 -0
- package/extensions/tools/initiatives.ts +328 -0
- package/extensions/tools/issue-labels.ts +273 -0
- package/extensions/tools/issue-relations.ts +207 -0
- package/extensions/tools/issue-statuses.ts +72 -0
- package/extensions/tools/issues.ts +674 -0
- package/extensions/tools/milestones.ts +250 -0
- package/extensions/tools/project-labels.ts +227 -0
- package/extensions/tools/project-relations.ts +219 -0
- package/extensions/tools/projects.ts +365 -0
- package/extensions/tools/teams.ts +107 -0
- package/extensions/tools/users.ts +107 -0
- package/extensions/tools/workspaces.ts +33 -0
- package/extensions/types.ts +31 -0
- package/extensions/util.ts +38 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# pi-linear
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Linear integration for [pi](https://pi.dev) with 55+ tools covering issues, projects, documents, initiatives, comments, relations, and more. Includes multi-workspace auth and a per-tool settings overlay.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
You need a Linear API key. To create one:
|
|
10
|
+
|
|
11
|
+
1. Go to **Linear Settings > Account > Security & Access > API**
|
|
12
|
+
2. Click **Create key** and give it a name (e.g. "pi")
|
|
13
|
+
3. Under **Permissions**, select **Only select permissions** with **Read** and **Write** enabled. Leave **Admin** unchecked.
|
|
14
|
+
4. Under **Team access**, choose **All teams you have access to** or scope to specific teams if you prefer
|
|
15
|
+
5. Click **Create** and copy the key
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install npm:@alasano/pi-linear
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Authentication
|
|
24
|
+
|
|
25
|
+
The extension auto-detects the `LINEAR_API_KEY` environment variable if set globally. No setup needed in that case.
|
|
26
|
+
|
|
27
|
+
For multi-workspace support or if you don't have the env var, use the `/linear-auth` command:
|
|
28
|
+
|
|
29
|
+
| Command | Description |
|
|
30
|
+
| ---------------------------- | -------------------------------------------------------- |
|
|
31
|
+
| `/linear-auth add <name>` | Add a workspace with an API key (prompts for key) |
|
|
32
|
+
| `/linear-auth remove <name>` | Remove a workspace (shows selector if name omitted) |
|
|
33
|
+
| `/linear-auth switch <name>` | Switch active workspace (shows selector if name omitted) |
|
|
34
|
+
| `/linear-auth status` | Show current auth source and configured workspaces |
|
|
35
|
+
| `/linear-auth` | Same as status |
|
|
36
|
+
|
|
37
|
+
When you add your first workspace, it becomes active automatically. Adding a second workspace prompts you to switch. After a `/reload` with two or more workspaces configured, the agent gets a `linear_switch_workspace` tool and can switch between them when asked.
|
|
38
|
+
|
|
39
|
+
Auth resolution order:
|
|
40
|
+
|
|
41
|
+
1. Active stored workspace (if any workspaces are configured)
|
|
42
|
+
2. `LINEAR_API_KEY` environment variable (fallback when no workspaces configured)
|
|
43
|
+
3. Interactive prompt (asks you to set up a key)
|
|
44
|
+
|
|
45
|
+
Credentials are stored at `~/.pi/agent/state/extensions/linear/credentials.json`.
|
|
46
|
+
|
|
47
|
+
## Tool settings
|
|
48
|
+
|
|
49
|
+
Run `/linear-settings` to open an overlay where you can enable or disable tools by category or individually. Disabled tools are removed from the LLM's context entirely. Preferences persist across sessions.
|
|
50
|
+
|
|
51
|
+
## Tools (55)
|
|
52
|
+
|
|
53
|
+
### Issues
|
|
54
|
+
|
|
55
|
+
| Tool | Description |
|
|
56
|
+
| ------------------------ | ------------------------------------------------------------------ |
|
|
57
|
+
| `linear_list_issues` | List issues with filters, pagination, and sort |
|
|
58
|
+
| `linear_get_issue` | Get full issue details by identifier (ENG-123) or id |
|
|
59
|
+
| `linear_create_issue` | Create an issue with full IssueCreateInput support |
|
|
60
|
+
| `linear_update_issue` | Update an issue with full IssueUpdateInput support |
|
|
61
|
+
| `linear_delete_issue` | Delete an issue (admins can permanently delete) |
|
|
62
|
+
| `linear_archive_issue` | Archive or trash an issue |
|
|
63
|
+
| `linear_unarchive_issue` | Restore an archived issue |
|
|
64
|
+
| `linear_search_issues` | Search issues by text, with optional comment search and team boost |
|
|
65
|
+
|
|
66
|
+
### Issue Labels
|
|
67
|
+
|
|
68
|
+
| Tool | Description |
|
|
69
|
+
| --------------------------- | ---------------------------------- |
|
|
70
|
+
| `linear_list_issue_labels` | List issue labels with team filter |
|
|
71
|
+
| `linear_create_issue_label` | Create an issue label |
|
|
72
|
+
| `linear_update_issue_label` | Update an issue label |
|
|
73
|
+
| `linear_delete_issue_label` | Delete an issue label |
|
|
74
|
+
|
|
75
|
+
### Issue Statuses
|
|
76
|
+
|
|
77
|
+
| Tool | Description |
|
|
78
|
+
| ---------------------------- | ------------------------------------- |
|
|
79
|
+
| `linear_list_issue_statuses` | List workflow states (issue statuses) |
|
|
80
|
+
|
|
81
|
+
### Issue Relations
|
|
82
|
+
|
|
83
|
+
| Tool | Description |
|
|
84
|
+
| ------------------------------ | ------------------------------------------------------- |
|
|
85
|
+
| `linear_list_issue_relations` | List issue relations |
|
|
86
|
+
| `linear_create_issue_relation` | Create a relation (blocks, duplicate, related, similar) |
|
|
87
|
+
| `linear_update_issue_relation` | Update a relation |
|
|
88
|
+
| `linear_delete_issue_relation` | Delete a relation |
|
|
89
|
+
|
|
90
|
+
### Projects
|
|
91
|
+
|
|
92
|
+
| Tool | Description |
|
|
93
|
+
| -------------------------- | ------------------------------------------------ |
|
|
94
|
+
| `linear_list_projects` | List projects with filters, pagination, and sort |
|
|
95
|
+
| `linear_get_project` | Get full project details by id |
|
|
96
|
+
| `linear_save_project` | Create or update a project |
|
|
97
|
+
| `linear_delete_project` | Delete a project |
|
|
98
|
+
| `linear_archive_project` | Archive or trash a project |
|
|
99
|
+
| `linear_unarchive_project` | Restore an archived project |
|
|
100
|
+
|
|
101
|
+
### Project Labels
|
|
102
|
+
|
|
103
|
+
| Tool | Description |
|
|
104
|
+
| ----------------------------- | ---------------------- |
|
|
105
|
+
| `linear_list_project_labels` | List project labels |
|
|
106
|
+
| `linear_create_project_label` | Create a project label |
|
|
107
|
+
| `linear_update_project_label` | Update a project label |
|
|
108
|
+
| `linear_delete_project_label` | Delete a project label |
|
|
109
|
+
|
|
110
|
+
### Project Relations
|
|
111
|
+
|
|
112
|
+
| Tool | Description |
|
|
113
|
+
| -------------------------------- | ------------------------- |
|
|
114
|
+
| `linear_list_project_relations` | List project relations |
|
|
115
|
+
| `linear_create_project_relation` | Create a project relation |
|
|
116
|
+
| `linear_update_project_relation` | Update a project relation |
|
|
117
|
+
| `linear_delete_project_relation` | Delete a project relation |
|
|
118
|
+
|
|
119
|
+
### Documents
|
|
120
|
+
|
|
121
|
+
| Tool | Description |
|
|
122
|
+
| --------------------------- | ------------------------------------------ |
|
|
123
|
+
| `linear_list_documents` | List documents with filters and pagination |
|
|
124
|
+
| `linear_get_document` | Get full document details by id |
|
|
125
|
+
| `linear_create_document` | Create a document |
|
|
126
|
+
| `linear_update_document` | Update a document |
|
|
127
|
+
| `linear_delete_document` | Delete a document |
|
|
128
|
+
| `linear_unarchive_document` | Restore an archived document |
|
|
129
|
+
|
|
130
|
+
### Comments
|
|
131
|
+
|
|
132
|
+
| Tool | Description |
|
|
133
|
+
| ----------------------- | -------------------------------------------------- |
|
|
134
|
+
| `linear_list_comments` | List comments with filters and pagination |
|
|
135
|
+
| `linear_create_comment` | Create a comment on an issue, project update, etc. |
|
|
136
|
+
| `linear_update_comment` | Update a comment |
|
|
137
|
+
| `linear_delete_comment` | Delete a comment |
|
|
138
|
+
|
|
139
|
+
### Initiatives
|
|
140
|
+
|
|
141
|
+
| Tool | Description |
|
|
142
|
+
| ----------------------------- | --------------------------------------------------- |
|
|
143
|
+
| `linear_list_initiatives` | List initiatives with filters, pagination, and sort |
|
|
144
|
+
| `linear_get_initiative` | Get full initiative details by id |
|
|
145
|
+
| `linear_save_initiative` | Create or update an initiative |
|
|
146
|
+
| `linear_delete_initiative` | Delete an initiative |
|
|
147
|
+
| `linear_archive_initiative` | Archive an initiative |
|
|
148
|
+
| `linear_unarchive_initiative` | Restore an archived initiative |
|
|
149
|
+
|
|
150
|
+
### Milestones
|
|
151
|
+
|
|
152
|
+
| Tool | Description |
|
|
153
|
+
| ------------------------- | ---------------------------- |
|
|
154
|
+
| `linear_list_milestones` | List project milestones |
|
|
155
|
+
| `linear_get_milestone` | Get milestone details by id |
|
|
156
|
+
| `linear_save_milestone` | Create or update a milestone |
|
|
157
|
+
| `linear_delete_milestone` | Delete a milestone |
|
|
158
|
+
|
|
159
|
+
### Teams
|
|
160
|
+
|
|
161
|
+
| Tool | Description |
|
|
162
|
+
| ------------------- | ------------------------------- |
|
|
163
|
+
| `linear_list_teams` | List teams with workflow states |
|
|
164
|
+
| `linear_get_team` | Get team details by id |
|
|
165
|
+
|
|
166
|
+
### Users
|
|
167
|
+
|
|
168
|
+
| Tool | Description |
|
|
169
|
+
| ------------------- | ---------------------- |
|
|
170
|
+
| `linear_list_users` | List users |
|
|
171
|
+
| `linear_get_user` | Get user details by id |
|
|
172
|
+
|
|
173
|
+
### Workspaces
|
|
174
|
+
|
|
175
|
+
| Tool | Description |
|
|
176
|
+
| ------------------------- | ----------------------------------------------------------- |
|
|
177
|
+
| `linear_switch_workspace` | Switch active workspace (only available with 2+ workspaces) |
|
|
178
|
+
|
|
179
|
+
## Requirements
|
|
180
|
+
|
|
181
|
+
- Pi interactive mode (settings overlay uses the widget API)
|
|
Binary file
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import type { LinearGraphQLError, LinearIssue } from './types';
|
|
6
|
+
import { asString } from './util';
|
|
7
|
+
import { ISSUE_SELECTION } from './selections';
|
|
8
|
+
|
|
9
|
+
const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql';
|
|
10
|
+
const IDENTIFIER_PATTERN = /^([A-Z][A-Z0-9]*)-(\d+)$/i;
|
|
11
|
+
|
|
12
|
+
export type WorkspaceCredentials = {
|
|
13
|
+
activeWorkspace: string | null;
|
|
14
|
+
workspaces: Record<string, { apiKey: string }>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function emptyCredentials(): WorkspaceCredentials {
|
|
18
|
+
return { activeWorkspace: null, workspaces: {} };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getCredentialFilePath() {
|
|
22
|
+
const piDir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), '.pi', 'agent');
|
|
23
|
+
return path.join(piDir, 'extensions', 'linear', 'credentials.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readCredentials(): Promise<WorkspaceCredentials> {
|
|
27
|
+
const filePath = getCredentialFilePath();
|
|
28
|
+
try {
|
|
29
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
if (
|
|
32
|
+
!parsed ||
|
|
33
|
+
typeof parsed !== 'object' ||
|
|
34
|
+
!parsed.workspaces ||
|
|
35
|
+
typeof parsed.workspaces !== 'object'
|
|
36
|
+
) {
|
|
37
|
+
return emptyCredentials();
|
|
38
|
+
}
|
|
39
|
+
const activeWorkspace =
|
|
40
|
+
typeof parsed.activeWorkspace === 'string' ? parsed.activeWorkspace : null;
|
|
41
|
+
const workspaces: Record<string, { apiKey: string }> = {};
|
|
42
|
+
for (const [name, entry] of Object.entries(parsed.workspaces)) {
|
|
43
|
+
if (
|
|
44
|
+
entry &&
|
|
45
|
+
typeof entry === 'object' &&
|
|
46
|
+
'apiKey' in entry &&
|
|
47
|
+
typeof (entry as { apiKey: unknown }).apiKey === 'string'
|
|
48
|
+
) {
|
|
49
|
+
workspaces[name] = { apiKey: (entry as { apiKey: string }).apiKey };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { activeWorkspace, workspaces };
|
|
53
|
+
} catch {
|
|
54
|
+
return emptyCredentials();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function writeCredentials(creds: WorkspaceCredentials): Promise<void> {
|
|
59
|
+
const filePath = getCredentialFilePath();
|
|
60
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
61
|
+
await fs.writeFile(filePath, JSON.stringify(creds, null, 2), {
|
|
62
|
+
mode: 0o600,
|
|
63
|
+
});
|
|
64
|
+
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getActiveApiKey(creds: WorkspaceCredentials): string | undefined {
|
|
68
|
+
if (!creds.activeWorkspace) return undefined;
|
|
69
|
+
return creds.workspaces[creds.activeWorkspace]?.apiKey;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function addWorkspace(name: string, apiKey: string): Promise<WorkspaceCredentials> {
|
|
73
|
+
const creds = await readCredentials();
|
|
74
|
+
creds.workspaces[name] = { apiKey };
|
|
75
|
+
if (!creds.activeWorkspace) {
|
|
76
|
+
creds.activeWorkspace = name;
|
|
77
|
+
}
|
|
78
|
+
await writeCredentials(creds);
|
|
79
|
+
return creds;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function removeWorkspace(name: string): Promise<WorkspaceCredentials> {
|
|
83
|
+
const creds = await readCredentials();
|
|
84
|
+
delete creds.workspaces[name];
|
|
85
|
+
if (creds.activeWorkspace === name) {
|
|
86
|
+
const remaining = Object.keys(creds.workspaces);
|
|
87
|
+
creds.activeWorkspace = remaining[0] ?? null;
|
|
88
|
+
}
|
|
89
|
+
await writeCredentials(creds);
|
|
90
|
+
return creds;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function switchWorkspace(name: string): Promise<WorkspaceCredentials> {
|
|
94
|
+
const creds = await readCredentials();
|
|
95
|
+
if (!creds.workspaces[name]) {
|
|
96
|
+
throw new Error(`Workspace "${name}" does not exist.`);
|
|
97
|
+
}
|
|
98
|
+
creds.activeWorkspace = name;
|
|
99
|
+
await writeCredentials(creds);
|
|
100
|
+
return creds;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function listWorkspaceNames(creds: WorkspaceCredentials): string[] {
|
|
104
|
+
return Object.keys(creds.workspaces);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getActiveWorkspaceName(creds: WorkspaceCredentials): string | null {
|
|
108
|
+
return creds.activeWorkspace;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function resolveApiKey(
|
|
112
|
+
ctx: ExtensionContext,
|
|
113
|
+
options?: { promptIfMissing?: boolean },
|
|
114
|
+
): Promise<{ apiKey?: string; source: 'env' | 'workspace' | 'none' }> {
|
|
115
|
+
const creds = await readCredentials();
|
|
116
|
+
const workspaceKey = getActiveApiKey(creds);
|
|
117
|
+
if (workspaceKey) return { apiKey: workspaceKey, source: 'workspace' };
|
|
118
|
+
|
|
119
|
+
const envApiKey = asString(process.env.LINEAR_API_KEY);
|
|
120
|
+
if (envApiKey) return { apiKey: envApiKey, source: 'env' };
|
|
121
|
+
|
|
122
|
+
const promptIfMissing = options?.promptIfMissing ?? true;
|
|
123
|
+
if (promptIfMissing && ctx.hasUI) {
|
|
124
|
+
const shouldSetKey = await ctx.ui.confirm(
|
|
125
|
+
'Linear API key required',
|
|
126
|
+
'No configured workspace or LINEAR_API_KEY env var found. Would you like to set one now?',
|
|
127
|
+
);
|
|
128
|
+
if (!shouldSetKey) return { source: 'none' };
|
|
129
|
+
|
|
130
|
+
const nameInput = await ctx.ui.input('Workspace name', 'my-workspace');
|
|
131
|
+
const name = asString(nameInput);
|
|
132
|
+
if (!name) return { source: 'none' };
|
|
133
|
+
|
|
134
|
+
const keyInput = await ctx.ui.input('Linear API key', 'lin_api_...');
|
|
135
|
+
const apiKey = asString(keyInput);
|
|
136
|
+
if (!apiKey) return { source: 'none' };
|
|
137
|
+
|
|
138
|
+
await addWorkspace(name, apiKey);
|
|
139
|
+
ctx.ui.notify(`Workspace "${name}" saved and set as active`, 'info');
|
|
140
|
+
return { apiKey, source: 'workspace' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { source: 'none' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function linearGraphQL<TData>(
|
|
147
|
+
apiKey: string,
|
|
148
|
+
query: string,
|
|
149
|
+
variables: Record<string, unknown>,
|
|
150
|
+
signal?: AbortSignal,
|
|
151
|
+
): Promise<TData> {
|
|
152
|
+
const response = await fetch(LINEAR_GRAPHQL_ENDPOINT, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
Authorization: apiKey,
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify({ query, variables }),
|
|
159
|
+
signal,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const body = (await response.json()) as {
|
|
163
|
+
data?: TData;
|
|
164
|
+
errors?: LinearGraphQLError[];
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
const message =
|
|
169
|
+
body.errors?.map((error) => error.message).join('; ') ||
|
|
170
|
+
`${response.status} ${response.statusText}`;
|
|
171
|
+
throw new Error(`Linear API request failed: ${message}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (body.errors?.length) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Linear GraphQL error: ${body.errors.map((error) => error.message).join('; ')}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!body.data) {
|
|
181
|
+
throw new Error('Linear GraphQL response did not include data.');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return body.data;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function parseIdentifier(issueRef: string): { teamKey: string; number: number } | undefined {
|
|
188
|
+
const match = issueRef.trim().match(IDENTIFIER_PATTERN);
|
|
189
|
+
if (!match) return undefined;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
teamKey: match[1]!.toUpperCase(),
|
|
193
|
+
number: Number(match[2]!),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function fetchIssueByIdentifier(
|
|
198
|
+
apiKey: string,
|
|
199
|
+
identifier: string,
|
|
200
|
+
signal?: AbortSignal,
|
|
201
|
+
): Promise<LinearIssue | undefined> {
|
|
202
|
+
const parsed = parseIdentifier(identifier);
|
|
203
|
+
if (!parsed) return undefined;
|
|
204
|
+
|
|
205
|
+
const data = await linearGraphQL<{ issues: { nodes: LinearIssue[] } }>(
|
|
206
|
+
apiKey,
|
|
207
|
+
`query GetIssueByIdentifier($teamKey: String!, $number: Float!) {
|
|
208
|
+
issues(
|
|
209
|
+
first: 1
|
|
210
|
+
filter: {
|
|
211
|
+
team: { key: { eq: $teamKey } }
|
|
212
|
+
number: { eq: $number }
|
|
213
|
+
}
|
|
214
|
+
) {
|
|
215
|
+
nodes {
|
|
216
|
+
${ISSUE_SELECTION}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}`,
|
|
220
|
+
{
|
|
221
|
+
teamKey: parsed.teamKey,
|
|
222
|
+
number: parsed.number,
|
|
223
|
+
},
|
|
224
|
+
signal,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
return data.issues.nodes[0];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function resolveIssueId(
|
|
231
|
+
apiKey: string,
|
|
232
|
+
issueRef: string,
|
|
233
|
+
signal?: AbortSignal,
|
|
234
|
+
): Promise<string> {
|
|
235
|
+
const identifierIssue = await fetchIssueByIdentifier(apiKey, issueRef, signal);
|
|
236
|
+
if (identifierIssue?.id) return identifierIssue.id;
|
|
237
|
+
|
|
238
|
+
return issueRef.trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function resolveTeamId(
|
|
242
|
+
apiKey: string,
|
|
243
|
+
options: { teamId?: string; teamKey?: string },
|
|
244
|
+
signal?: AbortSignal,
|
|
245
|
+
): Promise<string> {
|
|
246
|
+
if (options.teamId?.trim()) {
|
|
247
|
+
return options.teamId.trim();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const teamKey = options.teamKey?.trim();
|
|
251
|
+
if (!teamKey) {
|
|
252
|
+
throw new Error('Either teamId or teamKey must be provided.');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const data = await linearGraphQL<{ teams: { nodes: Array<{ id: string }> } }>(
|
|
256
|
+
apiKey,
|
|
257
|
+
`query ResolveTeam($teamKey: String!) {
|
|
258
|
+
teams(first: 1, filter: { key: { eq: $teamKey } }) {
|
|
259
|
+
nodes {
|
|
260
|
+
id
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}`,
|
|
264
|
+
{ teamKey },
|
|
265
|
+
signal,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const teamId = data.teams.nodes[0]?.id;
|
|
269
|
+
if (!teamId) {
|
|
270
|
+
throw new Error(`Linear team not found for key: ${teamKey}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return teamId;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function withLinearAuth<T>(
|
|
277
|
+
ctx: ExtensionContext,
|
|
278
|
+
signal: AbortSignal | undefined,
|
|
279
|
+
handler: (apiKey: string) => Promise<T>,
|
|
280
|
+
): Promise<T> {
|
|
281
|
+
const { apiKey } = await resolveApiKey(ctx);
|
|
282
|
+
if (!apiKey) {
|
|
283
|
+
throw new Error('Missing Linear API key. Set LINEAR_API_KEY or run /linear-auth.');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (signal?.aborted) {
|
|
287
|
+
throw new Error('Request cancelled.');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return handler(apiKey);
|
|
291
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
|
|
2
|
+
import {
|
|
3
|
+
readCredentials,
|
|
4
|
+
addWorkspace,
|
|
5
|
+
removeWorkspace,
|
|
6
|
+
switchWorkspace,
|
|
7
|
+
listWorkspaceNames,
|
|
8
|
+
getActiveWorkspaceName,
|
|
9
|
+
resolveApiKey,
|
|
10
|
+
} from './client';
|
|
11
|
+
import { asString } from './util';
|
|
12
|
+
import { teamTools } from './tools/teams';
|
|
13
|
+
import { userTools } from './tools/users';
|
|
14
|
+
import { issueStatusTools } from './tools/issue-statuses';
|
|
15
|
+
import { projectLabelTools } from './tools/project-labels';
|
|
16
|
+
import { milestoneTools } from './tools/milestones';
|
|
17
|
+
import { commentTools } from './tools/comments';
|
|
18
|
+
import { documentTools } from './tools/documents';
|
|
19
|
+
import { initiativeTools } from './tools/initiatives';
|
|
20
|
+
import { issueLabelTools } from './tools/issue-labels';
|
|
21
|
+
import { projectTools } from './tools/projects';
|
|
22
|
+
import { issueTools } from './tools/issues';
|
|
23
|
+
import { issueRelationTools } from './tools/issue-relations';
|
|
24
|
+
import { projectRelationTools } from './tools/project-relations';
|
|
25
|
+
import { workspaceTools } from './tools/workspaces';
|
|
26
|
+
import { registerLinearSettings } from './settings';
|
|
27
|
+
|
|
28
|
+
export default async function linearExtension(pi: ExtensionAPI) {
|
|
29
|
+
pi.registerCommand('linear-auth', {
|
|
30
|
+
description: 'Manage Linear workspace auth (usage: /linear-auth [add|remove|switch|status])',
|
|
31
|
+
handler: async (args, ctx) => {
|
|
32
|
+
const parts = args.trim().split(/\s+/);
|
|
33
|
+
const subcommand = parts[0]?.toLowerCase() || '';
|
|
34
|
+
const name = parts.slice(1).join(' ').trim();
|
|
35
|
+
|
|
36
|
+
switch (subcommand) {
|
|
37
|
+
case 'add': {
|
|
38
|
+
const workspaceName =
|
|
39
|
+
asString(name) || asString(await ctx.ui.input('Workspace name', 'my-workspace'));
|
|
40
|
+
if (!workspaceName) {
|
|
41
|
+
ctx.ui.notify('No workspace name provided', 'warning');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const keyInput = await ctx.ui.input('Linear API key', 'lin_api_...');
|
|
46
|
+
const apiKey = asString(keyInput);
|
|
47
|
+
if (!apiKey) {
|
|
48
|
+
ctx.ui.notify('No API key provided', 'warning');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const credsBefore = await readCredentials();
|
|
53
|
+
const countBefore = Object.keys(credsBefore.workspaces).length;
|
|
54
|
+
const isFirst = countBefore === 0;
|
|
55
|
+
|
|
56
|
+
await addWorkspace(workspaceName, apiKey);
|
|
57
|
+
|
|
58
|
+
if (isFirst) {
|
|
59
|
+
ctx.ui.notify(`Workspace "${workspaceName}" saved and set as active`, 'info');
|
|
60
|
+
} else {
|
|
61
|
+
const shouldSwitch = await ctx.ui.confirm(
|
|
62
|
+
'Switch workspace',
|
|
63
|
+
`Switch to "${workspaceName}" now?`,
|
|
64
|
+
);
|
|
65
|
+
if (shouldSwitch) {
|
|
66
|
+
await switchWorkspace(workspaceName);
|
|
67
|
+
ctx.ui.notify(`Switched to workspace "${workspaceName}"`, 'info');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (countBefore === 1) {
|
|
71
|
+
ctx.ui.notify('Workspace added. Run /reload to enable workspace switching.', 'info');
|
|
72
|
+
} else {
|
|
73
|
+
ctx.ui.notify('Workspace added.', 'info');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
case 'remove': {
|
|
80
|
+
const creds = await readCredentials();
|
|
81
|
+
const names = listWorkspaceNames(creds);
|
|
82
|
+
if (names.length === 0) {
|
|
83
|
+
ctx.ui.notify('No workspaces configured', 'warning');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rawSelection =
|
|
88
|
+
asString(name) ||
|
|
89
|
+
(await ctx.ui.select(
|
|
90
|
+
'Select workspace to remove',
|
|
91
|
+
names.map((n) => (n === creds.activeWorkspace ? `${n} (active)` : n)),
|
|
92
|
+
));
|
|
93
|
+
const workspaceName = rawSelection?.replace(/ \(active\)$/, '');
|
|
94
|
+
if (!workspaceName) {
|
|
95
|
+
ctx.ui.notify('No workspace selected', 'warning');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!creds.workspaces[workspaceName]) {
|
|
100
|
+
ctx.ui.notify(`Workspace "${workspaceName}" not found`, 'warning');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const countBefore = names.length;
|
|
105
|
+
const updated = await removeWorkspace(workspaceName);
|
|
106
|
+
|
|
107
|
+
if (creds.activeWorkspace === workspaceName && updated.activeWorkspace) {
|
|
108
|
+
ctx.ui.notify(
|
|
109
|
+
`Removed "${workspaceName}". Switched to "${updated.activeWorkspace}".`,
|
|
110
|
+
'info',
|
|
111
|
+
);
|
|
112
|
+
} else {
|
|
113
|
+
ctx.ui.notify(`Removed workspace "${workspaceName}"`, 'info');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (countBefore === 2) {
|
|
117
|
+
ctx.ui.notify('Workspace removed. Run /reload to update workspace tools.', 'info');
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case 'switch': {
|
|
123
|
+
const creds = await readCredentials();
|
|
124
|
+
const names = listWorkspaceNames(creds);
|
|
125
|
+
if (names.length === 0) {
|
|
126
|
+
ctx.ui.notify('No workspaces configured', 'warning');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const rawSelection =
|
|
131
|
+
asString(name) ||
|
|
132
|
+
(await ctx.ui.select(
|
|
133
|
+
'Select workspace',
|
|
134
|
+
names.map((n) => (n === creds.activeWorkspace ? `${n} (active)` : n)),
|
|
135
|
+
));
|
|
136
|
+
const workspaceName = rawSelection?.replace(/ \(active\)$/, '');
|
|
137
|
+
if (!workspaceName) {
|
|
138
|
+
ctx.ui.notify('No workspace selected', 'warning');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!creds.workspaces[workspaceName]) {
|
|
143
|
+
ctx.ui.notify(`Workspace "${workspaceName}" not found`, 'warning');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await switchWorkspace(workspaceName);
|
|
148
|
+
ctx.ui.notify(`Active workspace: ${workspaceName}`, 'info');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case 'status':
|
|
153
|
+
case '': {
|
|
154
|
+
const creds = await readCredentials();
|
|
155
|
+
const { source } = await resolveApiKey(ctx, {
|
|
156
|
+
promptIfMissing: false,
|
|
157
|
+
});
|
|
158
|
+
const names = listWorkspaceNames(creds);
|
|
159
|
+
const active = getActiveWorkspaceName(creds);
|
|
160
|
+
|
|
161
|
+
let sourceLabel: string;
|
|
162
|
+
if (source === 'workspace') {
|
|
163
|
+
sourceLabel = `workspace: ${active}`;
|
|
164
|
+
} else if (source === 'env') {
|
|
165
|
+
sourceLabel = 'env: LINEAR_API_KEY';
|
|
166
|
+
} else {
|
|
167
|
+
sourceLabel = 'none';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const lines = [`Auth source: ${sourceLabel}`];
|
|
171
|
+
if (names.length > 0) {
|
|
172
|
+
lines.push(
|
|
173
|
+
`Workspaces: ${names.map((n) => (n === active ? `${n} (active)` : n)).join(', ')}`,
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
lines.push('No workspaces configured');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
ctx.ui.notify(lines.join('\n'), source === 'none' ? 'warning' : 'info');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
default: {
|
|
184
|
+
ctx.ui.notify('Usage: /linear-auth [add|remove|switch|status]', 'warning');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const creds = await readCredentials();
|
|
191
|
+
|
|
192
|
+
const allTools = [
|
|
193
|
+
...teamTools(),
|
|
194
|
+
...userTools(),
|
|
195
|
+
...issueStatusTools(),
|
|
196
|
+
...projectLabelTools(),
|
|
197
|
+
...milestoneTools(),
|
|
198
|
+
...commentTools(),
|
|
199
|
+
...documentTools(),
|
|
200
|
+
...initiativeTools(),
|
|
201
|
+
...issueLabelTools(),
|
|
202
|
+
...projectTools(),
|
|
203
|
+
...issueTools(),
|
|
204
|
+
...issueRelationTools(),
|
|
205
|
+
...projectRelationTools(),
|
|
206
|
+
...workspaceTools(creds),
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
for (const tool of allTools) {
|
|
210
|
+
pi.registerTool(tool);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
registerLinearSettings(pi);
|
|
214
|
+
}
|