runline 0.30.0 → 0.31.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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugins/_shared/authedFetch.js +33 -0
- package/dist/plugins/_shared/provider.js +80 -0
- package/dist/plugins/_shared/tokenRefresh.js +5 -3
- package/dist/plugins/gett/src/index.js +358 -0
- package/dist/plugins/gett/src/places.js +140 -0
- package/dist/plugins/gett/src/rides.js +144 -0
- package/dist/plugins/gett/src/session.js +242 -0
- package/dist/plugins/gett/src/shared.js +180 -0
- package/dist/plugins/linear/src/attachments.js +2 -2
- package/dist/plugins/linear/src/cycles.js +6 -6
- package/dist/plugins/linear/src/index.js +2 -2
- package/dist/plugins/linear/src/initiatives.js +6 -6
- package/dist/plugins/linear/src/issues.js +9 -9
- package/dist/plugins/linear/src/labels.js +6 -6
- package/dist/plugins/linear/src/organization.js +2 -2
- package/dist/plugins/linear/src/projects.js +12 -12
- package/dist/plugins/linear/src/shared.js +148 -20
- package/dist/plugins/linear/src/states.js +3 -3
- package/dist/plugins/linear/src/teams.js +6 -5
- package/dist/plugins/linear/src/users.js +3 -3
- package/dist/plugins/linear/src/views.js +10 -8
- package/dist/plugins/linear/src/webhooks.js +5 -5
- package/dist/plugins/openai/src/index.js +28 -9
- package/dist/plugins/typesafe/src/guide.js +172 -0
- package/dist/plugins/typesafe/src/index.js +169 -0
- package/dist/plugins/typesafe/src/questions.js +180 -0
- package/dist/plugins/typesafe/src/shared.js +166 -0
- package/dist/plugins/wolt/src/account.js +427 -0
- package/dist/plugins/wolt/src/catalog.js +166 -0
- package/dist/plugins/wolt/src/index.js +526 -0
- package/dist/plugins/wolt/src/orders.js +349 -0
- package/dist/plugins/wolt/src/shared.js +218 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { bindGetAction, bindListAction, gql, INITIATIVE_FIELDS, key, requireUnscoped, } from "./shared.js";
|
|
2
|
+
import { bindGetAction, bindListAction, gql, INITIATIVE_FIELDS, key, requireUnscoped, withScopedNote, } from "./shared.js";
|
|
3
3
|
export function registerInitiativeActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -7,7 +7,7 @@ export function registerInitiativeActions(rl) {
|
|
|
7
7
|
getAction("initiative.get", "Get an initiative by ID or slug.", "initiative", INITIATIVE_FIELDS);
|
|
8
8
|
rl.registerAction("initiative.create", {
|
|
9
9
|
access: "write",
|
|
10
|
-
description: "Create an initiative. Status: Planned | Active | Completed.",
|
|
10
|
+
description: withScopedNote("Create an initiative. Status: Planned | Active | Completed."),
|
|
11
11
|
inputSchema: t.Object({
|
|
12
12
|
name: t.String({ description: "The name of the initiative" }),
|
|
13
13
|
description: t.Optional(t.String({ description: "The description of the initiative" })),
|
|
@@ -41,7 +41,7 @@ export function registerInitiativeActions(rl) {
|
|
|
41
41
|
});
|
|
42
42
|
rl.registerAction("initiative.update", {
|
|
43
43
|
access: "write",
|
|
44
|
-
description: "Update an initiative.",
|
|
44
|
+
description: withScopedNote("Update an initiative."),
|
|
45
45
|
inputSchema: t.Object({
|
|
46
46
|
id: t.String({
|
|
47
47
|
description: "The identifier of the initiative to update",
|
|
@@ -79,7 +79,7 @@ export function registerInitiativeActions(rl) {
|
|
|
79
79
|
});
|
|
80
80
|
rl.registerAction("initiative.delete", {
|
|
81
81
|
access: "write",
|
|
82
|
-
description: "Trash an initiative.",
|
|
82
|
+
description: withScopedNote("Trash an initiative."),
|
|
83
83
|
inputSchema: t.Object({
|
|
84
84
|
id: t.String({
|
|
85
85
|
description: "The identifier of the initiative to delete",
|
|
@@ -93,7 +93,7 @@ export function registerInitiativeActions(rl) {
|
|
|
93
93
|
});
|
|
94
94
|
rl.registerAction("initiative.addProject", {
|
|
95
95
|
access: "write",
|
|
96
|
-
description: "Associate a project with an initiative. Use this action for project-to-initiative linking; project.update does not accept initiativeId. Verify with initiative.get or the returned initiative.projects list.",
|
|
96
|
+
description: withScopedNote("Associate a project with an initiative. Use this action for project-to-initiative linking; project.update does not accept initiativeId. Verify with initiative.get or the returned initiative.projects list."),
|
|
97
97
|
inputSchema: t.Object({
|
|
98
98
|
initiativeId: t.String({
|
|
99
99
|
description: "The identifier of the initiative",
|
|
@@ -114,7 +114,7 @@ export function registerInitiativeActions(rl) {
|
|
|
114
114
|
});
|
|
115
115
|
rl.registerAction("initiative.removeProject", {
|
|
116
116
|
access: "write",
|
|
117
|
-
description: "Remove a project from an initiative. Pass the link id returned by initiative.addProject, then verify with initiative.get.",
|
|
117
|
+
description: withScopedNote("Remove a project from an initiative. Pass the link id returned by initiative.addProject, then verify with initiative.get."),
|
|
118
118
|
inputSchema: t.Object({
|
|
119
119
|
id: t.String({
|
|
120
120
|
description: "The identifier of the initiativeToProject to delete",
|
|
@@ -68,14 +68,14 @@ export function registerIssueActions(rl) {
|
|
|
68
68
|
}),
|
|
69
69
|
async execute(input, ctx) {
|
|
70
70
|
const fields = { ...input };
|
|
71
|
-
fields.labelIds = ensureScopeLabelsOnCreateOrReplace(ctx, fields.labelIds);
|
|
71
|
+
fields.labelIds = await ensureScopeLabelsOnCreateOrReplace(ctx, fields.labelIds);
|
|
72
72
|
const data = await gql(key(ctx), `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { input: fields });
|
|
73
73
|
return data.issueCreate?.issue;
|
|
74
74
|
},
|
|
75
75
|
});
|
|
76
76
|
rl.registerAction("issue.get", {
|
|
77
77
|
access: "read",
|
|
78
|
-
description: "Get an issue by ID or identifier (e.g. 'THE-154')",
|
|
78
|
+
description: "Get an issue by ID or identifier (e.g. 'THE-154').",
|
|
79
79
|
inputSchema: t.Object({
|
|
80
80
|
issueId: t.String({
|
|
81
81
|
description: "Required parameter name. Pass either the Linear issue UUID or issue identifier, e.g. 'THE-154'.",
|
|
@@ -84,14 +84,14 @@ export function registerIssueActions(rl) {
|
|
|
84
84
|
async execute(input, ctx) {
|
|
85
85
|
const data = await gql(key(ctx), `query($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`, { id: input.issueId });
|
|
86
86
|
const issue = data.issue;
|
|
87
|
-
if (!issueHasScope(ctx, issue))
|
|
87
|
+
if (!(await issueHasScope(ctx, issue)))
|
|
88
88
|
throw new Error("Linear issue is not available to this scoped connection");
|
|
89
89
|
return issue;
|
|
90
90
|
},
|
|
91
91
|
});
|
|
92
92
|
rl.registerAction("issue.list", {
|
|
93
93
|
access: "read",
|
|
94
|
-
description: "List issues.
|
|
94
|
+
description: "List issues. teamId and assigneeId are convenience filters; use `filter` for anything else - assignee { assignee: { isMe: { eq: true } } }, cycle { cycle: { id: { eq: 'cycle-id' } } }, open work { state: { type: { nin: ['completed','canceled'] } } }. Default hides archived.",
|
|
95
95
|
inputSchema: t.Object({
|
|
96
96
|
...LIST_INPUT_SCHEMA,
|
|
97
97
|
teamId: t.Optional(t.String({ description: "Convenience: filter by team" })),
|
|
@@ -105,7 +105,7 @@ export function registerIssueActions(rl) {
|
|
|
105
105
|
merged.team = { id: { eq: opts.teamId } };
|
|
106
106
|
if (opts.assigneeId)
|
|
107
107
|
merged.assignee = { id: { eq: opts.assigneeId } };
|
|
108
|
-
const filter = mergeIssueScopeFilter(ctx, Object.keys(merged).length > 0 ? merged : undefined);
|
|
108
|
+
const filter = await mergeIssueScopeFilter(ctx, Object.keys(merged).length > 0 ? merged : undefined);
|
|
109
109
|
const { argsDecl, argsCall, vars } = buildConnArgs({ ...opts, filter }, "IssueFilter");
|
|
110
110
|
const data = await gql(key(ctx), `query${argsDecl} { issues${argsCall} { nodes { ${ISSUE_LITE} } pageInfo { hasNextPage endCursor } } }`, vars);
|
|
111
111
|
const conn = data.issues;
|
|
@@ -169,9 +169,9 @@ export function registerIssueActions(rl) {
|
|
|
169
169
|
const { issueId, ...fields } = input;
|
|
170
170
|
await assertIssueInScope(ctx, String(issueId));
|
|
171
171
|
if (fields.removedLabelIds)
|
|
172
|
-
forbidScopeLabelRemoval(ctx, fields.removedLabelIds);
|
|
172
|
+
await forbidScopeLabelRemoval(ctx, fields.removedLabelIds);
|
|
173
173
|
if (fields.labelIds)
|
|
174
|
-
fields.labelIds = ensureScopeLabelsOnCreateOrReplace(ctx, fields.labelIds);
|
|
174
|
+
fields.labelIds = await ensureScopeLabelsOnCreateOrReplace(ctx, fields.labelIds);
|
|
175
175
|
const data = await gql(key(ctx), `mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { id: issueId, input: fields });
|
|
176
176
|
return data.issueUpdate?.issue;
|
|
177
177
|
},
|
|
@@ -246,7 +246,7 @@ export function registerIssueActions(rl) {
|
|
|
246
246
|
}`, {
|
|
247
247
|
term: opts.term,
|
|
248
248
|
first: opts.limit ?? 50,
|
|
249
|
-
filter: mergeIssueScopeFilter(ctx, opts.filter) ?? null,
|
|
249
|
+
filter: (await mergeIssueScopeFilter(ctx, opts.filter)) ?? null,
|
|
250
250
|
includeComments: opts.includeComments ?? null,
|
|
251
251
|
includeArchived: opts.includeArchived ?? null,
|
|
252
252
|
teamId: opts.teamId ?? null,
|
|
@@ -281,7 +281,7 @@ export function registerIssueActions(rl) {
|
|
|
281
281
|
async execute(input, ctx) {
|
|
282
282
|
const { issueId, labelId } = input;
|
|
283
283
|
await assertIssueInScope(ctx, issueId);
|
|
284
|
-
forbidScopeLabelRemoval(ctx, labelId);
|
|
284
|
+
await forbidScopeLabelRemoval(ctx, labelId);
|
|
285
285
|
const data = await gql(key(ctx), `mutation($id: String!, $labelId: String!) { issueRemoveLabel(id: $id, labelId: $labelId) { success } }`, { id: issueId, labelId });
|
|
286
286
|
return data.issueRemoveLabel;
|
|
287
287
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { bindGetAction, bindListAction, gql, key, LABEL_FIELDS, requireUnscoped, } from "./shared.js";
|
|
2
|
+
import { bindGetAction, bindListAction, gql, key, LABEL_FIELDS, requireUnscoped, withScopedNote, } from "./shared.js";
|
|
3
3
|
export function registerLabelActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -7,7 +7,7 @@ export function registerLabelActions(rl) {
|
|
|
7
7
|
getAction("label.get", "Get a label by ID.", "issueLabel", LABEL_FIELDS);
|
|
8
8
|
rl.registerAction("label.create", {
|
|
9
9
|
access: "write",
|
|
10
|
-
description: "Create a label. Omit teamId for a workspace-level label.",
|
|
10
|
+
description: withScopedNote("Create a label. Omit teamId for a workspace-level label."),
|
|
11
11
|
inputSchema: t.Object({
|
|
12
12
|
name: t.String({ description: "The name of the label" }),
|
|
13
13
|
teamId: t.Optional(t.String({
|
|
@@ -38,7 +38,7 @@ export function registerLabelActions(rl) {
|
|
|
38
38
|
});
|
|
39
39
|
rl.registerAction("label.update", {
|
|
40
40
|
access: "write",
|
|
41
|
-
description: "Update a label.",
|
|
41
|
+
description: withScopedNote("Update a label."),
|
|
42
42
|
inputSchema: t.Object({
|
|
43
43
|
id: t.String({ description: "The identifier of the label to update" }),
|
|
44
44
|
name: t.Optional(t.String({ description: "The name of the label" })),
|
|
@@ -62,7 +62,7 @@ export function registerLabelActions(rl) {
|
|
|
62
62
|
});
|
|
63
63
|
rl.registerAction("label.delete", {
|
|
64
64
|
access: "write",
|
|
65
|
-
description: "Delete a label.",
|
|
65
|
+
description: withScopedNote("Delete a label."),
|
|
66
66
|
inputSchema: t.Object({
|
|
67
67
|
id: t.String({ description: "The identifier of the label to delete" }),
|
|
68
68
|
}),
|
|
@@ -74,7 +74,7 @@ export function registerLabelActions(rl) {
|
|
|
74
74
|
});
|
|
75
75
|
rl.registerAction("label.retire", {
|
|
76
76
|
access: "write",
|
|
77
|
-
description: "Retire a label. Retired labels remain visible but cannot be applied to new issues.",
|
|
77
|
+
description: withScopedNote("Retire a label. Retired labels remain visible but cannot be applied to new issues."),
|
|
78
78
|
inputSchema: t.Object({
|
|
79
79
|
id: t.String({ description: "The identifier of the label to retire" }),
|
|
80
80
|
}),
|
|
@@ -86,7 +86,7 @@ export function registerLabelActions(rl) {
|
|
|
86
86
|
});
|
|
87
87
|
rl.registerAction("label.restore", {
|
|
88
88
|
access: "write",
|
|
89
|
-
description: "Restore a previously retired label.",
|
|
89
|
+
description: withScopedNote("Restore a previously retired label."),
|
|
90
90
|
inputSchema: t.Object({
|
|
91
91
|
id: t.String({ description: "The identifier of the label to restore" }),
|
|
92
92
|
}),
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { gql, key, ORG_FIELDS, requireUnscoped } from "./shared.js";
|
|
2
|
+
import { gql, key, ORG_FIELDS, requireUnscoped, withScopedNote, } from "./shared.js";
|
|
3
3
|
export function registerOrganizationActions(rl) {
|
|
4
4
|
rl.registerAction("org.get", {
|
|
5
5
|
access: "read",
|
|
6
|
-
description: "Get the authenticated workspace.",
|
|
6
|
+
description: withScopedNote("Get the authenticated workspace."),
|
|
7
7
|
inputSchema: t.Object({}),
|
|
8
8
|
async execute(_input, ctx) {
|
|
9
9
|
requireUnscoped(ctx, "org.get");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { bindGetAction, bindListAction, gql, key, MILESTONE_FIELDS, PROJECT_FIELDS, PROJECT_UPDATE_FIELDS, requireUnscoped, } from "./shared.js";
|
|
2
|
+
import { bindGetAction, bindListAction, gql, key, MILESTONE_FIELDS, PROJECT_FIELDS, PROJECT_UPDATE_FIELDS, requireUnscoped, withScopedNote, } from "./shared.js";
|
|
3
3
|
export function registerProjectActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -7,7 +7,7 @@ export function registerProjectActions(rl) {
|
|
|
7
7
|
getAction("project.get", "Get a project by ID or slug.", "project", PROJECT_FIELDS);
|
|
8
8
|
rl.registerAction("project.create", {
|
|
9
9
|
access: "write",
|
|
10
|
-
description: "Create a project. teamIds is required.",
|
|
10
|
+
description: withScopedNote("Create a project. teamIds is required."),
|
|
11
11
|
inputSchema: t.Object({
|
|
12
12
|
name: t.String({ description: "The name of the project" }),
|
|
13
13
|
teamIds: t.Array(t.Unknown(), {
|
|
@@ -68,7 +68,7 @@ export function registerProjectActions(rl) {
|
|
|
68
68
|
});
|
|
69
69
|
rl.registerAction("project.update", {
|
|
70
70
|
access: "write",
|
|
71
|
-
description: "Update a project.",
|
|
71
|
+
description: withScopedNote("Update a project."),
|
|
72
72
|
inputSchema: t.Object({
|
|
73
73
|
id: t.String({
|
|
74
74
|
description: "The identifier of the project to update (UUID or slug)",
|
|
@@ -126,7 +126,7 @@ export function registerProjectActions(rl) {
|
|
|
126
126
|
});
|
|
127
127
|
rl.registerAction("project.delete", {
|
|
128
128
|
access: "write",
|
|
129
|
-
description: "Trash (soft-delete) a project. Restorable via project.unarchive.",
|
|
129
|
+
description: withScopedNote("Trash (soft-delete) a project. Restorable via project.unarchive."),
|
|
130
130
|
inputSchema: t.Object({
|
|
131
131
|
id: t.String({ description: "The identifier of the project to delete" }),
|
|
132
132
|
}),
|
|
@@ -138,7 +138,7 @@ export function registerProjectActions(rl) {
|
|
|
138
138
|
});
|
|
139
139
|
rl.registerAction("project.unarchive", {
|
|
140
140
|
access: "write",
|
|
141
|
-
description: "Restore a previously trashed or archived project.",
|
|
141
|
+
description: withScopedNote("Restore a previously trashed or archived project."),
|
|
142
142
|
inputSchema: t.Object({
|
|
143
143
|
id: t.String({
|
|
144
144
|
description: "The identifier of the project to restore (UUID or slug)",
|
|
@@ -152,7 +152,7 @@ export function registerProjectActions(rl) {
|
|
|
152
152
|
});
|
|
153
153
|
rl.registerAction("project.search", {
|
|
154
154
|
access: "read",
|
|
155
|
-
description: "Search projects by text. Rate-limited to 30 req/min.",
|
|
155
|
+
description: withScopedNote("Search projects by text. Rate-limited to 30 req/min."),
|
|
156
156
|
inputSchema: t.Object({
|
|
157
157
|
term: t.String({ description: "Search string to look for" }),
|
|
158
158
|
limit: t.Optional(t.Number({
|
|
@@ -185,7 +185,7 @@ export function registerProjectActions(rl) {
|
|
|
185
185
|
getAction("milestone.get", "Get a project milestone by ID.", "projectMilestone", MILESTONE_FIELDS);
|
|
186
186
|
rl.registerAction("milestone.create", {
|
|
187
187
|
access: "write",
|
|
188
|
-
description: "Create a project milestone.",
|
|
188
|
+
description: withScopedNote("Create a project milestone."),
|
|
189
189
|
inputSchema: t.Object({
|
|
190
190
|
projectId: t.String({
|
|
191
191
|
description: "Related project for the project milestone",
|
|
@@ -213,7 +213,7 @@ export function registerProjectActions(rl) {
|
|
|
213
213
|
});
|
|
214
214
|
rl.registerAction("milestone.update", {
|
|
215
215
|
access: "write",
|
|
216
|
-
description: "Update a project milestone.",
|
|
216
|
+
description: withScopedNote("Update a project milestone."),
|
|
217
217
|
inputSchema: t.Object({
|
|
218
218
|
id: t.String({
|
|
219
219
|
description: "The identifier of the project milestone to update",
|
|
@@ -242,7 +242,7 @@ export function registerProjectActions(rl) {
|
|
|
242
242
|
});
|
|
243
243
|
rl.registerAction("milestone.delete", {
|
|
244
244
|
access: "write",
|
|
245
|
-
description: "Delete a project milestone.",
|
|
245
|
+
description: withScopedNote("Delete a project milestone."),
|
|
246
246
|
inputSchema: t.Object({
|
|
247
247
|
id: t.String({
|
|
248
248
|
description: "The identifier of the project milestone to delete",
|
|
@@ -258,7 +258,7 @@ export function registerProjectActions(rl) {
|
|
|
258
258
|
listAction("projectUpdate.list", "List project updates.", "projectUpdates", "ProjectUpdateFilter", PROJECT_UPDATE_FIELDS);
|
|
259
259
|
rl.registerAction("projectUpdate.create", {
|
|
260
260
|
access: "write",
|
|
261
|
-
description: "Post a status update on a project.",
|
|
261
|
+
description: withScopedNote("Post a status update on a project."),
|
|
262
262
|
inputSchema: t.Object({
|
|
263
263
|
projectId: t.String({
|
|
264
264
|
description: "The project to associate the project update with",
|
|
@@ -285,7 +285,7 @@ export function registerProjectActions(rl) {
|
|
|
285
285
|
});
|
|
286
286
|
rl.registerAction("projectUpdate.update", {
|
|
287
287
|
access: "write",
|
|
288
|
-
description: "Update a project status update.",
|
|
288
|
+
description: withScopedNote("Update a project status update."),
|
|
289
289
|
inputSchema: t.Object({
|
|
290
290
|
id: t.String({
|
|
291
291
|
description: "The identifier of the project update to update",
|
|
@@ -310,7 +310,7 @@ export function registerProjectActions(rl) {
|
|
|
310
310
|
});
|
|
311
311
|
rl.registerAction("projectUpdate.archive", {
|
|
312
312
|
access: "write",
|
|
313
|
-
description: "Archive a project status update.",
|
|
313
|
+
description: withScopedNote("Archive a project status update."),
|
|
314
314
|
inputSchema: t.Object({
|
|
315
315
|
id: t.String({
|
|
316
316
|
description: "The identifier of the project update to archive",
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import * as t from "typebox";
|
|
3
|
+
import { authedFetch } from "../../_shared/authedFetch.js";
|
|
2
4
|
const GQL_URL = "https://api.linear.app/graphql";
|
|
3
5
|
export async function gql(apiKey, query, variables) {
|
|
4
6
|
const body = { query };
|
|
5
7
|
if (variables)
|
|
6
8
|
body.variables = variables;
|
|
7
|
-
const res = await
|
|
9
|
+
const res = await authedFetch(GQL_URL, {
|
|
8
10
|
method: "POST",
|
|
9
11
|
headers: { Authorization: apiKey, "Content-Type": "application/json" },
|
|
10
12
|
body: JSON.stringify(body),
|
|
@@ -19,7 +21,11 @@ export async function gql(apiKey, query, variables) {
|
|
|
19
21
|
export function key(ctx) {
|
|
20
22
|
return ctx.connection.config.apiKey;
|
|
21
23
|
}
|
|
22
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The configured scope values, verbatim. Each is either a label UUID or a
|
|
26
|
+
* label name — `resolveScopeLabelIds` turns the latter into the former.
|
|
27
|
+
*/
|
|
28
|
+
export function scopeLabelValues(ctx) {
|
|
23
29
|
const raw = ctx.connection.config.scopeLabelIds;
|
|
24
30
|
if (Array.isArray(raw))
|
|
25
31
|
return raw
|
|
@@ -33,11 +39,115 @@ export function scopeLabelIds(ctx) {
|
|
|
33
39
|
.map((s) => s.trim())
|
|
34
40
|
.filter(Boolean);
|
|
35
41
|
}
|
|
42
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
43
|
+
/**
|
|
44
|
+
* The workspace's labels, per API key. Linear rejects a non-UUID in
|
|
45
|
+
* `labels: { id: { in: [...] } }` with "each value in in must be a UUID",
|
|
46
|
+
* so a human-written scope value like `requester:yosi` breaks every issue
|
|
47
|
+
* query (SHFT-1644) unless it is resolved to an id first. The directory is
|
|
48
|
+
* fetched once per key and re-read on a miss, so a label created later
|
|
49
|
+
* still resolves.
|
|
50
|
+
*
|
|
51
|
+
* Keyed by digest, so a long-lived Map never holds the API key itself.
|
|
52
|
+
*/
|
|
53
|
+
const labelDirectories = new Map();
|
|
54
|
+
const directoryKey = (apiKey) => createHash("sha256").update(apiKey).digest("hex");
|
|
55
|
+
async function fetchLabelDirectory(apiKey) {
|
|
56
|
+
const byName = new Map();
|
|
57
|
+
let after = null;
|
|
58
|
+
for (;;) {
|
|
59
|
+
const data = await gql(apiKey, `query($after: String) { issueLabels(first: 250, after: $after) { nodes { id name team { key } } pageInfo { hasNextPage endCursor } } }`, { after });
|
|
60
|
+
const conn = data.issueLabels;
|
|
61
|
+
for (const node of conn?.nodes ?? []) {
|
|
62
|
+
const name = String(node.name);
|
|
63
|
+
const team = node.team;
|
|
64
|
+
const entry = {
|
|
65
|
+
id: String(node.id),
|
|
66
|
+
name,
|
|
67
|
+
teamKey: typeof team?.key === "string" ? team.key : null,
|
|
68
|
+
};
|
|
69
|
+
const lookup = name.toLowerCase();
|
|
70
|
+
const existing = byName.get(lookup);
|
|
71
|
+
if (existing)
|
|
72
|
+
existing.push(entry);
|
|
73
|
+
else
|
|
74
|
+
byName.set(lookup, [entry]);
|
|
75
|
+
}
|
|
76
|
+
const pageInfo = conn?.pageInfo;
|
|
77
|
+
if (!pageInfo?.hasNextPage)
|
|
78
|
+
break;
|
|
79
|
+
const next = String(pageInfo.endCursor);
|
|
80
|
+
// A cursor that does not advance would page forever; stop instead.
|
|
81
|
+
if (next === after)
|
|
82
|
+
break;
|
|
83
|
+
after = next;
|
|
84
|
+
}
|
|
85
|
+
return byName;
|
|
86
|
+
}
|
|
87
|
+
function labelDirectory(apiKey, { refresh = false } = {}) {
|
|
88
|
+
const cacheKey = directoryKey(apiKey);
|
|
89
|
+
const cached = refresh ? undefined : labelDirectories.get(cacheKey);
|
|
90
|
+
if (cached)
|
|
91
|
+
return cached;
|
|
92
|
+
const pending = fetchLabelDirectory(apiKey);
|
|
93
|
+
labelDirectories.set(cacheKey, pending);
|
|
94
|
+
// A failed lookup must not be cached as the answer for this key.
|
|
95
|
+
pending.catch(() => {
|
|
96
|
+
if (labelDirectories.get(cacheKey) === pending)
|
|
97
|
+
labelDirectories.delete(cacheKey);
|
|
98
|
+
});
|
|
99
|
+
return pending;
|
|
100
|
+
}
|
|
101
|
+
function unknownLabelError(value) {
|
|
102
|
+
return new Error(`Linear scope label "${value}" is neither a label UUID nor the name of a label in this workspace. ` +
|
|
103
|
+
`Set scopeLabelIds (LINEAR_SCOPE_LABEL_IDS) to label UUIDs or exact label names, or unset it for full access.`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Label names are not unique in Linear: every team may own a `bug` alongside
|
|
107
|
+
* a workspace one, and a case-insensitive match widens that further. Picking
|
|
108
|
+
* whichever paginated last would scope the connection to a label nobody
|
|
109
|
+
* chose — and a scope that silently binds to the wrong label shows the agent
|
|
110
|
+
* issues it was never meant to read. An ambiguous name is refused instead,
|
|
111
|
+
* with the candidates to choose between.
|
|
112
|
+
*/
|
|
113
|
+
function ambiguousLabelError(value, matches) {
|
|
114
|
+
const candidates = matches
|
|
115
|
+
.map((m) => `${m.teamKey ? `team ${m.teamKey}` : "workspace"} "${m.name}" → ${m.id}`)
|
|
116
|
+
.join("; ");
|
|
117
|
+
return new Error(`Linear scope label "${value}" is ambiguous: ${matches.length} labels share that name (${candidates}). ` +
|
|
118
|
+
`Set scopeLabelIds (LINEAR_SCOPE_LABEL_IDS) to the UUID of the one you mean.`);
|
|
119
|
+
}
|
|
120
|
+
/** Scope label UUIDs, resolving any configured label *names* to their ids. */
|
|
121
|
+
export async function resolveScopeLabelIds(ctx) {
|
|
122
|
+
const values = scopeLabelValues(ctx);
|
|
123
|
+
if (values.length === 0)
|
|
124
|
+
return [];
|
|
125
|
+
if (values.every((v) => UUID_RE.test(v)))
|
|
126
|
+
return values;
|
|
127
|
+
const apiKey = key(ctx);
|
|
128
|
+
const missing = (directory) => values.some((v) => !UUID_RE.test(v) && !directory.has(v.toLowerCase()));
|
|
129
|
+
let directory = await labelDirectory(apiKey);
|
|
130
|
+
// A label created after the directory was cached would otherwise stay
|
|
131
|
+
// unresolvable for the lifetime of the process: re-read once before
|
|
132
|
+
// declaring a name unknown.
|
|
133
|
+
if (missing(directory))
|
|
134
|
+
directory = await labelDirectory(apiKey, { refresh: true });
|
|
135
|
+
return values.map((value) => {
|
|
136
|
+
if (UUID_RE.test(value))
|
|
137
|
+
return value;
|
|
138
|
+
const matches = directory.get(value.toLowerCase()) ?? [];
|
|
139
|
+
if (matches.length === 0)
|
|
140
|
+
throw unknownLabelError(value);
|
|
141
|
+
if (matches.length > 1)
|
|
142
|
+
throw ambiguousLabelError(value, matches);
|
|
143
|
+
return matches[0].id;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
36
146
|
export function isScoped(ctx) {
|
|
37
|
-
return
|
|
147
|
+
return scopeLabelValues(ctx).length > 0;
|
|
38
148
|
}
|
|
39
|
-
export function mergeIssueScopeFilter(ctx, filter) {
|
|
40
|
-
const ids =
|
|
149
|
+
export async function mergeIssueScopeFilter(ctx, filter) {
|
|
150
|
+
const ids = await resolveScopeLabelIds(ctx);
|
|
41
151
|
if (ids.length === 0)
|
|
42
152
|
return filter;
|
|
43
153
|
const scopeFilter = { labels: { id: { in: ids } } };
|
|
@@ -45,8 +155,8 @@ export function mergeIssueScopeFilter(ctx, filter) {
|
|
|
45
155
|
return scopeFilter;
|
|
46
156
|
return { and: [filter, scopeFilter] };
|
|
47
157
|
}
|
|
48
|
-
export function issueHasScope(ctx, issue) {
|
|
49
|
-
const ids = new Set(
|
|
158
|
+
export async function issueHasScope(ctx, issue) {
|
|
159
|
+
const ids = new Set(await resolveScopeLabelIds(ctx));
|
|
50
160
|
if (ids.size === 0)
|
|
51
161
|
return true;
|
|
52
162
|
const labels = issue?.labels?.nodes;
|
|
@@ -61,7 +171,7 @@ export async function assertIssueInScope(ctx, issueId) {
|
|
|
61
171
|
if (!isScoped(ctx))
|
|
62
172
|
return null;
|
|
63
173
|
const issue = await getIssueForScope(ctx, issueId);
|
|
64
|
-
if (!issue || !issueHasScope(ctx, issue))
|
|
174
|
+
if (!issue || !(await issueHasScope(ctx, issue)))
|
|
65
175
|
throw new Error("Linear issue is not available to this scoped connection");
|
|
66
176
|
return issue;
|
|
67
177
|
}
|
|
@@ -70,7 +180,7 @@ export async function assertCommentInScope(ctx, commentId) {
|
|
|
70
180
|
return;
|
|
71
181
|
const data = await gql(key(ctx), `query($id: String!) { comment(id: $id) { id issue { id identifier labels { nodes { id name } } } } }`, { id: commentId });
|
|
72
182
|
const issue = data.comment?.issue;
|
|
73
|
-
if (!issue || !issueHasScope(ctx, issue))
|
|
183
|
+
if (!issue || !(await issueHasScope(ctx, issue)))
|
|
74
184
|
throw new Error("Linear comment is not available to this scoped connection");
|
|
75
185
|
}
|
|
76
186
|
export async function assertAttachmentInScope(ctx, attachmentId) {
|
|
@@ -78,11 +188,11 @@ export async function assertAttachmentInScope(ctx, attachmentId) {
|
|
|
78
188
|
return;
|
|
79
189
|
const data = await gql(key(ctx), `query($id: String!) { attachment(id: $id) { id issue { id identifier labels { nodes { id name } } } } }`, { id: attachmentId });
|
|
80
190
|
const issue = data.attachment?.issue;
|
|
81
|
-
if (!issue || !issueHasScope(ctx, issue))
|
|
191
|
+
if (!issue || !(await issueHasScope(ctx, issue)))
|
|
82
192
|
throw new Error("Linear attachment is not available to this scoped connection");
|
|
83
193
|
}
|
|
84
|
-
export function forbidScopeLabelRemoval(ctx, labelIds) {
|
|
85
|
-
const scoped = new Set(
|
|
194
|
+
export async function forbidScopeLabelRemoval(ctx, labelIds) {
|
|
195
|
+
const scoped = new Set(await resolveScopeLabelIds(ctx));
|
|
86
196
|
if (scoped.size === 0)
|
|
87
197
|
return;
|
|
88
198
|
const ids = Array.isArray(labelIds)
|
|
@@ -92,8 +202,8 @@ export function forbidScopeLabelRemoval(ctx, labelIds) {
|
|
|
92
202
|
throw new Error("Cannot remove a required Linear scope label");
|
|
93
203
|
}
|
|
94
204
|
}
|
|
95
|
-
export function ensureScopeLabelsOnCreateOrReplace(ctx, labelIds) {
|
|
96
|
-
const scoped =
|
|
205
|
+
export async function ensureScopeLabelsOnCreateOrReplace(ctx, labelIds) {
|
|
206
|
+
const scoped = await resolveScopeLabelIds(ctx);
|
|
97
207
|
if (scoped.length === 0)
|
|
98
208
|
return labelIds;
|
|
99
209
|
const ids = new Set(Array.isArray(labelIds) ? labelIds.map(String) : []);
|
|
@@ -101,6 +211,16 @@ export function ensureScopeLabelsOnCreateOrReplace(ctx, labelIds) {
|
|
|
101
211
|
ids.add(id);
|
|
102
212
|
return [...ids];
|
|
103
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Appended to the description of every action a scoped connection cannot
|
|
216
|
+
* use. Descriptions are static, but they are also all an agent sees when it
|
|
217
|
+
* picks an action (`actions.find`, the Vex catalog): without this, a scoped
|
|
218
|
+
* agent discovers the action, calls it, and only then learns it is blocked.
|
|
219
|
+
*/
|
|
220
|
+
export const SCOPED_UNAVAILABLE_NOTE = "Unavailable on scoped connections.";
|
|
221
|
+
export function withScopedNote(description) {
|
|
222
|
+
return `${description} ${SCOPED_UNAVAILABLE_NOTE}`;
|
|
223
|
+
}
|
|
104
224
|
export function requireUnscoped(ctx, action) {
|
|
105
225
|
if (isScoped(ctx)) {
|
|
106
226
|
throw new Error(`${action} is not available to scoped Linear connections`);
|
|
@@ -195,13 +315,19 @@ export function bindListAction(rl) {
|
|
|
195
315
|
export function bindGetAction(rl) {
|
|
196
316
|
return (...args) => registerGetAction(rl, ...args);
|
|
197
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Root fields a scoped connection may not read.
|
|
320
|
+
*
|
|
321
|
+
* The scope restricts *issue content*, so workspace metadata an agent needs
|
|
322
|
+
* to route and report on its own issues — cycles, users, teams, states and
|
|
323
|
+
* labels — stays readable. Fields that would leak other people's content or
|
|
324
|
+
* secrets are blocked, and a blocked field's description says so.
|
|
325
|
+
*/
|
|
198
326
|
const SCOPED_BLOCKED_ROOT_FIELDS = new Set([
|
|
199
327
|
"attachments",
|
|
200
328
|
"comments",
|
|
201
329
|
"customView",
|
|
202
330
|
"customViews",
|
|
203
|
-
"cycle",
|
|
204
|
-
"cycles",
|
|
205
331
|
"initiative",
|
|
206
332
|
"initiatives",
|
|
207
333
|
"project",
|
|
@@ -209,8 +335,6 @@ const SCOPED_BLOCKED_ROOT_FIELDS = new Set([
|
|
|
209
335
|
"projectMilestone",
|
|
210
336
|
"projectMilestones",
|
|
211
337
|
"projectUpdates",
|
|
212
|
-
"user",
|
|
213
|
-
"users",
|
|
214
338
|
"webhook",
|
|
215
339
|
"webhooks",
|
|
216
340
|
]);
|
|
@@ -221,7 +345,9 @@ function requireRootFieldAvailable(ctx, action, rootField) {
|
|
|
221
345
|
export function registerListAction(rl, name, description, rootField, filterTypeName, selection) {
|
|
222
346
|
rl.registerAction(name, {
|
|
223
347
|
access: "read",
|
|
224
|
-
description
|
|
348
|
+
description: SCOPED_BLOCKED_ROOT_FIELDS.has(rootField)
|
|
349
|
+
? withScopedNote(description)
|
|
350
|
+
: description,
|
|
225
351
|
inputSchema: t.Object(LIST_INPUT_SCHEMA),
|
|
226
352
|
async execute(input, ctx) {
|
|
227
353
|
requireRootFieldAvailable(ctx, name, rootField);
|
|
@@ -236,7 +362,9 @@ export function registerListAction(rl, name, description, rootField, filterTypeN
|
|
|
236
362
|
export function registerGetAction(rl, name, description, rootField, selection) {
|
|
237
363
|
rl.registerAction(name, {
|
|
238
364
|
access: "read",
|
|
239
|
-
description
|
|
365
|
+
description: SCOPED_BLOCKED_ROOT_FIELDS.has(rootField)
|
|
366
|
+
? withScopedNote(description)
|
|
367
|
+
: description,
|
|
240
368
|
inputSchema: t.Object({
|
|
241
369
|
id: t.String({ description: "Identifier or slug" }),
|
|
242
370
|
}),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { bindGetAction, bindListAction, gql, key, requireUnscoped, STATE_FIELDS, } from "./shared.js";
|
|
2
|
+
import { bindGetAction, bindListAction, gql, key, requireUnscoped, STATE_FIELDS, withScopedNote, } from "./shared.js";
|
|
3
3
|
export function registerStateActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -7,7 +7,7 @@ export function registerStateActions(rl) {
|
|
|
7
7
|
getAction("state.get", "Get a workflow state by ID.", "workflowState", STATE_FIELDS);
|
|
8
8
|
rl.registerAction("state.create", {
|
|
9
9
|
access: "write",
|
|
10
|
-
description: "Create a workflow state in a team.",
|
|
10
|
+
description: withScopedNote("Create a workflow state in a team."),
|
|
11
11
|
inputSchema: t.Object({
|
|
12
12
|
teamId: t.String({ description: "The team associated with the state" }),
|
|
13
13
|
name: t.String({ description: "The name of the state" }),
|
|
@@ -32,7 +32,7 @@ export function registerStateActions(rl) {
|
|
|
32
32
|
});
|
|
33
33
|
rl.registerAction("state.update", {
|
|
34
34
|
access: "write",
|
|
35
|
-
description: "Update a workflow state. Type cannot be changed after creation.",
|
|
35
|
+
description: withScopedNote("Update a workflow state. Type cannot be changed after creation."),
|
|
36
36
|
inputSchema: t.Object({
|
|
37
37
|
id: t.String({ description: "The identifier of the state to update" }),
|
|
38
38
|
name: t.Optional(t.String({ description: "The name of the state" })),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { bindGetAction, bindListAction, gql, key, requireUnscoped, TEAM_FIELDS, USER_FIELDS, } from "./shared.js";
|
|
2
|
+
import { bindGetAction, bindListAction, gql, key, requireUnscoped, TEAM_FIELDS, USER_FIELDS, withScopedNote, } from "./shared.js";
|
|
3
3
|
export function registerTeamActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -7,7 +7,7 @@ export function registerTeamActions(rl) {
|
|
|
7
7
|
getAction("team.get", "Get a team by ID or key.", "team", TEAM_FIELDS);
|
|
8
8
|
rl.registerAction("team.create", {
|
|
9
9
|
access: "write",
|
|
10
|
-
description: "Create a team. Most settings have sensible defaults.",
|
|
10
|
+
description: withScopedNote("Create a team. Most settings have sensible defaults."),
|
|
11
11
|
inputSchema: t.Object({
|
|
12
12
|
name: t.String({ description: "The name of the team" }),
|
|
13
13
|
key: t.Optional(t.String({
|
|
@@ -51,7 +51,7 @@ export function registerTeamActions(rl) {
|
|
|
51
51
|
});
|
|
52
52
|
rl.registerAction("team.update", {
|
|
53
53
|
access: "write",
|
|
54
|
-
description: "Update a team. Requires team owner or workspace admin permissions.",
|
|
54
|
+
description: withScopedNote("Update a team. Requires team owner or workspace admin permissions."),
|
|
55
55
|
inputSchema: t.Object({
|
|
56
56
|
id: t.String({ description: "The identifier of the team to update" }),
|
|
57
57
|
name: t.Optional(t.String({ description: "The name of the team" })),
|
|
@@ -86,13 +86,14 @@ export function registerTeamActions(rl) {
|
|
|
86
86
|
});
|
|
87
87
|
rl.registerAction("team.members", {
|
|
88
88
|
access: "read",
|
|
89
|
-
description: "List members of a team.",
|
|
89
|
+
description: "List members of a team. Same people surface as user.list, narrowed to one team.",
|
|
90
90
|
inputSchema: t.Object({
|
|
91
91
|
teamId: t.String({ description: "The identifier of the team" }),
|
|
92
92
|
limit: t.Optional(t.Number({ description: "Max members to return (default 50)" })),
|
|
93
93
|
}),
|
|
94
94
|
async execute(input, ctx) {
|
|
95
|
-
|
|
95
|
+
// Team membership is workspace metadata, like user.list: readable
|
|
96
|
+
// under a scoped connection (SHFT-1644).
|
|
96
97
|
const { teamId, limit } = input;
|
|
97
98
|
const data = await gql(key(ctx), `query($id: String!, $first: Int) {
|
|
98
99
|
team(id: $id) { members(first: $first) { nodes { ${USER_FIELDS} } } }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { bindGetAction, bindListAction, gql, key, requireUnscoped, USER_FIELDS, } from "./shared.js";
|
|
2
|
+
import { bindGetAction, bindListAction, gql, key, requireUnscoped, USER_FIELDS, withScopedNote, } from "./shared.js";
|
|
3
3
|
export function registerUserActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -7,7 +7,7 @@ export function registerUserActions(rl) {
|
|
|
7
7
|
getAction("user.get", "Get a user by ID. Use 'me' to reference the authenticated user.", "user", USER_FIELDS);
|
|
8
8
|
rl.registerAction("user.me", {
|
|
9
9
|
access: "read",
|
|
10
|
-
description: "Get the authenticated user.",
|
|
10
|
+
description: "Get the authenticated user. Use this to resolve 'me' before filtering issues by assignee.",
|
|
11
11
|
inputSchema: t.Object({}),
|
|
12
12
|
async execute(_input, ctx) {
|
|
13
13
|
const data = await gql(key(ctx), `query { viewer { ${USER_FIELDS} } }`);
|
|
@@ -16,7 +16,7 @@ export function registerUserActions(rl) {
|
|
|
16
16
|
});
|
|
17
17
|
rl.registerAction("user.update", {
|
|
18
18
|
access: "write",
|
|
19
|
-
description: "Update a user. Use id='me' to update the authenticated user.",
|
|
19
|
+
description: withScopedNote("Update a user. Use id='me' to update the authenticated user."),
|
|
20
20
|
inputSchema: t.Object({
|
|
21
21
|
id: t.String({
|
|
22
22
|
description: "The identifier of the user to update. Use 'me' to reference the currently authenticated user",
|