diff --git a/extensions/linear/CHANGELOG.md b/extensions/linear/CHANGELOG.md index aa2755f602c..982edc60263 100644 --- a/extensions/linear/CHANGELOG.md +++ b/extensions/linear/CHANGELOG.md @@ -1,5 +1,12 @@ # Linear Changelog +## [Multi-Workspace Support] - {PR_MERGE_DATE} + +- Connect any number of Linear workspaces (Manage Workspaces command: add, switch, re-authenticate, log out) +- Workspace dropdown on every list command, workspace field on Create Issue / Create Project (with per-workspace remembered defaults), optional one-shot `workspace` argument on Quick Add Comment to Issue, and a dedicated Create Issue for Myself in Workspace command +- Menu bar notifications aggregate all connected workspaces +- AI tools accept a workspace and a new `get-workspaces` tool lists the connected ones + ## [My Issues Sub Views] - 2026-07-30 - Add a dropdown to the "My Issues" command to switch between the Assigned, Created, and Subscribed sub views, matching the Linear app. Assigned stays the default. diff --git a/extensions/linear/README.md b/extensions/linear/README.md index 9bc6ab9bf9a..41f30af296a 100644 --- a/extensions/linear/README.md +++ b/extensions/linear/README.md @@ -16,4 +16,26 @@ The Linear extension brings the speed, quality and joy of the app to every corne **Q2. How do I switch workspaces?** -**Ans.** You can't switch workspaces on the fly; however, you can go to `Preferences`, `click` "Logout" then run any `command` now selecting the appropriate workspace. +**Ans.** Connect additional workspaces with the **Manage Workspaces** command, then switch using the workspace dropdown on any list command, the Workspace field on forms, the optional `workspace` argument on **Quick Add Comment to Issue**, or the dedicated **Create Issue for Myself in Workspace** command. + +## Multiple Workspaces + +The extension supports any number of Linear workspaces — including the same workspace +connected under two different accounts. + +- **Add a workspace:** open **Manage Workspaces** → **Add Workspace** (⌘N). Linear has no + workspace picker on its consent page: first switch to the target workspace at + [linear.app](https://linear.app) (top-left workspace switcher), then approve the grant. +- **Switch:** every list command has a Workspace dropdown (visible once you have two or + more workspaces); forms have a Workspace field; **Quick Add Comment to Issue** accepts an + optional `workspace` argument (URL key, account email, or unique name prefix) that acts + once without changing your default, and a dedicated **Create Issue for Myself in + Workspace** command targets any workspace by URL key, account email, or unique name + prefix without changing your default. +- **Menu bar:** unread notifications aggregate across all connected workspaces, one + section each. +- **Log out:** use Manage Workspaces (its rows show the workspace and account, unlike + Raycast's built-in login list). Logging out removes the login from Raycast; the grant + at Linear expires on its own within 24 hours. +- **Preferences** (like a preferred team key) apply per command and are matched inside + whichever workspace the command acts in. diff --git a/extensions/linear/package.json b/extensions/linear/package.json index 83254528ce1..7a2809c2d10 100644 --- a/extensions/linear/package.json +++ b/extensions/linear/package.json @@ -45,6 +45,10 @@ "description": "Select the field to focus automatically once the form is rendered", "default": "title", "data": [ + { + "title": "Workspace", + "value": "workspaceKey" + }, { "title": "Team", "value": "teamId" @@ -208,6 +212,59 @@ } ] }, + { + "name": "create-issue-for-myself-in-workspace", + "title": "Create Issue for Myself in Workspace", + "description": "Create and assign a new issue to yourself in a specific workspace.", + "mode": "no-view", + "preferences": [ + { + "name": "preferredTeamKey", + "type": "textfield", + "title": "Preferred Team", + "placeholder": "RAY", + "description": "Specify the Key (e.g. RAY) of the preferred team. If not specified, the first team is used.", + "required": false + }, + { + "name": "preferredStatusName", + "type": "textfield", + "title": "Preferred Status", + "placeholder": "Todo", + "description": "Specify the name of the preferred status for new issues (e.g. Todo, Backlog). If not specified, uses Linear's default.", + "required": false + }, + { + "name": "shouldCloseMainWindow", + "type": "checkbox", + "title": "Advanced", + "label": "Close window immediately", + "description": "When enabled, the Raycast window is closed immediately, allowing you to carry on with other work.", + "default": false, + "required": false + } + ], + "arguments": [ + { + "name": "workspace", + "placeholder": "Workspace", + "type": "text", + "required": true + }, + { + "name": "title", + "placeholder": "Title", + "type": "text", + "required": true + }, + { + "name": "description", + "placeholder": "Description", + "type": "text", + "required": false + } + ] + }, { "name": "quick-add-comment-to-issue", "title": "Quick Add Comment to Issue", @@ -236,6 +293,12 @@ "placeholder": "Issue ID", "type": "text", "required": true + }, + { + "name": "workspace", + "placeholder": "Workspace", + "type": "text", + "required": false } ] }, @@ -244,6 +307,12 @@ "title": "Favorites", "description": "Browse through your Linear favorites.", "mode": "view" + }, + { + "name": "manage-workspaces", + "title": "Manage Workspaces", + "description": "Add, switch, re-authenticate, or log out of Linear workspaces.", + "mode": "view" } ], "tools": [ @@ -356,10 +425,15 @@ "name": "update-project-milestone", "title": "Update an Existing Project Milestone", "description": "Updates an existing project milestone in Linear" + }, + { + "name": "get-workspaces", + "title": "Get Workspaces", + "description": "List the connected Linear workspaces with their workspaceId, name, URL key, account email, and which one is active. Call this before acting in a specific workspace and pass its workspaceId to other tools." } ], "ai": { - "instructions": "- Always format object titles and names as markdown links using the object's URL. Example: Instead of saying \"Issue **Implement user profile page** has been created', say \"Issue [Implement user profile page](https://linear.app/company/issue/ISS-2) has been created\"\\n- Do not use names or titles as IDs in the system. Use the actual ID of the object\\n- When there is no exact match of issue / label titles with the provided name, analyze the input and titles of existing objects and use such objects only if the meaning is the same. Do not use objects that are not related to the input\\n- If user asks for his issues, it means issues assigned to him. Use `get-current-user` tool to get the user id and use it to filter\\n- \\n- If user ask to close the issue, it means to move it to done status\\n- When user asks about inbox they mean notifications", + "instructions": "- Always format object titles and names as markdown links using the object's URL. Example: Instead of saying \"Issue **Implement user profile page** has been created', say \"Issue [Implement user profile page](https://linear.app/company/issue/ISS-2) has been created\"\\n- Do not use names or titles as IDs in the system. Use the actual ID of the object\\n- When there is no exact match of issue / label titles with the provided name, analyze the input and titles of existing objects and use such objects only if the meaning is the same. Do not use objects that are not related to the input\\n- If user asks for his issues, it means issues assigned to him. Use `get-current-user` tool to get the user id and use it to filter\\n- \\n- If user ask to close the issue, it means to move it to done status\\n- When user asks about inbox they mean notifications\\n- If the user names a workspace, or more than one workspace might be connected, call `get-workspaces` once, then pass its `workspaceId` value to EVERY subsequent tool call in the conversation, and state in your answer which workspace you acted in\\n- Never guess or construct workspaceId values; only use values returned by `get-workspaces`. If the named workspace is not in the list, say so and list the available ones\\n- If no workspace is mentioned and only one is connected, omit `workspaceId`", "evals": [ { "input": "@linear what's in my inbox", @@ -1568,6 +1642,31 @@ } } ] + }, + { + "input": "@linear list my workspaces", + "mocks": { + "get-workspaces": [ + { "workspaceId": "org-a:user-1", "name": "ConceptM", "urlKey": "conceptm", "userEmail": "steve@conceptm.com", "isActive": true }, + { "workspaceId": "org-b:user-2", "name": "flox", "urlKey": "floxdotdev", "userEmail": "stevemorin@flox.dev", "isActive": false } + ] + }, + "expected": [{ "callsTool": "get-workspaces" }] + }, + { + "input": "@linear create an issue titled \"Test multi\" in the flox workspace for team ENG", + "mocks": { + "get-workspaces": [ + { "workspaceId": "org-a:user-1", "name": "ConceptM", "urlKey": "conceptm", "userEmail": "steve@conceptm.com", "isActive": true }, + { "workspaceId": "org-b:user-2", "name": "flox", "urlKey": "floxdotdev", "userEmail": "stevemorin@flox.dev", "isActive": false } + ], + "get-teams": [{ "id": "team-eng", "name": "Engineering", "key": "ENG" }], + "create-issue": { "success": true, "issue": { "id": "issue-1", "identifier": "ENG-1", "title": "Test multi", "url": "https://linear.app/floxdotdev/issue/ENG-1" } } + }, + "expected": [ + { "callsTool": "get-workspaces" }, + { "callsTool": { "name": "create-issue", "arguments": { "workspaceId": "org-b:user-2" } } } + ] } ] }, diff --git a/extensions/linear/src/active-cycle.tsx b/extensions/linear/src/active-cycle.tsx index f40ecc8060d..12c09265586 100644 --- a/extensions/linear/src/active-cycle.tsx +++ b/extensions/linear/src/active-cycle.tsx @@ -1,29 +1,50 @@ import { Action, ActionPanel, Icon, List } from "@raycast/api"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { getActiveCycleIssues } from "./api/getIssues"; import CreateIssueForm from "./components/CreateIssueForm"; import StateIssueList from "./components/StateIssueList"; import View from "./components/View"; +import { useWorkspaces } from "./components/WorkspaceContext"; +import { + isWorkspaceDropdownValue, + workspaceValueToKey, + WorkspaceDropdownSection, +} from "./components/WorkspaceDropdown"; import { getTeamIcon } from "./helpers/teams"; import useIssues from "./hooks/useIssues"; import useMe from "./hooks/useMe"; import usePriorities from "./hooks/usePriorities"; import useTeams from "./hooks/useTeams"; +import { useWorkspaceCachedState } from "./hooks/useWorkspaceCachedState"; function ActiveCycle() { const [teamQuery, setTeamQuery] = useState(""); const { teams, org, supportsTeamTypeahead, isLoadingTeams } = useTeams(teamQuery); - const [selectedTeam, setSelectedTeam] = useState(""); + const { switchWorkspace } = useWorkspaces(); + const [storedTeam, setStoredTeam] = useWorkspaceCachedState("active-cycle-team", ""); + // Restore validation (§4.5) runs ONCE against the initial, query-less team load — + // NOT against typeahead results, which would transiently "invalidate" the stored team + // and flip the visible cycle while the user types. State (not a ref) so a cold-cache + // resolution that lands after the first render still triggers a re-render to show it. + const [validatedTeam, setValidatedTeam] = useState(null); + useEffect(() => { + if (validatedTeam === null && teams && teamQuery === "") { + setValidatedTeam(teams.some((team) => team.id === storedTeam) ? storedTeam : (teams[0]?.id ?? "")); + } + // Intentional deps: validatedTeam and storedTeam are read but not tracked — + // this must run only when teams/teamQuery change, not on every storedTeam update. + }, [teams, teamQuery]); + const selectedTeam = validatedTeam ?? ""; const { priorities, isLoadingPriorities } = usePriorities(); const { me, isLoadingMe } = useMe(); const cycleId = useMemo(() => { return teams?.find((team) => team.id === selectedTeam)?.activeCycle?.id; - }, [selectedTeam]); + }, [selectedTeam, teams]); - const { issues, isLoadingIssues, mutateList } = useIssues(getActiveCycleIssues, [cycleId], { + const { issues, isLoadingIssues, mutateList } = useIssues((id?: string) => getActiveCycleIssues(id), [cycleId], { execute: !!cycleId && cycleId.trim().length > 0, }); @@ -32,15 +53,29 @@ function ActiveCycle() { searchBarAccessory={ { + if (isWorkspaceDropdownValue(value)) { + switchWorkspace(workspaceValueToKey(value)); + return; + } + if (value !== "-" && value !== selectedTeam) { + setValidatedTeam(value); + setStoredTeam(value); // S9: ignore mount echo (guarded by the !== check) + } + }} {...(supportsTeamTypeahead && { throttle: true, onSearchTextChange: setTeamQuery, isLoading: isLoadingTeams, })} > - {(!teams || teams.length === 0) && ( + + {/* Also render during the pre-validation frame (validatedTeam === null): on a warm + cache teams can already be populated while selectedTeam still falls back to "-" + (S9) — the controlled value must always match a rendered item, or Raycast's + first-item snap could fire a persisted workspace switch. */} + {(!teams || teams.length === 0 || validatedTeam === null) && ( )} {teams?.map((team) => ( diff --git a/extensions/linear/src/api/attachments.ts b/extensions/linear/src/api/attachments.ts index 0aba4010dad..8fbb44ff731 100644 --- a/extensions/linear/src/api/attachments.ts +++ b/extensions/linear/src/api/attachments.ts @@ -1,10 +1,10 @@ import { readFile } from "fs/promises"; import path from "path"; -import { UploadFile } from "@linear/sdk"; +import { LinearClient, UploadFile } from "@linear/sdk"; import { fileTypeFromFile } from "file-type"; -import { getLinearClient } from "./linearClient"; +import { resolveClient } from "./linearClient"; const DEFAULT_CONTENT_TYPE = "application/octet-stream"; @@ -20,8 +20,8 @@ export type UploadedFile = { name: string; }; -export async function uploadFile(filePath: string): Promise { - const { graphQLClient } = getLinearClient(); +export async function uploadFile(filePath: string, client?: LinearClient): Promise { + const { graphQLClient } = resolveClient(client); const buffer = await readFile(filePath); const type = await fileTypeFromFile(filePath); @@ -81,14 +81,14 @@ function escapeMarkdownLabel(value: string) { return value.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); } -export async function appendFileAttachments(markdown: string, attachmentPaths?: string[]) { +export async function appendFileAttachments(markdown: string, attachmentPaths?: string[], client?: LinearClient) { if (!attachmentPaths?.length) { return markdown; } const files: UploadedFile[] = []; for (const filePath of attachmentPaths) { - files.push(await uploadFile(filePath)); + files.push(await uploadFile(filePath, client)); } const attachments = files.map(({ assetUrl, contentType, name }) => { const label = escapeMarkdownLabel(name); @@ -103,10 +103,10 @@ export type CreateAttachmentPayload = { url: string; }; -export async function createAttachment(payload: CreateAttachmentPayload) { - const { graphQLClient } = getLinearClient(); +export async function createAttachment(payload: CreateAttachmentPayload, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); - const file = await uploadFile(payload.url); + const file = await uploadFile(payload.url, client); const attachmentInput = `issueId: "${payload.issueId}", title: "${file.name}", url: "${file.assetUrl}"`; @@ -129,8 +129,8 @@ export async function createAttachment(payload: CreateAttachmentPayload) { return { success: data?.attachmentCreate.success, id: data?.attachmentCreate.attachment.id }; } -export async function attachLinkUrl(payload: CreateAttachmentPayload) { - const { graphQLClient } = getLinearClient(); +export async function attachLinkUrl(payload: CreateAttachmentPayload, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const attachmentInput = `issueId: "${payload.issueId}", url: "${payload.url}"`; diff --git a/extensions/linear/src/api/createIssue.ts b/extensions/linear/src/api/createIssue.ts index 65e07818127..11369f58444 100644 --- a/extensions/linear/src/api/createIssue.ts +++ b/extensions/linear/src/api/createIssue.ts @@ -1,4 +1,6 @@ -import { getLinearClient } from "../api/linearClient"; +import { LinearClient } from "@linear/sdk"; + +import { resolveClient } from "../api/linearClient"; import { IssueFragment, IssueResult } from "./getIssues"; @@ -18,8 +20,8 @@ export type CreateIssuePayload = { parentId?: string; }; -export async function createIssue(payload: CreateIssuePayload) { - const { graphQLClient } = getLinearClient(); +export async function createIssue(payload: CreateIssuePayload, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const title = payload.title.replace(/"/g, "\\$&"); const description = payload.description?.replace(/\n/g, "\\n")?.replace(/"/g, "\\$&"); @@ -89,8 +91,8 @@ type CreateSubIssuePayload = { stateId?: string; }; -export async function createSubIssue(payload: CreateSubIssuePayload) { - const { graphQLClient } = getLinearClient(); +export async function createSubIssue(payload: CreateSubIssuePayload, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const title = payload.title.replace(/"/g, "\\$&"); const description = payload.description?.replace(/\n/g, "\\n").replace(/"/g, "\\$&"); diff --git a/extensions/linear/src/api/deleteNotification.ts b/extensions/linear/src/api/deleteNotification.ts index 2746fa43c8c..16d249e066e 100644 --- a/extensions/linear/src/api/deleteNotification.ts +++ b/extensions/linear/src/api/deleteNotification.ts @@ -1,9 +1,9 @@ -import { Notification } from "@linear/sdk"; +import { LinearClient, Notification } from "@linear/sdk"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; -export async function deleteNotification(id: Notification["id"]) { - const { graphQLClient } = getLinearClient(); +export async function deleteNotification(id: Notification["id"], client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { notificationArchive: { success: boolean } }, diff --git a/extensions/linear/src/api/documents.ts b/extensions/linear/src/api/documents.ts index 0e6b7099915..65c66387ffc 100644 --- a/extensions/linear/src/api/documents.ts +++ b/extensions/linear/src/api/documents.ts @@ -1,7 +1,9 @@ -import { getLinearClient } from "./linearClient"; +import { LinearClient } from "@linear/sdk"; -export async function deleteDocument(documentId: string) { - const { graphQLClient } = getLinearClient(); +import { resolveClient } from "./linearClient"; + +export async function deleteDocument(documentId: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest<{ documentDelete: { success: boolean } }, Record>( ` @@ -21,8 +23,8 @@ export type DocUpdatePayload = Partial<{ initiativeId: string; }>; -export async function updateDocument(documentId: string, payload: DocUpdatePayload) { - const { graphQLClient } = getLinearClient(); +export async function updateDocument(documentId: string, payload: DocUpdatePayload, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); let docUpdateInput = `projectId: ${payload.projectId ? `"${payload.projectId}"` : null}`; docUpdateInput += `, initiativeId: ${payload.initiativeId ? `"${payload.initiativeId}"` : null}`; diff --git a/extensions/linear/src/api/favorites.ts b/extensions/linear/src/api/favorites.ts index 39413550096..a4e1e687091 100644 --- a/extensions/linear/src/api/favorites.ts +++ b/extensions/linear/src/api/favorites.ts @@ -4,6 +4,7 @@ import { Document, Issue, IssueLabel, + LinearClient, Project, Initiative, Team, @@ -11,7 +12,7 @@ import { WorkflowState, } from "@linear/sdk"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; export type Favorite = { id: string; @@ -35,8 +36,8 @@ export type Favorite = { updatedAt: string; }; -export async function getFavorites() { - const { graphQLClient } = getLinearClient(); +export async function getFavorites(client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { viewer: { organization: { urlKey: string } }; favorites: { nodes: Favorite[] } }, Record diff --git a/extensions/linear/src/api/getCustomViews.ts b/extensions/linear/src/api/getCustomViews.ts index 81ef236eb63..923edbf9a23 100644 --- a/extensions/linear/src/api/getCustomViews.ts +++ b/extensions/linear/src/api/getCustomViews.ts @@ -1,7 +1,7 @@ -import { CustomView, Team } from "@linear/sdk"; +import { CustomView, LinearClient, Team } from "@linear/sdk"; import { IssueFragment, IssueResult } from "../api/getIssues"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; import { getPaginated, PageInfo } from "../api/pagination"; export type CustomViewResult = Pick & { @@ -9,8 +9,8 @@ export type CustomViewResult = Pick; }; -export async function getCustomViews(): Promise { - const { graphQLClient } = getLinearClient(); +export async function getCustomViews(client?: LinearClient): Promise { + const { graphQLClient } = resolveClient(client); const allViews = await getPaginated( async (cursor) => @@ -52,8 +52,8 @@ export async function getCustomViews(): Promise { return allViews.filter((v) => v.modelName === "Issue"); } -export async function getCustomViewIssues(viewId: string): Promise { - const { graphQLClient } = getLinearClient(); +export async function getCustomViewIssues(viewId: string, client?: LinearClient): Promise { + const { graphQLClient } = resolveClient(client); return getPaginated( async (cursor) => diff --git a/extensions/linear/src/api/getIssueTemplates.ts b/extensions/linear/src/api/getIssueTemplates.ts index 22111e3c97f..bdb1db4c20d 100644 --- a/extensions/linear/src/api/getIssueTemplates.ts +++ b/extensions/linear/src/api/getIssueTemplates.ts @@ -1,4 +1,6 @@ -import { getLinearClient } from "./linearClient"; +import { LinearClient } from "@linear/sdk"; + +import { resolveClient } from "./linearClient"; export type IssueTemplateResult = { id: string; @@ -52,12 +54,12 @@ function sortIssueTemplates(a: IssueTemplateResult, b: IssueTemplateResult) { return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.name.localeCompare(b.name); } -export async function getIssueTemplates(teamId?: string) { +export async function getIssueTemplates(teamId?: string, client?: LinearClient) { if (!teamId) { return []; } - const { graphQLClient } = getLinearClient(); + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< IssueTemplatesResponse, diff --git a/extensions/linear/src/api/getIssues.ts b/extensions/linear/src/api/getIssues.ts index 1c65471dcc7..29b90dc9d9c 100644 --- a/extensions/linear/src/api/getIssues.ts +++ b/extensions/linear/src/api/getIssues.ts @@ -3,6 +3,7 @@ import { Cycle, Issue, IssueRelation, + LinearClient, Project, ProjectMilestone, Team, @@ -11,7 +12,7 @@ import { } from "@linear/sdk"; import { getPreferenceValues } from "@raycast/api"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; import { LabelResult } from "./getLabels"; import { getPaginated, PageInfo } from "./pagination"; @@ -156,8 +157,8 @@ export type IssueResult = Pick< projectMilestone?: Pick; }; -export async function getLastUpdatedIssues(after?: string) { - const { graphQLClient } = getLinearClient(); +export async function getLastUpdatedIssues(after?: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { issues: { nodes: IssueResult[]; pageInfo: { endCursor: string; hasNextPage: boolean } } }, Record @@ -181,8 +182,8 @@ export async function getLastUpdatedIssues(after?: string) { return { issues: data?.issues.nodes, pageInfo: data?.issues.pageInfo }; } -export async function searchIssues(query: string, after?: string) { - const { graphQLClient } = getLinearClient(); +export async function searchIssues(query: string, after?: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { searchIssues: { nodes: IssueResult[]; pageInfo: { endCursor: string; hasNextPage: boolean } } }, { term: string; after?: string; first?: number; includeArchived?: boolean } @@ -206,8 +207,8 @@ export async function searchIssues(query: string, after?: string) { return { issues: data?.searchIssues.nodes, pageInfo: data?.searchIssues.pageInfo }; } -export async function filterIssues(filter: string, after?: string) { - const { graphQLClient } = getLinearClient(); +export async function filterIssues(filter: string, after?: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { issues: { nodes: IssueResult[]; pageInfo: { endCursor: string; hasNextPage: boolean } } }, { filter: string; after?: string } @@ -231,8 +232,8 @@ export async function filterIssues(filter: string, after?: string) { return { issues: data?.issues.nodes, pageInfo: data?.issues.pageInfo }; } -export async function getLastCreatedIssues() { - const { graphQLClient } = getLinearClient(); +export async function getLastCreatedIssues(client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest<{ issues: { nodes: IssueResult[] } }, Record>( ` query { @@ -248,8 +249,8 @@ export async function getLastCreatedIssues() { return data?.issues.nodes; } -export async function getMyIssues() { - const { graphQLClient } = getLinearClient(); +export async function getMyIssues(client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { viewer: { assignedIssues: { nodes: IssueResult[] } } }, Record @@ -270,8 +271,8 @@ export async function getMyIssues() { return data?.viewer.assignedIssues.nodes; } -export async function getCreatedIssues() { - const { graphQLClient } = getLinearClient(); +export async function getCreatedIssues(client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { viewer: { createdIssues: { nodes: IssueResult[] } } }, Record @@ -292,8 +293,8 @@ export async function getCreatedIssues() { return data?.viewer.createdIssues.nodes; } -export async function getSubscribedIssues() { - const { graphQLClient } = getLinearClient(); +export async function getSubscribedIssues(client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { pageSize, pageLimit } = getPageLimits(); @@ -337,12 +338,12 @@ export function getMyIssuesByView(view: MyIssuesView) { } } -export async function getActiveCycleIssues(cycleId?: string) { +export async function getActiveCycleIssues(cycleId?: string, client?: LinearClient) { if (!cycleId) { return []; } - const { graphQLClient } = getLinearClient(); + const { graphQLClient } = resolveClient(client); const { pageSize, pageLimit } = getPageLimits(); @@ -378,8 +379,8 @@ export async function getActiveCycleIssues(cycleId?: string) { return nodes; } -export async function getProjectIssues(projectId: string) { - const { graphQLClient } = getLinearClient(); +export async function getProjectIssues(projectId: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest<{ issues: { nodes: IssueResult[] } }, Record>( ` query($projectId: ID) { @@ -399,8 +400,8 @@ export async function getProjectIssues(projectId: string) { return data?.issues.nodes; } -export async function getProjectMilestoneIssues(milestoneId: string) { - const { graphQLClient } = getLinearClient(); +export async function getProjectMilestoneIssues(milestoneId: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest<{ issues: { nodes: IssueResult[] } }, Record>( ` query($milestoneId: ID) { @@ -420,8 +421,8 @@ export async function getProjectMilestoneIssues(milestoneId: string) { return data?.issues.nodes; } -export async function getSubIssues(issueId: string) { - const { graphQLClient } = getLinearClient(); +export async function getSubIssues(issueId: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { issue: { children: { nodes: IssueResult[] } } }, Record @@ -452,8 +453,8 @@ export type CommentResult = Pick & user: Pick; }; -export async function getComments(issueId: string) { - const { graphQLClient } = getLinearClient(); +export async function getComments(issueId: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { issue: { comments: { nodes: CommentResult[] } } }, @@ -516,8 +517,8 @@ export type IssueDetailResult = IssueResult & }; }; -export async function getIssueDetail(issueId: string) { - const { graphQLClient } = getLinearClient(); +export async function getIssueDetail(issueId: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest<{ issue: IssueDetailResult }, Record>( ` diff --git a/extensions/linear/src/api/getLabels.ts b/extensions/linear/src/api/getLabels.ts index 4a11e6da4b9..efb051df3bf 100644 --- a/extensions/linear/src/api/getLabels.ts +++ b/extensions/linear/src/api/getLabels.ts @@ -1,7 +1,7 @@ -import { IssueLabel } from "@linear/sdk"; +import { IssueLabel, LinearClient } from "@linear/sdk"; import { getPreferenceValues } from "@raycast/api"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; import { getPaginated, PageInfo } from "./pagination"; @@ -20,14 +20,14 @@ function getPageLimits() { export type LabelResult = Pick; -export async function getLabels(teamId?: string) { +export async function getLabels(teamId?: string, client?: LinearClient) { if (!teamId) { return []; } const { pageSize, pageLimit } = getPageLimits(); - const { graphQLClient } = getLinearClient(); + const { graphQLClient } = resolveClient(client); return getPaginated( async (cursor) => diff --git a/extensions/linear/src/api/getMilestones.ts b/extensions/linear/src/api/getMilestones.ts index bb8b85eab6f..908e2a550ef 100644 --- a/extensions/linear/src/api/getMilestones.ts +++ b/extensions/linear/src/api/getMilestones.ts @@ -1,6 +1,6 @@ -import { ProjectMilestone } from "@linear/sdk"; +import { LinearClient, ProjectMilestone } from "@linear/sdk"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; export type MilestoneResult = Pick< ProjectMilestone, @@ -23,8 +23,8 @@ const milestoneFragment = ` updatedAt `; -export async function getMilestones(projectId?: string) { - const { graphQLClient } = getLinearClient(); +export async function getMilestones(projectId?: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); if (projectId) { const { data } = await graphQLClient.rawRequest< diff --git a/extensions/linear/src/api/getNotifications.ts b/extensions/linear/src/api/getNotifications.ts index b5849302e72..a455589de0a 100644 --- a/extensions/linear/src/api/getNotifications.ts +++ b/extensions/linear/src/api/getNotifications.ts @@ -1,6 +1,15 @@ -import { Organization, Comment, User, IssueNotification, ProjectUpdate, Project, ActorBot } from "@linear/sdk"; +import { + Organization, + Comment, + User, + IssueNotification, + ProjectUpdate, + Project, + ActorBot, + LinearClient, +} from "@linear/sdk"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; import { IssueFragment, IssueResult } from "./getIssues"; @@ -27,8 +36,8 @@ export type NotificationResult = Pick< export type OrganizationResult = Pick; -export async function getNotifications() { - const { graphQLClient } = getLinearClient(); +export async function getNotifications(client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { notifications: { nodes: NotificationResult[] } } & { organization: OrganizationResult }, Record diff --git a/extensions/linear/src/api/getProjects.ts b/extensions/linear/src/api/getProjects.ts index bcd07a200ea..58f45635a7b 100644 --- a/extensions/linear/src/api/getProjects.ts +++ b/extensions/linear/src/api/getProjects.ts @@ -1,6 +1,6 @@ -import { Project, ProjectStatus, ProjectUpdate, User } from "@linear/sdk"; +import { LinearClient, Project, ProjectStatus, ProjectUpdate, User } from "@linear/sdk"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; export type ProjectResult = Pick< Project, @@ -84,13 +84,11 @@ const projectFragment = ` } `; -export async function getProjects({ - teamId, - searchText = "", - after = null, - first = null, -}: GetProjectsOptions): Promise { - const { graphQLClient } = getLinearClient(); +export async function getProjects( + { teamId, searchText = "", after = null, first = null }: GetProjectsOptions, + client?: LinearClient, +): Promise { + const { graphQLClient } = resolveClient(client); const projectsQueryFragment = ` projects(first: $first, after: $after, filter: { name: { containsIgnoreCase: $searchText } }) { @@ -161,8 +159,8 @@ const projectUpdateFragment = ` } `; -export async function getProjectUpdates(projectId: string) { - const { graphQLClient } = getLinearClient(); +export async function getProjectUpdates(projectId: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { project: { projectUpdates: { nodes: ProjectUpdateResult[] } } }, diff --git a/extensions/linear/src/api/getTeams.ts b/extensions/linear/src/api/getTeams.ts index fcaabc26585..978d46c592d 100644 --- a/extensions/linear/src/api/getTeams.ts +++ b/extensions/linear/src/api/getTeams.ts @@ -1,7 +1,7 @@ -import { Cycle, Organization, Team } from "@linear/sdk"; +import { Cycle, LinearClient, Organization, Team } from "@linear/sdk"; import { sortBy } from "lodash"; -import { getLinearClient } from "./linearClient"; +import { resolveClient } from "./linearClient"; export type TeamResult = Pick< Team, @@ -28,8 +28,8 @@ export type TeamsAndOrgResult = { organization: OrganizationResult; }; -export async function getTeams(query: string = "") { - const { graphQLClient, linearClient } = getLinearClient(); +export async function getTeams(query: string = "", client?: LinearClient) { + const { graphQLClient, linearClient } = resolveClient(client); const me = await linearClient.viewer; diff --git a/extensions/linear/src/api/initiatives.ts b/extensions/linear/src/api/initiatives.ts index 3e951cb6239..742c887377c 100644 --- a/extensions/linear/src/api/initiatives.ts +++ b/extensions/linear/src/api/initiatives.ts @@ -1,7 +1,7 @@ -import { Initiative, Project } from "@linear/sdk"; +import { Initiative, LinearClient, Project } from "@linear/sdk"; import { sortBy } from "lodash"; -import { getLinearClient } from "./linearClient"; +import { resolveClient } from "./linearClient"; export type InitiativeResult = Pick & { projects?: { nodes: Pick[] }; @@ -23,8 +23,8 @@ const initiativeFragment = ` } `; -export async function getInitiatives() { - const { graphQLClient } = getLinearClient(); +export async function getInitiatives(client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest>( ` query { diff --git a/extensions/linear/src/api/linearClient.ts b/extensions/linear/src/api/linearClient.ts index a07f0ea2bf5..3669ef457bd 100644 --- a/extensions/linear/src/api/linearClient.ts +++ b/extensions/linear/src/api/linearClient.ts @@ -1,26 +1,261 @@ import { LinearClient, LinearGraphQLClient } from "@linear/sdk"; -import { environment } from "@raycast/api"; -import { OAuthService } from "@raycast/utils"; - -let linearClient: LinearClient | null = null; - -export const linear = OAuthService.linear({ - scope: "read write", - onAuthorize({ token }) { - linearClient = new LinearClient({ - accessToken: token, - headers: { - "public-file-urls-expire-in": "60", - "linear-raycast-extension-name": environment.extensionName, - }, +import { environment, LaunchType, LocalStorage } from "@raycast/api"; + +import { refreshQuickCommandSubtitles } from "../helpers/refreshQuickSubtitles"; + +import { ensureToken, fetchViewerIdentity, getServiceForProviderId, linear } from "./oauth"; +import { + EntryRef, + entryKey, + getActiveEntry, + migrateIfNeeded, + reconcileEntries, + setActiveEntry, + WorkspaceEntry, + WorkspaceRegistry, +} from "./workspaces"; + +const clientsByProviderId = new Map(); +const tokensByProviderId = new Map(); +let activeProviderId: string | null = null; + +export type WorkspaceSnapshot = { + registry: WorkspaceRegistry; + activeEntry: WorkspaceEntry | null; + // Entry keys whose client holds no token — "Needs re-authentication" must surface in + // ordinary command UI too, not only in Manage Workspaces (§4.2 View-bootstrap reconciliation). + needsReauth: string[]; +}; + +let lastBootstrap: WorkspaceSnapshot | null = null; + +// Render-time access to the launch-resolved workspace state (set by bootstrapWorkspaceAuth, +// which the withWorkspaceAuth wrapper awaits before anything renders or executes). +export function getWorkspaceSnapshot(): WorkspaceSnapshot | null { + return lastBootstrap; +} + +export function makeClient(accessToken: string): LinearClient { + return new LinearClient({ + accessToken, + headers: { + "public-file-urls-expire-in": "60", + "linear-raycast-extension-name": environment.extensionName, + }, + }); +} + +function isBackgroundLaunch(): boolean { + return environment.launchType === LaunchType.Background; +} + +function serviceForEntry(entry: WorkspaceEntry) { + return getServiceForProviderId( + entry.providerId, + `Connect to ${entry.orgName} (${entry.userEmail})`, + `Linear — ${entry.orgName}`, + ); +} + +// Interactive grants bind to whatever account/workspace is active at linear.app (spike S3), +// so any token MINTED interactively into an entry-keyed slot must be identity-verified +// before use — otherwise workspace X's token silently lands under entry Y (a D10 +// violation: every "Y" query would really hit X). Slot 0 is NOT exempt: once migration +// has bound an identity to the "linear" entry, a fully revoked authorization re-granted +// while another workspace is active at linear.app could corrupt it the same way — +// verification is one API call and only runs on actual mints. +export async function ensureEntryToken(entry: WorkspaceEntry, options: { interactive: boolean }): Promise { + const service = serviceForEntry(entry); + const existing = await service.client.getTokens(); + const hadUsableToken = Boolean(existing?.accessToken && !existing.isExpired()); + const token = await ensureToken(service, options); + const possiblyMinted = options.interactive && !hadUsableToken; + if (possiblyMinted && token !== existing?.accessToken) { + const identity = await fetchViewerIdentity(token); + if (identity.orgId !== entry.orgId || identity.userId !== entry.userId) { + await service.client.removeTokens(); + throw new Error( + `Linear granted access for ${identity.orgName} (${identity.userEmail}), not ` + + `${entry.orgName} (${entry.userEmail}). Switch to that account and workspace at ` + + `linear.app (top-left switcher), then try again — or use Add Workspace to connect it.`, + ); + } + } + return token; +} + +// D11 follow-up: constructing a labeled OAuthService is pure in-process object creation — +// it never reaches Raycast, so the Settings account-row label only has a chance to update +// the next time a token is actually WRITTEN through that labeled client. This does a single +// benign re-write of slot 0's existing token set through the labeled client, once, so the +// row has a shot at picking up the "Linear — " label. Guarded by a LocalStorage flag so +// it only ever runs once; a failure here must never affect bootstrap. +const SLOT0_RELABEL_KEY = "slot0-relabelled-v1"; + +async function relabelSlot0Once(entry: WorkspaceEntry): Promise { + if (entry.providerId !== "linear") return; // only slot 0 needs this touch + if (isBackgroundLaunch()) return; // foreground only + try { + if (await LocalStorage.getItem(SLOT0_RELABEL_KEY)) return; + const labeled = getServiceForProviderId("linear", undefined, `Linear — ${entry.orgName}`); + const tokens = await labeled.client.getTokens(); + if (!tokens?.accessToken) return; // nothing to re-write; do NOT set the flag + // setTokens re-stamps updatedAt to now, so pass the REMAINING lifetime (same math as + // completeAddFromStaging), not the original duration. Omit expiresIn entirely when the + // stored set lacks it — defaulting would make a non-expiring token look expiring. + const elapsedSeconds = Math.floor((Date.now() - tokens.updatedAt.getTime()) / 1000); + await labeled.client.setTokens({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + idToken: tokens.idToken, + scope: tokens.scope, + ...(tokens.expiresIn !== undefined ? { expiresIn: Math.max(60, tokens.expiresIn - elapsedSeconds) } : {}), }); - }, -}); + await LocalStorage.setItem(SLOT0_RELABEL_KEY, new Date().toISOString()); + } catch { + // Never break launch for a cosmetic relabel; retry on a later launch. + } +} + +function cacheClient(providerId: string, token: string): LinearClient { + const cached = clientsByProviderId.get(providerId); + if (cached && tokensByProviderId.get(providerId) === token) return cached; + const client = makeClient(token); + clientsByProviderId.set(providerId, client); + tokensByProviderId.set(providerId, token); + return client; +} + +// Resolve the active workspace ONCE at command launch (design D2); later launches +// follow whatever the registry says then, but this process stays pinned. +export async function bootstrapWorkspaceAuth(options?: { interactive?: boolean }): Promise<{ + token: string; + registry: WorkspaceRegistry; + activeEntry: WorkspaceEntry | null; +}> { + const interactive = options?.interactive ?? !isBackgroundLaunch(); + const registry = await migrateIfNeeded({ allowWrite: interactive }); + const activeEntry = getActiveEntry(registry); + // Record the selection BEFORE any token work, so the auth error boundary can name + // the workspace that failed (edge case 1) even when the token fetch throws. + lastBootstrap = { registry, activeEntry, needsReauth: [] }; + + if (!activeEntry) { + // Fresh install: drive the slot-0 interactive login (today's exact first-run behavior), + // then adopt it on the next foreground bootstrap via migrateIfNeeded. + const token = await ensureToken(linear, { interactive }); + cacheClient("linear", token); + activeProviderId = "linear"; + return { token, registry, activeEntry: null }; + } + + const token = await ensureEntryToken(activeEntry, { interactive }); + cacheClient(activeEntry.providerId, token); + activeProviderId = activeEntry.providerId; + // Fire-and-forget, cosmetic only: nothing downstream depends on this completing, and + // awaiting it would add a getTokens+setTokens round-trip to every foreground launch + // until the one-time flag is set (D-fix, slot-0 relabel touch). + void relabelSlot0Once(activeEntry); + // View-bootstrap reconciliation (§4.2): entries whose tokens vanished surface as + // "Needs re-authentication" in ordinary command UI via the snapshot. + const reconciled = await reconcileEntries(registry); + lastBootstrap = { + registry, + activeEntry, + needsReauth: reconciled.filter((r) => !r.hasToken).map((r) => entryKey(r.entry)), + }; + return { token, registry, activeEntry }; +} +// P1/P1b/P2 switch semantics (design D2): ensure the target workspace's client BEFORE +// persisting it as the new global default. Persisting first (the earlier ordering) left +// a canceled/failed OAuth with the registry default already pointing at the broken +// workspace — the "no workspace registered" throw would fire only after that write. +// Look-up is read-only (allowWrite: false); only a successful ensure reaches the write. +export async function activateWorkspace(ref: EntryRef): Promise { + const lookupRegistry = await migrateIfNeeded({ allowWrite: false }); + const lookupEntry = lookupRegistry.workspaces.find((w) => entryKey(w) === entryKey(ref)); + if (!lookupEntry) throw new Error(`No workspace registered for ${entryKey(ref)}`); + await getLinearClientFor(ref, { interactive: true }); // ensures token + caches the client + + const registry = await setActiveEntry(ref); + const entry = registry.workspaces.find((w) => entryKey(w) === entryKey(ref)); + if (!entry) throw new Error(`No workspace registered for ${entryKey(ref)}`); + activeProviderId = entry.providerId; + lastBootstrap = { + registry, + activeEntry: entry, + // The target just authenticated successfully — drop any stale badge for it. + needsReauth: (lastBootstrap?.needsReauth ?? []).filter((key) => key !== entryKey(ref)), + }; + refreshQuickCommandSubtitles(); // fire-and-forget: root-search subtitles pegged to the active workspace are now stale + return entry; +} + +// Pinned-flow variant (design D2/D7): swaps THIS process's active client and snapshot +// WITHOUT persisting a new global default. Used by draft-opened forms whose draft +// targets a non-default workspace — the form must read AND submit in the draft's +// workspace while the registry default stays untouched. +export async function activateWorkspaceInMemory(ref: EntryRef): Promise { + const registry = await migrateIfNeeded({ allowWrite: false }); + const entry = registry.workspaces.find((w) => entryKey(w) === entryKey(ref)); + if (!entry) throw new Error(`No workspace registered for ${entryKey(ref)}`); + await getLinearClientFor(ref, { interactive: true }); + activeProviderId = entry.providerId; + lastBootstrap = { + registry, + activeEntry: entry, + needsReauth: (lastBootstrap?.needsReauth ?? []).filter((key) => key !== entryKey(ref)), + }; + return entry; +} + +// Synchronous fast path — signature identical to today's; all 16 src/api/* callers +// keep working unmodified in this slice. export function getLinearClient(): { linearClient: LinearClient; graphQLClient: LinearGraphQLClient } { - if (!linearClient) { + const client = activeProviderId ? clientsByProviderId.get(activeProviderId) : undefined; + if (!client) { throw new Error("No linear client initialized"); } + return { linearClient: client, graphQLClient: client.client }; +} + +// Entry-addressed client access for pinned flows, Manage Workspaces, the menu bar, +// and AI tools (D10: two entries can share an orgId — the ref carries userId too). +export async function getLinearClientFor( + ref: EntryRef, + options?: { interactive?: boolean }, +): Promise<{ linearClient: LinearClient; graphQLClient: LinearGraphQLClient }> { + const registry = await migrateIfNeeded({ allowWrite: false }); + const entry = registry.workspaces.find((w) => entryKey(w) === entryKey(ref)); + if (!entry) { + throw new Error(`No workspace registered for ${entryKey(ref)}`); + } + // Always re-ensure before reuse: a cached client's token can expire mid-process + // (24 h tokens; ensureToken short-circuits fast when the stored token is valid). + const interactive = options?.interactive ?? !isBackgroundLaunch(); + const token = await ensureEntryToken(entry, { interactive }); + const client = cacheClient(entry.providerId, token); + return { linearClient: client, graphQLClient: client.client }; +} + +// Explicit-client override for callers that serve more than one workspace in a single +// process (menu bar, AI tools): falls back to the sync fast path when no client is given. +export function resolveClient(client?: LinearClient): { + linearClient: LinearClient; + graphQLClient: LinearGraphQLClient; +} { + if (client) return { linearClient: client, graphQLClient: client.client }; + return getLinearClient(); +} - return { linearClient, graphQLClient: linearClient.client }; +// Used by View.tsx's auth error boundary ("Sign in Again"): clear exactly the +// active entry's tokens so the next bootstrap re-authenticates it. +export async function clearActiveWorkspaceTokens(): Promise { + const registry = await migrateIfNeeded({ allowWrite: false }); + const activeEntry = getActiveEntry(registry); + const service = activeEntry ? serviceForEntry(activeEntry) : linear; + await service.client.removeTokens(); + if (activeProviderId) clientsByProviderId.delete(activeProviderId); + activeProviderId = null; } diff --git a/extensions/linear/src/api/oauth.ts b/extensions/linear/src/api/oauth.ts new file mode 100644 index 00000000000..ee075bb327f --- /dev/null +++ b/extensions/linear/src/api/oauth.ts @@ -0,0 +1,125 @@ +import { OAuth } from "@raycast/api"; +import { OAuthService } from "@raycast/utils"; + +type AuthBackend = "manual" | "utils"; +// Flip to "utils" once the raycast/utils PR adding providerId/extraParameters ships (design D8). +export const AUTH_BACKEND: AuthBackend = "manual"; + +// Copied from @raycast/utils' built-in Linear provider (delete when AUTH_BACKEND becomes "utils"): +export const LINEAR_PROXY = "https://linear.oauth.raycast.com"; +export const LINEAR_PROXY_CLIENT_ID = "c8ff37b9225c3c9aefd7d66ea0e5b6f1"; + +// The existing login. Slot 0: providerId "linear", exactly today's service. +export const linear = OAuthService.linear({ scope: "read write" }); + +export function workspaceProviderId(orgId: string, userId: string): string { + return `linear-ws-${orgId}-${userId}`; +} + +export function makeLinearOAuthService(providerId: string, description: string, providerName = "Linear"): OAuthService { + if (AUTH_BACKEND === "utils") { + // Requires the raycast/utils PR (Plan 2): providerId + extraParameters options. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return OAuthService.linear({ scope: "read write", providerId, extraParameters: { prompt: "consent" } } as any); + } + return new OAuthService({ + client: new OAuth.PKCEClient({ + redirectMethod: OAuth.RedirectMethod.Web, + providerName, + providerIcon: "linear-app-icon.png", + providerId, + description, + }), + clientId: LINEAR_PROXY_CLIENT_ID, + scope: "read write", + authorizeUrl: `${LINEAR_PROXY}/authorize`, + tokenUrl: `${LINEAR_PROXY}/token`, + refreshTokenUrl: `${LINEAR_PROXY}/refresh-token`, + // prompt=consent is MANDATORY: without it an already-granted app silently skips + // Linear's consent screen, so a second-workspace grant is unreachable (spike S3). + extraParameters: { actor: "user", prompt: "consent" }, + }); +} + +export const stagingService = makeLinearOAuthService("linear-staging", "Connect a Linear workspace"); + +const servicesByProviderId = new Map(); + +export function getServiceForProviderId(providerId: string, description?: string, providerName?: string): OAuthService { + // D11: once slot 0's identity is known, entry-addressed callers pass a providerName + // and get a LABELED client (same providerId "linear" → same stored token, spike S1/S2). + // Label-less callers (pre-identity paths: migration's token read, fresh-install login) + // keep the stock handle. + if (providerId === "linear" && !providerName) return linear; + if (providerId === "linear-staging") return stagingService; + let service = servicesByProviderId.get(providerId); + if (!service) { + service = makeLinearOAuthService(providerId, description ?? "Connect your Linear account", providerName); + servicesByProviderId.set(providerId, service); + } + return service; +} + +// Background-safe refresh (spike S6). Never deletes tokens: 400, 5xx, and network +// errors all leave the stored token set untouched, unlike utils' refreshTokens(). +export async function refreshNonDestructive( + service: OAuthService, +): Promise<{ status: "ok"; accessToken: string } | { status: "failed" }> { + const tokens = await service.client.getTokens(); + if (!tokens?.refreshToken) return { status: "failed" }; + try { + const response = await fetch(`${LINEAR_PROXY}/refresh-token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: LINEAR_PROXY_CLIENT_ID, + refresh_token: tokens.refreshToken, + grant_type: "refresh_token", + }), + }); + if (!response.ok) return { status: "failed" }; + const next = (await response.json()) as { access_token: string; refresh_token?: string; expires_in?: number }; + await service.client.setTokens(next); + return { status: "ok", accessToken: next.access_token }; + } catch { + return { status: "failed" }; + } +} + +// Matches Raycast's own background-auth error so existing boundaries +// (BackgroundAuthBoundary in unread-notifications.tsx) keep working. +export const BACKGROUND_AUTH_ERROR = "OAuth request creation is not available when command is launched in background"; + +export async function ensureToken(service: OAuthService, options: { interactive: boolean }): Promise { + if (options.interactive) { + return service.authorize(); + } + const tokens = await service.client.getTokens(); + if (tokens?.accessToken && !tokens.isExpired()) return tokens.accessToken; + const refreshed = await refreshNonDestructive(service); + if (refreshed.status === "ok") return refreshed.accessToken; + throw new Error(BACKGROUND_AUTH_ERROR); +} + +export type ViewerIdentity = { userId: string; userEmail: string; orgId: string; orgName: string; urlKey: string }; + +export async function fetchViewerIdentity(accessToken: string): Promise { + const response = await fetch("https://api.linear.app/graphql", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ query: "query { viewer { id email organization { id name urlKey } } }" }), + }); + if (!response.ok) throw new Error(`Linear identity query failed: HTTP ${response.status}`); + const json = (await response.json()) as { + data?: { viewer?: { id: string; email: string; organization: { id: string; name: string; urlKey: string } } }; + }; + const viewer = json.data?.viewer; + if (!viewer) throw new Error("Linear identity query returned no viewer"); + return { + userId: viewer.id, + userEmail: viewer.email, + orgId: viewer.organization.id, + orgName: viewer.organization.name, + urlKey: viewer.organization.urlKey, + }; +} diff --git a/extensions/linear/src/api/updateIssue.ts b/extensions/linear/src/api/updateIssue.ts index 0a6005bee05..685f45b663f 100644 --- a/extensions/linear/src/api/updateIssue.ts +++ b/extensions/linear/src/api/updateIssue.ts @@ -1,6 +1,6 @@ -import { Issue } from "@linear/sdk"; +import { Issue, LinearClient } from "@linear/sdk"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; export type UpdateIssuePayload = { title: string; @@ -18,8 +18,8 @@ export type UpdateIssuePayload = { parentId?: string; }; -export async function updateIssue(issueId: Issue["id"], payload: Partial) { - const { graphQLClient } = getLinearClient(); +export async function updateIssue(issueId: Issue["id"], payload: Partial, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const inputParts: string[] = []; diff --git a/extensions/linear/src/api/updateNotification.ts b/extensions/linear/src/api/updateNotification.ts index 8a03febf6f1..ce8ecdb3cc3 100644 --- a/extensions/linear/src/api/updateNotification.ts +++ b/extensions/linear/src/api/updateNotification.ts @@ -1,14 +1,14 @@ -import { Notification } from "@linear/sdk"; +import { LinearClient, Notification } from "@linear/sdk"; -import { getLinearClient } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; export type UpdateNotificationPayload = { id: Notification["id"]; readAt: Date | null; }; -export async function updateNotification(payload: UpdateNotificationPayload) { - const { graphQLClient } = getLinearClient(); +export async function updateNotification(payload: UpdateNotificationPayload, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { notificationUpdate: { success: boolean } }, diff --git a/extensions/linear/src/api/withWorkspaceAuth.ts b/extensions/linear/src/api/withWorkspaceAuth.ts new file mode 100644 index 00000000000..173e0c327a5 --- /dev/null +++ b/extensions/linear/src/api/withWorkspaceAuth.ts @@ -0,0 +1,17 @@ +import { withAccessToken } from "@raycast/utils"; + +import { bootstrapWorkspaceAuth } from "./linearClient"; + +// Reuses the utils wrapper engine (component suspension + async-fn gating) with our +// bootstrap as the authorizer. The one-token-per-process cache inside withAccessToken +// is safe here: one process = one launch = one pinned active workspace (design D2); +// AI tools run one call per process (spike S8). Non-active workspaces NEVER go +// through this path — they use getLinearClientFor (§4.4). +const workspaceAuthorizer = { + authorize: async () => { + const { token } = await bootstrapWorkspaceAuth(); + return token; + }, +}; + +export const withWorkspaceAuth = withAccessToken(workspaceAuthorizer); diff --git a/extensions/linear/src/api/workspaces.ts b/extensions/linear/src/api/workspaces.ts new file mode 100644 index 00000000000..df089124e94 --- /dev/null +++ b/extensions/linear/src/api/workspaces.ts @@ -0,0 +1,196 @@ +import { LocalStorage } from "@raycast/api"; + +import { + ensureToken, + fetchViewerIdentity, + getServiceForProviderId, + linear, + workspaceProviderId, + ViewerIdentity, +} from "./oauth"; + +export type EntryRef = { orgId: string; userId: string }; + +// D10: the unit of identity is the (workspace, account) PAIR. Two entries may share +// an orgId (same workspace under two accounts) — never key anything by bare orgId. +export function entryKey(ref: EntryRef): string { + return `${ref.orgId}:${ref.userId}`; +} + +export type WorkspaceEntry = EntryRef & { + orgName: string; + urlKey: string; + userEmail: string; + providerId: string; +}; + +export type WorkspaceRegistry = { + version: 1; + updatedAt: string; + workspaces: WorkspaceEntry[]; + active: EntryRef | null; +}; + +const REGISTRY_KEY = "workspace-registry"; +// Recovery copy (edge case 9): Raycast has NO API to enumerate stored OAuth providerIds, +// so a corrupt primary value would otherwise strand every linear-ws-* token unrecoverably. +// Every write mirrors to the backup; reads fall back to it before giving up. +const REGISTRY_BACKUP_KEY = "workspace-registry-backup"; + +function emptyRegistry(): WorkspaceRegistry { + return { version: 1, updatedAt: new Date(0).toISOString(), workspaces: [], active: null }; +} + +function parseRegistry(raw: string | undefined): WorkspaceRegistry | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as WorkspaceRegistry; + if (parsed.version !== 1 || !Array.isArray(parsed.workspaces)) return null; + return parsed; + } catch { + return null; + } +} + +export async function readRegistry(): Promise { + const primary = parseRegistry(await LocalStorage.getItem(REGISTRY_KEY)); + if (primary) return primary; + const backup = parseRegistry(await LocalStorage.getItem(REGISTRY_BACKUP_KEY)); + if (backup) return backup; // corrupt/lost primary healed from the mirror (edge case 9) + return emptyRegistry(); +} + +// Foreground writers only. The menu-bar process is strictly read-only on the registry (§4.2). +export async function writeRegistry( + mutate: (current: WorkspaceRegistry) => WorkspaceRegistry, +): Promise { + const fresh = await readRegistry(); // re-read immediately before writing: last-write-wins + const next = { ...mutate(fresh), version: 1 as const, updatedAt: new Date().toISOString() }; + const serialized = JSON.stringify(next); + await LocalStorage.setItem(REGISTRY_KEY, serialized); + await LocalStorage.setItem(REGISTRY_BACKUP_KEY, serialized); + return next; +} + +export function getActiveEntry(registry: WorkspaceRegistry): WorkspaceEntry | null { + if (registry.active) { + const match = registry.workspaces.find((w) => entryKey(w) === entryKey(registry.active as EntryRef)); + if (match) return match; + } + return registry.workspaces[0] ?? null; +} + +export async function setActiveEntry(ref: EntryRef): Promise { + return writeRegistry((current) => ({ ...current, active: { orgId: ref.orgId, userId: ref.userId } })); +} + +export async function upsertWorkspaceEntry( + identity: ViewerIdentity, +): Promise<{ registry: WorkspaceRegistry; entry: WorkspaceEntry; isNew: boolean }> { + const ref: EntryRef = { orgId: identity.orgId, userId: identity.userId }; + let entry: WorkspaceEntry | undefined; + let isNew = false; + const registry = await writeRegistry((current) => { + const existing = current.workspaces.find((w) => entryKey(w) === entryKey(ref)); + if (existing) { + // Same (org, user): overwrite path — refresh identity fields, same providerId (D10). + entry = { ...existing, orgName: identity.orgName, urlKey: identity.urlKey, userEmail: identity.userEmail }; + return { ...current, workspaces: current.workspaces.map((w) => (entryKey(w) === entryKey(ref) ? entry! : w)) }; + } + // New (org, user) pair — including a same-org-different-user entry (deliberate multi-view, D10). + isNew = true; + entry = { + ...ref, + orgName: identity.orgName, + urlKey: identity.urlKey, + userEmail: identity.userEmail, + providerId: workspaceProviderId(identity.orgId, identity.userId), + }; + return { + ...current, + workspaces: [...current.workspaces, entry], + active: current.active ?? ref, + }; + }); + return { registry, entry: entry!, isNew }; +} + +export async function removeWorkspaceEntry(ref: EntryRef): Promise { + return writeRegistry((current) => { + const remaining = current.workspaces.filter((w) => entryKey(w) !== entryKey(ref)); + const activeRemoved = current.active !== null && entryKey(current.active) === entryKey(ref); + return { + ...current, + workspaces: remaining, + // Logging out the active entry falls back to the first remaining one (edge case 3). + active: activeRemoved + ? remaining[0] + ? { orgId: remaining[0].orgId, userId: remaining[0].userId } + : null + : current.active, + }; + }); +} + +// Entries whose client holds no token render "Needs re-authentication" — never crash, +// never silently drop (§4.2 reconciliation; built-in sign-out can clear any subset, spike S5). +export async function reconcileEntries( + registry: WorkspaceRegistry, +): Promise> { + return Promise.all( + registry.workspaces.map(async (entry) => { + try { + const service = getServiceForProviderId(entry.providerId, undefined, `Linear — ${entry.orgName}`); + const tokens = await service.client.getTokens(); + return { entry, hasToken: Boolean(tokens?.accessToken) }; + } catch { + // "Never crash, never drop" is structural: a failing token read marks the entry + // as needing re-auth (conservative) instead of rejecting the whole reconcile. + return { entry, hasToken: false }; + } + }), + ); +} + +// Upgrade path (WS-04): no registry yet + the existing `linear` client holds a token +// → one viewer.organization call adopts it as workspace #1, active, providerId "linear". +// allowWrite=false is the background path (menu bar is read-only on the registry, §4.2): +// it returns the adopted registry in memory without persisting; the next foreground +// launch persists it. +export async function migrateIfNeeded(options: { allowWrite: boolean }): Promise { + const current = await readRegistry(); + if (current.workspaces.length > 0) return current; + + const tokens = await linear.client.getTokens(); + if (!tokens?.accessToken) return current; // fresh install: nothing to adopt + + const accessToken = await ensureToken(linear, { interactive: false }).catch(() => null); + if (!accessToken) return current; // expired with no usable refresh: bootstrap will re-auth interactively + + let identity: ViewerIdentity; + try { + identity = await fetchViewerIdentity(accessToken); + } catch { + // Offline / API failure during the migration window must NOT break an existing + // single-workspace user (slice contract): stay unmigrated — bootstrap's slot-0 path + // then serves the stored token exactly as the pre-slice code did; migration retries + // on a later launch. + return current; + } + const entry: WorkspaceEntry = { + orgId: identity.orgId, + userId: identity.userId, + orgName: identity.orgName, + urlKey: identity.urlKey, + userEmail: identity.userEmail, + providerId: "linear", // slot 0 keeps its existing storage label + }; + const ref: EntryRef = { orgId: entry.orgId, userId: entry.userId }; + + if (!options.allowWrite) { + return { version: 1, updatedAt: new Date().toISOString(), workspaces: [entry], active: ref }; + } + return writeRegistry((fresh) => + fresh.workspaces.length > 0 ? fresh : { ...fresh, workspaces: [entry], active: ref }, + ); +} diff --git a/extensions/linear/src/assigned-issues.tsx b/extensions/linear/src/assigned-issues.tsx index 17d9caed5f3..283ada2d832 100644 --- a/extensions/linear/src/assigned-issues.tsx +++ b/extensions/linear/src/assigned-issues.tsx @@ -5,6 +5,12 @@ import { getMyIssuesByView, MyIssuesView } from "./api/getIssues"; import CreateIssueForm from "./components/CreateIssueForm"; import StateIssueList from "./components/StateIssueList"; import View from "./components/View"; +import { useWorkspaces } from "./components/WorkspaceContext"; +import { + isWorkspaceDropdownValue, + workspaceValueToKey, + WorkspaceDropdownSection, +} from "./components/WorkspaceDropdown"; import useIssues from "./hooks/useIssues"; import useMe from "./hooks/useMe"; import usePriorities from "./hooks/usePriorities"; @@ -32,6 +38,7 @@ const views: { id: MyIssuesView; title: string; icon: Icon; emptyDescription: st function MyIssues() { const [view, setView] = useState("assigned"); + const { switchWorkspace } = useWorkspaces(); const { issues, isLoadingIssues, mutateList } = useIssues(getMyIssuesByView, [view]); const { priorities, isLoadingPriorities } = usePriorities(); @@ -45,7 +52,18 @@ function MyIssues() { searchBarPlaceholder="Filter by ID, title, status, assignee or priority" filtering={{ keepSectionOrder: true }} searchBarAccessory={ - setView(value as MyIssuesView)}> + { + if (isWorkspaceDropdownValue(value)) { + switchWorkspace(workspaceValueToKey(value)); + return; + } + setView(value as MyIssuesView); + }} + > + {views.map(({ id, title, icon }) => ( ))} diff --git a/extensions/linear/src/components/CreateIssueForm.tsx b/extensions/linear/src/components/CreateIssueForm.tsx index 6b4104e08f3..0fb2da8d83f 100644 --- a/extensions/linear/src/components/CreateIssueForm.tsx +++ b/extensions/linear/src/components/CreateIssueForm.tsx @@ -37,8 +37,11 @@ import useProjects from "../hooks/useProjects"; import useStates from "../hooks/useStates"; import useTeams from "../hooks/useTeams"; import useUsers from "../hooks/useUsers"; +import { useWorkspaceCachedState } from "../hooks/useWorkspaceCachedState"; import IssueDetail from "./IssueDetail"; +import { useWorkspaces } from "./WorkspaceContext"; +import { WorkspaceFormDropdown } from "./WorkspaceDropdown"; type CreateIssueFormProps = { assigneeId?: string; @@ -55,6 +58,7 @@ type CreateIssueFormProps = { }; export type CreateIssueValues = { + workspaceKey?: string; templateId: string; teamId: string; title: string; @@ -107,6 +111,13 @@ function getCopyToastAction(copyToastAction: Preferences.CreateIssue["copyToastA export default function CreateIssueForm(props: CreateIssueFormProps) { const { push } = useNavigation(); const { autofocusField, copyToastAction } = getPreferenceValues(); + const { showSwitcher } = useWorkspaces(); + + // The workspace field lives outside useForm, so focus() cannot target it — the autoFocus + // prop does. With <2 entries the field renders null, so the preference must FALL BACK + // (to today's behavior) instead of leaving nothing focused. + const workspaceAutofocusActive = autofocusField === "workspaceKey" && showSwitcher; + const effectiveAutofocusField = autofocusField === "workspaceKey" && !showSwitcher ? undefined : autofocusField; const [teamQuery, setTeamQuery] = useState(""); const { teams, org, supportsTeamTypeahead, isLoadingTeams } = useTeams(teamQuery); @@ -176,8 +187,10 @@ export default function CreateIssueForm(props: CreateIssueFormProps) { links: "", }); - if (hasMoreThanOneTeam && autofocusField) { - focus(autofocusField); + if (workspaceAutofocusActive) { + // The workspace field already carries autoFocus; focus() cannot target it. + } else if (hasMoreThanOneTeam && effectiveAutofocusField) { + focus(effectiveAutofocusField); } else { focus("title"); } @@ -257,14 +270,164 @@ export default function CreateIssueForm(props: CreateIssueFormProps) { }, }); + type StoredIssueDefaults = Partial< + Pick + >; + const [storedDefaults, setStoredDefaults] = useWorkspaceCachedState("create-issue-defaults", {}); + const restoredRef = useRef(false); + + // Persist selections per workspace (replaces the old Form.Dropdown persistence prop, + // §4.5/B6). PER-FIELD READINESS + // semantics: a field's current value (INCLUDING an intentional empty like Unassigned / + // No Cycle / No Project) is written back only once that field's restore has completed — + // dependent option lists load at different times, and their fields are transiently + // empty until then; writing before readiness would clobber stored defaults, while + // truthy-only merging would make cleared choices resurrect on the next launch. + const readyFieldsRef = useRef>(new Set()); + + useEffect(() => { + if (!restoredRef.current) return; + const ready = readyFieldsRef.current; + setStoredDefaults({ + ...storedDefaults, + ...(ready.has("teamId") ? { teamId: values.teamId } : {}), + ...(ready.has("stateId") ? { stateId: values.stateId } : {}), + ...(ready.has("priority") ? { priority: values.priority } : {}), + ...(ready.has("assigneeId") ? { assigneeId: values.assigneeId } : {}), + ...(ready.has("cycleId") ? { cycleId: values.cycleId } : {}), + ...(ready.has("projectId") ? { projectId: values.projectId } : {}), + ...(ready.has("milestoneId") ? { milestoneId: values.milestoneId } : {}), + }); + // storedDefaults is deliberately omitted from deps: it's only read here (via the + // spread above) to preserve fields not yet ready, and including it would loop this + // effect against its own setStoredDefaults call. + }, [ + values.teamId, + values.stateId, + values.priority, + values.assigneeId, + values.cycleId, + values.projectId, + values.milestoneId, + ]); + + // Restore once per mount, validated against THIS workspace's data; a draft wins over restore. + // Priority is intentionally NOT restored here: setting teamId below triggers the + // template-reset effect further down (applyTemplate("") on team change), which would + // wipe priority straight back to "" on the very next commit. Priority (and assigneeId) + // are instead restored by the effect declared AFTER that reset effect, which re-applies + // them once the reset has already happened — see the comment there. + useEffect(() => { + if (restoredRef.current || isLoadingTeams || !teams) return; + restoredRef.current = true; + // A draft launch marks NOTHING ready (I4): the persist effect above only writes + // fields in readyFieldsRef, so leaving it unset here keeps a draft launch from ever + // clobbering the remembered per-workspace defaults. teamId also only restores when + // no explicit teamId prop was passed (I5) — pushed forms like SubIssues supply real + // context that a stale stored default must not override. + if (!props.draftValues) { + if (!props.teamId && storedDefaults.teamId && teams.some((team) => team.id === storedDefaults.teamId)) { + setValue("teamId", storedDefaults.teamId); + } + // stateId/assigneeId/cycleId/projectId/milestoneId are validated by their own data + // hooks' option lists as they load; setValue only when the id exists there: + readyFieldsRef.current.add("teamId"); + } + // Deliberately scoped to [isLoadingTeams, teams]: this restore runs once per mount + // (guarded by restoredRef above), so props.draftValues/storedDefaults changing later + // must not re-trigger it. + }, [isLoadingTeams, teams]); + const execute = !!values.teamId && values.teamId.trim().length > 0; const { issueTemplates, isLoadingIssueTemplates } = useIssueTemplates(values.teamId, { execute }); - const { states } = useStates(values.teamId, { execute }); + const { states, isLoadingStates } = useStates(values.teamId, { execute }); const { labels } = useLabels(values.teamId, { execute }); - const { cycles } = useCycles(values.teamId, { execute }); + const { cycles, isLoadingCycles } = useCycles(values.teamId, { execute }); const { issues } = useIssues(getLastCreatedIssues, [], { execute }); - const { projects } = useProjects(values.teamId, { execute }); - const { milestones } = useMilestones(values.projectId, { execute: !!values.projectId }); + const { projects, isLoadingProjects } = useProjects(values.teamId, { execute }); + const { milestones, isLoadingMilestones } = useMilestones(values.projectId, { execute: !!values.projectId }); + + // Team-dependent fields: each restores once its own option list has loaded, validated + // against THIS workspace's data (discard-on-restore, §4.5), then marks itself ready + // regardless of whether a value applied so the persist effect starts tracking it. + useEffect(() => { + if (readyFieldsRef.current.has("stateId") || !execute || isLoadingStates) return; + // Draft launches mark nothing ready (I4) — see the teamId restore effect above. + if (!props.draftValues) { + if (storedDefaults.stateId && states?.some((state) => state.id === storedDefaults.stateId)) { + setValue("stateId", storedDefaults.stateId); + } + readyFieldsRef.current.add("stateId"); + } + // Deliberately scoped: guarded by readyFieldsRef.has("stateId") above, so this only + // ever acts on the first load of this workspace's state list. + }, [execute, isLoadingStates, states]); + + useEffect(() => { + if (readyFieldsRef.current.has("assigneeId") || isLoadingUsers) return; + // assigneeId only restores when no explicit assigneeId prop was passed (I5) — + // assigned-issues.tsx supplies real context that a stale stored default must not override. + if ( + !props.draftValues && + !props.assigneeId && + storedDefaults.assigneeId && + users?.some((user) => user.id === storedDefaults.assigneeId) + ) { + setValue("assigneeId", storedDefaults.assigneeId); + } + // Ready-marking for assigneeId is owned by the post-template-reset effect declared + // after applyTemplate below (it re-applies assigneeId once a team change's reset has + // happened, then marks ready) — this effect only provides an early, best-effort + // restore for when the user list resolves before that reset happens. + }, [isLoadingUsers, users]); + + useEffect(() => { + if (readyFieldsRef.current.has("cycleId") || isLoadingCycles) return; + // Draft launches mark nothing ready (I4); cycleId only restores when no explicit + // cycleId prop was passed (I5) — SubIssues/active-cycle supply real context that a + // stale stored default must not override. + if (!props.draftValues) { + if (!props.cycleId && storedDefaults.cycleId && cycles?.some((cycle) => cycle.id === storedDefaults.cycleId)) { + setValue("cycleId", storedDefaults.cycleId); + } + readyFieldsRef.current.add("cycleId"); + } + // Deliberately scoped: guarded by readyFieldsRef.has("cycleId") above, so this only + // ever acts on the first load of this team's cycle list. + }, [isLoadingCycles, cycles]); + + useEffect(() => { + if (readyFieldsRef.current.has("projectId") || isLoadingProjects) return; + // Draft launches mark nothing ready (I4); projectId only restores when no explicit + // projectId prop was passed (I5) — SubIssues/ProjectIssues supply real context that a + // stale stored default must not override. + if (!props.draftValues) { + if ( + !props.projectId && + storedDefaults.projectId && + projects?.some((project) => project.id === storedDefaults.projectId) + ) { + setValue("projectId", storedDefaults.projectId); + } + readyFieldsRef.current.add("projectId"); + } + // Deliberately scoped: guarded by readyFieldsRef.has("projectId") above, so this + // only ever acts on the first load of this team's project list. + }, [isLoadingProjects, projects]); + + useEffect(() => { + if (readyFieldsRef.current.has("milestoneId") || isLoadingMilestones) return; + // Draft launches mark nothing ready (I4) — see the teamId restore effect above. + // No caller currently passes a milestoneId prop, so no I5 prop-guard is needed here. + if (!props.draftValues) { + if (storedDefaults.milestoneId && milestones?.some((milestone) => milestone.id === storedDefaults.milestoneId)) { + setValue("milestoneId", storedDefaults.milestoneId); + } + readyFieldsRef.current.add("milestoneId"); + } + // Deliberately scoped: guarded by readyFieldsRef.has("milestoneId") above, so this + // only ever acts on the first load of this project's milestone list. + }, [isLoadingMilestones, milestones]); useEffect(() => { if (teams?.length === 1) { @@ -281,6 +444,26 @@ export default function CreateIssueForm(props: CreateIssueFormProps) { applyTemplate(""); }, [values.teamId]); + // Declared after the template effect on purpose: a team change resets the dependent + // fields via applyTemplate(""), so the workspace's remembered defaults for the wiped + // fields are re-applied here — and only marked ready once this has run. + useEffect(() => { + if (!restoredRef.current || props.draftValues) return; + if (storedDefaults.priority) setValue("priority", storedDefaults.priority); + // assigneeId only restores when no explicit assigneeId prop was passed (I5). + if ( + !props.assigneeId && + storedDefaults.assigneeId && + users?.some((user) => user.id === storedDefaults.assigneeId) + ) { + setValue("assigneeId", storedDefaults.assigneeId); + } + readyFieldsRef.current.add("priority"); + readyFieldsRef.current.add("assigneeId"); + // Deliberate deps: fires when the restored/changed team lands; storedDefaults/users + // are read via the render closure. + }, [values.teamId]); + function applyTemplate(templateId: string) { setValue("templateId", templateId); @@ -462,11 +645,12 @@ export default function CreateIssueForm(props: CreateIssueFormProps) { } isLoading={isLoadingTeams || isLoadingUsers || props.isLoading} > + + {(supportsTeamTypeahead || hasMoreThanOneTeam) && ( <> - + {hasStates ? orderedStates.map((state) => { return ( @@ -524,7 +708,7 @@ export default function CreateIssueForm(props: CreateIssueFormProps) { : null} - + {hasPriorities ? props.priorities?.map(({ priority, label }) => { return ( @@ -541,7 +725,6 @@ export default function CreateIssueForm(props: CreateIssueFormProps) { @@ -586,7 +769,7 @@ export default function CreateIssueForm(props: CreateIssueFormProps) { {hasCycles || hasProjects || hasIssues ? : null} {hasCycles ? ( - + + + {milestones.map((milestone) => { diff --git a/extensions/linear/src/components/CreateMilestoneForm.tsx b/extensions/linear/src/components/CreateMilestoneForm.tsx index 07299bf389f..34034d3a2ae 100644 --- a/extensions/linear/src/components/CreateMilestoneForm.tsx +++ b/extensions/linear/src/components/CreateMilestoneForm.tsx @@ -1,10 +1,12 @@ import { Action, ActionPanel, Form, Toast, showToast, useNavigation } from "@raycast/api"; import { useForm, FormValidation } from "@raycast/utils"; +import { useEffect, useRef } from "react"; import { getLinearClient } from "../api/linearClient"; import { getErrorMessage } from "../helpers/errors"; import { getProjectIcon } from "../helpers/projects"; import useProjects from "../hooks/useProjects"; +import { useWorkspaceCachedState } from "../hooks/useWorkspaceCachedState"; export type CreateMilestoneValues = { projectId: string; @@ -19,7 +21,7 @@ export default function CreateMilestoneForm({ projectId }: { projectId?: string const { projects, isLoadingProjects } = useProjects(); - const { handleSubmit, itemProps, focus, reset } = useForm({ + const { handleSubmit, itemProps, values, setValue, focus, reset } = useForm({ async onSubmit(values) { const toast = await showToast({ style: Toast.Style.Animated, title: "Creating Milestone" }); @@ -63,6 +65,27 @@ export default function CreateMilestoneForm({ projectId }: { projectId?: string }, }); + const [storedProject, setStoredProject] = useWorkspaceCachedState("create-milestone-project", ""); + const restoredRef = useRef(false); + + // Persist the Project selection per workspace (replaces the old Form.Dropdown + // persistence prop, §4.5/B6). This dropdown has no "no project" option, so an empty + // value is never an intentional choice — only non-empty values are worth remembering. + useEffect(() => { + if (!restoredRef.current) return; + if (values.projectId) setStoredProject(values.projectId); + }, [values.projectId]); + + // Restore once per mount, validated against THIS workspace's data; an explicit + // projectId prop (e.g. opened from a project's context) wins over the stored default. + useEffect(() => { + if (restoredRef.current || isLoadingProjects || !projects) return; + restoredRef.current = true; + if (!projectId && storedProject && projects.some((project) => project.id === storedProject)) { + setValue("projectId", storedProject); + } + }, [isLoadingProjects, projects]); + return (
} > - + {projects?.map((project) => ( ))} diff --git a/extensions/linear/src/components/CreateProjectForm.tsx b/extensions/linear/src/components/CreateProjectForm.tsx index 40ad8f060e6..24b04579c9a 100644 --- a/extensions/linear/src/components/CreateProjectForm.tsx +++ b/extensions/linear/src/components/CreateProjectForm.tsx @@ -1,6 +1,6 @@ import { Action, ActionPanel, Form, Icon, open, Toast, showToast, Keyboard } from "@raycast/api"; import { useForm, FormValidation } from "@raycast/utils"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getLinearClient } from "../api/linearClient"; import { getErrorMessage } from "../helpers/errors"; @@ -11,8 +11,12 @@ import { getUserIcon } from "../helpers/users"; import useProjectStatuses from "../hooks/useProjectStatuses"; import useTeams from "../hooks/useTeams"; import useUsers from "../hooks/useUsers"; +import { useWorkspaceCachedState } from "../hooks/useWorkspaceCachedState"; + +import { WorkspaceFormDropdown } from "./WorkspaceDropdown"; export type CreateProjectValues = { + workspaceKey?: string; teamIds: string[]; name: string; description: string; @@ -23,7 +27,13 @@ export type CreateProjectValues = { statusId: string; }; -export default function CreateProjectForm({ draftValues }: { draftValues?: CreateProjectValues }) { +export default function CreateProjectForm({ + draftValues, + isLoading, +}: { + draftValues?: CreateProjectValues; + isLoading?: boolean; +}) { const { linearClient } = getLinearClient(); const { teams, org, isLoadingTeams } = useTeams(); @@ -33,7 +43,7 @@ export default function CreateProjectForm({ draftValues }: { draftValues?: Creat const { users: leads, supportsUserTypeahead, isLoadingUsers: isLoadingLeads } = useUsers(leadQuery); const { states, isLoadingStates } = useProjectStatuses(); - const { handleSubmit, itemProps, focus, reset } = useForm({ + const { handleSubmit, itemProps, values, setValue, focus, reset } = useForm({ async onSubmit(values) { const toast = await showToast({ style: Toast.Style.Animated, title: "Creating project" }); @@ -100,16 +110,72 @@ export default function CreateProjectForm({ draftValues }: { draftValues?: Creat }, }); + type StoredProjectDefaults = Partial>; + const [storedDefaults, setStoredDefaults] = useWorkspaceCachedState( + "create-project-defaults", + {}, + ); + const restoredRef = useRef(false); + // PER-FIELD READINESS, same semantics as CreateIssueForm: a field's value is written + // back only once that field's own restore has completed (see that file for the full + // rationale). + const readyFieldsRef = useRef>(new Set()); + + useEffect(() => { + if (!restoredRef.current) return; + const ready = readyFieldsRef.current; + setStoredDefaults({ + ...storedDefaults, + ...(ready.has("statusId") ? { statusId: values.statusId } : {}), + ...(ready.has("leadId") ? { leadId: values.leadId } : {}), + }); + // storedDefaults intentionally omitted from deps: it's only read here (via the + // spread above) to preserve fields not yet ready, and including it would loop this + // effect against its own setStoredDefaults call. + }, [values.statusId, values.leadId]); + + // Status and Lead load independently here (no team-dependency chain like + // CreateIssueForm), so each restores once its own option list has loaded, validated + // against THIS workspace's data (discard-on-restore, §4.5), then marks itself ready + // regardless of whether a value applied. + useEffect(() => { + if (readyFieldsRef.current.has("statusId") || isLoadingStates) return; + // Draft launches mark nothing ready (I4): the persist effect above only writes + // fields in readyFieldsRef, so leaving it unset here keeps a draft launch from ever + // clobbering the remembered per-workspace defaults. + if (!draftValues) { + if (storedDefaults.statusId && states?.some((status) => status.id === storedDefaults.statusId)) { + setValue("statusId", storedDefaults.statusId); + } + readyFieldsRef.current.add("statusId"); + } + restoredRef.current = true; + }, [isLoadingStates, states]); + + useEffect(() => { + if (readyFieldsRef.current.has("leadId") || isLoadingLeads) return; + // Draft launches mark nothing ready (I4) — see the statusId restore effect above. + if (!draftValues) { + if (storedDefaults.leadId && leads?.some((user) => user.id === storedDefaults.leadId)) { + setValue("leadId", storedDefaults.leadId); + } + readyFieldsRef.current.add("leadId"); + } + restoredRef.current = true; + }, [isLoadingLeads, leads]); + return ( } > + + {teams?.map((team) => ( @@ -128,7 +194,7 @@ export default function CreateProjectForm({ draftValues }: { draftValues?: Creat - + {states?.map((status) => ( diff --git a/extensions/linear/src/components/EditIssueForm.tsx b/extensions/linear/src/components/EditIssueForm.tsx index c95672b1124..09b4fe7d1c1 100644 --- a/extensions/linear/src/components/EditIssueForm.tsx +++ b/extensions/linear/src/components/EditIssueForm.tsx @@ -296,7 +296,7 @@ export default function EditIssueForm(props: EditIssueFormProps) { ) : null} {hasMilestones ? ( - + {milestones.map((milestone) => { diff --git a/extensions/linear/src/components/ProjectIssues.tsx b/extensions/linear/src/components/ProjectIssues.tsx index 4c56897ac63..8e3b98b0a2f 100644 --- a/extensions/linear/src/components/ProjectIssues.tsx +++ b/extensions/linear/src/components/ProjectIssues.tsx @@ -1,12 +1,12 @@ import { IssuePriorityValue, User } from "@linear/sdk"; import { Action, ActionPanel, List } from "@raycast/api"; -import { useCachedState } from "@raycast/utils"; import { useMemo } from "react"; import { getProjectIssues } from "../api/getIssues"; import { getMilestoneIcon } from "../helpers/milestones"; import useIssues from "../hooks/useIssues"; import useMilestones from "../hooks/useMilestones"; +import { useWorkspaceCachedState } from "../hooks/useWorkspaceCachedState"; import CreateIssueForm from "./CreateIssueForm"; import StateIssueList from "./StateIssueList"; @@ -19,8 +19,8 @@ type ProjectIssuesProps = { }; export default function ProjectIssues({ projectId, priorities, me }: ProjectIssuesProps) { - const { issues, isLoadingIssues, mutateList } = useIssues(getProjectIssues, [projectId]); - const [milestone, setMilestone] = useCachedState(""); + const { issues, isLoadingIssues, mutateList } = useIssues((id: string) => getProjectIssues(id), [projectId]); + const [milestone, setMilestone] = useWorkspaceCachedState(`project-milestone-filter-${projectId}`, ""); const { milestones } = useMilestones(projectId); const filteredIssues = useMemo(() => { diff --git a/extensions/linear/src/components/ProjectList.tsx b/extensions/linear/src/components/ProjectList.tsx index 85a1aa8e6c4..2f2ac347824 100644 --- a/extensions/linear/src/components/ProjectList.tsx +++ b/extensions/linear/src/components/ProjectList.tsx @@ -7,11 +7,15 @@ import { useInitiatives } from "../hooks/useInitiatives"; import useMe from "../hooks/useMe"; import usePriorities from "../hooks/usePriorities"; import useProjects from "../hooks/useProjects"; +import { useWorkspaceCachedState } from "../hooks/useWorkspaceCachedState"; import Project from "./Project"; +import { useWorkspaces } from "./WorkspaceContext"; +import { isWorkspaceDropdownValue, workspaceValueToKey, WorkspaceDropdownSection } from "./WorkspaceDropdown"; export default function ProjectList() { - const [initiativeId, setInitiativeId] = useState(""); + const { showSwitcher, switchWorkspace } = useWorkspaces(); + const [storedInitiative, setStoredInitiative] = useWorkspaceCachedState("project-list-initiative", ""); const [searchText, setSearchText] = useState(""); const { projects, isLoadingProjects, mutateProjects, pagination } = useProjects(undefined, { searchText, @@ -21,6 +25,8 @@ export default function ProjectList() { const { priorities, isLoadingPriorities } = usePriorities(); const { me, isLoadingMe } = useMe(); + const initiativeId = (initiatives ?? []).some((i) => i.id === storedInitiative) ? storedInitiative : ""; + const filteredProjects = useMemo(() => { if (!projects) { return []; @@ -48,22 +54,35 @@ export default function ProjectList() { return ( 0 + {...((initiatives ?? []).length > 0 || showSwitcher ? { searchBarAccessory: ( - + { + if (isWorkspaceDropdownValue(value)) { + switchWorkspace(workspaceValueToKey(value)); + return; + } + if (value !== initiativeId) setStoredInitiative(value); + }} + > + - - {initiatives?.map((initiative) => ( - - ))} - + {(initiatives ?? []).length > 0 && ( + + {initiatives?.map((initiative) => ( + + ))} + + )} ), } diff --git a/extensions/linear/src/components/View.tsx b/extensions/linear/src/components/View.tsx index 0af14868482..530fede5714 100644 --- a/extensions/linear/src/components/View.tsx +++ b/extensions/linear/src/components/View.tsx @@ -1,10 +1,12 @@ import { Action, ActionPanel, Detail } from "@raycast/api"; -import { withAccessToken } from "@raycast/utils"; import React, { useEffect } from "react"; -import { linear } from "../api/linearClient"; +import { bootstrapWorkspaceAuth, clearActiveWorkspaceTokens, getWorkspaceSnapshot } from "../api/linearClient"; +import { withWorkspaceAuth } from "../api/withWorkspaceAuth"; import { checkLinearApp } from "../helpers/isLinearInstalled"; +import { WorkspaceProvider } from "./WorkspaceContext"; + /** * Makes sure that we have a authenticated linear client available in the children */ @@ -13,7 +15,7 @@ function View({ children }: { children: React.ReactNode }) { checkLinearApp(); }, []); - return children; + return {children}; } interface AuthErrorBoundaryState { @@ -49,18 +51,26 @@ class AuthErrorBoundary extends React.Component<{ children: React.ReactNode }, A // and let everything else surface normally. if (!isAuthError) throw error; + const failedWorkspaceName = getWorkspaceSnapshot()?.activeEntry?.orgName; + return ( { - await linear.client.removeTokens(); - this.setState({ error: null }); + await clearActiveWorkspaceTokens(); + try { + await bootstrapWorkspaceAuth(); // fresh interactive flow, identity-verified (ensureEntryToken) + this.setState({ error: null }); + } catch (error) { + this.setState({ error: error instanceof Error ? error : new Error(String(error)) }); + } }} /> + } /> @@ -68,7 +78,7 @@ class AuthErrorBoundary extends React.Component<{ children: React.ReactNode }, A } } -const AuthenticatedView = withAccessToken(linear)(View); +const AuthenticatedView = withWorkspaceAuth(View); export default function ViewWithErrorBoundary({ children }: { children: React.ReactNode }) { return ( diff --git a/extensions/linear/src/components/WorkspaceContext.tsx b/extensions/linear/src/components/WorkspaceContext.tsx new file mode 100644 index 00000000000..09d92f315ab --- /dev/null +++ b/extensions/linear/src/components/WorkspaceContext.tsx @@ -0,0 +1,110 @@ +import { showToast, Toast } from "@raycast/api"; +import { createContext, ReactNode, useContext, useMemo, useState } from "react"; + +import { activateWorkspace, activateWorkspaceInMemory, getWorkspaceSnapshot } from "../api/linearClient"; +import { entryKey, WorkspaceEntry } from "../api/workspaces"; + +type WorkspaceContextValue = { + entries: WorkspaceEntry[]; + activeEntry: WorkspaceEntry | null; + workspaceKey: string; + showSwitcher: boolean; + // Entry keys currently lacking tokens (from the bootstrap snapshot, §4.2) — dropdowns + // badge these rows so a dead workspace is visible from ordinary command UI. + needsReauth: string[]; + switchWorkspace: (key: string) => Promise; + // Pins THIS process to a workspace without persisting a new global default (D2/D7). + pinWorkspace: (key: string) => Promise; +}; + +const WorkspaceContext = createContext(null); + +export function WorkspaceProvider({ children }: { children: ReactNode }) { + const snapshot = getWorkspaceSnapshot(); + const [activeEntry, setActiveEntryState] = useState(snapshot?.activeEntry ?? null); + const entries = snapshot?.registry.workspaces ?? []; + + const value = useMemo(() => { + const workspaceKey = activeEntry ? entryKey(activeEntry) : "single"; + return { + entries, + activeEntry, + workspaceKey, + showSwitcher: entries.length >= 2, // invisible below 2 entries (spec §5) + needsReauth: snapshot?.needsReauth ?? [], + async switchWorkspace(key: string) { + // S9 guard: controlled dropdowns still report their current value on mount; + // a no-op selection must never trigger a switch. + if (!activeEntry || key === entryKey(activeEntry)) return; + const target = entries.find((w) => entryKey(w) === key); + if (!target) return; + try { + const entry = await activateWorkspace({ orgId: target.orgId, userId: target.userId }); + setActiveEntryState(entry); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: `Could not switch to ${target.orgName}`, + message: error instanceof Error ? error.message : String(error), + }); + } + }, + async pinWorkspace(key: string) { + if (!activeEntry || key === entryKey(activeEntry)) return; + const target = entries.find((w) => entryKey(w) === key); + if (!target) return; + const entry = await activateWorkspaceInMemory({ orgId: target.orgId, userId: target.userId }); + setActiveEntryState(entry); // context + remount boundary follow; registry default untouched + }, + }; + // Deliberate deps: entries identity is tracked via the joined key string, not the + // array reference (the snapshot array is re-derived each render). + }, [activeEntry, entries.map(entryKey).join("|")]); + + // Remount boundary (§4.5): the whole command subtree remounts on switch, so + // keepPreviousData caches and pushed detail views (WS-30) can never render the + // previous workspace's data under the new selection. + return ( + + {children} + + ); +} + +function WorkspaceBoundary({ children }: { children: ReactNode }) { + const { workspaceKey } = useWorkspaces(); + return {children}; +} + +function ItemWithKey({ children }: { children: ReactNode }) { + return <>{children}; +} + +async function noSwitchHere() { + // Pushed views are pinned to their launch workspace (design D2); switching only + // happens at command roots where the provider's remount boundary exists. +} + +// CRITICAL: Raycast's useNavigation().push / Action.Push mounts the target in a FRESH +// React tree — ancestor contexts do NOT reach pushed views (see the in-repo precedent: +// extensions/bitwarden re-provides its contexts around every pushed component). Since +// the 20 data hooks and useWorkspaceCachedState all call useWorkspaces(), throwing +// outside the provider would crash every pushed screen (IssueDetail, ProjectIssues, +// pushed CreateIssueForm, …) — even for single-workspace users. Outside the provider we +// therefore fall back to the module-level snapshot (same process, so it is always set +// by the time anything renders); switching stays provider-only. +export function useWorkspaces(): WorkspaceContextValue { + const ctx = useContext(WorkspaceContext); + if (ctx) return ctx; + const snapshot = getWorkspaceSnapshot(); + const activeEntry = snapshot?.activeEntry ?? null; + return { + entries: snapshot?.registry.workspaces ?? [], + activeEntry, + workspaceKey: activeEntry ? entryKey(activeEntry) : "single", + showSwitcher: false, // pushed views never render a switcher — they are pinned flows + needsReauth: snapshot?.needsReauth ?? [], + switchWorkspace: noSwitchHere, + pinWorkspace: noSwitchHere, + }; +} diff --git a/extensions/linear/src/components/WorkspaceDropdown.tsx b/extensions/linear/src/components/WorkspaceDropdown.tsx new file mode 100644 index 00000000000..6c6cb000a7f --- /dev/null +++ b/extensions/linear/src/components/WorkspaceDropdown.tsx @@ -0,0 +1,98 @@ +import { Form, Image, List } from "@raycast/api"; + +import { entryKey, WorkspaceEntry } from "../api/workspaces"; + +import { useWorkspaces } from "./WorkspaceContext"; + +function entryTitle(entry: WorkspaceEntry, entries: WorkspaceEntry[], needsReauth: string[]): string { + // Two entries can share an orgName/orgId across accounts (D10) — disambiguate with the email. + const duplicated = entries.filter((w) => w.orgId === entry.orgId).length > 1; + const base = duplicated ? `${entry.orgName} (${entry.userEmail})` : entry.orgName; + // §4.2: a token-less entry is visible as needing re-auth from ordinary command UI too. + return needsReauth.includes(entryKey(entry)) ? `${base} — needs re-authentication` : base; +} + +const workspaceIcon: Image.ImageLike = "linear-app-icon.png"; + +// P1: sole searchBarAccessory for list commands with a free search bar. +export function WorkspaceListDropdown() { + const { entries, activeEntry, showSwitcher, needsReauth, switchWorkspace } = useWorkspaces(); + if (!showSwitcher || !activeEntry) return null; + return ( + + {entries.map((w) => ( + + ))} + + ); +} + +// P1b: prepended section inside an EXISTING dropdown; values namespaced "ws:" so they +// can never collide with the host dropdown's own values (DocumentList precedent). +export const WORKSPACE_VALUE_PREFIX = "ws:"; + +export function isWorkspaceDropdownValue(value: string): boolean { + return value.startsWith(WORKSPACE_VALUE_PREFIX); +} + +export function workspaceValueToKey(value: string): string { + return value.slice(WORKSPACE_VALUE_PREFIX.length); +} + +export function WorkspaceDropdownSection() { + const { entries, activeEntry, showSwitcher, needsReauth } = useWorkspaces(); + if (!showSwitcher) return null; + return ( + + {entries.map((w) => { + // S9-safe: selecting the current row still routes through switchWorkspace, which + // no-ops when the key already matches the active entry. + const isCurrent = activeEntry !== null && entryKey(w) === entryKey(activeEntry); + return ( + + ); + })} + + ); +} + +// P2: first form field, above Team. Changing it sets the global default and the form +// resets downstream fields (the remount boundary in WorkspaceProvider does the reset). +// The item id is "workspaceKey" so Raycast drafts capture the value under +// draftValues.workspaceKey (D7) — the field is controlled from context, not useForm. +export function WorkspaceFormDropdown(props: { autoFocus?: boolean }) { + const { entries, activeEntry, showSwitcher, needsReauth, switchWorkspace } = useWorkspaces(); + if (!showSwitcher || !activeEntry) return null; + return ( + + {entries.map((w) => ( + + ))} + + ); +} diff --git a/extensions/linear/src/components/docs/DocumentList.tsx b/extensions/linear/src/components/docs/DocumentList.tsx index 78b9fd42712..da95b61c1bb 100644 --- a/extensions/linear/src/components/docs/DocumentList.tsx +++ b/extensions/linear/src/components/docs/DocumentList.tsx @@ -7,7 +7,10 @@ import { getProjectIcon } from "../../helpers/projects"; import { useDocuments } from "../../hooks/useDocuments"; import { useInitiatives } from "../../hooks/useInitiatives"; import useProjects from "../../hooks/useProjects"; +import { useWorkspaceCachedState } from "../../hooks/useWorkspaceCachedState"; import { DocumentEntity } from "../../tools/get-documents"; +import { useWorkspaces } from "../WorkspaceContext"; +import { isWorkspaceDropdownValue, workspaceValueToKey, WorkspaceDropdownSection } from "../WorkspaceDropdown"; import { Document } from "./Document"; @@ -17,10 +20,33 @@ type DocumentListProps = { export function DocumentList({ project }: DocumentListProps) { const [query, setQuery] = useState(""); - const [entity, setEntity] = useState({ projectId: "" }); + const { showSwitcher, switchWorkspace } = useWorkspaces(); + const [storedEntity, setStoredEntity] = useWorkspaceCachedState("document-list-entity", ""); const { projects, isLoadingProjects } = useProjects(); const { initiatives, isLoadingInitiatives } = useInitiatives(); + + // Restore validation: the stored value is used only if its id still exists in the + // loaded initiatives/projects — otherwise "" (All Documents). A pushed "Project + // Documents" view (`project` set) never reads persisted state — it stays pinned to + // its launch project, the dropdown isn't even rendered for it. + const validatedEntityValue = useMemo(() => { + if (project) return ""; + if (storedEntity.startsWith("initiative:")) { + const id = storedEntity.replace("initiative:", ""); + return (initiatives ?? []).some((initiative) => initiative.id === id) ? storedEntity : ""; + } + if (storedEntity.startsWith("project:")) { + const id = storedEntity.replace("project:", ""); + return (projects ?? []).some((p) => p.id === id) ? storedEntity : ""; + } + return ""; + }, [project, storedEntity, initiatives, projects]); + + const entity: DocumentEntity = validatedEntityValue.startsWith("initiative:") + ? { initiativeId: validatedEntityValue.replace("initiative:", "") } + : { projectId: validatedEntityValue.replace("project:", "") }; + const { docs, isLoadingDocs, supportsDocTypeahead, mutateDocs } = useDocuments(query, entity); const filteredDocs = useMemo(() => { @@ -50,19 +76,21 @@ export function DocumentList({ project }: DocumentListProps) { 0 || (initiatives ?? []).length > 0) + {...(!project && ((projects ?? []).length > 0 || (initiatives ?? []).length > 0 || showSwitcher) ? { searchBarAccessory: ( { - const entity: DocumentEntity = newValue.startsWith("initiative:") - ? { initiativeId: newValue.replace("initiative:", "") } - : { projectId: newValue.replace("project:", "") }; - setEntity(entity); + if (isWorkspaceDropdownValue(newValue)) { + switchWorkspace(workspaceValueToKey(newValue)); + return; + } + if (newValue !== validatedEntityValue) setStoredEntity(newValue); }} - storeValue > + {(initiatives ?? []).length > 0 && ( diff --git a/extensions/linear/src/create-issue-for-myself-in-workspace.ts b/extensions/linear/src/create-issue-for-myself-in-workspace.ts new file mode 100644 index 00000000000..cd23a2fc625 --- /dev/null +++ b/extensions/linear/src/create-issue-for-myself-in-workspace.ts @@ -0,0 +1,41 @@ +import { getPreferenceValues, Toast, showToast } from "@raycast/api"; + +import { withWorkspaceAuth } from "./api/withWorkspaceAuth"; +import { createIssueForMyself } from "./helpers/createIssueForMyself"; +import { resolveWorkspaceArgument, updateWorkspaceChoicesSubtitle } from "./helpers/workspaceArgument"; + +const command = async (props: { + arguments: Arguments.CreateIssueForMyselfInWorkspace; + launchContext?: { refreshSubtitle?: boolean }; +}) => { + await updateWorkspaceChoicesSubtitle(); // awaited: the no-view process exits when the command resolves — a fire-and-forget write would be killed + + if (props.launchContext?.refreshSubtitle) { + return; // background refresh launch — subtitle updated above, do nothing else + } + + if (!props.arguments.workspace?.trim()) { + await showToast({ + style: Toast.Style.Failure, + title: "Workspace required", + message: "Name the target workspace (URL key, email, or unique name prefix).", + }); + return; + } + + const resolved = await resolveWorkspaceArgument(props.arguments.workspace); + if (!resolved.ok) { + await showToast({ style: Toast.Style.Failure, title: "Workspace not matched", message: resolved.message }); + return; + } + + const preferences = getPreferenceValues(); + + await createIssueForMyself( + { title: props.arguments.title, description: props.arguments.description }, + preferences, + resolved.client, + ); +}; + +export default withWorkspaceAuth(command); diff --git a/extensions/linear/src/create-issue-for-myself.ts b/extensions/linear/src/create-issue-for-myself.ts index e66e457af9c..4fd10a0523f 100644 --- a/extensions/linear/src/create-issue-for-myself.ts +++ b/extensions/linear/src/create-issue-for-myself.ts @@ -1,99 +1,26 @@ -import { Clipboard, closeMainWindow, getPreferenceValues, open, Toast, showToast, Keyboard } from "@raycast/api"; -import { withAccessToken } from "@raycast/utils"; +import { getPreferenceValues } from "@raycast/api"; -import { getTeams } from "./api/getTeams"; -import { getLinearClient, linear } from "./api/linearClient"; +import { withWorkspaceAuth } from "./api/withWorkspaceAuth"; +import { createIssueForMyself } from "./helpers/createIssueForMyself"; +import { updateActiveWorkspaceSubtitle } from "./helpers/workspaceArgument"; -const command = async (props: { arguments: Arguments.CreateIssueForMyself }) => { - const toast = await showToast({ style: Toast.Style.Animated, title: "Creating issue" }); +const command = async (props: { + arguments: Arguments.CreateIssueForMyself; + launchContext?: { refreshSubtitle?: boolean }; +}) => { + await updateActiveWorkspaceSubtitle(); // awaited: the no-view process exits when the command resolves — a fire-and-forget write would be killed - try { - const { linearClient } = getLinearClient(); - - const preferences = getPreferenceValues(); - - if (preferences.shouldCloseMainWindow) { - await closeMainWindow(); - } - - const viewer = await linearClient.viewer; - const { teams } = await getTeams(); - - let teamId: string | undefined; - - if (preferences.preferredTeamKey) { - const team = teams.find((t) => t.key === preferences.preferredTeamKey); - if (team) { - teamId = team.id; - } - } - - if (!teamId) { - teamId = teams[0].id; - } - - if (!teamId) { - throw Error("No team found"); - } - - let stateId: string | undefined; - - if (preferences.preferredStatusName) { - const states = await linearClient.workflowStates({ - filter: { - team: { id: { eq: teamId } }, - name: { eq: preferences.preferredStatusName }, - }, - }); - - const state = states.nodes[0]; - - if (!state) { - throw Error(`Status "${preferences.preferredStatusName}" not found`); - } - - stateId = state.id; - } - - const payload = await linearClient.createIssue({ - teamId: teamId, - title: props.arguments.title, - description: props.arguments.description, - assigneeId: viewer.id, - stateId: stateId, - }); - - const issue = await payload.issue; - if (!payload.success || !issue) { - throw Error("Something went wrong"); - } + if (props.launchContext?.refreshSubtitle) { + return; // background refresh launch — subtitle updated above, do nothing else + } - toast.style = Toast.Style.Success; - toast.title = `Created issue • ${issue.identifier}`; - toast.primaryAction = { - title: "Open Issue", - shortcut: Keyboard.Shortcut.Common.OpenWith, - onAction: async () => { - await open(issue.url); - await toast.hide(); - }, - }; + const preferences = getPreferenceValues(); - toast.secondaryAction = { - title: "Copy Issue ID", - shortcut: Keyboard.Shortcut.Common.Copy, - onAction: () => Clipboard.copy(issue.identifier), - }; - } catch (e) { - toast.style = Toast.Style.Failure; - toast.title = "Failed creating issue"; - toast.message = e instanceof Error ? e.message : String(e); - toast.primaryAction = { - title: "Copy Error Log", - shortcut: Keyboard.Shortcut.Common.Copy, - onAction: () => Clipboard.copy(e instanceof Error ? (e.stack ?? e.message) : String(e)), - }; - } + await createIssueForMyself( + { title: props.arguments.title, description: props.arguments.description }, + preferences, + undefined, + ); }; -export default withAccessToken(linear)(command); +export default withWorkspaceAuth(command); diff --git a/extensions/linear/src/create-issue.tsx b/extensions/linear/src/create-issue.tsx index 2b9800f0e65..53a14a601ce 100644 --- a/extensions/linear/src/create-issue.tsx +++ b/extensions/linear/src/create-issue.tsx @@ -1,15 +1,70 @@ +import { showToast, Toast } from "@raycast/api"; +import { useEffect, useState } from "react"; + +import { entryKey } from "./api/workspaces"; import CreateIssueForm, { CreateIssueValues } from "./components/CreateIssueForm"; import View from "./components/View"; +import { useWorkspaces } from "./components/WorkspaceContext"; import useMe from "./hooks/useMe"; import usePriorities from "./hooks/usePriorities"; +// Module-scoped, not component state: the component sits inside the provider's +// key={workspaceKey} remount boundary, so a user changing the Workspace field in a +// draft-opened form remounts Form and would re-run a []-deps effect, re-pinning the +// workspace back and showing a now-false "draft from another workspace" toast (I1). +// Module state survives that remount and dies only with the process — draft pinning is +// a launch-time decision, so "once per launch" is the correct scope, not "once per mount". +let draftPinHandled = false; + function Form({ draftValues }: { draftValues?: CreateIssueValues }) { + const { entries, activeEntry, pinWorkspace } = useWorkspaces(); + const draftKey = draftValues?.workspaceKey; + const draftEntry = draftKey ? entries.find((w) => entryKey(w) === draftKey) : undefined; + const needsPin = Boolean(draftEntry && activeEntry && draftKey !== entryKey(activeEntry)); + const [pinned, setPinned] = useState(!needsPin || draftPinHandled); // draftPinHandled: the launch-time pin already ran; later boundary remounts must not re-gate rendering + + useEffect(() => { + if (draftPinHandled) return; + draftPinHandled = true; + (async () => { + if (needsPin && draftEntry) { + // Pin THIS process to the draft's workspace: field, hooks, and submission all + // target it; the global default stays untouched (D7 — field only, with a toast). + try { + await pinWorkspace(draftKey!); + await showToast({ + style: Toast.Style.Success, + title: "Draft from another workspace", + message: `This draft targets ${draftEntry.orgName}; your default workspace is unchanged.`, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: `Could not switch to ${draftEntry.orgName}`, + message: `Using ${activeEntry?.orgName ?? "the active workspace"} instead: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + setPinned(true); + } else if (draftKey && !draftEntry) { + // Removed workspace (edge case 7): fall back to the active one, keep other values. + await showToast({ + style: Toast.Style.Failure, + title: "Draft's workspace is no longer connected", + message: `Using ${activeEntry?.orgName ?? "the active workspace"} instead.`, + }); + } + })(); + // Runs once on mount by design (draft pinning is a launch-time decision). + }, []); + const { priorities, isLoadingPriorities } = usePriorities(); const { me, isLoadingMe } = useMe(); return ( entryKey(w) === draftKey) : undefined; + const needsPin = Boolean(draftEntry && activeEntry && draftKey !== entryKey(activeEntry)); + const [pinned, setPinned] = useState(!needsPin || draftPinHandled); // draftPinHandled: the launch-time pin already ran; later boundary remounts must not re-gate rendering + + useEffect(() => { + if (draftPinHandled) return; + draftPinHandled = true; + (async () => { + if (needsPin && draftEntry) { + // Pin THIS process to the draft's workspace: field, hooks, and submission all + // target it; the global default stays untouched (D7 — field only, with a toast). + try { + await pinWorkspace(draftKey!); + await showToast({ + style: Toast.Style.Success, + title: "Draft from another workspace", + message: `This draft targets ${draftEntry.orgName}; your default workspace is unchanged.`, + }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: `Could not switch to ${draftEntry.orgName}`, + message: `Using ${activeEntry?.orgName ?? "the active workspace"} instead: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + setPinned(true); + } else if (draftKey && !draftEntry) { + // Removed workspace (edge case 7): fall back to the active one, keep other values. + await showToast({ + style: Toast.Style.Failure, + title: "Draft's workspace is no longer connected", + message: `Using ${activeEntry?.orgName ?? "the active workspace"} instead.`, + }); + } + })(); + // Runs once on mount by design (draft pinning is a launch-time decision). + }, []); + + return ; +} export default function Command({ draftValues }: { draftValues?: CreateProjectValues }) { return ( - + ); } diff --git a/extensions/linear/src/created-issues.tsx b/extensions/linear/src/created-issues.tsx index 37762b13472..20d9b5d53aa 100644 --- a/extensions/linear/src/created-issues.tsx +++ b/extensions/linear/src/created-issues.tsx @@ -4,6 +4,7 @@ import { getCreatedIssues } from "./api/getIssues"; import CreateIssueForm from "./components/CreateIssueForm"; import StateIssueList from "./components/StateIssueList"; import View from "./components/View"; +import { WorkspaceListDropdown } from "./components/WorkspaceDropdown"; import useIssues from "./hooks/useIssues"; import useMe from "./hooks/useMe"; import usePriorities from "./hooks/usePriorities"; @@ -18,6 +19,7 @@ function CreatedIssues() { isLoading={isLoadingIssues || isLoadingPriorities || isLoadingMe} searchBarPlaceholder="Filter by ID, title, status, assignee or priority" filtering={{ keepSectionOrder: true }} + searchBarAccessory={} > { + void key; + return getFavorites(); + }, + [workspaceKey], + ); const favorites = data?.favorites ?? []; const urlKey = data?.organization?.urlKey; @@ -23,7 +32,7 @@ function Favorites() { const baseLinearUrl = `https://linear.app/${urlKey}`; return ( - + }> {favorites.map( ({ id, type, customView, cycle, document, issue, label, project, initiative, user, updatedAt }) => { let props: Pick | null = null; diff --git a/extensions/linear/src/helpers/createIssueForMyself.ts b/extensions/linear/src/helpers/createIssueForMyself.ts new file mode 100644 index 00000000000..62eaab21a88 --- /dev/null +++ b/extensions/linear/src/helpers/createIssueForMyself.ts @@ -0,0 +1,99 @@ +import { LinearClient } from "@linear/sdk"; +import { Clipboard, closeMainWindow, open, Toast, showToast, Keyboard } from "@raycast/api"; + +import { getTeams } from "../api/getTeams"; +import { getLinearClient } from "../api/linearClient"; + +export async function createIssueForMyself( + args: { title: string; description?: string }, + preferences: { preferredTeamKey?: string; preferredStatusName?: string; shouldCloseMainWindow: boolean }, + client?: LinearClient, +): Promise { + const toast = await showToast({ style: Toast.Style.Animated, title: "Creating issue" }); + + try { + const linearClient = client ?? getLinearClient().linearClient; + + if (preferences.shouldCloseMainWindow) { + await closeMainWindow(); + } + + const viewer = await linearClient.viewer; + const { teams } = await getTeams(undefined, client); + + let teamId: string | undefined; + + if (preferences.preferredTeamKey) { + const team = teams.find((t) => t.key === preferences.preferredTeamKey); + if (team) { + teamId = team.id; + } + } + + if (!teamId) { + teamId = teams[0].id; + } + + if (!teamId) { + throw Error("No team found"); + } + + let stateId: string | undefined; + + if (preferences.preferredStatusName) { + const states = await linearClient.workflowStates({ + filter: { + team: { id: { eq: teamId } }, + name: { eq: preferences.preferredStatusName }, + }, + }); + + const state = states.nodes[0]; + + if (!state) { + throw Error(`Status "${preferences.preferredStatusName}" not found`); + } + + stateId = state.id; + } + + const payload = await linearClient.createIssue({ + teamId: teamId, + title: args.title, + description: args.description, + assigneeId: viewer.id, + stateId: stateId, + }); + + const issue = await payload.issue; + if (!payload.success || !issue) { + throw Error("Something went wrong"); + } + + toast.style = Toast.Style.Success; + toast.title = `Created issue • ${issue.identifier}`; + toast.primaryAction = { + title: "Open Issue", + shortcut: Keyboard.Shortcut.Common.OpenWith, + onAction: async () => { + await open(issue.url); + await toast.hide(); + }, + }; + + toast.secondaryAction = { + title: "Copy Issue ID", + shortcut: Keyboard.Shortcut.Common.Copy, + onAction: () => Clipboard.copy(issue.identifier), + }; + } catch (e) { + toast.style = Toast.Style.Failure; + toast.title = "Failed creating issue"; + toast.message = e instanceof Error ? e.message : String(e); + toast.primaryAction = { + title: "Copy Error Log", + shortcut: Keyboard.Shortcut.Common.Copy, + onAction: () => Clipboard.copy(e instanceof Error ? (e.stack ?? e.message) : String(e)), + }; + } +} diff --git a/extensions/linear/src/helpers/refreshQuickSubtitles.ts b/extensions/linear/src/helpers/refreshQuickSubtitles.ts new file mode 100644 index 00000000000..c7ebb57f461 --- /dev/null +++ b/extensions/linear/src/helpers/refreshQuickSubtitles.ts @@ -0,0 +1,20 @@ +import { launchCommand, LaunchType } from "@raycast/api"; + +// updateCommandMetadata only updates the CURRENT command, so subtitle freshness after a +// workspace change requires each quick command to briefly run itself: a background +// launch with refreshSubtitle context makes it update its subtitle and exit. Failures +// are swallowed — subtitle freshness is cosmetic and background launches can be +// unavailable (e.g. command disabled). +const QUICK_COMMANDS: Array<{ name: string; arguments: Record }> = [ + { name: "create-issue-for-myself", arguments: { title: "" } }, + { name: "create-issue-for-myself-in-workspace", arguments: { workspace: "", title: "" } }, + { name: "quick-add-comment-to-issue", arguments: { comment: "", issueId: "" } }, +]; + +export function refreshQuickCommandSubtitles(): void { + for (const { name, arguments: args } of QUICK_COMMANDS) { + launchCommand({ name, type: LaunchType.Background, arguments: args, context: { refreshSubtitle: true } }).catch( + () => {}, + ); + } +} diff --git a/extensions/linear/src/helpers/workspaceArgument.ts b/extensions/linear/src/helpers/workspaceArgument.ts new file mode 100644 index 00000000000..403bda1ea32 --- /dev/null +++ b/extensions/linear/src/helpers/workspaceArgument.ts @@ -0,0 +1,99 @@ +import { LinearClient } from "@linear/sdk"; +import { updateCommandMetadata } from "@raycast/api"; + +import { getLinearClientFor } from "../api/linearClient"; +import { entryKey, getActiveEntry, migrateIfNeeded, WorkspaceEntry } from "../api/workspaces"; + +// P3 matching: exact urlKey, else exact account email, else unique orgName prefix, else unique email prefix. +// A match is a ONE-SHOT override — it never writes the registry (design D2). +export async function resolveWorkspaceArgument( + argument: string | undefined, +): Promise< + { ok: true; entry: WorkspaceEntry | null; client: LinearClient | undefined } | { ok: false; message: string } +> { + const query = argument?.trim(); + if (!query) return { ok: true, entry: null, client: undefined }; // no arg → active workspace via sync fast path + + const registry = await migrateIfNeeded({ allowWrite: false }); + const lower = query.toLowerCase(); + const workspaces = registry.workspaces; + // D10: two entries can share BOTH urlKey and orgName (same org under two accounts), + // so the account email is a first-class matcher, not just display text. + const byUrlKey = workspaces.filter((w) => w.urlKey.toLowerCase() === lower); + const byEmail = workspaces.filter((w) => w.userEmail.toLowerCase() === lower); + const byNamePrefix = workspaces.filter((w) => w.orgName.toLowerCase().startsWith(lower)); + const byEmailPrefix = workspaces.filter((w) => w.userEmail.toLowerCase().startsWith(lower)); + const candidates = + byUrlKey.length === 1 + ? byUrlKey + : byEmail.length === 1 + ? byEmail + : byNamePrefix.length === 1 + ? byNamePrefix + : byEmailPrefix.length === 1 + ? byEmailPrefix + : byUrlKey.length > 0 + ? byUrlKey + : byEmail.length > 0 + ? byEmail // one account in several orgs: list them as ambiguous + : byNamePrefix.length > 0 + ? byNamePrefix + : byEmailPrefix.length > 0 + ? byEmailPrefix + : []; + + if (candidates.length === 0) { + const names = workspaces.map((w) => `${w.orgName} (${w.urlKey}, ${w.userEmail})`).join(", "); + return { ok: false, message: `No workspace matches "${query}". Connected: ${names || "none"}` }; + } + if (candidates.length > 1) { + const names = candidates.map((w) => `${w.orgName} (${w.userEmail})`).join(", "); + return { ok: false, message: `"${query}" is ambiguous: ${names}. Use the account email to pick one.` }; + } + const entry = candidates[0]; + const { linearClient } = await getLinearClientFor( + { orgId: entry.orgId, userId: entry.userId }, + { interactive: true }, + ); + return { ok: true, entry, client: linearClient }; +} + +// Root-search subtitle for the command pegged to the active workspace: shows +// which workspace it will create in. Hidden entirely for single-workspace +// users (updateCommandMetadata clears it). +export async function updateActiveWorkspaceSubtitle(): Promise { + try { + const registry = await migrateIfNeeded({ allowWrite: false }); + if (registry.workspaces.length < 2) { + await updateCommandMetadata({ subtitle: null }); + return; + } + const active = getActiveEntry(registry); + await updateCommandMetadata({ + subtitle: active ? `Linear — will create in ${active.orgName}` : null, + }); + } catch { + // Subtitle is cosmetic — never let it affect the command. + } +} + +// Root-search subtitle for the quick commands that take a `workspace` +// argument: shows the active workspace plus the alternatives the user can +// type. Hidden entirely for single-workspace users (updateCommandMetadata +// clears it). +export async function updateWorkspaceChoicesSubtitle(): Promise { + try { + const registry = await migrateIfNeeded({ allowWrite: false }); + if (registry.workspaces.length < 2) { + await updateCommandMetadata({ subtitle: null }); + return; + } + const active = getActiveEntry(registry); + const others = registry.workspaces.filter((w) => !active || entryKey(w) !== entryKey(active)).map((w) => w.orgName); + await updateCommandMetadata({ + subtitle: active ? `Linear Workspace - Default: ${active.orgName}, others: ${others.join(", ")}` : null, + }); + } catch { + // Subtitle is cosmetic — never let it affect the command. + } +} diff --git a/extensions/linear/src/hooks/useAllWorkspaceNotifications.ts b/extensions/linear/src/hooks/useAllWorkspaceNotifications.ts new file mode 100644 index 00000000000..fa99ba258f9 --- /dev/null +++ b/extensions/linear/src/hooks/useAllWorkspaceNotifications.ts @@ -0,0 +1,48 @@ +import { useCachedPromise } from "@raycast/utils"; + +import { getNotifications, NotificationResult } from "../api/getNotifications"; +import { getLinearClientFor } from "../api/linearClient"; +import { migrateIfNeeded, WorkspaceEntry } from "../api/workspaces"; + +// No client on the row: useCachedPromise JSON-serializes this result to Raycast's +// UNENCRYPTED cache. A LinearClient instance carries its bearer token in an +// Authorization header — persisting it here would write the token to disk, and a +// rehydrated cache row would carry a method-less plain object anyway. Actions resolve +// the client on demand via getLinearClientFor (see unread-notifications.tsx). +export type WorkspaceNotificationRow = { + entry: WorkspaceEntry; + status: "ok" | "needs-reauth"; + urlKey: string; + notifications: NotificationResult[]; +}; + +// Menu-bar data source (P4): strictly read-only on the registry; per-entry clients; +// background-safe token access only (never authorize(), §4.3). One row per ENTRY — +// two entries can share an orgId (D10), so grouping is by entryKey. +export default function useAllWorkspaceNotifications() { + const { data, isLoading, mutate } = useCachedPromise(async (): Promise => { + const registry = await migrateIfNeeded({ allowWrite: false }); + return Promise.all( + registry.workspaces.map(async (entry): Promise => { + try { + const { linearClient } = await getLinearClientFor( + { orgId: entry.orgId, userId: entry.userId }, + { interactive: false }, + ); + const { notifications, urlKey } = await getNotifications(linearClient); + return { + entry, + status: "ok", + urlKey: urlKey ?? entry.urlKey, + notifications: notifications ?? [], + }; + } catch { + // Expired-with-no-refresh, revoked, or transient failure: render the + // re-auth row; NEVER log the workspace out from the menu bar (S6/S7, T13). + return { entry, status: "needs-reauth", urlKey: entry.urlKey, notifications: [] }; + } + }), + ); + }, []); + return { rows: data ?? [], isLoading, mutate }; +} diff --git a/extensions/linear/src/hooks/useCustomViews.ts b/extensions/linear/src/hooks/useCustomViews.ts index d4747d1ddd9..7459667bfee 100644 --- a/extensions/linear/src/hooks/useCustomViews.ts +++ b/extensions/linear/src/hooks/useCustomViews.ts @@ -2,17 +2,30 @@ import { useCachedPromise } from "@raycast/utils"; import { getCustomViews, getCustomViewIssues, CustomViewResult } from "../api/getCustomViews"; import { IssueResult } from "../api/getIssues"; +import { useWorkspaces } from "../components/WorkspaceContext"; export function useCustomViews() { - const { data, error, isLoading } = useCachedPromise(getCustomViews); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading } = useCachedPromise( + (key: string) => { + void key; + return getCustomViews(); + }, + [workspaceKey], + ); return { customViews: data, customViewsError: error, isLoadingCustomViews: isLoading }; } export function useCustomViewIssues(viewId: string) { - const { data, error, isLoading, mutate } = useCachedPromise(getCustomViewIssues, [viewId], { - execute: !!viewId, - }); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string, viewId: string) => getCustomViewIssues(viewId), + [workspaceKey, viewId], + { + execute: !!viewId, + }, + ); return { issues: data, diff --git a/extensions/linear/src/hooks/useCycles.ts b/extensions/linear/src/hooks/useCycles.ts index 2ba15bcad0e..77065137524 100644 --- a/extensions/linear/src/hooks/useCycles.ts +++ b/extensions/linear/src/hooks/useCycles.ts @@ -1,18 +1,20 @@ import { useCachedPromise } from "@raycast/utils"; import { getLinearClient } from "../api/linearClient"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useCycles(teamId?: string, config?: { execute?: boolean }) { const { linearClient } = getLinearClient(); + const { workspaceKey } = useWorkspaces(); const { data, error, isLoading } = useCachedPromise( - async (teamId: string | undefined) => { + async (key: string, teamId: string | undefined) => { const cycles = await linearClient.cycles({ filter: { team: { id: { eq: teamId } } } }); // The cycles seem to be ordered from the furthest cycle to the closest cycle return cycles.nodes.sort((a, b) => a.number - b.number); }, - [teamId], + [workspaceKey, teamId], { execute: config?.execute !== false && !!teamId }, ); diff --git a/extensions/linear/src/hooks/useDocuments.ts b/extensions/linear/src/hooks/useDocuments.ts index ec86bacec6b..5c9b01bdcfb 100644 --- a/extensions/linear/src/hooks/useDocuments.ts +++ b/extensions/linear/src/hooks/useDocuments.ts @@ -1,13 +1,19 @@ import { useCachedPromise } from "@raycast/utils"; +import { useWorkspaces } from "../components/WorkspaceContext"; import { getDocumentContent } from "../tools/get-document-content"; import { DocumentEntity, getDocuments } from "../tools/get-documents"; export function useDocuments(query: string = "", entity: DocumentEntity = { projectId: "" }) { - const { data, error, isLoading, mutate } = useCachedPromise(getDocuments, [query, entity], { - failureToastOptions: { title: "Failed to load documents" }, - keepPreviousData: true, - }); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string, query: string, entity: DocumentEntity) => getDocuments(query, entity), + [workspaceKey, query, entity], + { + failureToastOptions: { title: "Failed to load documents" }, + keepPreviousData: true, + }, + ); return { docs: data?.docs, @@ -19,9 +25,14 @@ export function useDocuments(query: string = "", entity: DocumentEntity = { proj } export function useDocumentContent(documentId: string) { - const { data, error, isLoading, mutate } = useCachedPromise(getDocumentContent, [documentId], { - failureToastOptions: { title: "Failed to load document content" }, - }); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string, documentId: string) => getDocumentContent(documentId), + [workspaceKey, documentId], + { + failureToastOptions: { title: "Failed to load document content" }, + }, + ); return { doc: data, diff --git a/extensions/linear/src/hooks/useInitiatives.ts b/extensions/linear/src/hooks/useInitiatives.ts index 6e97a331fbe..3c1db47a782 100644 --- a/extensions/linear/src/hooks/useInitiatives.ts +++ b/extensions/linear/src/hooks/useInitiatives.ts @@ -1,12 +1,21 @@ import { useCachedPromise } from "@raycast/utils"; +import { useWorkspaces } from "../components/WorkspaceContext"; import { getInitiatives } from "../tools/get-initiatives"; export function useInitiatives() { - const { data, error, isLoading, mutate } = useCachedPromise(getInitiatives, [], { - failureToastOptions: { title: "Failed to load initiatives" }, - keepPreviousData: true, - }); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string) => { + void key; + return getInitiatives(); + }, + [workspaceKey], + { + failureToastOptions: { title: "Failed to load initiatives" }, + keepPreviousData: true, + }, + ); return { initiatives: data, diff --git a/extensions/linear/src/hooks/useIssueComments.ts b/extensions/linear/src/hooks/useIssueComments.ts index 1d6143c2dcb..77bc28016d0 100644 --- a/extensions/linear/src/hooks/useIssueComments.ts +++ b/extensions/linear/src/hooks/useIssueComments.ts @@ -2,9 +2,14 @@ import { Issue } from "@linear/sdk"; import { useCachedPromise } from "@raycast/utils"; import { getComments } from "../api/getIssues"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useIssueComments(issueId: Issue["id"]) { - const { data, error, isLoading, mutate } = useCachedPromise(getComments, [issueId]); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string, issueId: Issue["id"]) => getComments(issueId), + [workspaceKey, issueId], + ); return { comments: data, commentsError: error, isLoadingComments: isLoading, mutateComments: mutate }; } diff --git a/extensions/linear/src/hooks/useIssueDetail.ts b/extensions/linear/src/hooks/useIssueDetail.ts index dbb2a28e8c6..abd191f9322 100644 --- a/extensions/linear/src/hooks/useIssueDetail.ts +++ b/extensions/linear/src/hooks/useIssueDetail.ts @@ -1,16 +1,22 @@ import { useCachedPromise } from "@raycast/utils"; import { getIssueDetail, IssueResult, IssueDetailResult } from "../api/getIssues"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useIssueDetail(existingIssue: IssueResult) { const issueId = existingIssue.id; + const { workspaceKey } = useWorkspaces(); - const { data, error, isLoading, mutate } = useCachedPromise(getIssueDetail, [issueId], { - initialData: { - ...existingIssue, - description: "", - } as IssueDetailResult, - }); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string, issueId: string) => getIssueDetail(issueId), + [workspaceKey, issueId], + { + initialData: { + ...existingIssue, + description: "", + } as IssueDetailResult, + }, + ); return { issue: data, issueError: error, isLoadingIssue: isLoading, mutateDetail: mutate }; } diff --git a/extensions/linear/src/hooks/useIssueTemplates.ts b/extensions/linear/src/hooks/useIssueTemplates.ts index 8178cc833de..e896dd568ab 100644 --- a/extensions/linear/src/hooks/useIssueTemplates.ts +++ b/extensions/linear/src/hooks/useIssueTemplates.ts @@ -1,11 +1,17 @@ import { useCachedPromise } from "@raycast/utils"; import { getIssueTemplates } from "../api/getIssueTemplates"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useIssueTemplates(teamId?: string, config?: { execute?: boolean }) { - const { data, error, isLoading } = useCachedPromise(getIssueTemplates, [teamId], { - execute: config?.execute !== false && !!teamId, - }); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading } = useCachedPromise( + (key: string, teamId?: string) => getIssueTemplates(teamId), + [workspaceKey, teamId], + { + execute: config?.execute !== false && !!teamId, + }, + ); return { issueTemplates: data, issueTemplatesError: error, isLoadingIssueTemplates: (!data && !error) || isLoading }; } diff --git a/extensions/linear/src/hooks/useIssues.ts b/extensions/linear/src/hooks/useIssues.ts index 4243d8d9787..e7369082387 100644 --- a/extensions/linear/src/hooks/useIssues.ts +++ b/extensions/linear/src/hooks/useIssues.ts @@ -1,13 +1,19 @@ import { useCachedPromise } from "@raycast/utils"; import { IssueResult } from "../api/getIssues"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useIssues( fetcher: (...args: T[]) => Promise, args: T[] = [], config?: { execute?: boolean; keepPreviousData?: boolean }, ) { - const { data, error, isLoading, mutate } = useCachedPromise(fetcher, args, config); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string, ...rest: T[]) => fetcher(...rest), + [workspaceKey, ...args], + config, + ); return { issues: data, issuesError: error, isLoadingIssues: isLoading, mutateList: mutate }; } diff --git a/extensions/linear/src/hooks/useLabels.ts b/extensions/linear/src/hooks/useLabels.ts index 305a4e40876..9388ebb6e9f 100644 --- a/extensions/linear/src/hooks/useLabels.ts +++ b/extensions/linear/src/hooks/useLabels.ts @@ -1,11 +1,17 @@ import { useCachedPromise } from "@raycast/utils"; import { getLabels } from "../api/getLabels"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useLabels(teamId?: string, config?: { execute?: boolean }) { - const { data, error, isLoading } = useCachedPromise(getLabels, [teamId], { - execute: config?.execute !== false && !!teamId, - }); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading } = useCachedPromise( + (key: string, teamId?: string) => getLabels(teamId), + [workspaceKey, teamId], + { + execute: config?.execute !== false && !!teamId, + }, + ); return { labels: data, labelsError: error, isLoadingLabels: (!data && !error) || isLoading }; } diff --git a/extensions/linear/src/hooks/useMe.ts b/extensions/linear/src/hooks/useMe.ts index b7b21f60e51..83421153ad9 100644 --- a/extensions/linear/src/hooks/useMe.ts +++ b/extensions/linear/src/hooks/useMe.ts @@ -1,10 +1,18 @@ import { useCachedPromise } from "@raycast/utils"; import { getLinearClient } from "../api/linearClient"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useMe() { const { linearClient } = getLinearClient(); - const { data, error, isLoading } = useCachedPromise(() => linearClient.viewer); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading } = useCachedPromise( + (key: string) => { + void key; + return linearClient.viewer; + }, + [workspaceKey], + ); return { me: data, meError: error, isLoadingMe: (!data && !error) || isLoading }; } diff --git a/extensions/linear/src/hooks/useMilestones.ts b/extensions/linear/src/hooks/useMilestones.ts index 90503c0906a..e8253873f8a 100644 --- a/extensions/linear/src/hooks/useMilestones.ts +++ b/extensions/linear/src/hooks/useMilestones.ts @@ -1,11 +1,17 @@ import { useCachedPromise } from "@raycast/utils"; import { getMilestones } from "../api/getMilestones"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useMilestones(projectId?: string, config?: { execute?: boolean }) { - const { data, error, isLoading, mutate } = useCachedPromise(getMilestones, [projectId], { - execute: config?.execute !== false, - }); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string, projectId?: string) => getMilestones(projectId), + [workspaceKey, projectId], + { + execute: config?.execute !== false, + }, + ); return { milestones: data, diff --git a/extensions/linear/src/hooks/useNotifications.ts b/extensions/linear/src/hooks/useNotifications.ts index d25f3aaed30..047eb55a6c1 100644 --- a/extensions/linear/src/hooks/useNotifications.ts +++ b/extensions/linear/src/hooks/useNotifications.ts @@ -2,9 +2,17 @@ import { useCachedPromise } from "@raycast/utils"; import { chain } from "lodash"; import { getNotifications } from "../api/getNotifications"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useNotifications() { - const { data, error, isLoading, mutate } = useCachedPromise(getNotifications); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string) => { + void key; + return getNotifications(); + }, + [workspaceKey], + ); const { notifications, urlKey } = data || {}; diff --git a/extensions/linear/src/hooks/usePriorities.ts b/extensions/linear/src/hooks/usePriorities.ts index 5fbaba24250..fc984f3b4eb 100644 --- a/extensions/linear/src/hooks/usePriorities.ts +++ b/extensions/linear/src/hooks/usePriorities.ts @@ -1,11 +1,20 @@ import { useCachedPromise } from "@raycast/utils"; import { getLinearClient } from "../api/linearClient"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function usePriorities() { const { linearClient } = getLinearClient(); + const { workspaceKey } = useWorkspaces(); - const { data, error, isLoading } = useCachedPromise(() => linearClient.issuePriorityValues, [], { initialData: [] }); + const { data, error, isLoading } = useCachedPromise( + (key: string) => { + void key; + return linearClient.issuePriorityValues; + }, + [workspaceKey], + { initialData: [] }, + ); return { priorities: data, prioritiesError: error, isLoadingPriorities: (!data && !error) || isLoading }; } diff --git a/extensions/linear/src/hooks/useProjectStatuses.ts b/extensions/linear/src/hooks/useProjectStatuses.ts index 6ec974fa09e..ca1814affca 100644 --- a/extensions/linear/src/hooks/useProjectStatuses.ts +++ b/extensions/linear/src/hooks/useProjectStatuses.ts @@ -1,13 +1,19 @@ import { useCachedPromise } from "@raycast/utils"; import { getLinearClient } from "../api/linearClient"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useProjectStatuses() { const { linearClient } = getLinearClient(); - const { data: states, isLoading: isLoadingStates } = useCachedPromise(async () => { - const states = await linearClient.projectStatuses(); - return states.nodes.sort((a, b) => a.position - b.position); - }); + const { workspaceKey } = useWorkspaces(); + const { data: states, isLoading: isLoadingStates } = useCachedPromise( + async (key: string) => { + void key; + const states = await linearClient.projectStatuses(); + return states.nodes.sort((a, b) => a.position - b.position); + }, + [workspaceKey], + ); return { states, isLoadingStates }; } diff --git a/extensions/linear/src/hooks/useProjectUpdates.ts b/extensions/linear/src/hooks/useProjectUpdates.ts index 5e90e197321..e959404b4c8 100644 --- a/extensions/linear/src/hooks/useProjectUpdates.ts +++ b/extensions/linear/src/hooks/useProjectUpdates.ts @@ -1,9 +1,14 @@ import { useCachedPromise } from "@raycast/utils"; import { getProjectUpdates } from "../api/getProjects"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useProjectUpdates(projectId: string) { - const { data, error, isLoading, mutate } = useCachedPromise(getProjectUpdates, [projectId]); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading, mutate } = useCachedPromise( + (key: string, projectId: string) => getProjectUpdates(projectId), + [workspaceKey, projectId], + ); return { updates: data, updatesError: error, isLoadingUpdates: isLoading, mutateUpdates: mutate }; } diff --git a/extensions/linear/src/hooks/useProjects.ts b/extensions/linear/src/hooks/useProjects.ts index 87ada166339..1f4c13dea89 100644 --- a/extensions/linear/src/hooks/useProjects.ts +++ b/extensions/linear/src/hooks/useProjects.ts @@ -2,20 +2,22 @@ import { useCachedPromise } from "@raycast/utils"; import type { PaginationOptions } from "@raycast/utils/dist/types"; import { getProjects, type ProjectResult } from "../api/getProjects"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useProjects( teamId?: string, config?: { execute?: boolean; searchText?: string; pageSize?: number }, ) { + const { workspaceKey } = useWorkspaces(); const { data, error, isLoading, mutate, pagination } = useCachedPromise( - (teamId?: string, searchText?: string) => (pagination: PaginationOptions) => + (key: string, teamId?: string, searchText?: string) => (pagination: PaginationOptions) => getProjects({ teamId, searchText, after: pagination.cursor, first: config?.pageSize, }), - [teamId, config?.searchText], + [workspaceKey, teamId, config?.searchText], { execute: config?.execute !== false, keepPreviousData: true, diff --git a/extensions/linear/src/hooks/useSearchIssues.ts b/extensions/linear/src/hooks/useSearchIssues.ts index a61596204ea..d9c1f140ed9 100644 --- a/extensions/linear/src/hooks/useSearchIssues.ts +++ b/extensions/linear/src/hooks/useSearchIssues.ts @@ -2,10 +2,12 @@ import { useCachedPromise } from "@raycast/utils"; import { UseCachedPromiseReturnType } from "@raycast/utils/dist/types"; import { IssueResult, getLastUpdatedIssues, searchIssues } from "../api/getIssues"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useSearchIssues(query: string) { + const { workspaceKey } = useWorkspaces(); return useCachedPromise( - (query: string) => + (key: string, query: string) => async ({ cursor }) => { if (!query) { const { issues, pageInfo } = await getLastUpdatedIssues(cursor); @@ -15,6 +17,6 @@ export default function useSearchIssues(query: string) { const { issues, pageInfo } = await searchIssues(query, cursor); return { data: issues ?? [], hasMore: pageInfo?.hasNextPage, cursor: pageInfo?.endCursor }; }, - [query], + [workspaceKey, query], ) as UseCachedPromiseReturnType; } diff --git a/extensions/linear/src/hooks/useStates.ts b/extensions/linear/src/hooks/useStates.ts index b27867dc4e5..f8a517b469a 100644 --- a/extensions/linear/src/hooks/useStates.ts +++ b/extensions/linear/src/hooks/useStates.ts @@ -1,20 +1,22 @@ import { useCachedPromise } from "@raycast/utils"; import { getLinearClient } from "../api/linearClient"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useStates(teamId?: string, config?: { execute?: boolean }) { const { linearClient } = getLinearClient(); + const { workspaceKey } = useWorkspaces(); const { data: states, error: statesError, isLoading: isLoadingStates, } = useCachedPromise( - async (teamId: string | undefined) => { + async (key: string, teamId: string | undefined) => { const states = await linearClient.workflowStates({ filter: { team: { id: { eq: teamId } } } }); return states.nodes.sort((a, b) => a.position - b.position); }, - [teamId], + [workspaceKey, teamId], { initialData: [], execute: config?.execute !== false, diff --git a/extensions/linear/src/hooks/useTeams.ts b/extensions/linear/src/hooks/useTeams.ts index 8619bc86621..bd2a930a3f0 100644 --- a/extensions/linear/src/hooks/useTeams.ts +++ b/extensions/linear/src/hooks/useTeams.ts @@ -1,9 +1,14 @@ import { useCachedPromise } from "@raycast/utils"; import { getTeams } from "../api/getTeams"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useTeams(query: string = "") { - const { data, error, isLoading } = useCachedPromise(getTeams, [query]); + const { workspaceKey } = useWorkspaces(); + const { data, error, isLoading } = useCachedPromise( + (key: string, query: string) => getTeams(query), + [workspaceKey, query], + ); return { teams: data?.teams, diff --git a/extensions/linear/src/hooks/useUsers.ts b/extensions/linear/src/hooks/useUsers.ts index c90a81a8563..14a0c434d07 100644 --- a/extensions/linear/src/hooks/useUsers.ts +++ b/extensions/linear/src/hooks/useUsers.ts @@ -1,18 +1,20 @@ import { useCachedPromise } from "@raycast/utils"; import { getLinearClient } from "../api/linearClient"; +import { useWorkspaces } from "../components/WorkspaceContext"; export default function useUsers(query: string = "") { const { linearClient } = getLinearClient(); + const { workspaceKey } = useWorkspaces(); const { data, error, isLoading } = useCachedPromise( - async (contains: string) => { + async (key: string, contains: string) => { const users = await linearClient.users( contains.trim().length > 0 ? { filter: { name: { containsIgnoreCase: contains } } } : undefined, ); return { users: users?.nodes ?? [], hasMoreUsers: !!users?.pageInfo?.hasNextPage }; }, - [query], + [workspaceKey, query], { initialData: [] }, ); diff --git a/extensions/linear/src/hooks/useWorkspaceCachedState.ts b/extensions/linear/src/hooks/useWorkspaceCachedState.ts new file mode 100644 index 00000000000..22bb64e8305 --- /dev/null +++ b/extensions/linear/src/hooks/useWorkspaceCachedState.ts @@ -0,0 +1,11 @@ +import { useCachedState } from "@raycast/utils"; + +import { useWorkspaces } from "../components/WorkspaceContext"; + +// Workspace-scoped persisted UI state (§4.5): key gains the ENTRY key (D10), so two +// entries — even two views of the same org under different accounts — never share state. +export function useWorkspaceCachedState(baseKey: string, defaultValue: T): [T, (value: T) => void] { + const { workspaceKey } = useWorkspaces(); + const [value, setValue] = useCachedState(`${baseKey}-${workspaceKey}`, defaultValue); + return [value, setValue]; +} diff --git a/extensions/linear/src/manage-workspaces.tsx b/extensions/linear/src/manage-workspaces.tsx new file mode 100644 index 00000000000..9382cf08a74 --- /dev/null +++ b/extensions/linear/src/manage-workspaces.tsx @@ -0,0 +1,359 @@ +import { + Action, + ActionPanel, + Alert, + Color, + confirmAlert, + Icon, + List, + LocalStorage, + showToast, + Toast, +} from "@raycast/api"; +import { useCallback, useEffect, useState } from "react"; + +import { ensureEntryToken, makeClient } from "./api/linearClient"; +import { fetchViewerIdentity, getServiceForProviderId, stagingService, ViewerIdentity } from "./api/oauth"; +import { + entryKey, + EntryRef, + getActiveEntry, + migrateIfNeeded, + readRegistry, + reconcileEntries, + removeWorkspaceEntry, + setActiveEntry, + upsertWorkspaceEntry, + WorkspaceEntry, + WorkspaceRegistry, +} from "./api/workspaces"; +import { refreshQuickCommandSubtitles } from "./helpers/refreshQuickSubtitles"; + +type Row = { entry: WorkspaceEntry; hasToken: boolean }; + +// Written when addWorkspace starts an interactive grant, cleared once completeAddFromStaging +// reaches a definitive outcome for that token (success or a proven-bad token) — see fix F-3. +// Gates the MOUNT-TIME recovery call so a stray staging token can't be replayed as an +// implicit "add" on every open of Manage Workspaces. +const ADD_IN_FLIGHT_KEY = "staging-add-in-flight"; + +// fetchViewerIdentity's error messages embed the HTTP status ("HTTP 401"). A 401/403 means +// the staging token itself was rejected (permanent); anything else (network failure, 5xx) is +// treated as transient so a stranded-but-still-good token survives to be retried. +function isPermanentTokenFailure(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /HTTP (401|403)/.test(message); +} + +async function revokeAtLinear(accessToken: string): Promise { + try { + const response = await fetch("https://api.linear.app/oauth/revoke", { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + return response.ok; + } catch { + return false; + } +} + +export default function ManageWorkspaces() { + const [isLoading, setIsLoading] = useState(true); + const [registry, setRegistry] = useState(null); + const [rows, setRows] = useState([]); + + const reload = useCallback(async () => { + setIsLoading(true); + const fresh = await migrateIfNeeded({ allowWrite: true }); + setRegistry(fresh); + setRows(await reconcileEntries(fresh)); + setIsLoading(false); + }, []); + + // Finish an interrupted add: registry write from an authorized staging token (§4.1 step 5). + // Ordering fix (F-2): the token is VERIFIED before anything is written to the registry — + // the original order wrote the entry first and verified after, so a failure between those + // two steps left a token-less entry behind that a stray staging token would keep resurrecting + // on every subsequent mount. + const completeAddFromStaging = useCallback(async () => { + const stagingTokens = await stagingService.client.getTokens(); + if (!stagingTokens?.accessToken) { + // Nothing staged — clear a stale in-flight marker from a crash before any grant landed. + await LocalStorage.removeItem(ADD_IN_FLIGHT_KEY); + return false; + } + + let identity: ViewerIdentity; + try { + identity = await fetchViewerIdentity(stagingTokens.accessToken); + // Second, SDK-path verification of the same token before it is ever written anywhere. + await makeClient(stagingTokens.accessToken).viewer; + } catch (error) { + if (isPermanentTokenFailure(error)) { + // The token itself is rejected (permanent failure) — clear it so a dead token + // doesn't keep silently failing on every future mount. + await stagingService.client.removeTokens(); + await LocalStorage.removeItem(ADD_IN_FLIGHT_KEY); + } + // A transient failure (network, 5xx) leaves staging untouched so recovery can retry. + throw error; + } + + const { entry, isNew } = await upsertWorkspaceEntry(identity); + const destination = getServiceForProviderId(entry.providerId, undefined, `Linear — ${entry.orgName}`); + // setTokens re-stamps updatedAt to NOW, so pass the REMAINING lifetime, not the + // original duration — crash recovery can run hours after the grant, and copying the + // full duration would make isExpired() lag the server's fixed expiry. Omit expiresIn + // entirely when the stored set lacks it (do not default it — that would make a + // non-expiring token look expiring). + const elapsedSeconds = Math.floor((Date.now() - stagingTokens.updatedAt.getTime()) / 1000); + try { + await destination.client.setTokens({ + accessToken: stagingTokens.accessToken, + refreshToken: stagingTokens.refreshToken, + idToken: stagingTokens.idToken, + scope: stagingTokens.scope, + ...(stagingTokens.expiresIn !== undefined + ? { expiresIn: Math.max(60, stagingTokens.expiresIn - elapsedSeconds) } + : {}), + }); + } finally { + // The identity is verified and the entry is written by this point — the staging + // token must not linger regardless of whether setTokens itself succeeded. + await stagingService.client.removeTokens(); + await LocalStorage.removeItem(ADD_IN_FLIGHT_KEY); + } + await showToast({ + style: Toast.Style.Success, + title: isNew ? `Added ${identity.orgName}` : `Re-authenticated ${identity.orgName}`, + message: identity.userEmail, + }); + refreshQuickCommandSubtitles(); // fire-and-forget: workspace membership changed, subtitles listing "others" are now stale + return true; + }, []); + + useEffect(() => { + (async () => { + // Crash/cancel recovery on mount: only resume an add the user actually started (F-3) — + // a stray staging token must never be replayed as an implicit "add" on every mount. + try { + const addInFlight = await LocalStorage.getItem(ADD_IN_FLIGHT_KEY); + if (addInFlight) { + await completeAddFromStaging(); + } + } catch { + // Leave staging for the next attempt; never crash the management surface. + } + await reload(); + })(); + }, [completeAddFromStaging, reload]); + + async function addWorkspace() { + const proceed = await confirmAlert({ + title: "Add a Linear Workspace", + // S3: there is NO workspace picker on Linear's consent page — the grant binds to + // whichever workspace is active at linear.app. The user steers it there first. + message: + "First, in your browser, go to linear.app and switch to the workspace you want to add (top-left workspace switcher). Then continue — Linear will ask you to approve access for that workspace.", + primaryAction: { title: "Continue to Linear" }, + }); + if (!proceed) return; + try { + await stagingService.client.removeTokens(); // clear any prior abort (§4.1 step 1) + // Mark that an add is genuinely in flight, so a crash before this completes still lets + // mount-time recovery resume it (F-3) — cleared inside completeAddFromStaging once the + // token is proven good or proven bad. + await LocalStorage.setItem(ADD_IN_FLIGHT_KEY, "1"); + await stagingService.authorize(); // interactive; prompt=consent forces the consent screen + await completeAddFromStaging(); + await reload(); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Adding workspace failed", + message: error instanceof Error ? error.message : String(error), + }); + } + } + + async function reauthenticate(entry: WorkspaceEntry) { + try { + const service = getServiceForProviderId(entry.providerId, undefined, `Linear — ${entry.orgName}`); + await service.client.removeTokens(); + // ensureEntryToken (NOT plain authorize): the grant follows the account/workspace + // active at linear.app (S3), so the minted token's (orgId, userId) is verified + // against THIS entry and rejected with a corrective message on mismatch (D10). + await ensureEntryToken(entry, { interactive: true }); + await showToast({ style: Toast.Style.Success, title: `Re-authenticated ${entry.orgName}` }); + await reload(); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Re-authentication failed", + message: error instanceof Error ? error.message : String(error), + }); + } + } + + async function logOut(entry: WorkspaceEntry, options: { revoke: boolean }) { + const confirmed = await confirmAlert({ + title: options.revoke ? `Log Out and Revoke ${entry.orgName}?` : `Log Out of ${entry.orgName}?`, + message: options.revoke + ? // S7 blast radius, disclosed verbatim: authorization-scoped + eventually consistent + // (observed: ~10 s clean case, 8–12 min worst case). + "Revoking at Linear invalidates every Raycast token for this account and workspace — including the primary Linear login if it uses the same account and workspace — and can take from seconds up to ~12 minutes to land. A workspace re-added during that window may be logged out again when the revocation lands." + : "Removes this workspace's login from Raycast. The authorization at Linear stays (remove it at linear.app under Security & access if you want); its current access token expires on its own within 24 hours.", + primaryAction: { + title: options.revoke ? "Log Out and Revoke" : "Log Out", + style: Alert.ActionStyle.Destructive, + }, + }); + if (!confirmed) return; + const ref: EntryRef = { orgId: entry.orgId, userId: entry.userId }; + // Named-step tracker: logOut had no error handling at all, so a throw anywhere in this + // sequence (e.g. removeTokens or the registry write) silently left the entry behind, + // reporting "removed" via Settings while Manage Workspaces still showed it needing + // re-authentication. Every await below updates `step` first so a failure toast can say + // which part of the log-out actually failed. + let step = "removing the local login"; + try { + const service = getServiceForProviderId(entry.providerId, undefined, `Linear — ${entry.orgName}`); + let revoked = false; + if (options.revoke) { + step = "revoking access at Linear"; + const tokens = await service.client.getTokens(); + if (tokens?.accessToken) revoked = await revokeAtLinear(tokens.accessToken); + } + step = "removing the stored token"; + await service.client.removeTokens(); // default: local deletion (S7) + step = "removing the workspace from the list"; + await removeWorkspaceEntry(ref); + let after = await readRegistry(); + if (after.workspaces.some((w) => entryKey(w) === entryKey(ref))) { + // Removal did not persist — retry once before treating it as a hard failure. + await removeWorkspaceEntry(ref); + after = await readRegistry(); + if (after.workspaces.some((w) => entryKey(w) === entryKey(ref))) { + throw new Error(`Removing ${entry.orgName} from the workspace list did not persist.`); + } + } + step = "refreshing the workspace list"; + const fresh = await migrateIfNeeded({ allowWrite: true }); + const nextActive = getActiveEntry(fresh); + await showToast({ + style: Toast.Style.Success, + title: options.revoke + ? revoked + ? `Removed from Raycast; revocation requested at Linear` + : `Removed from Raycast (revoke request failed)` + : `Logged out of ${entry.orgName}`, + message: nextActive ? `Active workspace: ${nextActive.orgName}` : undefined, + }); + refreshQuickCommandSubtitles(); // fire-and-forget: workspace membership changed, subtitles listing "others" are now stale + await reload(); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: `Logging out of ${entry.orgName} failed while ${step}`, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + async function logOutAll() { + const confirmed = await confirmAlert({ + title: "Log Out of All Workspaces?", + message: + "Removes every workspace login from Raycast (tokens are deleted locally; each access token expires at Linear on its own within 24 hours).", + primaryAction: { title: "Log Out of All", style: Alert.ActionStyle.Destructive }, + }); + if (!confirmed) return; + for (const row of rows) { + const service = getServiceForProviderId(row.entry.providerId, undefined, `Linear — ${row.entry.orgName}`); + await service.client.removeTokens(); + await removeWorkspaceEntry({ orgId: row.entry.orgId, userId: row.entry.userId }); + } + await showToast({ style: Toast.Style.Success, title: "Logged out of all workspaces" }); + refreshQuickCommandSubtitles(); // fire-and-forget: workspace membership changed, subtitles listing "others" are now stale + await reload(); + } + + const activeEntry = registry ? getActiveEntry(registry) : null; + + return ( + + + + + } + /> + {rows.map(({ entry, hasToken }) => { + const isActive = activeEntry !== null && entryKey(activeEntry) === entryKey(entry); + const ref: EntryRef = { orgId: entry.orgId, userId: entry.userId }; + return ( + + {hasToken && !isActive ? ( + { + await setActiveEntry(ref); + refreshQuickCommandSubtitles(); // fire-and-forget: the active workspace changed, subtitles pegged to it are now stale + await reload(); + }} + /> + ) : null} + {!hasToken ? ( + reauthenticate(entry)} /> + ) : null} + + logOut(entry, { revoke: false })} + /> + logOut(entry, { revoke: true })} + /> + + + } + /> + ); + })} + + ); +} diff --git a/extensions/linear/src/notifications.tsx b/extensions/linear/src/notifications.tsx index d3a340ff196..442df29d81c 100644 --- a/extensions/linear/src/notifications.tsx +++ b/extensions/linear/src/notifications.tsx @@ -7,6 +7,7 @@ import { updateNotification } from "./api/updateNotification"; import IssueDetail from "./components/IssueDetail"; import OpenInLinear from "./components/OpenInLinear"; import View from "./components/View"; +import { WorkspaceListDropdown } from "./components/WorkspaceDropdown"; import { getBotIcon } from "./helpers/bots"; import { getErrorMessage } from "./helpers/errors"; import { getNotificationIcon, getNotificationURL } from "./helpers/notifications"; @@ -15,6 +16,18 @@ import useMe from "./hooks/useMe"; import useNotifications from "./hooks/useNotifications"; import usePriorities from "./hooks/usePriorities"; +// The dev-build install may not have "Unread Notifications" (a menu-bar command) activated, +// which makes launchCommand reject with "must be activated before it can be run in the +// background". That refresh is best-effort — it must never turn a completed mutation +// (mark as read/unread, mark all as read) into a reported failure (todoist precedent: +// extensions/todoist/src/helpers/menu-bar.ts). +function refreshUnreadNotificationsMenuBar() { + return launchCommand({ name: "unread-notifications", type: LaunchType.Background }).catch(() => { + // The menu-bar command may not be activated; a failed refresh must not report the + // completed mutation as a failure. + }); +} + function Notifications() { const { urlKey, @@ -74,7 +87,7 @@ function Notifications() { }); await showToast({ style: Toast.Style.Success, title: "Marked as read" }); - await launchCommand({ name: "unread-notifications", type: LaunchType.Background }); + await refreshUnreadNotificationsMenuBar(); } catch (error) { showToast({ style: Toast.Style.Failure, @@ -115,7 +128,7 @@ function Notifications() { }); await showToast({ style: Toast.Style.Success, title: "Marked as unread" }); - await launchCommand({ name: "unread-notifications", type: LaunchType.Background }); + await refreshUnreadNotificationsMenuBar(); } catch (error) { showToast({ style: Toast.Style.Failure, @@ -203,7 +216,7 @@ function Notifications() { ); await showToast({ style: Toast.Style.Success, title: "Marked all as read" }); - await launchCommand({ name: "unread-notifications", type: LaunchType.Background }); + await refreshUnreadNotificationsMenuBar(); } catch (error) { showToast({ style: Toast.Style.Failure, @@ -214,7 +227,10 @@ function Notifications() { } return ( - + } + > {sections.map(({ title, notifications }) => { diff --git a/extensions/linear/src/quick-add-comment-to-issue.ts b/extensions/linear/src/quick-add-comment-to-issue.ts index df3bc2f78d4..d7b60462092 100644 --- a/extensions/linear/src/quick-add-comment-to-issue.ts +++ b/extensions/linear/src/quick-add-comment-to-issue.ts @@ -8,11 +8,21 @@ import { showHUD, Keyboard, } from "@raycast/api"; -import { withAccessToken } from "@raycast/utils"; -import { getLinearClient, linear } from "./api/linearClient"; +import { getLinearClient } from "./api/linearClient"; +import { withWorkspaceAuth } from "./api/withWorkspaceAuth"; +import { resolveWorkspaceArgument, updateWorkspaceChoicesSubtitle } from "./helpers/workspaceArgument"; + +const command = async (props: { + arguments: Arguments.QuickAddCommentToIssue; + launchContext?: { refreshSubtitle?: boolean }; +}) => { + await updateWorkspaceChoicesSubtitle(); // awaited: the no-view process exits when the command resolves — a fire-and-forget write would be killed + + if (props.launchContext?.refreshSubtitle) { + return; // background refresh launch — subtitle updated above, do nothing else + } -const command = async (props: { arguments: Arguments.QuickAddCommentToIssue }) => { const { issueId, comment } = props.arguments; const toast = await showToast({ @@ -20,10 +30,18 @@ const command = async (props: { arguments: Arguments.QuickAddCommentToIssue }) = title: `Adding comment to ${issueId}`, }); + const resolved = await resolveWorkspaceArgument(props.arguments.workspace); + if (!resolved.ok) { + toast.style = Toast.Style.Failure; + toast.title = "Workspace not matched"; + toast.message = resolved.message; + return; + } + const preferences = getPreferenceValues(); try { - const { linearClient } = getLinearClient(); + const linearClient = resolved.client ?? getLinearClient().linearClient; if (preferences.shouldCloseMainWindow) { await closeMainWindow(); @@ -75,4 +93,4 @@ const command = async (props: { arguments: Arguments.QuickAddCommentToIssue }) = } }; -export default withAccessToken(linear)(command); +export default withWorkspaceAuth(command); diff --git a/extensions/linear/src/search-custom-views.tsx b/extensions/linear/src/search-custom-views.tsx index 4f2e353c3cf..ab3f0d8b302 100644 --- a/extensions/linear/src/search-custom-views.tsx +++ b/extensions/linear/src/search-custom-views.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import IssueListItem from "./components/IssueListItem"; import View from "./components/View"; +import { WorkspaceListDropdown } from "./components/WorkspaceDropdown"; import { getIcon } from "./helpers/icons"; import { useCustomViews, useCustomViewIssues } from "./hooks/useCustomViews"; import useMe from "./hooks/useMe"; @@ -50,7 +51,11 @@ function CustomViewList() { }, [customViewsError]); return ( - + } + > {customViews?.map((view) => { const accessories: List.Item.Accessory[] = []; diff --git a/extensions/linear/src/search-issues.tsx b/extensions/linear/src/search-issues.tsx index e3936a161e8..e69228d365b 100644 --- a/extensions/linear/src/search-issues.tsx +++ b/extensions/linear/src/search-issues.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import IssueListItem from "./components/IssueListItem"; import View from "./components/View"; +import { WorkspaceListDropdown } from "./components/WorkspaceDropdown"; import useMe from "./hooks/useMe"; import usePriorities from "./hooks/usePriorities"; import useSearchIssues from "./hooks/useSearchIssues"; @@ -24,6 +25,7 @@ function SearchIssues() { throttle searchBarPlaceholder="Globally search issues across projects" pagination={pagination} + searchBarAccessory={} > {data?.map((issue) => ( diff --git a/extensions/linear/src/tools/add-label.ts b/extensions/linear/src/tools/add-label.ts index f042bec7fc5..1507c157232 100644 --- a/extensions/linear/src/tools/add-label.ts +++ b/extensions/linear/src/tools/add-label.ts @@ -1,6 +1,6 @@ -import { withAccessToken } from "@raycast/utils"; +import { resolveClient } from "../api/linearClient"; -import { getLinearClient, linear } from "../api/linearClient"; +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** The ID of the issue to add the label to. Format is a combination of a team key and a unique number, like `ENG-123` */ @@ -8,10 +8,14 @@ type Input = { /** The ID of the label to add to the issue. Never use title as ID: you have to use `get-labels` tool to get the actual ID from the list of labels */ labelId: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async ({ issueId, labelId }: Input) => { - const { linearClient } = getLinearClient(); +export default withToolAuth(async ({ issueId, labelId, workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const issue = await linearClient.issue(issueId); const currentLabelIds = issue.labelIds || []; const result = await linearClient.updateIssue(issueId, { @@ -25,13 +29,16 @@ export default withAccessToken(linear)(async ({ issueId, labelId }: Input) => { return result.issue; }); -export const confirmation = withAccessToken(linear)(async ({ issueId, labelId }: Input) => { - const { linearClient } = getLinearClient(); +export const confirmation = withToolAuth(async ({ issueId, labelId, workspaceId }: Input) => { + const workspaceName = await describeToolWorkspace(workspaceId); + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const label = await linearClient.issueLabel(labelId); const issue = await linearClient.issue(issueId); return { info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), { name: "Issue", value: issue.title }, { name: "Label", value: label.name }, ], diff --git a/extensions/linear/src/tools/create-comment.ts b/extensions/linear/src/tools/create-comment.ts index 4854d54751c..0185229fef9 100644 --- a/extensions/linear/src/tools/create-comment.ts +++ b/extensions/linear/src/tools/create-comment.ts @@ -1,9 +1,9 @@ import path from "path"; -import { withAccessToken } from "@raycast/utils"; - import { appendFileAttachments } from "../api/attachments"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** The ID of the issue to associate the comment with. Format is a combination of a team key and a unique number, like `ENG-123` */ @@ -20,11 +20,15 @@ type Input = { /** A list of absolute local file paths to upload and append to the comment */ attachmentPaths?: string[]; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async (inputs: Input) => { - const { linearClient } = getLinearClient(); - const body = await appendFileAttachments(inputs.body, inputs.attachmentPaths); +export default withToolAuth(async (inputs: Input) => { + const client = await resolveToolClient(inputs.workspaceId); + const { linearClient } = resolveClient(client); + const body = await appendFileAttachments(inputs.body, inputs.attachmentPaths, client); const result = await linearClient.createComment({ issueId: inputs.issueId, parentId: inputs.parentId, @@ -39,34 +43,33 @@ export default withAccessToken(linear)(async (inputs: Input) => { return result.comment; }); -export const confirmation = withAccessToken(linear)(async ({ - issueId, - parentId, - projectUpdateId, - body, - attachmentPaths, -}: Input) => { - const { linearClient } = getLinearClient(); +export const confirmation = withToolAuth( + async ({ issueId, parentId, projectUpdateId, body, attachmentPaths, workspaceId }: Input) => { + const workspaceName = await describeToolWorkspace(workspaceId); + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); - let title: string = ""; + let title: string = ""; - if (issueId) { - const issue = await linearClient.issue(issueId); - title = issue.title; - } else if (parentId) { - const parentComment = await linearClient.comment({ id: parentId }); - title = parentComment.body; - } else if (projectUpdateId) { - title = "Project Update"; - } + if (issueId) { + const issue = await linearClient.issue(issueId); + title = issue.title; + } else if (parentId) { + const parentComment = await linearClient.comment({ id: parentId }); + title = parentComment.body; + } else if (projectUpdateId) { + title = "Project Update"; + } - return { - info: [ - { name: "Title", value: title }, - { name: "Comment", value: body }, - ...(attachmentPaths?.length - ? [{ name: "Attachments", value: attachmentPaths.map((filePath) => path.basename(filePath)).join(", ") }] - : []), - ], - }; -}); + return { + info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), + { name: "Title", value: title }, + { name: "Comment", value: body }, + ...(attachmentPaths?.length + ? [{ name: "Attachments", value: attachmentPaths.map((filePath) => path.basename(filePath)).join(", ") }] + : []), + ], + }; + }, +); diff --git a/extensions/linear/src/tools/create-document.ts b/extensions/linear/src/tools/create-document.ts index 334bb31e3b0..81a69a17491 100644 --- a/extensions/linear/src/tools/create-document.ts +++ b/extensions/linear/src/tools/create-document.ts @@ -1,6 +1,6 @@ -import { withAccessToken } from "@raycast/utils"; +import { resolveClient } from "../api/linearClient"; -import { getLinearClient, linear } from "../api/linearClient"; +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** The content of the document/PRD as a Markdown string */ @@ -11,10 +11,14 @@ type Input = { /** The ID of the project the document/PRD belongs to */ projectId: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async (inputs: Input) => { - const { linearClient } = getLinearClient(); +export default withToolAuth(async ({ workspaceId, ...inputs }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const result = await linearClient.createDocument(inputs); if (!result.success) { @@ -24,13 +28,16 @@ export default withAccessToken(linear)(async (inputs: Input) => { return result.document; }); -export const confirmation = withAccessToken(linear)(async ({ title, projectId }: Input) => { - const { linearClient } = getLinearClient(); +export const confirmation = withToolAuth(async ({ title, projectId, workspaceId }: Input) => { + const workspaceName = await describeToolWorkspace(workspaceId); + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const project = await linearClient.project(projectId); return { info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), { name: "Title", value: title }, { name: "Project", value: project.name }, ], diff --git a/extensions/linear/src/tools/create-issue.ts b/extensions/linear/src/tools/create-issue.ts index aa8f6931e9d..7a83d8afb3d 100644 --- a/extensions/linear/src/tools/create-issue.ts +++ b/extensions/linear/src/tools/create-issue.ts @@ -1,9 +1,7 @@ -import { withAccessToken } from "@raycast/utils"; - import { createIssue, CreateIssuePayload } from "../api/createIssue"; -import { linear } from "../api/linearClient"; import { formatConfirmation } from "./formatConfirmation"; +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** The priority of the issue (0-4, where 0 is no priority and 4 is urgent) */ @@ -45,9 +43,13 @@ type Input = { /** The estimate of the issue using a 0-5 scale */ estimate?: number; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async (inputs: Input) => { +export default withToolAuth(async (inputs: Input) => { + const client = await resolveToolClient(inputs.workspaceId); const payload: CreateIssuePayload = { teamId: inputs.teamId, title: inputs.title, @@ -61,19 +63,26 @@ export default withAccessToken(linear)(async (inputs: Input) => { priority: inputs.priority || 0, }; - return createIssue(payload); + return createIssue(payload, client); }); -export const confirmation = withAccessToken(linear)(async (inputs: Input) => { - const { title, ...fields } = inputs; +export const confirmation = withToolAuth(async (inputs: Input) => { + const workspaceName = await describeToolWorkspace(inputs.workspaceId); + const client = await resolveToolClient(inputs.workspaceId); + const { title, workspaceId, ...fields } = inputs; + void workspaceId; // destructured only to exclude it from `fields` const details = await Promise.all( Object.keys(fields).map((key) => { const name = key as keyof typeof fields; - return formatConfirmation({ name, value: fields[name] }); + return formatConfirmation({ name, value: fields[name], client }); }), ); return { - info: [formatConfirmation({ name: "Title", value: title }), ...details], + info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), + formatConfirmation({ name: "Title", value: title, client }), + ...details, + ], }; }); diff --git a/extensions/linear/src/tools/create-project-update.ts b/extensions/linear/src/tools/create-project-update.ts index f179f23ab4c..c8f0977817e 100644 --- a/extensions/linear/src/tools/create-project-update.ts +++ b/extensions/linear/src/tools/create-project-update.ts @@ -1,6 +1,6 @@ -import { withAccessToken } from "@raycast/utils"; +import { resolveClient } from "../api/linearClient"; -import { getLinearClient, linear } from "../api/linearClient"; +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** The ID of the project to create an update for. Use the 'get-projects' tool to get the project ID. */ @@ -11,10 +11,14 @@ type Input = { /** The health status of the project */ health: "onTrack" | "atRisk" | "offTrack"; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async (inputs: Input) => { - const { linearClient } = getLinearClient(); +export default withToolAuth(async ({ workspaceId, ...inputs }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); // @ts-expect-error the enum is correct const result = await linearClient.createProjectUpdate(inputs); @@ -25,12 +29,17 @@ export default withAccessToken(linear)(async (inputs: Input) => { return result.projectUpdate; }); -export const confirmation = withAccessToken(linear)(async ({ projectId }: Input) => { - const { linearClient } = getLinearClient(); +export const confirmation = withToolAuth(async ({ projectId, workspaceId }: Input) => { + const workspaceName = await describeToolWorkspace(workspaceId); + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const project = await linearClient.project(projectId); return { - info: [{ name: "Project", value: project.name }], + info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), + { name: "Project", value: project.name }, + ], }; }); diff --git a/extensions/linear/src/tools/filter-issues.ts b/extensions/linear/src/tools/filter-issues.ts index 075573ad386..fa61e526e15 100644 --- a/extensions/linear/src/tools/filter-issues.ts +++ b/extensions/linear/src/tools/filter-issues.ts @@ -1,10 +1,10 @@ -import { withAccessToken } from "@raycast/utils"; - import { filterIssues } from "../api/getIssues"; -import { linear } from "../api/linearClient"; -export default withAccessToken(linear)(async (inputs: { - /** +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; + +export default withToolAuth( + async (inputs: { + /** * Filter object. * * Examples: @@ -127,7 +127,12 @@ export default withAccessToken(linear)(async (inputs: { * IMPORTANT: Format filter as a JSON string * IMPORTANT: When user asks about my issues (assigned to me, my issues, etc), always filter by assignee. */ - filter: string; -}) => { - return (await filterIssues(inputs.filter)).issues; -}); + filter: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; + }) => { + const client = await resolveToolClient(inputs.workspaceId); + return (await filterIssues(inputs.filter, undefined, client)).issues; + }, +); diff --git a/extensions/linear/src/tools/formatConfirmation.ts b/extensions/linear/src/tools/formatConfirmation.ts index b0825b96eb7..b451ad040e1 100644 --- a/extensions/linear/src/tools/formatConfirmation.ts +++ b/extensions/linear/src/tools/formatConfirmation.ts @@ -1,13 +1,17 @@ -import { getLinearClient } from "../api/linearClient"; +import { LinearClient } from "@linear/sdk"; + +import { resolveClient } from "../api/linearClient"; export function formatConfirmation({ name, value, + client, }: { name: string; value: undefined | null | number | string | string[]; + client?: LinearClient; }) { - const { linearClient } = getLinearClient(); + const { linearClient } = resolveClient(client); const formatters = { assigneeId: async (assigneeId: string) => { diff --git a/extensions/linear/src/tools/full-text-search-issues.ts b/extensions/linear/src/tools/full-text-search-issues.ts index 953e7afdaaa..88dbfb9fe6d 100644 --- a/extensions/linear/src/tools/full-text-search-issues.ts +++ b/extensions/linear/src/tools/full-text-search-issues.ts @@ -1,11 +1,16 @@ -import { withAccessToken } from "@raycast/utils"; - import { searchIssues } from "../api/getIssues"; -import { linear } from "../api/linearClient"; -export default withAccessToken(linear)(async (inputs: { - /** The query to search for. Only use plain text: it doesn't support any operators */ - query: string; -}) => { - return (await searchIssues(inputs.query)).issues; -}); +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; + +export default withToolAuth( + async (inputs: { + /** The query to search for. Only use plain text: it doesn't support any operators */ + query: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; + }) => { + const client = await resolveToolClient(inputs.workspaceId); + return (await searchIssues(inputs.query, undefined, client)).issues; + }, +); diff --git a/extensions/linear/src/tools/get-current-user.ts b/extensions/linear/src/tools/get-current-user.ts index a921d813a31..864d928ff18 100644 --- a/extensions/linear/src/tools/get-current-user.ts +++ b/extensions/linear/src/tools/get-current-user.ts @@ -1,9 +1,15 @@ -import { withAccessToken } from "@raycast/utils"; +import { resolveClient } from "../api/linearClient"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; -export default withAccessToken(linear)(async () => { - const { linearClient } = getLinearClient(); +type Input = { + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async ({ workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const user = await linearClient.client.rawRequest(` query { diff --git a/extensions/linear/src/tools/get-document-content.ts b/extensions/linear/src/tools/get-document-content.ts index 2c20654fae0..eceac53fba4 100644 --- a/extensions/linear/src/tools/get-document-content.ts +++ b/extensions/linear/src/tools/get-document-content.ts @@ -1,9 +1,9 @@ -import { Document } from "@linear/sdk"; -import { withAccessToken } from "@raycast/utils"; +import { Document, LinearClient } from "@linear/sdk"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; import { DocumentResult } from "./get-documents"; +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; export type DocumentWithContent = Pick & DocumentResult; @@ -35,8 +35,8 @@ const docFragment = ` } `; -export async function getDocumentContent(documentId: string) { - const { graphQLClient } = getLinearClient(); +export async function getDocumentContent(documentId: string, client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest< { documents: { nodes: DocumentWithContent[] } }, @@ -58,9 +58,15 @@ export async function getDocumentContent(documentId: string) { return data?.documents.nodes?.[0]; } -export default withAccessToken(linear)(async (inputs: { +type Input = { /** The ID of the document/PRD to fetch */ documentId: string; -}) => { - return await getDocumentContent(inputs.documentId); + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async (inputs: Input) => { + const client = await resolveToolClient(inputs.workspaceId); + return await getDocumentContent(inputs.documentId, client); }); diff --git a/extensions/linear/src/tools/get-documents.ts b/extensions/linear/src/tools/get-documents.ts index 5d7e34eff40..035905bbd86 100644 --- a/extensions/linear/src/tools/get-documents.ts +++ b/extensions/linear/src/tools/get-documents.ts @@ -1,8 +1,9 @@ -import { Document, Initiative, Project, User } from "@linear/sdk"; -import { withAccessToken } from "@raycast/utils"; +import { Document, Initiative, LinearClient, Project, User } from "@linear/sdk"; import { sortBy } from "lodash"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; export type DocumentResult = Pick< Document, @@ -49,8 +50,12 @@ const docFragment = ` export type DocumentEntity = { projectId: string } | { initiativeId: string }; -export async function getDocuments(query: string = "", entity: DocumentEntity = { projectId: "" }) { - const { graphQLClient } = getLinearClient(); +export async function getDocuments( + query: string = "", + entity: DocumentEntity = { projectId: "" }, + client?: LinearClient, +) { + const { graphQLClient } = resolveClient(client); const searchProject = "projectId" in entity && entity.projectId.length > 0; const searchInitiative = "initiativeId" in entity && entity.initiativeId.length > 0; @@ -86,19 +91,25 @@ export async function getDocuments(query: string = "", entity: DocumentEntity = return { docs, hasMoreDocs }; } -export default withAccessToken(linear)(async (inputs: { +type Input = { /** Search query to filter documents */ query?: string; /** Restrict the documents/PRDs returned to a specific initiative */ initiativeId?: string; /** Restrict the documents/PRDs returned to a specific project */ projectId?: string; -}) => { + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async (inputs: Input) => { + const client = await resolveToolClient(inputs.workspaceId); let entity: DocumentEntity = { projectId: "" }; if (inputs.projectId) { entity = { projectId: inputs.projectId }; } else if (inputs.initiativeId) { entity = { initiativeId: inputs.initiativeId }; } - return (await getDocuments(inputs.query || undefined, entity)).docs; + return (await getDocuments(inputs.query || undefined, entity, client)).docs; }); diff --git a/extensions/linear/src/tools/get-initiatives.ts b/extensions/linear/src/tools/get-initiatives.ts index 7c2aea54554..20fe87aef16 100644 --- a/extensions/linear/src/tools/get-initiatives.ts +++ b/extensions/linear/src/tools/get-initiatives.ts @@ -1,8 +1,9 @@ -import { Initiative, Project } from "@linear/sdk"; -import { withAccessToken } from "@raycast/utils"; +import { Initiative, LinearClient, Project } from "@linear/sdk"; import { sortBy } from "lodash"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; export type InitiativeResult = Pick & { projects?: { nodes: Pick[] }; @@ -24,8 +25,8 @@ const initiativeFragment = ` } `; -export async function getInitiatives() { - const { graphQLClient } = getLinearClient(); +export async function getInitiatives(client?: LinearClient) { + const { graphQLClient } = resolveClient(client); const { data } = await graphQLClient.rawRequest>( ` query { @@ -41,6 +42,12 @@ export async function getInitiatives() { return sortBy(data?.initiatives.nodes ?? [], (i) => i.sortOrder ?? Infinity); } -export default withAccessToken(linear)(async () => { - return await getInitiatives(); +type Input = { + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async ({ workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + return await getInitiatives(client); }); diff --git a/extensions/linear/src/tools/get-issue-states.ts b/extensions/linear/src/tools/get-issue-states.ts index de80c9b5572..4340a0dcb91 100644 --- a/extensions/linear/src/tools/get-issue-states.ts +++ b/extensions/linear/src/tools/get-issue-states.ts @@ -1,40 +1,48 @@ import { WorkflowState } from "@linear/sdk"; -import { withAccessToken } from "@raycast/utils"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; export type StatusResult = Pick; -export default withAccessToken(linear)(async ({ - teamId, -}: { - /** The ID of the team to fetch the statuses for. Do not ask user to specify team if there is only one in the list */ - teamId: string; -}) => { - const { linearClient } = getLinearClient(); - - const allStates: StatusResult[] = []; - let hasNextPage = true; - let endCursor = null; - - while (hasNextPage) { - const states = await linearClient.workflowStates({ - filter: { team: { id: { eq: teamId } } }, - after: endCursor, - first: 100, - }); - allStates.push( - ...states.nodes.map((state) => ({ - id: state.id, - name: state.name, - description: state.description, - position: state.position, - type: state.type, - })), - ); - hasNextPage = states.pageInfo.hasNextPage; - endCursor = states.pageInfo.endCursor; - } - - return allStates; -}); +export default withToolAuth( + async ({ + teamId, + workspaceId, + }: { + /** The ID of the team to fetch the statuses for. Do not ask user to specify team if there is only one in the list */ + teamId: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; + }) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); + + const allStates: StatusResult[] = []; + let hasNextPage = true; + let endCursor = null; + + while (hasNextPage) { + const states = await linearClient.workflowStates({ + filter: { team: { id: { eq: teamId } } }, + after: endCursor, + first: 100, + }); + allStates.push( + ...states.nodes.map((state) => ({ + id: state.id, + name: state.name, + description: state.description, + position: state.position, + type: state.type, + })), + ); + hasNextPage = states.pageInfo.hasNextPage; + endCursor = states.pageInfo.endCursor; + } + + return allStates; + }, +); diff --git a/extensions/linear/src/tools/get-labels.ts b/extensions/linear/src/tools/get-labels.ts index 10d70561eac..d29c6621780 100644 --- a/extensions/linear/src/tools/get-labels.ts +++ b/extensions/linear/src/tools/get-labels.ts @@ -1,12 +1,19 @@ import { IssueLabel } from "@linear/sdk"; -import { withAccessToken } from "@raycast/utils"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; export type LabelResult = Pick; -export default withAccessToken(linear)(async () => { - const { linearClient } = getLinearClient(); +type Input = { + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async ({ workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const allLabels: LabelResult[] = []; let hasNextPage = true; diff --git a/extensions/linear/src/tools/get-members.ts b/extensions/linear/src/tools/get-members.ts index 9676998f071..0ed52ce0fb9 100644 --- a/extensions/linear/src/tools/get-members.ts +++ b/extensions/linear/src/tools/get-members.ts @@ -1,12 +1,19 @@ import { User } from "@linear/sdk"; -import { withAccessToken } from "@raycast/utils"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; export type MemberResult = Pick; -export default withAccessToken(linear)(async () => { - const { linearClient } = getLinearClient(); +type Input = { + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async ({ workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const allMembers: MemberResult[] = []; let hasNextPage = true; diff --git a/extensions/linear/src/tools/get-notifications.ts b/extensions/linear/src/tools/get-notifications.ts index de9db05bb8c..a0099c3867f 100644 --- a/extensions/linear/src/tools/get-notifications.ts +++ b/extensions/linear/src/tools/get-notifications.ts @@ -1,10 +1,15 @@ -import { withAccessToken } from "@raycast/utils"; - import { getNotifications } from "../api/getNotifications"; -import { linear } from "../api/linearClient"; -export default withAccessToken(linear)(async () => { - const { notifications } = await getNotifications(); +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; + +type Input = { + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async ({ workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + const { notifications } = await getNotifications(client); return notifications; }); diff --git a/extensions/linear/src/tools/get-project-statuses.ts b/extensions/linear/src/tools/get-project-statuses.ts index bf12482d6ed..752b19c8e1f 100644 --- a/extensions/linear/src/tools/get-project-statuses.ts +++ b/extensions/linear/src/tools/get-project-statuses.ts @@ -1,9 +1,15 @@ -import { withAccessToken } from "@raycast/utils"; +import { resolveClient } from "../api/linearClient"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; -export default withAccessToken(linear)(async () => { - const { linearClient } = getLinearClient(); +type Input = { + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async ({ workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const statuses = []; const projectStatuses = await linearClient.projectStatuses(); diff --git a/extensions/linear/src/tools/get-project-updates.ts b/extensions/linear/src/tools/get-project-updates.ts index f99153161e0..89412e65635 100644 --- a/extensions/linear/src/tools/get-project-updates.ts +++ b/extensions/linear/src/tools/get-project-updates.ts @@ -1,42 +1,50 @@ import { ProjectUpdate } from "@linear/sdk"; -import { withAccessToken } from "@raycast/utils"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; export type ProjectUpdateResult = Pick; -export default withAccessToken(linear)(async ({ - projectId, -}: { - /** The ID of the project to fetch the updates for. Use the 'get-projects' tool to get the project ID. */ - projectId: string; -}) => { - const { linearClient } = getLinearClient(); - - const allUpdates: ProjectUpdateResult[] = []; - let hasNextPage = true; - let endCursor = null; - - while (hasNextPage) { - const projectUpdates = await linearClient.projectUpdates({ - filter: { - project: { id: { eq: projectId } }, - }, - after: endCursor, - first: 100, // Fetch 100 updates at a time - }); - allUpdates.push( - ...projectUpdates.nodes.map((update) => ({ - id: update.id, - health: update.health, - body: update.body, - updatedAt: update.updatedAt, - url: update.url, - })), - ); - hasNextPage = projectUpdates.pageInfo.hasNextPage; - endCursor = projectUpdates.pageInfo.endCursor; - } - - return allUpdates; -}); +export default withToolAuth( + async ({ + projectId, + workspaceId, + }: { + /** The ID of the project to fetch the updates for. Use the 'get-projects' tool to get the project ID. */ + projectId: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; + }) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); + + const allUpdates: ProjectUpdateResult[] = []; + let hasNextPage = true; + let endCursor = null; + + while (hasNextPage) { + const projectUpdates = await linearClient.projectUpdates({ + filter: { + project: { id: { eq: projectId } }, + }, + after: endCursor, + first: 100, // Fetch 100 updates at a time + }); + allUpdates.push( + ...projectUpdates.nodes.map((update) => ({ + id: update.id, + health: update.health, + body: update.body, + updatedAt: update.updatedAt, + url: update.url, + })), + ); + hasNextPage = projectUpdates.pageInfo.hasNextPage; + endCursor = projectUpdates.pageInfo.endCursor; + } + + return allUpdates; + }, +); diff --git a/extensions/linear/src/tools/get-projects.ts b/extensions/linear/src/tools/get-projects.ts index 529ccd179e2..2eb4cf5853a 100644 --- a/extensions/linear/src/tools/get-projects.ts +++ b/extensions/linear/src/tools/get-projects.ts @@ -1,15 +1,22 @@ import { Project } from "@linear/sdk"; -import { withAccessToken } from "@raycast/utils"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; export type ProjectResult = Pick< Project, "id" | "description" | "name" | "content" | "progress" | "targetDate" | "startDate" >; -export default withAccessToken(linear)(async () => { - const { linearClient } = getLinearClient(); +type Input = { + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async ({ workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const allProjects: ProjectResult[] = []; let hasNextPage = true; diff --git a/extensions/linear/src/tools/get-teams.ts b/extensions/linear/src/tools/get-teams.ts index 5ad85bf643f..b180ab7e644 100644 --- a/extensions/linear/src/tools/get-teams.ts +++ b/extensions/linear/src/tools/get-teams.ts @@ -1,11 +1,17 @@ import { Team } from "@linear/sdk"; -import { withAccessToken } from "@raycast/utils"; import { getTeams } from "../api/getTeams"; -import { linear } from "../api/linearClient"; + +import { resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; export type TeamResult = Pick; -export default withAccessToken(linear)(async () => { - return getTeams(); +type Input = { + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; +}; + +export default withToolAuth(async ({ workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + return getTeams(undefined, client); }); diff --git a/extensions/linear/src/tools/get-workspaces.ts b/extensions/linear/src/tools/get-workspaces.ts new file mode 100644 index 00000000000..c5b74445d8f --- /dev/null +++ b/extensions/linear/src/tools/get-workspaces.ts @@ -0,0 +1,15 @@ +import { entryKey, getActiveEntry, migrateIfNeeded } from "../api/workspaces"; + +// Registry-only read: works even when a workspace's token is dead, and never +// triggers an OAuth flow — so it is deliberately NOT wrapped in withWorkspaceAuth. +export default async function () { + const registry = await migrateIfNeeded({ allowWrite: false }); + const active = getActiveEntry(registry); + return registry.workspaces.map((w) => ({ + workspaceId: entryKey(w), + name: w.orgName, + urlKey: w.urlKey, + userEmail: w.userEmail, + isActive: active !== null && entryKey(w) === entryKey(active), + })); +} diff --git a/extensions/linear/src/tools/remove-label.ts b/extensions/linear/src/tools/remove-label.ts index 841ff975d42..e025214afbb 100644 --- a/extensions/linear/src/tools/remove-label.ts +++ b/extensions/linear/src/tools/remove-label.ts @@ -1,7 +1,8 @@ import { Action } from "@raycast/api"; -import { withAccessToken } from "@raycast/utils"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** The ID of the issue to add the label to. Format is a combination of a team key and a unique number, like `ENG-123` */ @@ -9,10 +10,14 @@ type Input = { /** The ID of the label to add to the issue. Never use title as ID: you have to use `get-labels` tool to get the actual ID from the list of labels */ labelId: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async ({ issueId, labelId }: Input) => { - const { linearClient } = getLinearClient(); +export default withToolAuth(async ({ issueId, labelId, workspaceId }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const issue = await linearClient.issue(issueId); const currentLabelIds = issue.labelIds || []; const updatedLabelIds = currentLabelIds.filter((id) => id !== labelId); @@ -27,8 +32,10 @@ export default withAccessToken(linear)(async ({ issueId, labelId }: Input) => { return result.issue; }); -export const confirmation = withAccessToken(linear)(async ({ issueId, labelId }: Input) => { - const { linearClient } = getLinearClient(); +export const confirmation = withToolAuth(async ({ issueId, labelId, workspaceId }: Input) => { + const workspaceName = await describeToolWorkspace(workspaceId); + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const label = await linearClient.issueLabel(labelId); const issue = await linearClient.issue(issueId); @@ -36,6 +43,7 @@ export const confirmation = withAccessToken(linear)(async ({ issueId, labelId }: return { style: Action.Style.Destructive, info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), { name: "Issue", value: issue.title }, { name: "Label", value: label.name }, ], diff --git a/extensions/linear/src/tools/resolveToolWorkspace.ts b/extensions/linear/src/tools/resolveToolWorkspace.ts new file mode 100644 index 00000000000..a2c77aed6b2 --- /dev/null +++ b/extensions/linear/src/tools/resolveToolWorkspace.ts @@ -0,0 +1,69 @@ +import { LinearClient } from "@linear/sdk"; + +import { bootstrapWorkspaceAuth, getLinearClientFor } from "../api/linearClient"; +import { entryKey, getActiveEntry, migrateIfNeeded } from "../api/workspaces"; + +function parseWorkspaceId(workspaceId: string): { orgId: string; userId: string } { + const separator = workspaceId.indexOf(":"); + if (separator <= 0 || separator === workspaceId.length - 1) { + throw new Error( + `Invalid workspaceId "${workspaceId}". Use a workspaceId value returned by the get-workspaces tool.`, + ); + } + return { orgId: workspaceId.slice(0, separator), userId: workspaceId.slice(separator + 1) }; +} + +// AI-tool wrapper (replaces withWorkspaceAuth for tools — §4.3: AI tools NEVER trigger +// an OAuth browser flow). Bootstraps the active workspace non-interactively so the sync +// fast path serves workspaceId-less calls; when the call names its target explicitly, +// a dead ACTIVE workspace must not block it (resolveToolClient resolves the target +// independently), so bootstrap failure is tolerated in that case. +export function withToolAuth( + fn: (inputs: T) => Promise, +): (inputs: T) => Promise { + return async (inputs: T) => { + try { + await bootstrapWorkspaceAuth({ interactive: false }); + } catch { + if (!inputs?.workspaceId) { + throw new Error( + "No authenticated Linear workspace is available. Ask the user to open the Manage Workspaces command in Raycast to sign in (or re-authenticate), or pass a workspaceId from get-workspaces to act in another workspace.", + ); + } + } + // Defensive: previously zero-arg tools now destructure inputs; guarantee an object. + return fn(inputs ?? ({} as T)); + }; +} + +// Per-call workspace routing (D6/S8): every AI tool call is a fresh process, so the +// workspaceId input is the ONLY way to route a call. Absent → active workspace. +export async function resolveToolClient(workspaceId?: string): Promise { + if (!workspaceId) return undefined; + const ref = parseWorkspaceId(workspaceId); + const registry = await migrateIfNeeded({ allowWrite: false }); + if (!registry.workspaces.some((w) => entryKey(w) === workspaceId)) { + // Unknown/stale id is a ROUTING error, not an auth error — tell the model to re-list. + throw new Error( + `No connected workspace matches workspaceId "${workspaceId}". Call get-workspaces again and use one of its returned values.`, + ); + } + try { + // interactive: false — AI tools never open a browser flow (§4.3). + const { linearClient } = await getLinearClientFor(ref, { interactive: false }); + return linearClient; + } catch { + throw new Error( + `The workspace for "${workspaceId}" needs re-authentication. Ask the user to open the Manage Workspaces command in Raycast.`, + ); + } +} + +export async function describeToolWorkspace(workspaceId?: string): Promise { + const registry = await migrateIfNeeded({ allowWrite: false }); + if (!workspaceId && registry.workspaces.length < 2) return undefined; // single workspace: keep confirmations unchanged (T1) + const target = workspaceId ? registry.workspaces.find((w) => entryKey(w) === workspaceId) : getActiveEntry(registry); + if (!target) return workspaceId; // unknown id: show it verbatim so the user can spot the problem + const duplicated = registry.workspaces.filter((w) => w.orgId === target.orgId).length > 1; + return duplicated ? `${target.orgName} (${target.userEmail})` : target.orgName; +} diff --git a/extensions/linear/src/tools/update-comment.ts b/extensions/linear/src/tools/update-comment.ts index a11556c5a2e..289ccdf0a37 100644 --- a/extensions/linear/src/tools/update-comment.ts +++ b/extensions/linear/src/tools/update-comment.ts @@ -1,9 +1,9 @@ import path from "path"; -import { withAccessToken } from "@raycast/utils"; - import { appendFileAttachments } from "../api/attachments"; -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; + +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** The comment content in markdown format */ @@ -14,11 +14,15 @@ type Input = { /** The ID of the comment to update */ id: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async (inputs: Input) => { - const { linearClient } = getLinearClient(); - const body = await appendFileAttachments(inputs.body, inputs.attachmentPaths); +export default withToolAuth(async (inputs: Input) => { + const client = await resolveToolClient(inputs.workspaceId); + const { linearClient } = resolveClient(client); + const body = await appendFileAttachments(inputs.body, inputs.attachmentPaths, client); const result = await linearClient.updateComment(inputs.id, { body }); if (!result.success) { @@ -27,14 +31,17 @@ export default withAccessToken(linear)(async (inputs: Input) => { return result.comment; }); -export const confirmation = withAccessToken(linear)(async ({ id, body, attachmentPaths }: Input) => { - const { linearClient } = getLinearClient(); +export const confirmation = withToolAuth(async ({ id, body, attachmentPaths, workspaceId }: Input) => { + const workspaceName = await describeToolWorkspace(workspaceId); + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const comment = await linearClient.comment({ id }); return { message: `Are you sure you want to update the [comment](${comment.url})?`, info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), { name: "Comment", value: body }, ...(attachmentPaths?.length ? [{ name: "Attachments", value: attachmentPaths.map((filePath) => path.basename(filePath)).join(", ") }] diff --git a/extensions/linear/src/tools/update-document.ts b/extensions/linear/src/tools/update-document.ts index 327de5715af..a69fa8d50c7 100644 --- a/extensions/linear/src/tools/update-document.ts +++ b/extensions/linear/src/tools/update-document.ts @@ -1,6 +1,6 @@ -import { withAccessToken } from "@raycast/utils"; +import { resolveClient } from "../api/linearClient"; -import { getLinearClient, linear } from "../api/linearClient"; +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** The ID of the document/PRD to update */ @@ -14,10 +14,14 @@ type Input = { /** The ID of the project the document/PRD belongs to */ projectId?: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async (inputs: Input) => { - const { linearClient } = getLinearClient(); +export default withToolAuth(async (inputs: Input) => { + const client = await resolveToolClient(inputs.workspaceId); + const { linearClient } = resolveClient(client); const result = await linearClient.updateDocument(inputs.documentId, { content: inputs.content, projectId: inputs.projectId, @@ -31,13 +35,18 @@ export default withAccessToken(linear)(async (inputs: Input) => { return result.document; }); -export const confirmation = withAccessToken(linear)(async ({ documentId }: Input) => { - const { linearClient } = getLinearClient(); +export const confirmation = withToolAuth(async ({ documentId, workspaceId }: Input) => { + const workspaceName = await describeToolWorkspace(workspaceId); + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const document = await linearClient.document(documentId); return { message: `Are you sure you want to update the [document](${document.url})?`, - info: [{ name: "Title", value: document.title }], + info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), + { name: "Title", value: document.title }, + ], }; }); diff --git a/extensions/linear/src/tools/update-issue.ts b/extensions/linear/src/tools/update-issue.ts index 19c38554b90..44fb361a39f 100644 --- a/extensions/linear/src/tools/update-issue.ts +++ b/extensions/linear/src/tools/update-issue.ts @@ -1,9 +1,8 @@ -import { withAccessToken } from "@raycast/utils"; - -import { getLinearClient, linear } from "../api/linearClient"; +import { resolveClient } from "../api/linearClient"; import { updateIssue } from "../api/updateIssue"; import { formatConfirmation } from "./formatConfirmation"; +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** @@ -45,11 +44,16 @@ type Input = { /** A detailed description of the issue */ description?: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async (inputs: Input) => { - const { issueId, ...update } = inputs; - const result = await updateIssue(issueId, update); +export default withToolAuth(async (inputs: Input) => { + const client = await resolveToolClient(inputs.workspaceId); + const { issueId, workspaceId, ...update } = inputs; + void workspaceId; // destructured only to exclude it from `update` + const result = await updateIssue(issueId, update, client); if (!result.success) { throw new Error("Failed to update issue"); @@ -58,21 +62,28 @@ export default withAccessToken(linear)(async (inputs: Input) => { return result; }); -export const confirmation = withAccessToken(linear)(async (inputs: Input) => { - const { issueId, ...fieldsToUpdate } = inputs; - const { linearClient } = getLinearClient(); +export const confirmation = withToolAuth(async (inputs: Input) => { + const workspaceName = await describeToolWorkspace(inputs.workspaceId); + const client = await resolveToolClient(inputs.workspaceId); + const { issueId, workspaceId, ...fieldsToUpdate } = inputs; + void workspaceId; // destructured only to exclude it from `fieldsToUpdate` + const { linearClient } = resolveClient(client); const issue = await linearClient.issue(issueId); const details = await Promise.all( Object.keys(fieldsToUpdate).map((key) => { const name = key as keyof typeof fieldsToUpdate; - return formatConfirmation({ name, value: fieldsToUpdate[name] }); + return formatConfirmation({ name, value: fieldsToUpdate[name], client }); }), ); return { message: `Are you sure you want to update the [issue](${issue.url})?`, - info: [{ name: "Issue", value: issue.title }, ...details], + info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), + { name: "Issue", value: issue.title }, + ...details, + ], }; }); diff --git a/extensions/linear/src/tools/update-project-milestone.ts b/extensions/linear/src/tools/update-project-milestone.ts index 2aeba80d88f..23f60843364 100644 --- a/extensions/linear/src/tools/update-project-milestone.ts +++ b/extensions/linear/src/tools/update-project-milestone.ts @@ -1,6 +1,6 @@ -import { withAccessToken } from "@raycast/utils"; +import { resolveClient } from "../api/linearClient"; -import { getLinearClient, linear } from "../api/linearClient"; +import { describeToolWorkspace, resolveToolClient, withToolAuth } from "./resolveToolWorkspace"; type Input = { /** The ID of the project update to modify. */ @@ -14,10 +14,14 @@ type Input = { /** The new target date of the milestone in ISO date format (e.g., '2023-12-31') */ targetDate?: string; + + /** The workspace to act in: a workspaceId value returned by the get-workspaces tool. Omit to use the active workspace. */ + workspaceId?: string; }; -export default withAccessToken(linear)(async ({ milestoneId, ...inputs }: Input) => { - const { linearClient } = getLinearClient(); +export default withToolAuth(async ({ milestoneId, workspaceId, ...inputs }: Input) => { + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const result = await linearClient.updateProjectMilestone(milestoneId, inputs); if (!result.success) { @@ -27,12 +31,17 @@ export default withAccessToken(linear)(async ({ milestoneId, ...inputs }: Input) return JSON.stringify(result.projectMilestone); }); -export const confirmation = withAccessToken(linear)(async ({ milestoneId }: Input) => { - const { linearClient } = getLinearClient(); +export const confirmation = withToolAuth(async ({ milestoneId, workspaceId }: Input) => { + const workspaceName = await describeToolWorkspace(workspaceId); + const client = await resolveToolClient(workspaceId); + const { linearClient } = resolveClient(client); const milestone = await linearClient.projectMilestone(milestoneId); return { - info: [{ name: "Name", value: milestone.name }], + info: [ + ...(workspaceName ? [{ name: "Workspace", value: workspaceName }] : []), + { name: "Name", value: milestone.name }, + ], }; }); diff --git a/extensions/linear/src/unread-notifications.tsx b/extensions/linear/src/unread-notifications.tsx index 7d4b7e35786..c68549f8506 100644 --- a/extensions/linear/src/unread-notifications.tsx +++ b/extensions/linear/src/unread-notifications.tsx @@ -13,143 +13,156 @@ import { import React from "react"; import { NotificationResult } from "./api/getNotifications"; +import { getLinearClientFor } from "./api/linearClient"; import { updateNotification } from "./api/updateNotification"; -import View from "./components/View"; +import { entryKey } from "./api/workspaces"; import { getNotificationMenuBarTitle, getNotificationURL } from "./helpers/notifications"; import { getUserIcon } from "./helpers/users"; -import useNotifications from "./hooks/useNotifications"; +import useAllWorkspaceNotifications, { WorkspaceNotificationRow } from "./hooks/useAllWorkspaceNotifications"; const preferences = getPreferenceValues(); function UnreadNotifications() { - const { isLoadingNotifications, unreadNotifications, urlKey, mutateNotifications } = useNotifications(); - - async function markNotificationAsRead(notification: NotificationResult) { - await mutateNotifications(updateNotification({ id: notification.id, readAt: new Date() }), { - optimisticUpdate(data) { - if (!data) { - return data; - } - return { - ...data, - notifications: data?.notifications?.map((x) => (x.id === notification.id ? { ...x, readAt: new Date() } : x)), - }; - }, - shouldRevalidateAfter: true, - }); + const { rows, isLoading, mutate } = useAllWorkspaceNotifications(); + + const unreadOf = (row: WorkspaceNotificationRow) => + row.notifications.filter((n) => !n.readAt && (!n.snoozedUntilAt || n.snoozedUntilAt < new Date())); + + const totalUnread = rows.flatMap(unreadOf); // count sums reachable entries only (P4) + + const truncate = (text: string, maxLength: number) => { + const ellipsis = text.length > maxLength ? "…" : ""; + return text.substring(0, maxLength).trim() + ellipsis; + }; + + async function markAsRead(row: WorkspaceNotificationRow, notification: NotificationResult) { + if (row.status !== "ok") return; + try { + // Resolved at ACTION TIME, never stored on the row (C1): a cached client would + // serialize its bearer token to Raycast's unencrypted cache. + const { linearClient } = await getLinearClientFor( + { orgId: row.entry.orgId, userId: row.entry.userId }, + { interactive: false }, + ); + await updateNotification({ id: notification.id, readAt: new Date() }, linearClient); // that section's client — never the global one + } catch { + // Background client resolution failed (expired/revoked token, transient network + // error). No toast — this is an unattended menu-bar context — but silently no-op'ing + // would hide the failure entirely; refresh instead so the row surfaces as + // "needs re-authentication" (or clears itself on a transient blip). + } finally { + await mutate(); + } } - async function openNotification(notification: NotificationResult) { - const applications = await getApplications(); - const linearApp = applications.find((app) => app.bundleId === "com.linear"); - const url = getNotificationURL(notification); - if (url) { - await open(url, linearApp); - } else { - await openInbox(); + async function markAllAsRead(row: WorkspaceNotificationRow) { + if (row.status !== "ok") return; + const readAt = new Date(); + try { + const { linearClient } = await getLinearClientFor( + { orgId: row.entry.orgId, userId: row.entry.userId }, + { interactive: false }, + ); + await Promise.all(unreadOf(row).map((n) => updateNotification({ id: n.id, readAt }, linearClient))); + } catch { + // See markAsRead: refresh on failure instead of silently no-op'ing. + } finally { + await mutate(); } - await markNotificationAsRead(notification); } - async function openInbox() { + async function openInbox(row: WorkspaceNotificationRow) { const applications = await getApplications(); const linearApp = applications.find((app) => app.bundleId === "com.linear"); - await open(`https://linear.app/${urlKey}/inbox`, linearApp); + await open(`https://linear.app/${row.urlKey}/inbox`, linearApp); } - async function markAllAsRead() { - if (unreadNotifications.length === 0) { - return; - } - - const readAt = new Date(); - - await mutateNotifications( - Promise.all(unreadNotifications.map((notification) => updateNotification({ id: notification.id, readAt }))), - { - optimisticUpdate(data) { - if (!data) { - return data; - } - return { - ...data, - notifications: data?.notifications?.map((x) => (x.readAt ? x : { ...x, readAt })), - }; - }, - shouldRevalidateAfter: true, - }, - ); + async function openNotification(row: WorkspaceNotificationRow, notification: NotificationResult) { + const applications = await getApplications(); + const linearApp = applications.find((app) => app.bundleId === "com.linear"); + // WS-31: the notification's own workspace URL — API-provided absolute url first, + // inbox fallback built from the ROW's urlKey, never the active workspace's. + const url = getNotificationURL(notification); + await open(url ?? `https://linear.app/${row.urlKey}/inbox`, linearApp); + await markAsRead(row, notification); } - const truncate = (text: string, maxLength: number) => { - const ellipsis = text.length > maxLength ? "…" : ""; - return text.substring(0, maxLength).trim() + ellipsis; - }; + if (!preferences.alwaysShow && !isLoading && totalUnread.length === 0) return null; - if (!preferences.alwaysShow && !isLoadingNotifications && unreadNotifications && unreadNotifications.length === 0) { - return null; - } + const multi = rows.length >= 2; return ( - - - {unreadNotifications.length > 0 ? ( - - ) : null} - - - - - - {unreadNotifications.map((notification) => { - // Use Linear API's title and subtitle fields for consistent notification display - const title = truncate(notification.subtitle, 30); - const icon = notification.actor ? getUserIcon(notification.actor) : "linear-app-icon.png"; - const subtitle = truncate(notification.title, 20); - const tooltip = `${notification.subtitle}: ${notification.title}`; - - return ( + {rows.map((row) => ( + r.entry.orgId === row.entry.orgId).length > 1 ? ` (${row.entry.userEmail})` : ""), + } + : {})} + > + {row.status === "needs-reauth" ? ( openNotification(notification)} - alternate={ + title={`Re-authenticate ${row.entry.orgName}`} + icon={Icon.Key} + onAction={() => launchCommand({ name: "manage-workspaces", type: LaunchType.UserInitiated })} + /> + ) : ( + <> + openInbox(row)} + /> + {unreadOf(row).length > 0 ? ( markNotificationAsRead(notification)} + title="Mark All as Read" + icon={Icon.CheckCircle} + shortcut={{ + macOS: { modifiers: ["cmd", "shift"], key: "u" }, + Windows: { modifiers: ["ctrl", "shift"], key: "u" }, + }} + onAction={() => markAllAsRead(row)} /> - } - /> - ); - })} - - + ) : null} + {unreadOf(row).map((notification) => { + const title = truncate(notification.subtitle, 30); + const icon = notification.actor ? getUserIcon(notification.actor) : "linear-app-icon.png"; + const subtitle = truncate(notification.title, 20); + const tooltip = `${notification.subtitle}: ${notification.title}`; + + return ( + openNotification(row, notification)} + alternate={ + markAsRead(row, notification)} + /> + } + /> + ); + })} + + )} + + ))} - - - + ); }