diff --git a/packages/runline-plugins/linear/src/attachments.ts b/packages/runline-plugins/linear/src/attachments.ts index 48564c5..af92162 100644 --- a/packages/runline-plugins/linear/src/attachments.ts +++ b/packages/runline-plugins/linear/src/attachments.ts @@ -7,13 +7,13 @@ import { gql, key, requireUnscoped, + withScopedNote, } from "./shared.js"; export function registerAttachmentActions(rl: RunlinePluginAPI) { rl.registerAction("attachment.list", { access: "read", - description: - "List issue attachments. Disabled for scoped Linear connections.", + description: withScopedNote("List issue attachments."), inputSchema: t.Object({ limit: t.Optional(t.Number()) }), async execute(input, ctx) { requireUnscoped(ctx, "attachment.list"); diff --git a/packages/runline-plugins/linear/src/cycles.ts b/packages/runline-plugins/linear/src/cycles.ts index c696de3..5b0be7d 100644 --- a/packages/runline-plugins/linear/src/cycles.ts +++ b/packages/runline-plugins/linear/src/cycles.ts @@ -7,6 +7,7 @@ import { gql, key, requireUnscoped, + withScopedNote, } from "./shared.js"; export function registerCycleActions(rl: RunlinePluginAPI) { @@ -15,7 +16,7 @@ export function registerCycleActions(rl: RunlinePluginAPI) { listAction( "cycle.list", - "List cycles. Use filter for isActive/isNext/isPrevious.", + "List cycles. The current cycle is filter { isActive: { eq: true } }; isNext and isPrevious work the same way. Combine with issue.list to report on a cycle's work.", "cycles", "CycleFilter", CYCLE_FIELDS, @@ -23,7 +24,7 @@ export function registerCycleActions(rl: RunlinePluginAPI) { getAction("cycle.get", "Get a cycle by ID.", "cycle", CYCLE_FIELDS); rl.registerAction("cycle.create", { access: "write", - description: "Create a cycle for a team.", + description: withScopedNote("Create a cycle for a team."), inputSchema: t.Object({ teamId: t.String({ description: "The team to associate the cycle with" }), startsAt: t.String({ @@ -63,7 +64,7 @@ export function registerCycleActions(rl: RunlinePluginAPI) { }); rl.registerAction("cycle.update", { access: "write", - description: "Update a cycle.", + description: withScopedNote("Update a cycle."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the cycle to update" }), name: t.Optional( @@ -105,8 +106,9 @@ export function registerCycleActions(rl: RunlinePluginAPI) { // they are not archived with it. rl.registerAction("cycle.archive", { access: "write", - description: + description: withScopedNote( "Archive one cycle. Issues assigned to it are unlinked from the cycle first, not archived. This is the per-cycle removal; Linear has no single-cycle delete.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the cycle to archive" }), }), @@ -132,8 +134,9 @@ export function registerCycleActions(rl: RunlinePluginAPI) { // team, and the description leads with the blast radius. rl.registerAction("team.cyclesDeleteAll", { access: "write", - description: + description: withScopedNote( "DESTRUCTIVE: delete ALL cycle data for a team and disable the cycles feature. Removes every cycle and its issue associations, not just one. To remove a single cycle use cycle.archive.", + ), inputSchema: t.Object({ teamId: t.String({ description: diff --git a/packages/runline-plugins/linear/src/index.ts b/packages/runline-plugins/linear/src/index.ts index 0502c39..e459b68 100644 --- a/packages/runline-plugins/linear/src/index.ts +++ b/packages/runline-plugins/linear/src/index.ts @@ -16,7 +16,7 @@ import { registerWebhookActions } from "./webhooks.js"; export default function linear(rl: RunlinePluginAPI) { rl.setName("linear"); - rl.setVersion("0.4.0"); + rl.setVersion("0.5.0"); rl.setConnectionSchema( t.Object({ apiKey: t.String({ @@ -27,7 +27,7 @@ export default function linear(rl: RunlinePluginAPI) { scopeLabelIds: t.Optional( t.String({ description: - "Comma-separated Linear issue label IDs. When set, issue/comment/attachment access is restricted to issues with one of these labels.", + "Comma-separated Linear issue label UUIDs (label names also accepted and resolved). When set, issue/comment/attachment access is restricted to issues with one of these labels; workspace metadata such as teams, states, labels, cycles and users stays readable.", env: "LINEAR_SCOPE_LABEL_IDS", }), ), diff --git a/packages/runline-plugins/linear/src/initiatives.ts b/packages/runline-plugins/linear/src/initiatives.ts index 10123b8..baca9c5 100644 --- a/packages/runline-plugins/linear/src/initiatives.ts +++ b/packages/runline-plugins/linear/src/initiatives.ts @@ -7,6 +7,7 @@ import { INITIATIVE_FIELDS, key, requireUnscoped, + withScopedNote, } from "./shared.js"; export function registerInitiativeActions(rl: RunlinePluginAPI) { @@ -28,7 +29,9 @@ export function registerInitiativeActions(rl: RunlinePluginAPI) { ); rl.registerAction("initiative.create", { access: "write", - description: "Create an initiative. Status: Planned | Active | Completed.", + description: withScopedNote( + "Create an initiative. Status: Planned | Active | Completed.", + ), inputSchema: t.Object({ name: t.String({ description: "The name of the initiative" }), description: t.Optional( @@ -89,7 +92,7 @@ export function registerInitiativeActions(rl: RunlinePluginAPI) { }); rl.registerAction("initiative.update", { access: "write", - description: "Update an initiative.", + description: withScopedNote("Update an initiative."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the initiative to update", @@ -154,7 +157,7 @@ export function registerInitiativeActions(rl: RunlinePluginAPI) { }); rl.registerAction("initiative.delete", { access: "write", - description: "Trash an initiative.", + description: withScopedNote("Trash an initiative."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the initiative to delete", @@ -172,8 +175,9 @@ export function registerInitiativeActions(rl: RunlinePluginAPI) { }); rl.registerAction("initiative.addProject", { access: "write", - description: + 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.", + ), inputSchema: t.Object({ initiativeId: t.String({ description: "The identifier of the initiative", @@ -204,8 +208,9 @@ export function registerInitiativeActions(rl: RunlinePluginAPI) { }); rl.registerAction("initiative.removeProject", { access: "write", - description: + description: withScopedNote( "Remove a project from an initiative. Pass the link id returned by initiative.addProject, then verify with initiative.get.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the initiativeToProject to delete", diff --git a/packages/runline-plugins/linear/src/issues.ts b/packages/runline-plugins/linear/src/issues.ts index f618f6c..f23d0d2 100644 --- a/packages/runline-plugins/linear/src/issues.ts +++ b/packages/runline-plugins/linear/src/issues.ts @@ -138,7 +138,7 @@ export function registerIssueActions(rl: RunlinePluginAPI) { }), async execute(input, ctx) { const fields = { ...(input as Record) }; - fields.labelIds = ensureScopeLabelsOnCreateOrReplace( + fields.labelIds = await ensureScopeLabelsOnCreateOrReplace( ctx, fields.labelIds, ); @@ -153,7 +153,7 @@ export function registerIssueActions(rl: RunlinePluginAPI) { rl.registerAction("issue.get", { access: "read", - description: "Get an issue by ID or identifier (e.g. 'THE-154')", + description: "Get an issue by ID or identifier (e.g. 'THE-154').", inputSchema: t.Object({ issueId: t.String({ description: @@ -167,7 +167,7 @@ export function registerIssueActions(rl: RunlinePluginAPI) { { id: (input as { issueId: string }).issueId }, ); const issue = data.issue; - if (!issueHasScope(ctx, issue)) + if (!(await issueHasScope(ctx, issue))) throw new Error( "Linear issue is not available to this scoped connection", ); @@ -178,7 +178,7 @@ export function registerIssueActions(rl: RunlinePluginAPI) { rl.registerAction("issue.list", { access: "read", description: - "List issues. Pass `filter` for state/label/project/etc. Default hides archived.", + "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.", inputSchema: t.Object( { ...LIST_INPUT_SCHEMA, @@ -200,7 +200,7 @@ export function registerIssueActions(rl: RunlinePluginAPI) { const merged: Record = { ...(opts.filter ?? {}) }; if (opts.teamId) merged.team = { id: { eq: opts.teamId } }; if (opts.assigneeId) merged.assignee = { id: { eq: opts.assigneeId } }; - const filter = mergeIssueScopeFilter( + const filter = await mergeIssueScopeFilter( ctx, Object.keys(merged).length > 0 ? merged : undefined, ); @@ -332,9 +332,9 @@ export function registerIssueActions(rl: RunlinePluginAPI) { const { issueId, ...fields } = input as Record; await assertIssueInScope(ctx, String(issueId)); if (fields.removedLabelIds) - forbidScopeLabelRemoval(ctx, fields.removedLabelIds); + await forbidScopeLabelRemoval(ctx, fields.removedLabelIds); if (fields.labelIds) - fields.labelIds = ensureScopeLabelsOnCreateOrReplace( + fields.labelIds = await ensureScopeLabelsOnCreateOrReplace( ctx, fields.labelIds, ); @@ -455,10 +455,10 @@ export function registerIssueActions(rl: RunlinePluginAPI) { term: opts.term, first: opts.limit ?? 50, filter: - mergeIssueScopeFilter( + (await mergeIssueScopeFilter( ctx, opts.filter as Record | undefined, - ) ?? null, + )) ?? null, includeComments: opts.includeComments ?? null, includeArchived: opts.includeArchived ?? null, teamId: opts.teamId ?? null, @@ -506,7 +506,7 @@ export function registerIssueActions(rl: RunlinePluginAPI) { labelId: string; }; await assertIssueInScope(ctx, issueId); - forbidScopeLabelRemoval(ctx, labelId); + await forbidScopeLabelRemoval(ctx, labelId); const data = await gql( key(ctx), `mutation($id: String!, $labelId: String!) { issueRemoveLabel(id: $id, labelId: $labelId) { success } }`, @@ -594,7 +594,8 @@ export function registerIssueActions(rl: RunlinePluginAPI) { rl.registerAction("issue.listComments", { access: "read", - description: "List comments on an issue.", + description: + "List comments on an issue. Equivalent to comment.list({ issueId }); prefer that action when you also need workspace-wide listing.", inputSchema: t.Object({ issueId: t.String(), limit: t.Optional(t.Number()), diff --git a/packages/runline-plugins/linear/src/labels.ts b/packages/runline-plugins/linear/src/labels.ts index 51c1ae7..3fa110b 100644 --- a/packages/runline-plugins/linear/src/labels.ts +++ b/packages/runline-plugins/linear/src/labels.ts @@ -7,6 +7,7 @@ import { key, LABEL_FIELDS, requireUnscoped, + withScopedNote, } from "./shared.js"; export function registerLabelActions(rl: RunlinePluginAPI) { @@ -23,7 +24,9 @@ export function registerLabelActions(rl: RunlinePluginAPI) { getAction("label.get", "Get a label by ID.", "issueLabel", LABEL_FIELDS); rl.registerAction("label.create", { access: "write", - description: "Create a label. Omit teamId for a workspace-level label.", + description: withScopedNote( + "Create a label. Omit teamId for a workspace-level label.", + ), inputSchema: t.Object({ name: t.String({ description: "The name of the label" }), teamId: t.Optional( @@ -78,7 +81,7 @@ export function registerLabelActions(rl: RunlinePluginAPI) { }); rl.registerAction("label.update", { access: "write", - description: "Update a label.", + description: withScopedNote("Update a label."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the label to update" }), name: t.Optional(t.String({ description: "The name of the label" })), @@ -123,7 +126,7 @@ export function registerLabelActions(rl: RunlinePluginAPI) { }); rl.registerAction("label.delete", { access: "write", - description: "Delete a label.", + description: withScopedNote("Delete a label."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the label to delete" }), }), @@ -139,8 +142,9 @@ export function registerLabelActions(rl: RunlinePluginAPI) { }); rl.registerAction("label.retire", { access: "write", - description: + description: withScopedNote( "Retire a label. Retired labels remain visible but cannot be applied to new issues.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the label to retire" }), }), @@ -156,7 +160,7 @@ export function registerLabelActions(rl: RunlinePluginAPI) { }); rl.registerAction("label.restore", { access: "write", - description: "Restore a previously retired label.", + description: withScopedNote("Restore a previously retired label."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the label to restore" }), }), diff --git a/packages/runline-plugins/linear/src/organization.ts b/packages/runline-plugins/linear/src/organization.ts index 94b0cee..0639d45 100644 --- a/packages/runline-plugins/linear/src/organization.ts +++ b/packages/runline-plugins/linear/src/organization.ts @@ -1,11 +1,17 @@ import type { RunlinePluginAPI } from "runline"; import * as t from "typebox"; -import { gql, key, ORG_FIELDS, requireUnscoped } from "./shared.js"; +import { + gql, + key, + ORG_FIELDS, + requireUnscoped, + withScopedNote, +} from "./shared.js"; export function registerOrganizationActions(rl: RunlinePluginAPI) { rl.registerAction("org.get", { access: "read", - description: "Get the authenticated workspace.", + description: withScopedNote("Get the authenticated workspace."), inputSchema: t.Object({}), async execute(_input, ctx) { requireUnscoped(ctx, "org.get"); diff --git a/packages/runline-plugins/linear/src/projects.ts b/packages/runline-plugins/linear/src/projects.ts index df9e068..fca4dc8 100644 --- a/packages/runline-plugins/linear/src/projects.ts +++ b/packages/runline-plugins/linear/src/projects.ts @@ -9,6 +9,7 @@ import { PROJECT_FIELDS, PROJECT_UPDATE_FIELDS, requireUnscoped, + withScopedNote, } from "./shared.js"; export function registerProjectActions(rl: RunlinePluginAPI) { @@ -30,7 +31,7 @@ export function registerProjectActions(rl: RunlinePluginAPI) { ); rl.registerAction("project.create", { access: "write", - description: "Create a project. teamIds is required.", + description: withScopedNote("Create a project. teamIds is required."), inputSchema: t.Object({ name: t.String({ description: "The name of the project" }), teamIds: t.Array(t.Unknown(), { @@ -143,7 +144,7 @@ export function registerProjectActions(rl: RunlinePluginAPI) { }); rl.registerAction("project.update", { access: "write", - description: "Update a project.", + description: withScopedNote("Update a project."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the project to update (UUID or slug)", @@ -245,8 +246,9 @@ export function registerProjectActions(rl: RunlinePluginAPI) { }); rl.registerAction("project.delete", { access: "write", - description: + description: withScopedNote( "Trash (soft-delete) a project. Restorable via project.unarchive.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the project to delete" }), }), @@ -262,7 +264,9 @@ export function registerProjectActions(rl: RunlinePluginAPI) { }); rl.registerAction("project.unarchive", { access: "write", - description: "Restore a previously trashed or archived project.", + description: withScopedNote( + "Restore a previously trashed or archived project.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the project to restore (UUID or slug)", @@ -280,7 +284,9 @@ export function registerProjectActions(rl: RunlinePluginAPI) { }); rl.registerAction("project.search", { access: "read", - description: "Search projects by text. Rate-limited to 30 req/min.", + description: withScopedNote( + "Search projects by text. Rate-limited to 30 req/min.", + ), inputSchema: t.Object({ term: t.String({ description: "Search string to look for" }), limit: t.Optional( @@ -336,7 +342,7 @@ export function registerProjectActions(rl: RunlinePluginAPI) { ); rl.registerAction("milestone.create", { access: "write", - description: "Create a project milestone.", + description: withScopedNote("Create a project milestone."), inputSchema: t.Object({ projectId: t.String({ description: "Related project for the project milestone", @@ -380,7 +386,7 @@ export function registerProjectActions(rl: RunlinePluginAPI) { }); rl.registerAction("milestone.update", { access: "write", - description: "Update a project milestone.", + description: withScopedNote("Update a project milestone."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the project milestone to update", @@ -426,7 +432,7 @@ export function registerProjectActions(rl: RunlinePluginAPI) { }); rl.registerAction("milestone.delete", { access: "write", - description: "Delete a project milestone.", + description: withScopedNote("Delete a project milestone."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the project milestone to delete", @@ -454,7 +460,7 @@ export function registerProjectActions(rl: RunlinePluginAPI) { ); rl.registerAction("projectUpdate.create", { access: "write", - description: "Post a status update on a project.", + description: withScopedNote("Post a status update on a project."), inputSchema: t.Object({ projectId: t.String({ description: "The project to associate the project update with", @@ -496,7 +502,7 @@ export function registerProjectActions(rl: RunlinePluginAPI) { }); rl.registerAction("projectUpdate.update", { access: "write", - description: "Update a project status update.", + description: withScopedNote("Update a project status update."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the project update to update", @@ -533,7 +539,7 @@ export function registerProjectActions(rl: RunlinePluginAPI) { }); rl.registerAction("projectUpdate.archive", { access: "write", - description: "Archive a project status update.", + description: withScopedNote("Archive a project status update."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the project update to archive", diff --git a/packages/runline-plugins/linear/src/shared.ts b/packages/runline-plugins/linear/src/shared.ts index 8259fe0..39b5982 100644 --- a/packages/runline-plugins/linear/src/shared.ts +++ b/packages/runline-plugins/linear/src/shared.ts @@ -29,7 +29,11 @@ export function key(ctx: Ctx) { return ctx.connection.config.apiKey as string; } -export function scopeLabelIds(ctx: Ctx): string[] { +/** + * The configured scope values, verbatim. Each is either a label UUID or a + * label name — `resolveScopeLabelIds` turns the latter into the former. + */ +export function scopeLabelValues(ctx: Ctx): string[] { const raw = ctx.connection.config.scopeLabelIds; if (Array.isArray(raw)) return raw @@ -43,23 +47,104 @@ export function scopeLabelIds(ctx: Ctx): string[] { .filter(Boolean); } +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Label name → id, per API key. Linear rejects a non-UUID in + * `labels: { id: { in: [...] } }` with "each value in in must be a UUID", + * so a human-written scope value like `requester:yosi` used to break every + * issue query (SHFT-1644). The directory is fetched once per key and + * re-read on a miss, so a label created later still resolves. + */ +const labelDirectories = new Map>>(); + +async function fetchLabelDirectory( + apiKey: string, +): Promise> { + const byName = new Map(); + let after: string | null = null; + for (;;) { + const data: Record = await gql( + apiKey, + `query($after: String) { issueLabels(first: 250, after: $after) { nodes { id name } pageInfo { hasNextPage endCursor } } }`, + { after }, + ); + const conn = data.issueLabels as Record | undefined; + for (const node of (conn?.nodes as Array>) ?? []) { + byName.set(String(node.name).toLowerCase(), String(node.id)); + } + const pageInfo = conn?.pageInfo as Record | undefined; + if (!pageInfo?.hasNextPage) return byName; + const next = String(pageInfo.endCursor); + // A cursor that does not advance would page forever; stop instead. + if (next === after) return byName; + after = next; + } +} + +function labelDirectory( + ctx: Ctx, + { refresh = false }: { refresh?: boolean } = {}, +): Promise> { + const apiKey = key(ctx); + const cached = refresh ? undefined : labelDirectories.get(apiKey); + if (cached) return cached; + const pending = fetchLabelDirectory(apiKey); + labelDirectories.set(apiKey, pending); + // A failed lookup must not be cached as the answer for this key. + pending.catch(() => { + if (labelDirectories.get(apiKey) === pending) + labelDirectories.delete(apiKey); + }); + return pending; +} + +/** Scope label UUIDs, resolving any configured label *names* to their ids. */ +export async function resolveScopeLabelIds(ctx: Ctx): Promise { + const values = scopeLabelValues(ctx); + if (values.length === 0) return []; + if (values.every((v) => UUID_RE.test(v))) return values; + + let byName = await labelDirectory(ctx); + // A label created after the directory was cached would otherwise stay + // unresolvable for the lifetime of the process: re-read once before + // declaring a name unknown. + if (values.some((v) => !UUID_RE.test(v) && !byName.has(v.toLowerCase()))) + byName = await labelDirectory(ctx, { refresh: true }); + + return values.map((value) => { + if (UUID_RE.test(value)) return value; + const id = byName.get(value.toLowerCase()); + if (!id) + throw new Error( + `Linear scope label "${value}" is neither a label UUID nor the name of a label in this workspace. ` + + `Set scopeLabelIds (LINEAR_SCOPE_LABEL_IDS) to label UUIDs or exact label names, or unset it for full access.`, + ); + return id; + }); +} + export function isScoped(ctx: Ctx): boolean { - return scopeLabelIds(ctx).length > 0; + return scopeLabelValues(ctx).length > 0; } -export function mergeIssueScopeFilter( +export async function mergeIssueScopeFilter( ctx: Ctx, filter?: Record, -): Record | undefined { - const ids = scopeLabelIds(ctx); +): Promise | undefined> { + const ids = await resolveScopeLabelIds(ctx); if (ids.length === 0) return filter; const scopeFilter = { labels: { id: { in: ids } } }; if (!filter || Object.keys(filter).length === 0) return scopeFilter; return { and: [filter, scopeFilter] }; } -export function issueHasScope(ctx: Ctx, issue: unknown): boolean { - const ids = new Set(scopeLabelIds(ctx)); +export async function issueHasScope( + ctx: Ctx, + issue: unknown, +): Promise { + const ids = new Set(await resolveScopeLabelIds(ctx)); if (ids.size === 0) return true; const labels = ( (issue as Record | null)?.labels as @@ -92,7 +177,7 @@ export async function assertIssueInScope( ): Promise | null> { if (!isScoped(ctx)) return null; const issue = await getIssueForScope(ctx, issueId); - if (!issue || !issueHasScope(ctx, issue)) + if (!issue || !(await issueHasScope(ctx, issue))) throw new Error("Linear issue is not available to this scoped connection"); return issue; } @@ -108,7 +193,7 @@ export async function assertCommentInScope( { id: commentId }, ); const issue = (data.comment as Record | null)?.issue; - if (!issue || !issueHasScope(ctx, issue)) + if (!issue || !(await issueHasScope(ctx, issue))) throw new Error( "Linear comment is not available to this scoped connection", ); @@ -125,14 +210,17 @@ export async function assertAttachmentInScope( { id: attachmentId }, ); const issue = (data.attachment as Record | null)?.issue; - if (!issue || !issueHasScope(ctx, issue)) + if (!issue || !(await issueHasScope(ctx, issue))) throw new Error( "Linear attachment is not available to this scoped connection", ); } -export function forbidScopeLabelRemoval(ctx: Ctx, labelIds: unknown): void { - const scoped = new Set(scopeLabelIds(ctx)); +export async function forbidScopeLabelRemoval( + ctx: Ctx, + labelIds: unknown, +): Promise { + const scoped = new Set(await resolveScopeLabelIds(ctx)); if (scoped.size === 0) return; const ids = Array.isArray(labelIds) ? labelIds.map(String) @@ -142,17 +230,29 @@ export function forbidScopeLabelRemoval(ctx: Ctx, labelIds: unknown): void { } } -export function ensureScopeLabelsOnCreateOrReplace( +export async function ensureScopeLabelsOnCreateOrReplace( ctx: Ctx, labelIds: unknown, -): unknown { - const scoped = scopeLabelIds(ctx); +): Promise { + const scoped = await resolveScopeLabelIds(ctx); if (scoped.length === 0) return labelIds; const ids = new Set(Array.isArray(labelIds) ? labelIds.map(String) : []); for (const id of scoped) ids.add(id); return [...ids]; } +/** + * Appended to the description of every action a scoped connection cannot + * use. Descriptions are static, but they are also all an agent sees when it + * picks an action (`actions.find`, the Vex catalog): without this, a scoped + * agent discovers the action, calls it, and only then learns it is blocked. + */ +export const SCOPED_UNAVAILABLE_NOTE = "Unavailable on scoped connections."; + +export function withScopedNote(description: string): string { + return `${description} ${SCOPED_UNAVAILABLE_NOTE}`; +} + export function requireUnscoped(ctx: Ctx, action: string): void { if (isScoped(ctx)) { throw new Error(`${action} is not available to scoped Linear connections`); @@ -296,13 +396,19 @@ export function bindGetAction(rl: RunlinePluginAPI) { return (...args: GetActionArgs) => registerGetAction(rl, ...args); } +/** + * Root fields a scoped connection may not read. + * + * The scope restricts *issue content*, so workspace metadata an agent needs + * to route and report on its own issues — cycles, users (alongside teams, + * states and labels, which were never blocked) — stays readable. Fields that + * would leak other people's content or secrets stay blocked. + */ const SCOPED_BLOCKED_ROOT_FIELDS = new Set([ "attachments", "comments", "customView", "customViews", - "cycle", - "cycles", "initiative", "initiatives", "project", @@ -310,8 +416,6 @@ const SCOPED_BLOCKED_ROOT_FIELDS = new Set([ "projectMilestone", "projectMilestones", "projectUpdates", - "user", - "users", "webhook", "webhooks", ]); @@ -334,7 +438,9 @@ export function registerListAction( ) { rl.registerAction(name, { access: "read", - description, + description: SCOPED_BLOCKED_ROOT_FIELDS.has(rootField) + ? withScopedNote(description) + : description, inputSchema: t.Object(LIST_INPUT_SCHEMA), async execute(input, ctx) { requireRootFieldAvailable(ctx, name, rootField); @@ -360,7 +466,9 @@ export function registerGetAction( ) { rl.registerAction(name, { access: "read", - description, + description: SCOPED_BLOCKED_ROOT_FIELDS.has(rootField) + ? withScopedNote(description) + : description, inputSchema: t.Object({ id: t.String({ description: "Identifier or slug" }), }), diff --git a/packages/runline-plugins/linear/src/states.ts b/packages/runline-plugins/linear/src/states.ts index 488fbd8..f3de3f0 100644 --- a/packages/runline-plugins/linear/src/states.ts +++ b/packages/runline-plugins/linear/src/states.ts @@ -7,6 +7,7 @@ import { key, requireUnscoped, STATE_FIELDS, + withScopedNote, } from "./shared.js"; export function registerStateActions(rl: RunlinePluginAPI) { @@ -28,7 +29,7 @@ export function registerStateActions(rl: RunlinePluginAPI) { ); rl.registerAction("state.create", { access: "write", - description: "Create a workflow state in a team.", + description: withScopedNote("Create a workflow state in a team."), inputSchema: t.Object({ teamId: t.String({ description: "The team associated with the state" }), name: t.String({ description: "The name of the state" }), @@ -65,8 +66,9 @@ export function registerStateActions(rl: RunlinePluginAPI) { }); rl.registerAction("state.update", { access: "write", - description: + description: withScopedNote( "Update a workflow state. Type cannot be changed after creation.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the state to update" }), name: t.Optional(t.String({ description: "The name of the state" })), diff --git a/packages/runline-plugins/linear/src/teams.ts b/packages/runline-plugins/linear/src/teams.ts index 39ee4ca..2c97b6c 100644 --- a/packages/runline-plugins/linear/src/teams.ts +++ b/packages/runline-plugins/linear/src/teams.ts @@ -8,6 +8,7 @@ import { requireUnscoped, TEAM_FIELDS, USER_FIELDS, + withScopedNote, } from "./shared.js"; export function registerTeamActions(rl: RunlinePluginAPI) { @@ -24,7 +25,9 @@ export function registerTeamActions(rl: RunlinePluginAPI) { getAction("team.get", "Get a team by ID or key.", "team", TEAM_FIELDS); rl.registerAction("team.create", { access: "write", - description: "Create a team. Most settings have sensible defaults.", + description: withScopedNote( + "Create a team. Most settings have sensible defaults.", + ), inputSchema: t.Object({ name: t.String({ description: "The name of the team" }), key: t.Optional( @@ -109,8 +112,9 @@ export function registerTeamActions(rl: RunlinePluginAPI) { }); rl.registerAction("team.update", { access: "write", - description: + description: withScopedNote( "Update a team. Requires team owner or workspace admin permissions.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the team to update" }), name: t.Optional(t.String({ description: "The name of the team" })), @@ -173,7 +177,8 @@ export function registerTeamActions(rl: RunlinePluginAPI) { }); rl.registerAction("team.members", { access: "read", - description: "List members of a team.", + description: + "List members of a team. Same people surface as user.list, narrowed to one team.", inputSchema: t.Object({ teamId: t.String({ description: "The identifier of the team" }), limit: t.Optional( @@ -181,7 +186,8 @@ export function registerTeamActions(rl: RunlinePluginAPI) { ), }), async execute(input, ctx) { - requireUnscoped(ctx, "team.members"); + // Team membership is workspace metadata, like user.list: readable + // under a scoped connection (SHFT-1644). const { teamId, limit } = input as { teamId: string; limit?: number }; const data = await gql( key(ctx), diff --git a/packages/runline-plugins/linear/src/users.ts b/packages/runline-plugins/linear/src/users.ts index d07c0b5..6eccf25 100644 --- a/packages/runline-plugins/linear/src/users.ts +++ b/packages/runline-plugins/linear/src/users.ts @@ -7,6 +7,7 @@ import { key, requireUnscoped, USER_FIELDS, + withScopedNote, } from "./shared.js"; export function registerUserActions(rl: RunlinePluginAPI) { @@ -28,7 +29,8 @@ export function registerUserActions(rl: RunlinePluginAPI) { ); rl.registerAction("user.me", { access: "read", - description: "Get the authenticated user.", + description: + "Get the authenticated user. Use this to resolve 'me' before filtering issues by assignee.", inputSchema: t.Object({}), async execute(_input, ctx) { const data = await gql(key(ctx), `query { viewer { ${USER_FIELDS} } }`); @@ -37,7 +39,9 @@ export function registerUserActions(rl: RunlinePluginAPI) { }); rl.registerAction("user.update", { access: "write", - description: "Update a user. Use id='me' to update the authenticated user.", + description: withScopedNote( + "Update a user. Use id='me' to update the authenticated user.", + ), inputSchema: t.Object({ id: t.String({ description: diff --git a/packages/runline-plugins/linear/src/views.ts b/packages/runline-plugins/linear/src/views.ts index bfd1c1e..ffa23a5 100644 --- a/packages/runline-plugins/linear/src/views.ts +++ b/packages/runline-plugins/linear/src/views.ts @@ -15,6 +15,7 @@ import { mergeIssueScopeFilter, PROJECT_FIELDS, requireUnscoped, + withScopedNote, } from "./shared.js"; const ISSUE_FILTER_DESCRIPTION = @@ -34,7 +35,10 @@ export function registerViewActions(rl: RunlinePluginAPI) { ) { rl.registerAction(name, { access: "read", - description, + description: + connectionField === "issues" + ? description + : withScopedNote(description), inputSchema: t.Object({ viewId: t.String({ description: "The custom view ID or slug" }), ...LIST_INPUT_SCHEMA, @@ -51,11 +55,11 @@ export function registerViewActions(rl: RunlinePluginAPI) { viewId: string; includeSubTeams?: boolean; }; + if (connectionField !== "issues") requireUnscoped(ctx, name); const scopedOpts = connectionField === "issues" - ? { ...opts, filter: mergeIssueScopeFilter(ctx, opts.filter) } + ? { ...opts, filter: await mergeIssueScopeFilter(ctx, opts.filter) } : opts; - if (connectionField !== "issues") requireUnscoped(ctx, name); const { argsDecl, argsCall, vars } = buildConnArgs( scopedOpts, filterTypeName, @@ -100,8 +104,9 @@ export function registerViewActions(rl: RunlinePluginAPI) { ); rl.registerAction("view.create", { access: "write", - description: + description: withScopedNote( "Create a custom view. Set filterData for issue views; projectFilterData, initiativeFilterData, or feedItemFilterData for other view types. Read matches back with view.issues/projects/initiatives/updates.", + ), inputSchema: t.Object({ name: t.String({ description: "The name of the custom view" }), description: t.Optional( @@ -165,8 +170,9 @@ export function registerViewActions(rl: RunlinePluginAPI) { }); rl.registerAction("view.update", { access: "write", - description: + description: withScopedNote( "Update a custom view. All fields optional; only provided fields are updated.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the custom view to update", @@ -230,7 +236,7 @@ export function registerViewActions(rl: RunlinePluginAPI) { }); rl.registerAction("view.delete", { access: "write", - description: "Delete a custom view.", + description: withScopedNote("Delete a custom view."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the custom view to delete", diff --git a/packages/runline-plugins/linear/src/webhooks.ts b/packages/runline-plugins/linear/src/webhooks.ts index 127be2b..fcf3662 100644 --- a/packages/runline-plugins/linear/src/webhooks.ts +++ b/packages/runline-plugins/linear/src/webhooks.ts @@ -7,6 +7,7 @@ import { key, requireUnscoped, WEBHOOK_FIELDS, + withScopedNote, } from "./shared.js"; export function registerWebhookActions(rl: RunlinePluginAPI) { @@ -23,8 +24,9 @@ export function registerWebhookActions(rl: RunlinePluginAPI) { getAction("webhook.get", "Get a webhook by ID.", "webhook", WEBHOOK_FIELDS); rl.registerAction("webhook.create", { access: "write", - description: + description: withScopedNote( "Create a webhook. resourceTypes example: ['Issue','Comment','Project'].", + ), inputSchema: t.Object({ url: t.String({ description: "The URL that will be called on data changes", @@ -74,8 +76,9 @@ export function registerWebhookActions(rl: RunlinePluginAPI) { }); rl.registerAction("webhook.update", { access: "write", - description: + description: withScopedNote( "Update a webhook. teamId and allPublicTeams cannot be changed after creation.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the webhook to update" }), url: t.Optional( @@ -111,7 +114,7 @@ export function registerWebhookActions(rl: RunlinePluginAPI) { }); rl.registerAction("webhook.delete", { access: "write", - description: "Delete a webhook.", + description: withScopedNote("Delete a webhook."), inputSchema: t.Object({ id: t.String({ description: "The identifier of the webhook to delete" }), }), @@ -127,7 +130,9 @@ export function registerWebhookActions(rl: RunlinePluginAPI) { }); rl.registerAction("webhook.rotateSecret", { access: "write", - description: "Rotate a webhook's signing secret. Returns the new secret.", + description: withScopedNote( + "Rotate a webhook's signing secret. Returns the new secret.", + ), inputSchema: t.Object({ id: t.String({ description: "The identifier of the webhook to rotate the secret for", diff --git a/packages/runline/src/tests/linear-plugin.test.ts b/packages/runline/src/tests/linear-plugin.test.ts index 3793a02..9b483b1 100644 --- a/packages/runline/src/tests/linear-plugin.test.ts +++ b/packages/runline/src/tests/linear-plugin.test.ts @@ -132,33 +132,63 @@ function ctx(config: Record = {}): ActionContext { }; } +type MockOpts = { + /** + * Answer the scope-label directory lookup (name → id) transparently. + * Scoped suites below configure the scope by label *name*; the identity + * mapping keeps their assertions about ids unchanged. The resolution + * suite turns this off to assert the lookup itself. + */ + autoResolveLabels?: boolean; +}; + +const DIRECTORY_QUERY = /issueLabels\(first: 250/; + function mockLinear( assertRequest: (body: { query: string; variables?: Record; }) => unknown, + opts?: MockOpts, ) { - mockLinearSequence([assertRequest]); + mockLinearSequence([assertRequest], opts); } function mockLinearSequence( assertions: Array< (body: { query: string; variables?: Record }) => unknown >, + opts: MockOpts = {}, ) { let i = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { assert.equal(String(input), "https://api.linear.app/graphql"); assert.equal(init?.method, "POST"); - assert.equal( - init?.headers?.["Authorization" as keyof HeadersInit], - "lin_test", + assert.match( + String(init?.headers?.["Authorization" as keyof HeadersInit]), + /^lin_/, ); const body = JSON.parse(String(init?.body)) as { query: string; variables?: Record; }; + if (opts.autoResolveLabels !== false && DIRECTORY_QUERY.test(body.query)) { + return new Response( + JSON.stringify({ + data: { + issueLabels: { + nodes: ["label-allowed", "label-other"].map((name) => ({ + id: name, + name, + })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } const assertRequest = assertions[i++]; assert.ok(assertRequest, `unexpected Linear request ${i}: ${body.query}`); const data = assertRequest(body); @@ -192,6 +222,60 @@ describe("linear plugin action surface", () => { }); }); +// The description is all an agent sees when it picks an action (actions.find, +// the Vex catalog). If a scoped connection cannot run an action, the +// description has to say so - otherwise the agent discovers it, calls it, and +// burns a turn learning it was blocked. +describe("linear scoped-availability notes match behaviour", () => { + const NOTE = "Unavailable on scoped connections."; + const SENTINEL = "linear-test: reached the network"; + + it("carries the note on exactly the actions a scoped connection cannot run", async () => { + const plugin = makeLinear(); + // Any action that gets past the scope gate hits fetch; stop it there. + globalThis.fetch = (async () => { + throw new Error(SENTINEL); + }) as typeof fetch; + + const scoped = ctx({ + apiKey: "lin_notes", + scopeLabelIds: "11111111-1111-4111-8111-111111111111", + }); + const missingNote: string[] = []; + const staleNote: string[] = []; + + for (const action of plugin.actions) { + let isBlocked = false; + try { + await action.execute( + { id: "x", teamId: "x", issueId: "x", viewId: "x" }, + scoped, + ); + } catch (err) { + isBlocked = String(err).includes( + "not available to scoped Linear connections", + ); + if (!isBlocked && !String(err).includes(SENTINEL)) + throw new Error(`${action.name}: unexpected failure - ${err}`); + } + const hasNote = String(action.description).includes(NOTE); + if (isBlocked && !hasNote) missingNote.push(action.name); + if (!isBlocked && hasNote) staleNote.push(action.name); + } + + assert.deepEqual( + missingNote, + [], + "blocked under scope but the description does not say so", + ); + assert.deepEqual( + staleNote, + [], + "description claims blocked but the action runs", + ); + }); +}); + describe("linear plugin comment actions", () => { it("issue.addComment calls Linear's commentCreate mutation", async () => { const action = getAction(makeLinear(), "issue.addComment"); @@ -451,7 +535,8 @@ describe("linear plugin custom view actions", () => { }); describe("linear plugin scoped issue access", () => { - const scopedCtx = () => ctx({ scopeLabelIds: "label-allowed" }); + const scopedCtx = () => + ctx({ apiKey: "lin_scoped", scopeLabelIds: "label-allowed" }); it("auto-applies configured scope labels on issue.create", async () => { const action = getAction(makeLinear(), "issue.create"); @@ -568,7 +653,6 @@ describe("linear plugin scoped issue access", () => { for (const name of [ "project.list", "project.get", - "user.list", "org.get", "webhook.list", ] as const) { @@ -580,6 +664,179 @@ describe("linear plugin scoped issue access", () => { } }); + // SHFT-1644: the scope restricts issue *content*. Blocking cycles and + // users left a scoped agent unable to answer "what is assigned to me this + // cycle" at all, so it bypassed the plugin and called Linear directly. + it("keeps cycle and user reads available under scoped config", async () => { + const reads = [ + ["cycle.list", {}, "cycles", { nodes: [], pageInfo: {} }], + ["cycle.get", { id: "cycle-1" }, "cycle", { id: "cycle-1" }], + ["user.list", {}, "users", { nodes: [], pageInfo: {} }], + ["user.get", { id: "me" }, "user", { id: "user-1" }], + [ + "team.members", + { teamId: "team-1" }, + "team", + { members: { nodes: [] } }, + ], + ] as const; + + for (const [name, input, rootField, payload] of reads) { + const action = getAction(makeLinear(), name); + mockLinear((body) => { + assert.match(body.query, new RegExp(`${rootField}\\(`)); + return { [rootField]: payload }; + }); + await action.execute(input, scopedCtx()); + } + }); + + it("still blocks cycle and user writes under scoped config", async () => { + for (const [name, input] of [ + ["cycle.archive", { id: "cycle-1" }], + ["team.cyclesDeleteAll", { teamId: "team-1" }], + ["user.update", { id: "me", name: "x" }], + ] as const) { + const action = getAction(makeLinear(), name); + await assert.rejects( + action.execute(input, scopedCtx()), + /not available to scoped Linear connections/, + ); + } + }); + + // SHFT-1644: a scope value that is not a UUID (e.g. `requester:yosi`) made + // Linear reject every issue query with "each value in in must be a UUID". + // Names must resolve to ids, and an unknown one must say so. + it("resolves configured label names to UUIDs, once per connection", async () => { + const action = getAction(makeLinear(), "issue.list"); + const LABEL_ID = "e9fc5d53-a992-4952-9607-fa49ef892689"; + + mockLinearSequence( + [ + (body) => { + assert.match(body.query, /issueLabels\(first: 250/); + return { + issueLabels: { + nodes: [{ id: LABEL_ID, name: "requester:yosi" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }; + }, + (body) => { + assert.match(body.query, /issues\(/); + assert.deepEqual(body.variables?.filter, { + labels: { id: { in: [LABEL_ID] } }, + }); + return { issues: { nodes: [], pageInfo: { hasNextPage: false } } }; + }, + (body) => { + // second call reuses the cached directory: no lookup request + assert.match(body.query, /issues\(/); + assert.deepEqual(body.variables?.filter, { + labels: { id: { in: [LABEL_ID] } }, + }); + return { issues: { nodes: [], pageInfo: { hasNextPage: false } } }; + }, + ], + { autoResolveLabels: false }, + ); + + const scoped = ctx({ + apiKey: "lin_resolve", + scopeLabelIds: "requester:yosi", + }); + await action.execute({}, scoped); + await action.execute({}, scoped); + }); + + it("fails with an actionable message when the scope value matches nothing", async () => { + const action = getAction(makeLinear(), "issue.list"); + const empty = (body: { query: string }) => { + assert.match(body.query, /issueLabels\(first: 250/); + return { + issueLabels: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }; + }; + + // The miss triggers one cache-bypassing re-read before giving up. + mockLinearSequence([empty, empty], { autoResolveLabels: false }); + + await assert.rejects( + action.execute( + {}, + ctx({ apiKey: "lin_unknown", scopeLabelIds: "requester:yosi" }), + ), + /neither a label UUID nor the name of a label/, + ); + }); + + // A label created after the directory was first read must not stay + // unresolvable for the lifetime of the process. + it("re-reads the directory when a name is missing from the cache", async () => { + const action = getAction(makeLinear(), "issue.list"); + const LABEL_ID = "7c1d2a18-0a2c-4d31-9c2e-7f0f3b0c55aa"; + const scoped = ctx({ apiKey: "lin_late", scopeLabelIds: "shipped-later" }); + + mockLinearSequence( + [ + // first read: the label does not exist yet (cached) + () => ({ + issueLabels: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }), + // forced re-read after the miss: still absent, so this call fails + () => ({ + issueLabels: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }), + // the label now exists; the next attempt re-reads and finds it + () => ({ + issueLabels: { + nodes: [{ id: LABEL_ID, name: "shipped-later" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }), + (body) => { + assert.match(body.query, /issues\(/); + assert.deepEqual(body.variables?.filter, { + labels: { id: { in: [LABEL_ID] } }, + }); + return { issues: { nodes: [], pageInfo: { hasNextPage: false } } }; + }, + ], + { autoResolveLabels: false }, + ); + + await assert.rejects( + action.execute({}, scoped), + /neither a label UUID nor the name of a label/, + ); + await action.execute({}, scoped); + }); + + it("skips the lookup entirely when every scope value is a UUID", async () => { + const action = getAction(makeLinear(), "issue.list"); + const LABEL_ID = "e9fc5d53-a992-4952-9607-fa49ef892689"; + + mockLinear((body) => { + assert.match(body.query, /issues\(/); + assert.deepEqual(body.variables?.filter, { + labels: { id: { in: [LABEL_ID] } }, + }); + return { issues: { nodes: [], pageInfo: { hasNextPage: false } } }; + }); + + await action.execute({}, ctx({ scopeLabelIds: LABEL_ID })); + }); + it("injects the scope label filter on issue.search with caller filters", async () => { const action = getAction(makeLinear(), "issue.search");