From da605179cf87a88c54ab0fd64af45605313516b3 Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Sun, 5 Jul 2026 19:56:05 +0530 Subject: [PATCH 01/16] feat(linear): import Linear issues into threads Adds a native Linear integration for the web app (issue #3703): - contracts: Linear issue/auth schemas + 5 WS RPCs - server: LinearApi Effect service (GraphQL over HttpClient), PAT stored via ServerSecretStore with T3CODE_LINEAR_API_TOKEN env fallback - client-runtime/web: Linear atom family + state wiring - Settings -> Linear page to connect/disconnect a personal API key - per-folder Linear icon in the sidebar opening a browse/search/select popover with combine/subtasks toggle - import flow: fetch issues -> format markdown -> new draft thread -> pre-fill composer (formatLinearIssues, unit tested) - docs/integrations/linear.md --- apps/server/src/linear/LinearApi.ts | 564 ++++++++++++++++++ apps/server/src/ws.ts | 30 + apps/web/src/components/Icons.tsx | 9 + .../src/components/LinearBrowsePopover.tsx | 303 ++++++++++ apps/web/src/components/Sidebar.tsx | 8 + .../components/settings/LinearSettings.tsx | 147 +++++ .../settings/SettingsSidebarNav.tsx | 3 + apps/web/src/hooks/useLinearImport.ts | 82 +++ apps/web/src/lib/linearFormat.test.ts | 92 +++ apps/web/src/lib/linearFormat.ts | 118 ++++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.linear.tsx | 7 + apps/web/src/state/linear.ts | 5 + docs/integrations/linear.md | 43 ++ packages/client-runtime/package.json | 4 + packages/client-runtime/src/state/linear.ts | 40 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/linear.ts | 167 ++++++ packages/contracts/src/rpc.ts | 63 ++ 19 files changed, 1707 insertions(+) create mode 100644 apps/server/src/linear/LinearApi.ts create mode 100644 apps/web/src/components/LinearBrowsePopover.tsx create mode 100644 apps/web/src/components/settings/LinearSettings.tsx create mode 100644 apps/web/src/hooks/useLinearImport.ts create mode 100644 apps/web/src/lib/linearFormat.test.ts create mode 100644 apps/web/src/lib/linearFormat.ts create mode 100644 apps/web/src/routes/settings.linear.tsx create mode 100644 apps/web/src/state/linear.ts create mode 100644 docs/integrations/linear.md create mode 100644 packages/client-runtime/src/state/linear.ts create mode 100644 packages/contracts/src/linear.ts diff --git a/apps/server/src/linear/LinearApi.ts b/apps/server/src/linear/LinearApi.ts new file mode 100644 index 00000000000..21469edb779 --- /dev/null +++ b/apps/server/src/linear/LinearApi.ts @@ -0,0 +1,564 @@ +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, + type LinearApiOperation, + type LinearAttachment, + type LinearAuthStatus, + type LinearComment, + type LinearIssueDetail, + type LinearIssueSummary, + type LinearLinkedPullRequest, + type LinearSubIssue, +} from "@t3tools/contracts"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +const DEFAULT_API_BASE_URL = "https://api.linear.app/graphql"; + +/** Secret-store key holding the Linear personal access token. */ +export const LINEAR_API_TOKEN_SECRET = "linear.api-token"; + +/** How many linked issues/comments/attachments to request per issue. */ +const ISSUE_RELATION_LIMIT = 50; + +const LinearApiEnvConfig = Config.all({ + baseUrl: Config.string("T3CODE_LINEAR_API_BASE_URL").pipe( + Config.withDefault(DEFAULT_API_BASE_URL), + ), + envToken: Config.string("T3CODE_LINEAR_API_TOKEN").pipe(Config.option), +}); + +// ── GraphQL response schemas (partial — excess keys are ignored) ───── + +const GraphQlErrorEntry = Schema.Struct({ + message: Schema.optional(Schema.String), +}); + +const NamedNode = Schema.Struct({ name: Schema.optional(Schema.String) }); + +const RawViewer = Schema.Struct({ + name: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), +}); + +const RawIssueSummary = Schema.Struct({ + id: Schema.String, + identifier: Schema.String, + title: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + priorityLabel: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(NamedNode)), + assignee: Schema.optional(Schema.NullOr(NamedNode)), + team: Schema.optional(Schema.NullOr(Schema.Struct({ key: Schema.optional(Schema.String) }))), +}); + +const RawIssueConnection = Schema.Struct({ + nodes: Schema.Array(RawIssueSummary), +}); + +const RawIssueDetail = Schema.Struct({ + id: Schema.String, + identifier: Schema.String, + title: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + description: Schema.optional(Schema.NullOr(Schema.String)), + priorityLabel: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(NamedNode)), + assignee: Schema.optional(Schema.NullOr(NamedNode)), + team: Schema.optional(Schema.NullOr(Schema.Struct({ key: Schema.optional(Schema.String) }))), + labels: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(NamedNode) }))), + children: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + identifier: Schema.String, + title: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(NamedNode)), + }), + ), + }), + ), + ), + attachments: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + title: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + sourceType: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + }), + ), + ), + comments: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + body: Schema.optional(Schema.String), + createdAt: Schema.optional(Schema.String), + user: Schema.optional(Schema.NullOr(NamedNode)), + }), + ), + }), + ), + ), +}); + +const viewerEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr(Schema.Struct({ viewer: Schema.optional(Schema.NullOr(RawViewer)) })), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const searchEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + searchIssues: Schema.optional(Schema.NullOr(RawIssueConnection)), + issues: Schema.optional(Schema.NullOr(RawIssueConnection)), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const issueEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr(Schema.Struct({ issue: Schema.optional(Schema.NullOr(RawIssueDetail)) })), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +// ── GraphQL documents ──────────────────────────────────────────────── + +const SUMMARY_FIELDS = `id identifier title url priorityLabel state { name } assignee { name } team { key }`; + +const SEARCH_DOCUMENT = `query T3CodeLinearSearch($term: String!, $first: Int!) { + searchIssues(term: $term, first: $first) { nodes { ${SUMMARY_FIELDS} } } +}`; + +const RECENT_DOCUMENT = `query T3CodeLinearRecent($first: Int!) { + issues(first: $first, orderBy: updatedAt) { nodes { ${SUMMARY_FIELDS} } } +}`; + +const ISSUE_DOCUMENT = `query T3CodeLinearIssue($id: String!, $relations: Int!) { + issue(id: $id) { + id identifier title url description priorityLabel + state { name } + assignee { name } + team { key } + labels(first: $relations) { nodes { name } } + children(first: $relations) { nodes { identifier title state { name } } } + attachments(first: $relations) { nodes { title url sourceType } } + comments(first: $relations) { nodes { body createdAt user { name } } } + } +}`; + +const VIEWER_DOCUMENT = `query T3CodeLinearViewer { viewer { name email } }`; + +// ── Helpers ────────────────────────────────────────────────────────── + +function clean(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined; +} + +function bytesToString(value: Uint8Array): string { + return new TextDecoder().decode(value); +} + +function stringToBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +const PULL_REQUEST_URL_PATTERN = + /(github\.com\/[^/]+\/[^/]+\/pull\/\d+)|(\/-\/merge_requests\/\d+)|(bitbucket\.org\/[^/]+\/[^/]+\/pull-requests\/\d+)/i; + +function isPullRequestAttachment(url: string | undefined): boolean { + // A Linear attachment is only treated as a linked PR when its URL matches a + // known pull/merge-request path. The `sourceType` (e.g. "github") is too + // broad on its own — it also covers commit and file links. + return url !== undefined && PULL_REQUEST_URL_PATTERN.test(url); +} + +function toSummary(raw: typeof RawIssueSummary.Type): LinearIssueSummary { + return { + id: raw.id, + identifier: raw.identifier, + title: clean(raw.title) ?? raw.identifier, + url: raw.url ?? "", + stateName: clean(raw.state?.name), + priorityLabel: clean(raw.priorityLabel), + assigneeName: clean(raw.assignee?.name), + teamKey: clean(raw.team?.key), + }; +} + +function toDetail(raw: typeof RawIssueDetail.Type): LinearIssueDetail { + const labels: Array = []; + for (const label of raw.labels?.nodes ?? []) { + const name = clean(label.name); + if (name !== undefined) labels.push(name); + } + + const subIssues: Array = (raw.children?.nodes ?? []).map((child) => ({ + identifier: child.identifier, + title: clean(child.title) ?? child.identifier, + stateName: clean(child.state?.name), + })); + + const attachments: Array = []; + const linkedPullRequests: Array = []; + for (const attachment of raw.attachments?.nodes ?? []) { + const url = clean(attachment.url); + if (url === undefined) continue; + const title = clean(attachment.title); + attachments.push({ url, title }); + if (isPullRequestAttachment(url)) { + linkedPullRequests.push({ url, title }); + } + } + + const comments: Array = (raw.comments?.nodes ?? []) + .map((comment) => ({ + author: clean(comment.user?.name), + body: comment.body ?? "", + createdAt: clean(comment.createdAt), + })) + .filter((comment) => comment.body.trim().length > 0); + + return { + id: raw.id, + identifier: raw.identifier, + title: clean(raw.title) ?? raw.identifier, + url: raw.url ?? "", + stateName: clean(raw.state?.name), + priorityLabel: clean(raw.priorityLabel), + assigneeName: clean(raw.assignee?.name), + teamKey: clean(raw.team?.key), + description: raw.description ?? "", + labels, + subIssues, + linkedPullRequests, + attachments, + comments, + }; +} + +function firstGraphQlErrorMessage( + errors: ReadonlyArray | undefined, +): string | undefined { + for (const entry of errors ?? []) { + const message = clean(entry.message); + if (message !== undefined) return message; + } + return undefined; +} + +function isAuthMessage(message: string | undefined): boolean { + if (message === undefined) return false; + const lowered = message.toLowerCase(); + return ( + lowered.includes("authentication") || + lowered.includes("authorization") || + lowered.includes("unauthorized") || + lowered.includes("api key") || + lowered.includes("access token") + ); +} + +// ── Service ────────────────────────────────────────────────────────── + +export class LinearApi extends Context.Service< + LinearApi, + { + readonly probeAuth: Effect.Effect; + readonly searchIssues: (input: { + readonly query: string; + readonly limit: number; + }) => Effect.Effect< + { readonly issues: ReadonlyArray; readonly truncated: boolean }, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly fetchIssues: (input: { + readonly ids: ReadonlyArray; + }) => Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly setToken: (token: string) => Effect.Effect; + readonly clearToken: Effect.Effect; + } +>()("t3/linear/LinearApi") {} + +export const make = Effect.gen(function* () { + const config = yield* LinearApiEnvConfig; + const httpClient = yield* HttpClient.HttpClient; + const secrets = yield* ServerSecretStore.ServerSecretStore; + + const resolveToken = ( + operation: LinearApiOperation, + ): Effect.Effect, LinearTokenStoreError> => + secrets.get(LINEAR_API_TOKEN_SECRET).pipe( + Effect.mapError( + (cause) => + new LinearTokenStoreError({ + operation, + detail: "Failed to read the stored Linear token.", + cause, + }), + ), + Effect.map( + Option.match({ + onSome: (bytes) => { + const token = clean(bytesToString(bytes)); + return token !== undefined ? Option.some(token) : config.envToken; + }, + onNone: () => config.envToken, + }), + ), + ); + + const requireToken = (operation: LinearApiOperation) => + resolveToken(operation).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new LinearAuthError({ + operation, + detail: "Connect Linear in Settings to continue.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + const runGraphql = ( + operation: LinearApiOperation, + token: string, + document: string, + variables: Record, + envelopeSchema: S, + ): Effect.Effect => { + const request = HttpClientRequest.post(config.baseUrl).pipe( + HttpClientRequest.setHeader("authorization", token), + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJsonUnsafe({ query: document, variables }), + ); + return httpClient.execute(request).pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation, + detail: "Failed to reach the Linear API.", + cause, + }), + ), + Effect.flatMap((response) => + HttpClientResponse.matchStatus({ + "2xx": (success) => + HttpClientResponse.schemaBodyJson(envelopeSchema)(success).pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation, + status: success.status, + detail: "Linear returned a response that could not be decoded.", + cause, + }), + ), + ), + orElse: (failed) => + failed.status === 401 || failed.status === 403 + ? Effect.fail( + new LinearAuthError({ + operation, + detail: "Linear rejected the API token.", + }), + ) + : Effect.fail( + new LinearRequestError({ + operation, + status: failed.status, + detail: `Linear returned HTTP ${failed.status}.`, + }), + ), + })(response), + ), + ); + }; + + const failFromGraphQlErrors = ( + operation: LinearApiOperation, + errors: ReadonlyArray | undefined, + ): Effect.Effect => { + const message = firstGraphQlErrorMessage(errors); + return isAuthMessage(message) + ? Effect.fail(new LinearAuthError({ operation, ...(message ? { detail: message } : {}) })) + : Effect.fail( + new LinearRequestError({ + operation, + detail: message ?? "Linear reported an error for the request.", + }), + ); + }; + + const probeAuth: LinearApi["Service"]["probeAuth"] = resolveToken("probeAuth").pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.succeed({ + status: "unauthenticated", + detail: "No Linear API token is configured.", + }), + onSome: (token) => + runGraphql("probeAuth", token, VIEWER_DOCUMENT, {}, viewerEnvelope).pipe( + Effect.map((envelope): LinearAuthStatus => { + if (envelope.errors !== undefined && envelope.errors.length > 0) { + return { + status: "unauthenticated", + detail: "The stored Linear token was rejected.", + }; + } + const viewer = envelope.data?.viewer ?? undefined; + const name = clean(viewer?.name); + return { + status: "authenticated", + account: { + name: name ?? "Linear account", + ...(clean(viewer?.email) ? { email: clean(viewer?.email) } : {}), + }, + }; + }), + Effect.orElseSucceed( + (): LinearAuthStatus => ({ + status: "unauthenticated", + detail: "The stored Linear token was rejected.", + }), + ), + ), + }), + ), + ); + + const searchIssues: LinearApi["Service"]["searchIssues"] = (input) => { + const term = input.query.trim(); + return requireToken("searchIssues").pipe( + Effect.flatMap((token) => + term.length === 0 + ? runGraphql( + "searchIssues", + token, + RECENT_DOCUMENT, + { first: input.limit }, + searchEnvelope, + ) + : runGraphql( + "searchIssues", + token, + SEARCH_DOCUMENT, + { term, first: input.limit }, + searchEnvelope, + ), + ), + Effect.flatMap((envelope) => { + if (envelope.errors !== undefined && envelope.errors.length > 0) { + return failFromGraphQlErrors("searchIssues", envelope.errors); + } + const connection = envelope.data?.searchIssues ?? envelope.data?.issues ?? null; + const nodes = connection?.nodes ?? []; + return Effect.succeed({ + issues: nodes.map(toSummary), + truncated: nodes.length >= input.limit, + }); + }), + ); + }; + + const fetchIssue = ( + token: string, + id: string, + ): Effect.Effect => + runGraphql( + "fetchIssues", + token, + ISSUE_DOCUMENT, + { id, relations: ISSUE_RELATION_LIMIT }, + issueEnvelope, + ).pipe( + Effect.flatMap((envelope) => { + if (envelope.errors !== undefined && envelope.errors.length > 0) { + return failFromGraphQlErrors("fetchIssues", envelope.errors); + } + const issue = envelope.data?.issue ?? null; + return Effect.succeed(issue === null ? null : toDetail(issue)); + }), + ); + + const fetchIssues: LinearApi["Service"]["fetchIssues"] = (input) => + requireToken("fetchIssues").pipe( + Effect.flatMap((token) => + Effect.forEach(input.ids, (id) => fetchIssue(token, id), { concurrency: 4 }), + ), + Effect.map((results) => + results.filter((issue): issue is LinearIssueDetail => issue !== null), + ), + ); + + const persistToken = ( + operation: LinearApiOperation, + token: string, + ): Effect.Effect => + secrets.set(LINEAR_API_TOKEN_SECRET, stringToBytes(token)).pipe( + Effect.mapError( + (cause) => + new LinearTokenStoreError({ + operation, + detail: "Failed to store the Linear token.", + cause, + }), + ), + ); + + const setToken: LinearApi["Service"]["setToken"] = (token) => + persistToken("setToken", token.trim()).pipe(Effect.flatMap(() => probeAuth)); + + const clearToken: LinearApi["Service"]["clearToken"] = secrets + .remove(LINEAR_API_TOKEN_SECRET) + .pipe( + Effect.mapError( + (cause) => + new LinearTokenStoreError({ + operation: "clearToken", + detail: "Failed to remove the Linear token.", + cause, + }), + ), + Effect.flatMap(() => probeAuth), + ); + + return LinearApi.of({ + probeAuth, + searchIssues, + fetchIssues, + setToken, + clearToken, + }); +}); + +export const layer = Layer.effect(LinearApi, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9020e99f670..45d9257caf9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -99,6 +99,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import * as LinearApi from "./linear/LinearApi.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; @@ -299,6 +300,11 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], + [WS_METHODS.linearAuthStatus, AuthOrchestrationReadScope], + [WS_METHODS.linearSearchIssues, AuthOrchestrationReadScope], + [WS_METHODS.linearFetchIssues, AuthOrchestrationReadScope], + [WS_METHODS.linearSetToken, AuthOrchestrationOperateScope], + [WS_METHODS.linearClearToken, AuthOrchestrationOperateScope], [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], @@ -419,6 +425,7 @@ const makeWsRpcLayer = ( const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const linear = yield* LinearApi.LinearApi; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map((settings) => settings.automaticGitFetchInterval), Effect.catch((cause) => @@ -1322,6 +1329,28 @@ const makeWsRpcLayer = ( "rpc.aggregate": "source-control", }, ), + [WS_METHODS.linearAuthStatus]: (_input) => + observeRpcEffect(WS_METHODS.linearAuthStatus, linear.probeAuth, { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearSearchIssues]: (input) => + observeRpcEffect(WS_METHODS.linearSearchIssues, linear.searchIssues(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearFetchIssues]: (input) => + observeRpcEffect( + WS_METHODS.linearFetchIssues, + linear.fetchIssues(input).pipe(Effect.map((issues) => ({ issues }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearSetToken]: (input) => + observeRpcEffect(WS_METHODS.linearSetToken, linear.setToken(input.token), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearClearToken]: (_input) => + observeRpcEffect(WS_METHODS.linearClearToken, linear.clearToken, { + "rpc.aggregate": "linear", + }), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, @@ -1815,6 +1844,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(LinearApi.layer), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..467833b701f 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -15,6 +15,15 @@ export const GitHubIcon: Icon = (props) => ( ); +export const LinearIcon: Icon = (props) => ( + + + +); + export const GitIcon: Icon = (props) => ( void; +}) { + return ( + + + + {issue.identifier} + + {issue.title} + + + {issue.stateName ? {issue.stateName} : null} + {issue.assigneeName ? · {issue.assigneeName} : null} + {issue.priorityLabel ? · {issue.priorityLabel} : null} + + + + ); +} + +export function LinearBrowsePopover({ + environmentId, + projectId, + projectName, +}: { + environmentId: EnvironmentId; + projectId: ProjectId; + projectName: string; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + const [selected, setSelected] = useState>(() => new Set()); + const [combine, setCombine] = useState(true); + const [importing, setImporting] = useState(false); + + const runImport = useLinearImport(); + + useEffect(() => { + const id = setTimeout(() => setDebounced(query), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(id); + }, [query]); + + const authQuery = useEnvironmentQuery( + open ? linearEnvironment.authStatus({ environmentId, input: {} }) : null, + ); + const connected = authQuery.data?.status === "authenticated"; + + const searchQuery = useEnvironmentQuery( + open && connected + ? linearEnvironment.searchIssues({ + environmentId, + input: { query: debounced, limit: SEARCH_LIMIT }, + }) + : null, + ); + + const issues = searchQuery.data?.issues ?? []; + const selectedCount = selected.size; + + const toggle = useCallback((id: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const reset = useCallback(() => { + setQuery(""); + setDebounced(""); + setSelected(new Set()); + setCombine(true); + }, []); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) reset(); + }, + [reset], + ); + + const handleImport = useCallback(async () => { + if (selectedCount === 0 || importing) return; + setImporting(true); + try { + const result = await runImport({ + target: { environmentId, projectId }, + ids: [...selected], + mode: combine ? "combine" : "subtasks", + }); + if (result.ok) { + handleOpenChange(false); + } else { + toastManager.add({ + type: "error", + title: "Linear import failed", + description: result.error ?? "The issues could not be imported.", + }); + } + } finally { + setImporting(false); + } + }, [ + combine, + environmentId, + handleOpenChange, + importing, + projectId, + runImport, + selected, + selectedCount, + ]); + + const body = useMemo(() => { + if (authQuery.isPending && authQuery.data === null) { + return ( +
+ Checking Linear connection… +
+ ); + } + if (!connected) { + return ( + +

Linear isn’t connected

+

+ Add a personal API key in Settings → Linear to import issues. +

+
+ ); + } + if (searchQuery.error !== null) { + return ( + +

Couldn’t load issues

+

{searchQuery.error}

+
+ ); + } + if (searchQuery.isPending && issues.length === 0) { + return ( +
+ Searching… +
+ ); + } + if (issues.length === 0) { + return ( + +

No issues found

+

+ {debounced.trim().length > 0 + ? "Try a different search term." + : "No recent issues were returned."} +

+
+ ); + } + return ( + +
+ {issues.map((issue) => ( + toggle(issue.id)} + /> + ))} +
+
+ ); + }, [ + authQuery.data, + authQuery.isPending, + connected, + debounced, + issues, + searchQuery.error, + searchQuery.isPending, + selected, + toggle, + ]); + + return ( + +
+ + + + + } + /> + } + /> + Import from Linear + +
+ +
+
+ + Import from Linear + {selectedCount > 0 ? ( + + {selectedCount} selected + + ) : null} +
+ + {connected ? ( +
+ + setQuery(event.target.value)} + placeholder="Search issues…" + className="h-8 pl-8 text-[13px]" + aria-label="Search Linear issues" + /> +
+ ) : null} + + {body} + + {connected ? ( +
+ + +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..cf627340e86 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { ThreadWorktreeIndicator, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; +import { LinearBrowsePopover } from "./LinearBrowsePopover"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -2283,6 +2284,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec )} + {project.memberProjects[0] ? ( + + ) : null} { + const trimmed = token.trim(); + if (environmentId === null || trimmed.length === 0 || busy) return; + setBusy(true); + try { + const result = await setToken({ environmentId, input: { token: trimmed } }); + if (result._tag === "Success" && result.value.status === "authenticated") { + setTokenValue(""); + authQuery.refresh(); + toastManager.add({ + type: "success", + title: "Linear connected", + description: result.value.account + ? `Connected as ${result.value.account.name}.` + : "Linear is now connected.", + }); + } else { + const detail = + result._tag === "Success" + ? (result.value.detail ?? "Linear rejected the token.") + : "The token could not be saved."; + toastManager.add({ type: "error", title: "Could not connect Linear", description: detail }); + } + } finally { + setBusy(false); + } + }, [authQuery, busy, environmentId, setToken, token]); + + const handleDisconnect = useCallback(async () => { + if (environmentId === null || busy) return; + setBusy(true); + try { + await clearToken({ environmentId, input: {} }); + authQuery.refresh(); + toastManager.add({ type: "success", title: "Linear disconnected" }); + } finally { + setBusy(false); + } + }, [authQuery, busy, clearToken, environmentId]); + + const statusBadge = authQuery.isPending ? ( + Checking… + ) : connected ? ( + Connected + ) : ( + Not connected + ); + + return ( + + }> + + {statusBadge} + {connected ? ( + + ) : null} + + } + > + {connected ? null : ( +
+
+ setTokenValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleSave(); + } + }} + /> + +
+

+ Create a personal API key in{" "} + + Linear → Settings → Security & access + + . The key is stored securely on the server and never shown again. +

+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..88ce4bdc977 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -7,6 +7,7 @@ import { KeyboardIcon, Link2Icon, Settings2Icon, + SquareKanbanIcon, } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; @@ -27,6 +28,7 @@ export type SettingsSectionPath = | "/settings/keybindings" | "/settings/providers" | "/settings/source-control" + | "/settings/linear" | "/settings/connections" | "/settings/archived"; @@ -39,6 +41,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, + { label: "Linear", to: "/settings/linear", icon: SquareKanbanIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/hooks/useLinearImport.ts b/apps/web/src/hooks/useLinearImport.ts new file mode 100644 index 00000000000..b095b847dac --- /dev/null +++ b/apps/web/src/hooks/useLinearImport.ts @@ -0,0 +1,82 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { formatLinearIssues, type LinearImportMode } from "../lib/linearFormat"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { useProjects } from "../state/entities"; +import { linearEnvironment } from "../state/linear"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useClientSettings } from "./useSettings"; +import { useNewThreadHandler } from "./useHandleNewThread"; + +export interface LinearImportTarget { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +} + +export interface LinearImportResult { + readonly ok: boolean; + readonly error?: string; +} + +/** + * Imports the selected Linear issues into a fresh draft thread for `target` + * and pre-fills the composer with the formatted issue context. The user then + * reviews and sends, which promotes the draft to a real thread. + */ +export function useLinearImport() { + const projects = useProjects(); + const groupingSettings = useClientSettings(selectProjectGroupingSettings); + const newThread = useNewThreadHandler(); + const fetchIssues = useAtomCommand(linearEnvironment.fetchIssues, "linear fetch issues"); + + return useCallback( + async (input: { + readonly target: LinearImportTarget; + readonly ids: ReadonlyArray; + readonly mode: LinearImportMode; + }): Promise => { + if (input.ids.length === 0) { + return { ok: false, error: "Select at least one issue to import." }; + } + + const projectRef = scopeProjectRef(input.target.environmentId, input.target.projectId); + const result = await fetchIssues({ + environmentId: input.target.environmentId, + input: { ids: [...input.ids] }, + }); + if (result._tag !== "Success") { + return { ok: false, error: "Failed to load the selected Linear issues." }; + } + const issues = result.value.issues; + if (issues.length === 0) { + return { ok: false, error: "The selected issues could not be loaded." }; + } + + const markdown = formatLinearIssues(issues, input.mode); + await newThread(projectRef); + + const project = projects.find( + (candidate) => + candidate.id === input.target.projectId && + candidate.environmentId === input.target.environmentId, + ); + const logicalProjectKey = project + ? deriveLogicalProjectKeyFromSettings(project, groupingSettings) + : scopedProjectKey(projectRef); + const draft = useComposerDraftStore + .getState() + .getDraftSessionByLogicalProjectKey(logicalProjectKey); + if (draft) { + useComposerDraftStore.getState().setPrompt(draft.draftId, markdown); + } + return { ok: true }; + }, + [fetchIssues, groupingSettings, newThread, projects], + ); +} diff --git a/apps/web/src/lib/linearFormat.test.ts b/apps/web/src/lib/linearFormat.test.ts new file mode 100644 index 00000000000..2ffb6c9eca9 --- /dev/null +++ b/apps/web/src/lib/linearFormat.test.ts @@ -0,0 +1,92 @@ +import type { LinearIssueDetail } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { formatLinearIssues } from "./linearFormat"; + +function makeIssue(overrides: Partial = {}): LinearIssueDetail { + return { + id: "id-1", + identifier: "ENG-1", + title: "Fix the thing", + url: "https://linear.app/acme/issue/ENG-1", + stateName: "In Progress", + priorityLabel: "High", + assigneeName: "Ada", + teamKey: "ENG", + description: "Do the work.\n\n- [ ] Step one\n- [ ] Step two", + labels: ["bug", "backend"], + subIssues: [{ identifier: "ENG-2", title: "Subtask", stateName: "Todo" }], + linkedPullRequests: [{ url: "https://github.com/acme/repo/pull/42", title: "PR 42" }], + attachments: [{ url: "https://example.com/design", title: "Design" }], + comments: [{ author: "Grace", body: "Looks good", createdAt: "2026-01-02" }], + ...overrides, + }; +} + +describe("formatLinearIssues", () => { + it("returns an empty string when there are no issues", () => { + expect(formatLinearIssues([], "combine")).toBe(""); + }); + + it("formats a single issue with all context", () => { + const output = formatLinearIssues([makeIssue()], "combine"); + expect(output).toContain("Work on this Linear issue:"); + expect(output).toContain("## ENG-1: Fix the thing"); + expect(output).toContain("Status: In Progress"); + expect(output).toContain("Priority: High"); + expect(output).toContain("Assignee: Ada"); + expect(output).toContain("Labels: bug, backend"); + expect(output).toContain("https://linear.app/acme/issue/ENG-1"); + // Acceptance-criteria checklist is preserved verbatim from the description. + expect(output).toContain("- [ ] Step one"); + expect(output).toContain("**Sub-issues**"); + expect(output).toContain("ENG-2: Subtask (Todo)"); + expect(output).toContain("**Linked pull requests**"); + expect(output).toContain("[PR 42](https://github.com/acme/repo/pull/42)"); + expect(output).toContain("**Attachments**"); + expect(output).toContain("**Comments**"); + expect(output).toContain("Grace"); + expect(output).toContain("Looks good"); + }); + + it("combines multiple issues under one task heading", () => { + const output = formatLinearIssues( + [makeIssue(), makeIssue({ id: "id-2", identifier: "ENG-9", title: "Second" })], + "combine", + ); + expect(output).toContain("Work on these Linear issues together as one task:"); + expect(output).toContain("## ENG-1: Fix the thing"); + expect(output).toContain("## ENG-9: Second"); + expect(output).not.toContain("Subtask 1"); + }); + + it("labels multiple issues as ordered subtasks", () => { + const output = formatLinearIssues( + [makeIssue(), makeIssue({ id: "id-2", identifier: "ENG-9", title: "Second" })], + "subtasks", + ); + expect(output).toContain("should be implemented as subtasks"); + expect(output).toContain("## Subtask 1 — ENG-1: Fix the thing"); + expect(output).toContain("## Subtask 2 — ENG-9: Second"); + }); + + it("omits sections that have no data", () => { + const output = formatLinearIssues( + [ + makeIssue({ + description: "", + labels: [], + subIssues: [], + linkedPullRequests: [], + attachments: [], + comments: [], + }), + ], + "combine", + ); + expect(output).not.toContain("**Sub-issues**"); + expect(output).not.toContain("**Linked pull requests**"); + expect(output).not.toContain("**Comments**"); + expect(output).not.toContain("**Description**"); + }); +}); diff --git a/apps/web/src/lib/linearFormat.ts b/apps/web/src/lib/linearFormat.ts new file mode 100644 index 00000000000..1abd10cf4b9 --- /dev/null +++ b/apps/web/src/lib/linearFormat.ts @@ -0,0 +1,118 @@ +import type { LinearIssueDetail } from "@t3tools/contracts"; + +/** + * How multiple imported Linear issues are laid out in the composer prompt. + * - `combine`: merge every issue into one comprehensive task. + * - `subtasks`: present each issue as an independent, ordered subtask. + */ +export type LinearImportMode = "combine" | "subtasks"; + +function metadataLine(issue: LinearIssueDetail): string | null { + const parts: Array = []; + if (issue.stateName) parts.push(`Status: ${issue.stateName}`); + if (issue.priorityLabel) parts.push(`Priority: ${issue.priorityLabel}`); + if (issue.assigneeName) parts.push(`Assignee: ${issue.assigneeName}`); + if (issue.teamKey) parts.push(`Team: ${issue.teamKey}`); + if (issue.labels.length > 0) parts.push(`Labels: ${issue.labels.join(", ")}`); + return parts.length > 0 ? parts.join(" · ") : null; +} + +function sectionList(title: string, lines: ReadonlyArray): ReadonlyArray { + if (lines.length === 0) return []; + return [`**${title}**`, ...lines.map((line) => `- ${line}`), ""]; +} + +/** Render a single issue as a self-contained markdown block under `heading`. */ +function formatIssueBlock(issue: LinearIssueDetail, heading: string): string { + const lines: Array = [heading, ""]; + + const meta = metadataLine(issue); + if (meta) { + lines.push(meta, ""); + } + if (issue.url) { + lines.push(`Linear: ${issue.url}`, ""); + } + + const description = issue.description.trim(); + if (description.length > 0) { + // The description carries any acceptance criteria / checklists inline as + // markdown; preserve it verbatim so checkboxes survive the import. + lines.push("**Description**", "", description, ""); + } + + lines.push( + ...sectionList( + "Sub-issues", + issue.subIssues.map((sub) => + sub.stateName + ? `${sub.identifier}: ${sub.title} (${sub.stateName})` + : `${sub.identifier}: ${sub.title}`, + ), + ), + ); + + lines.push( + ...sectionList( + "Linked pull requests", + issue.linkedPullRequests.map((pr) => (pr.title ? `[${pr.title}](${pr.url})` : pr.url)), + ), + ); + + lines.push( + ...sectionList( + "Attachments", + issue.attachments.map((attachment) => + attachment.title ? `[${attachment.title}](${attachment.url})` : attachment.url, + ), + ), + ); + + if (issue.comments.length > 0) { + lines.push("**Comments**", ""); + for (const comment of issue.comments) { + const author = comment.author ?? "Unknown"; + const when = comment.createdAt ? ` (${comment.createdAt})` : ""; + lines.push(`> **${author}**${when}:`); + for (const bodyLine of comment.body.trim().split("\n")) { + lines.push(`> ${bodyLine}`); + } + lines.push(""); + } + } + + return lines.join("\n").trimEnd(); +} + +/** + * Build the composer prompt for one or more imported Linear issues. + * Pure and deterministic — safe to unit test. + */ +export function formatLinearIssues( + issues: ReadonlyArray, + mode: LinearImportMode, +): string { + if (issues.length === 0) return ""; + + if (issues.length === 1) { + const issue = issues[0]!; + return `Work on this Linear issue:\n\n${formatIssueBlock( + issue, + `## ${issue.identifier}: ${issue.title}`, + )}\n`; + } + + if (mode === "subtasks") { + const blocks = issues.map((issue, index) => + formatIssueBlock(issue, `## Subtask ${index + 1} — ${issue.identifier}: ${issue.title}`), + ); + return `These related Linear issues should be implemented as subtasks:\n\n${blocks.join( + "\n\n", + )}\n`; + } + + const blocks = issues.map((issue) => + formatIssueBlock(issue, `## ${issue.identifier}: ${issue.title}`), + ); + return `Work on these Linear issues together as one task:\n\n${blocks.join("\n\n")}\n`; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..d8a0040ce85 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsLinearRouteImport } from './routes/settings.linear' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' @@ -52,6 +53,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsLinearRoute = SettingsLinearRouteImport.update({ + id: '/linear', + path: '/linear', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -98,6 +104,7 @@ export interface FileRoutesByFullPath { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/linear': typeof SettingsLinearRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -111,6 +118,7 @@ export interface FileRoutesByTo { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/linear': typeof SettingsLinearRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -127,6 +135,7 @@ export interface FileRoutesById { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/linear': typeof SettingsLinearRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -144,6 +153,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/linear' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -157,6 +167,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/linear' | '/settings/providers' | '/settings/source-control' | '/' @@ -172,6 +183,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/linear' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -229,6 +241,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/linear': { + id: '/settings/linear' + path: '/linear' + fullPath: '/settings/linear' + preLoaderRoute: typeof SettingsLinearRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -301,6 +320,7 @@ interface SettingsRouteChildren { SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsLinearRoute: typeof SettingsLinearRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -311,6 +331,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsLinearRoute: SettingsLinearRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/settings.linear.tsx b/apps/web/src/routes/settings.linear.tsx new file mode 100644 index 00000000000..8bc9fab4e9b --- /dev/null +++ b/apps/web/src/routes/settings.linear.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { LinearSettingsPanel } from "../components/settings/LinearSettings"; + +export const Route = createFileRoute("/settings/linear")({ + component: LinearSettingsPanel, +}); diff --git a/apps/web/src/state/linear.ts b/apps/web/src/state/linear.ts new file mode 100644 index 00000000000..8861cdcd3ea --- /dev/null +++ b/apps/web/src/state/linear.ts @@ -0,0 +1,5 @@ +import { createLinearEnvironmentAtoms } from "@t3tools/client-runtime/state/linear"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const linearEnvironment = createLinearEnvironmentAtoms(connectionAtomRuntime); diff --git a/docs/integrations/linear.md b/docs/integrations/linear.md new file mode 100644 index 00000000000..e1c8a7af519 --- /dev/null +++ b/docs/integrations/linear.md @@ -0,0 +1,43 @@ +# Linear Integration + +T3 Code connects to [Linear](https://linear.app) so you can turn an issue into a working thread +without copying context by hand. Browse and search your Linear issues straight from a folder in the +sidebar, select one or more, and a new thread opens with its full context already in the composer. + +## What You Can Do + +- **Import issues into threads** – Click the Linear icon on any folder row in the sidebar to browse + and search issues. Pick one or several, then import them into a new thread for that folder. +- **Bring the whole issue** – Title, description, acceptance criteria / checklists, labels, priority, + assignee, state, sub-issues, linked pull requests, comments, and attachment links are all pulled + into the composer as markdown. Review and edit before you send. +- **Combine or split** – When you select more than one issue, choose whether to **combine** them into + one task or treat them as **related subtasks**. + +## Getting Started + +1. In Linear, open **Settings → Security & access → Personal API keys** and create a key. +2. In T3 Code, open **Settings → Linear** and paste the key, then **Connect**. The key is stored + securely on the server (via the encrypted secret store) and is never shown again. +3. The connection status shows the authenticated Linear account once the key is validated. + +Alternatively, set the `T3CODE_LINEAR_API_TOKEN` environment variable on the machine running T3 Code +(and optionally `T3CODE_LINEAR_API_BASE_URL` to override the API endpoint). A token stored through the +Settings UI takes precedence over the environment variable. + +## Importing + +1. Hover a folder in the sidebar and click the **Linear** icon (next to the new-thread button). +2. Search for issues, tick the ones you want, and choose **Combine into one task** or **As related + subtasks** when multiple are selected. +3. Click **Import**. A new draft thread opens for that folder with the issue context pre-filled — + review it and send to start working. + +## Requirements & Troubleshooting + +- **Not connected** – Add a personal API key in **Settings → Linear**, or set + `T3CODE_LINEAR_API_TOKEN` and restart T3 Code. +- **Token rejected** – Regenerate the key in Linear and reconnect; keys can be revoked from Linear’s + security settings. +- **No issues found** – Free-text search matches issue titles/identifiers; clear the search box to see + recent issues. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..6b3eb254c78 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -115,6 +115,10 @@ "types": "./src/state/sourceControl.ts", "default": "./src/state/sourceControl.ts" }, + "./state/linear": { + "types": "./src/state/linear.ts", + "default": "./src/state/linear.ts" + }, "./state/terminal": { "types": "./src/state/terminal.ts", "default": "./src/state/terminal.ts" diff --git a/packages/client-runtime/src/state/linear.ts b/packages/client-runtime/src/state/linear.ts new file mode 100644 index 00000000000..4e122ec1f97 --- /dev/null +++ b/packages/client-runtime/src/state/linear.ts @@ -0,0 +1,40 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createLinearEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const commandScheduler = createAtomCommandScheduler(); + return { + authStatus: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:auth-status", + tag: WS_METHODS.linearAuthStatus, + }), + searchIssues: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:search-issues", + tag: WS_METHODS.linearSearchIssues, + }), + fetchIssues: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:fetch-issues", + tag: WS_METHODS.linearFetchIssues, + scheduler: commandScheduler, + }), + setToken: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:set-token", + tag: WS_METHODS.linearSetToken, + scheduler: commandScheduler, + }), + clearToken: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:clear-token", + tag: WS_METHODS.linearClearToken, + scheduler: commandScheduler, + }), + }; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..3055ddf7f20 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -17,6 +17,7 @@ export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; +export * from "./linear.ts"; export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; diff --git a/packages/contracts/src/linear.ts b/packages/contracts/src/linear.ts new file mode 100644 index 00000000000..d8300364388 --- /dev/null +++ b/packages/contracts/src/linear.ts @@ -0,0 +1,167 @@ +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; + +const LINEAR_SEARCH_MAX_LIMIT = 50; +const LINEAR_SEARCH_QUERY_MAX_LENGTH = 256; +const LINEAR_TOKEN_MAX_LENGTH = 512; + +// ── Auth status ────────────────────────────────────────────────────── + +export const LinearAuthStatusValue = Schema.Literals(["authenticated", "unauthenticated"]); +export type LinearAuthStatusValue = typeof LinearAuthStatusValue.Type; + +export const LinearAccount = Schema.Struct({ + name: TrimmedNonEmptyString, + email: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearAccount = typeof LinearAccount.Type; + +export const LinearAuthStatus = Schema.Struct({ + status: LinearAuthStatusValue, + account: Schema.optional(LinearAccount), + detail: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearAuthStatus = typeof LinearAuthStatus.Type; + +// ── Issue shapes ───────────────────────────────────────────────────── + +export const LinearIssueSummary = Schema.Struct({ + id: TrimmedNonEmptyString, + identifier: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + url: Schema.String, + stateName: Schema.optional(TrimmedNonEmptyString), + priorityLabel: Schema.optional(TrimmedNonEmptyString), + assigneeName: Schema.optional(TrimmedNonEmptyString), + teamKey: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearIssueSummary = typeof LinearIssueSummary.Type; + +export const LinearSubIssue = Schema.Struct({ + identifier: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + stateName: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearSubIssue = typeof LinearSubIssue.Type; + +export const LinearLinkedPullRequest = Schema.Struct({ + url: Schema.String, + title: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearLinkedPullRequest = typeof LinearLinkedPullRequest.Type; + +export const LinearComment = Schema.Struct({ + author: Schema.optional(TrimmedNonEmptyString), + body: Schema.String, + createdAt: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearComment = typeof LinearComment.Type; + +export const LinearAttachment = Schema.Struct({ + title: Schema.optional(TrimmedNonEmptyString), + url: Schema.String, +}); +export type LinearAttachment = typeof LinearAttachment.Type; + +export const LinearIssueDetail = Schema.Struct({ + id: TrimmedNonEmptyString, + identifier: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + url: Schema.String, + stateName: Schema.optional(TrimmedNonEmptyString), + priorityLabel: Schema.optional(TrimmedNonEmptyString), + assigneeName: Schema.optional(TrimmedNonEmptyString), + teamKey: Schema.optional(TrimmedNonEmptyString), + description: Schema.String, + labels: Schema.Array(TrimmedNonEmptyString), + subIssues: Schema.Array(LinearSubIssue), + linkedPullRequests: Schema.Array(LinearLinkedPullRequest), + attachments: Schema.Array(LinearAttachment), + comments: Schema.Array(LinearComment), +}); +export type LinearIssueDetail = typeof LinearIssueDetail.Type; + +// ── RPC inputs / results ───────────────────────────────────────────── + +export const LinearSearchIssuesInput = Schema.Struct({ + query: TrimmedString.check(Schema.isMaxLength(LINEAR_SEARCH_QUERY_MAX_LENGTH)), + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(LINEAR_SEARCH_MAX_LIMIT)), +}); +export type LinearSearchIssuesInput = typeof LinearSearchIssuesInput.Type; + +export const LinearSearchIssuesResult = Schema.Struct({ + issues: Schema.Array(LinearIssueSummary), + truncated: Schema.Boolean, +}); +export type LinearSearchIssuesResult = typeof LinearSearchIssuesResult.Type; + +export const LinearFetchIssuesInput = Schema.Struct({ + ids: Schema.Array(TrimmedNonEmptyString), +}); +export type LinearFetchIssuesInput = typeof LinearFetchIssuesInput.Type; + +export const LinearFetchIssuesResult = Schema.Struct({ + issues: Schema.Array(LinearIssueDetail), +}); +export type LinearFetchIssuesResult = typeof LinearFetchIssuesResult.Type; + +export const LinearSetTokenInput = Schema.Struct({ + token: TrimmedNonEmptyString.check(Schema.isMaxLength(LINEAR_TOKEN_MAX_LENGTH)), +}); +export type LinearSetTokenInput = typeof LinearSetTokenInput.Type; + +// ── Errors ─────────────────────────────────────────────────────────── + +export const LinearApiOperation = Schema.Literals([ + "probeAuth", + "searchIssues", + "fetchIssues", + "setToken", + "clearToken", +]); +export type LinearApiOperation = typeof LinearApiOperation.Type; + +export class LinearAuthError extends Schema.TaggedErrorClass()("LinearAuthError", { + operation: LinearApiOperation, + detail: Schema.optional(Schema.String), +}) { + override get message(): string { + const suffix = this.detail === undefined ? "" : ` ${this.detail}`; + return `Linear authentication required for ${this.operation}.${suffix}`; + } +} + +export class LinearRequestError extends Schema.TaggedErrorClass()( + "LinearRequestError", + { + operation: LinearApiOperation, + status: Schema.optional(Schema.Int), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Linear API failed in ${this.operation}: ${this.detail}`; + } +} + +export class LinearTokenStoreError extends Schema.TaggedErrorClass()( + "LinearTokenStoreError", + { + operation: LinearApiOperation, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Linear token storage failed in ${this.operation}: ${this.detail}`; + } +} + +export const LinearError = Schema.Union([ + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, +]); +export type LinearError = typeof LinearError.Type; +export const isLinearError = Schema.is(LinearError); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..b857425a755 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -143,6 +143,17 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + LinearAuthError, + LinearAuthStatus, + LinearFetchIssuesInput, + LinearFetchIssuesResult, + LinearRequestError, + LinearSearchIssuesInput, + LinearSearchIssuesResult, + LinearSetTokenInput, + LinearTokenStoreError, +} from "./linear.ts"; export const WS_METHODS = { // Project registry methods @@ -223,6 +234,13 @@ export const WS_METHODS = { sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + // Linear methods + linearAuthStatus: "linear.authStatus", + linearSearchIssues: "linear.searchIssues", + linearFetchIssues: "linear.fetchIssues", + linearSetToken: "linear.setToken", + linearClearToken: "linear.clearToken", + // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", @@ -354,6 +372,46 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make( }, ); +export const WsLinearAuthStatusRpc = Rpc.make(WS_METHODS.linearAuthStatus, { + payload: Schema.Struct({}), + success: LinearAuthStatus, + error: Schema.Union([LinearRequestError, LinearTokenStoreError, EnvironmentAuthorizationError]), +}); + +export const WsLinearSearchIssuesRpc = Rpc.make(WS_METHODS.linearSearchIssues, { + payload: LinearSearchIssuesInput, + success: LinearSearchIssuesResult, + error: Schema.Union([ + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, + EnvironmentAuthorizationError, + ]), +}); + +export const WsLinearFetchIssuesRpc = Rpc.make(WS_METHODS.linearFetchIssues, { + payload: LinearFetchIssuesInput, + success: LinearFetchIssuesResult, + error: Schema.Union([ + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, + EnvironmentAuthorizationError, + ]), +}); + +export const WsLinearSetTokenRpc = Rpc.make(WS_METHODS.linearSetToken, { + payload: LinearSetTokenInput, + success: LinearAuthStatus, + error: Schema.Union([LinearRequestError, LinearTokenStoreError, EnvironmentAuthorizationError]), +}); + +export const WsLinearClearTokenRpc = Rpc.make(WS_METHODS.linearClearToken, { + payload: Schema.Struct({}), + success: LinearAuthStatus, + error: Schema.Union([LinearTokenStoreError, EnvironmentAuthorizationError]), +}); + export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -699,6 +757,11 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, + WsLinearAuthStatusRpc, + WsLinearSearchIssuesRpc, + WsLinearFetchIssuesRpc, + WsLinearSetTokenRpc, + WsLinearClearTokenRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, WsProjectsSearchEntriesRpc, From b3ad31a6ab6ad36c0d02a7e44faeba96aab32327 Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Sun, 5 Jul 2026 22:28:14 +0530 Subject: [PATCH 02/16] =?UTF-8?q?feat(linear):=20deep-integration=20founda?= =?UTF-8?q?tion=20=E2=80=94=20list/filter/mutation=20API=20+=20link=20cont?= =?UTF-8?q?ract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contracts: LinearIssueFilter, paginated LinearListIssues, team/state/project/ label/user metadata, write-mutation inputs, LinearIssueLink; 9 new RPCs - server LinearApi: listIssues (filter+cursor), listTeams/workflowStates/projects/ labels/users, updateIssueState/createComment/createAttachment; ws handlers+scopes - client-runtime: list/metadata query families + mutation commands - orchestration contract: optional linearIssue link on thread/shell + create/meta commands + turn-start bootstrap (persistence wiring follows) --- apps/server/src/linear/LinearApi.ts | 458 +++++++++++++++++++- apps/server/src/ws.ts | 55 +++ packages/client-runtime/src/state/linear.ts | 39 ++ packages/contracts/src/linear.ts | 158 +++++++ packages/contracts/src/orchestration.ts | 8 + packages/contracts/src/rpc.ts | 91 ++++ 6 files changed, 801 insertions(+), 8 deletions(-) diff --git a/apps/server/src/linear/LinearApi.ts b/apps/server/src/linear/LinearApi.ts index 21469edb779..63bd16cf4a2 100644 --- a/apps/server/src/linear/LinearApi.ts +++ b/apps/server/src/linear/LinearApi.ts @@ -13,9 +13,18 @@ import { type LinearAuthStatus, type LinearComment, type LinearIssueDetail, + type LinearIssueFilter, type LinearIssueSummary, + type LinearLabel, type LinearLinkedPullRequest, + type LinearListIssuesResult, + type LinearMutationResult, + type LinearProject, type LinearSubIssue, + type LinearTeam, + type LinearUser, + type LinearWorkflowState, + type LinearWorkflowStateType, } from "@t3tools/contracts"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; @@ -44,7 +53,18 @@ const GraphQlErrorEntry = Schema.Struct({ const NamedNode = Schema.Struct({ name: Schema.optional(Schema.String) }); +const StateNode = Schema.Struct({ + name: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), +}); + +const TeamRef = Schema.Struct({ + id: Schema.optional(Schema.String), + key: Schema.optional(Schema.String), +}); + const RawViewer = Schema.Struct({ + id: Schema.optional(Schema.String), name: Schema.optional(Schema.String), email: Schema.optional(Schema.String), }); @@ -55,9 +75,9 @@ const RawIssueSummary = Schema.Struct({ title: Schema.optional(Schema.String), url: Schema.optional(Schema.String), priorityLabel: Schema.optional(Schema.String), - state: Schema.optional(Schema.NullOr(NamedNode)), + state: Schema.optional(Schema.NullOr(StateNode)), assignee: Schema.optional(Schema.NullOr(NamedNode)), - team: Schema.optional(Schema.NullOr(Schema.Struct({ key: Schema.optional(Schema.String) }))), + team: Schema.optional(Schema.NullOr(TeamRef)), }); const RawIssueConnection = Schema.Struct({ @@ -71,9 +91,9 @@ const RawIssueDetail = Schema.Struct({ url: Schema.optional(Schema.String), description: Schema.optional(Schema.NullOr(Schema.String)), priorityLabel: Schema.optional(Schema.String), - state: Schema.optional(Schema.NullOr(NamedNode)), + state: Schema.optional(Schema.NullOr(StateNode)), assignee: Schema.optional(Schema.NullOr(NamedNode)), - team: Schema.optional(Schema.NullOr(Schema.Struct({ key: Schema.optional(Schema.String) }))), + team: Schema.optional(Schema.NullOr(TeamRef)), labels: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(NamedNode) }))), children: Schema.optional( Schema.NullOr( @@ -142,9 +162,134 @@ const issueEnvelope = Schema.Struct({ errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), }); +const RawPageInfo = Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawIssuePage = Schema.Struct({ + nodes: Schema.Array(RawIssueSummary), + pageInfo: Schema.optional(RawPageInfo), +}); + +const listEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr(Schema.Struct({ issues: Schema.optional(Schema.NullOr(RawIssuePage)) })), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawTeam = Schema.Struct({ + id: Schema.String, + key: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), +}); +const teamsEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + teams: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawTeam) }))), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawState = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + position: Schema.optional(Schema.Number), + color: Schema.optional(Schema.NullOr(Schema.String)), +}); +const statesEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + team: Schema.optional( + Schema.NullOr( + Schema.Struct({ + states: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawState) })), + ), + }), + ), + ), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawProject = Schema.Struct({ id: Schema.String, name: Schema.optional(Schema.String) }); +const projectsEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + projects: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawProject) })), + ), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawLabel = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + color: Schema.optional(Schema.NullOr(Schema.String)), +}); +const labelsEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + issueLabels: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLabel) })), + ), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const RawUser = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + displayName: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), +}); +const usersEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + users: Schema.optional(Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawUser) }))), + viewer: Schema.optional( + Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.String) })), + ), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + +const SuccessNode = Schema.Struct({ success: Schema.optional(Schema.Boolean) }); +const mutationEnvelope = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Struct({ + issueUpdate: Schema.optional(Schema.NullOr(SuccessNode)), + commentCreate: Schema.optional(Schema.NullOr(SuccessNode)), + attachmentCreate: Schema.optional(Schema.NullOr(SuccessNode)), + }), + ), + ), + errors: Schema.optional(Schema.Array(GraphQlErrorEntry)), +}); + // ── GraphQL documents ──────────────────────────────────────────────── -const SUMMARY_FIELDS = `id identifier title url priorityLabel state { name } assignee { name } team { key }`; +const SUMMARY_FIELDS = `id identifier title url priorityLabel state { name type } assignee { name } team { id key }`; const SEARCH_DOCUMENT = `query T3CodeLinearSearch($term: String!, $first: Int!) { searchIssues(term: $term, first: $first) { nodes { ${SUMMARY_FIELDS} } } @@ -154,12 +299,31 @@ const RECENT_DOCUMENT = `query T3CodeLinearRecent($first: Int!) { issues(first: $first, orderBy: updatedAt) { nodes { ${SUMMARY_FIELDS} } } }`; +const LIST_ISSUES_DOCUMENT = `query T3CodeLinearList($filter: IssueFilter, $first: Int!, $after: String) { + issues(filter: $filter, first: $first, after: $after, orderBy: updatedAt) { + nodes { ${SUMMARY_FIELDS} } + pageInfo { hasNextPage endCursor } + } +}`; + +const TEAMS_DOCUMENT = `query T3CodeLinearTeams { teams(first: 250) { nodes { id key name } } }`; + +const STATES_DOCUMENT = `query T3CodeLinearStates($teamId: String!) { + team(id: $teamId) { states { nodes { id name type position color } } } +}`; + +const PROJECTS_DOCUMENT = `query T3CodeLinearProjects { projects(first: 250) { nodes { id name } } }`; + +const LABELS_DOCUMENT = `query T3CodeLinearLabels { issueLabels(first: 250) { nodes { id name color } } }`; + +const USERS_DOCUMENT = `query T3CodeLinearUsers { users(first: 250) { nodes { id name displayName email } } viewer { id } }`; + const ISSUE_DOCUMENT = `query T3CodeLinearIssue($id: String!, $relations: Int!) { issue(id: $id) { id identifier title url description priorityLabel - state { name } + state { name type } assignee { name } - team { key } + team { id key } labels(first: $relations) { nodes { name } } children(first: $relations) { nodes { identifier title state { name } } } attachments(first: $relations) { nodes { title url sourceType } } @@ -167,7 +331,19 @@ const ISSUE_DOCUMENT = `query T3CodeLinearIssue($id: String!, $relations: Int!) } }`; -const VIEWER_DOCUMENT = `query T3CodeLinearViewer { viewer { name email } }`; +const VIEWER_DOCUMENT = `query T3CodeLinearViewer { viewer { id name email } }`; + +const UPDATE_ISSUE_STATE_DOCUMENT = `mutation T3CodeLinearUpdateState($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { success } +}`; + +const CREATE_COMMENT_DOCUMENT = `mutation T3CodeLinearComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { success } +}`; + +const CREATE_ATTACHMENT_DOCUMENT = `mutation T3CodeLinearAttachment($input: AttachmentCreateInput!) { + attachmentCreate(input: $input) { success } +}`; // ── Helpers ────────────────────────────────────────────────────────── @@ -176,6 +352,21 @@ function clean(value: string | null | undefined): string | undefined { return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined; } +const WORKFLOW_STATE_TYPES: ReadonlyArray = [ + "backlog", + "unstarted", + "started", + "completed", + "canceled", + "triage", +]; + +function coerceStateType(value: string | null | undefined): LinearWorkflowStateType | undefined { + return value != null && (WORKFLOW_STATE_TYPES as ReadonlyArray).includes(value) + ? (value as LinearWorkflowStateType) + : undefined; +} + function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } @@ -201,9 +392,11 @@ function toSummary(raw: typeof RawIssueSummary.Type): LinearIssueSummary { title: clean(raw.title) ?? raw.identifier, url: raw.url ?? "", stateName: clean(raw.state?.name), + stateType: coerceStateType(raw.state?.type), priorityLabel: clean(raw.priorityLabel), assigneeName: clean(raw.assignee?.name), teamKey: clean(raw.team?.key), + teamId: clean(raw.team?.id), }; } @@ -246,9 +439,11 @@ function toDetail(raw: typeof RawIssueDetail.Type): LinearIssueDetail { title: clean(raw.title) ?? raw.identifier, url: raw.url ?? "", stateName: clean(raw.state?.name), + stateType: coerceStateType(raw.state?.type), priorityLabel: clean(raw.priorityLabel), assigneeName: clean(raw.assignee?.name), teamKey: clean(raw.team?.key), + teamId: clean(raw.team?.id), description: raw.description ?? "", labels, subIssues, @@ -258,6 +453,62 @@ function toDetail(raw: typeof RawIssueDetail.Type): LinearIssueDetail { }; } +function toTeam(raw: typeof RawTeam.Type): LinearTeam { + return { id: raw.id, key: clean(raw.key) ?? raw.id, name: clean(raw.name) ?? raw.id }; +} + +function toWorkflowState(raw: typeof RawState.Type, teamId: string): LinearWorkflowState { + return { + id: raw.id, + name: clean(raw.name) ?? raw.id, + type: coerceStateType(raw.type) ?? "unstarted", + position: raw.position ?? 0, + color: clean(raw.color), + teamId, + }; +} + +function toProject(raw: typeof RawProject.Type): LinearProject { + return { id: raw.id, name: clean(raw.name) ?? raw.id }; +} + +function toLabel(raw: typeof RawLabel.Type): LinearLabel { + return { id: raw.id, name: clean(raw.name) ?? raw.id, color: clean(raw.color) }; +} + +function toUser(raw: typeof RawUser.Type, viewerId: string | undefined): LinearUser { + return { + id: raw.id, + name: clean(raw.name) ?? clean(raw.displayName) ?? raw.id, + displayName: clean(raw.displayName), + email: clean(raw.email), + ...(viewerId !== undefined && raw.id === viewerId ? { isMe: true } : {}), + }; +} + +/** Build a Linear `IssueFilter` object from our filter contract. */ +function buildIssueFilter( + filter: LinearIssueFilter | undefined, +): Record | undefined { + if (filter === undefined) return undefined; + const out: Record = {}; + if (filter.teamId) out.team = { id: { eq: filter.teamId } }; + if (filter.assigneeId) out.assignee = { id: { eq: filter.assigneeId } }; + if (filter.stateId) out.state = { id: { eq: filter.stateId } }; + else if (filter.stateType) out.state = { type: { eq: filter.stateType } }; + if (filter.projectId) out.project = { id: { eq: filter.projectId } }; + if (filter.labelId) out.labels = { some: { id: { eq: filter.labelId } } }; + if (typeof filter.priority === "number") out.priority = { eq: filter.priority }; + const query = filter.query?.trim(); + if (query !== undefined && query.length > 0) { + out.or = [ + { title: { containsIgnoreCase: query } }, + { description: { containsIgnoreCase: query } }, + ]; + } + return Object.keys(out).length > 0 ? out : undefined; +} + function firstGraphQlErrorMessage( errors: ReadonlyArray | undefined, ): string | undefined { @@ -299,6 +550,59 @@ export class LinearApi extends Context.Service< ReadonlyArray, LinearAuthError | LinearRequestError | LinearTokenStoreError >; + readonly listIssues: (input: { + readonly filter?: LinearIssueFilter | undefined; + readonly first: number; + readonly after?: string | undefined; + }) => Effect.Effect< + LinearListIssuesResult, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listTeams: Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listWorkflowStates: (input: { + readonly teamId: string; + }) => Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listProjects: Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listLabels: Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly listUsers: Effect.Effect< + ReadonlyArray, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly updateIssueState: (input: { + readonly issueId: string; + readonly stateId: string; + }) => Effect.Effect< + LinearMutationResult, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly createComment: (input: { + readonly issueId: string; + readonly body: string; + }) => Effect.Effect< + LinearMutationResult, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; + readonly createAttachment: (input: { + readonly issueId: string; + readonly url: string; + readonly title?: string | undefined; + readonly subtitle?: string | undefined; + }) => Effect.Effect< + LinearMutationResult, + LinearAuthError | LinearRequestError | LinearTokenStoreError + >; readonly setToken: (token: string) => Effect.Effect; readonly clearToken: Effect.Effect; } @@ -520,6 +824,135 @@ export const make = Effect.gen(function* () { ), ); + // Run a read query: resolve token → POST → fail on GraphQL errors → envelope. + const runReadQuery = ( + operation: LinearApiOperation, + document: string, + variables: Record, + envelope: S, + ): Effect.Effect< + S["Type"], + LinearAuthError | LinearRequestError | LinearTokenStoreError, + S["DecodingServices"] + > => + requireToken(operation).pipe( + Effect.flatMap((token) => runGraphql(operation, token, document, variables, envelope)), + Effect.flatMap((env) => { + const errs = (env as { errors?: ReadonlyArray }).errors; + return errs !== undefined && errs.length > 0 + ? failFromGraphQlErrors(operation, errs) + : Effect.succeed(env); + }), + ); + + const listIssues: LinearApi["Service"]["listIssues"] = (input) => + runReadQuery( + "listIssues", + LIST_ISSUES_DOCUMENT, + { + filter: buildIssueFilter(input.filter) ?? null, + first: input.first, + after: input.after ?? null, + }, + listEnvelope, + ).pipe( + Effect.map((envelope) => { + const page = envelope.data?.issues ?? null; + const nodes = page?.nodes ?? []; + return { + issues: nodes.map(toSummary), + pageInfo: { + hasNextPage: page?.pageInfo?.hasNextPage ?? false, + ...(clean(page?.pageInfo?.endCursor) + ? { endCursor: clean(page?.pageInfo?.endCursor)! } + : {}), + }, + }; + }), + ); + + const listTeams: LinearApi["Service"]["listTeams"] = runReadQuery( + "listTeams", + TEAMS_DOCUMENT, + {}, + teamsEnvelope, + ).pipe(Effect.map((envelope) => (envelope.data?.teams?.nodes ?? []).map(toTeam))); + + const listWorkflowStates: LinearApi["Service"]["listWorkflowStates"] = (input) => + runReadQuery( + "listWorkflowStates", + STATES_DOCUMENT, + { teamId: input.teamId }, + statesEnvelope, + ).pipe( + Effect.map((envelope) => + (envelope.data?.team?.states?.nodes ?? []) + .map((state) => toWorkflowState(state, input.teamId)) + .sort((a, b) => a.position - b.position), + ), + ); + + const listProjects: LinearApi["Service"]["listProjects"] = runReadQuery( + "listProjects", + PROJECTS_DOCUMENT, + {}, + projectsEnvelope, + ).pipe(Effect.map((envelope) => (envelope.data?.projects?.nodes ?? []).map(toProject))); + + const listLabels: LinearApi["Service"]["listLabels"] = runReadQuery( + "listLabels", + LABELS_DOCUMENT, + {}, + labelsEnvelope, + ).pipe(Effect.map((envelope) => (envelope.data?.issueLabels?.nodes ?? []).map(toLabel))); + + const listUsers: LinearApi["Service"]["listUsers"] = runReadQuery( + "listUsers", + USERS_DOCUMENT, + {}, + usersEnvelope, + ).pipe( + Effect.map((envelope) => { + const viewerId = clean(envelope.data?.viewer?.id); + return (envelope.data?.users?.nodes ?? []).map((user) => toUser(user, viewerId)); + }), + ); + + const mutationSucceeded = ( + node: { readonly success?: boolean | undefined } | null | undefined, + ): LinearMutationResult => ({ success: node?.success ?? false }); + + const updateIssueState: LinearApi["Service"]["updateIssueState"] = (input) => + runReadQuery( + "updateIssueState", + UPDATE_ISSUE_STATE_DOCUMENT, + { id: input.issueId, stateId: input.stateId }, + mutationEnvelope, + ).pipe(Effect.map((envelope) => mutationSucceeded(envelope.data?.issueUpdate))); + + const createComment: LinearApi["Service"]["createComment"] = (input) => + runReadQuery( + "createComment", + CREATE_COMMENT_DOCUMENT, + { issueId: input.issueId, body: input.body }, + mutationEnvelope, + ).pipe(Effect.map((envelope) => mutationSucceeded(envelope.data?.commentCreate))); + + const createAttachment: LinearApi["Service"]["createAttachment"] = (input) => + runReadQuery( + "createAttachment", + CREATE_ATTACHMENT_DOCUMENT, + { + input: { + issueId: input.issueId, + url: input.url, + title: input.title ?? "T3 Code", + ...(input.subtitle !== undefined ? { subtitle: input.subtitle } : {}), + }, + }, + mutationEnvelope, + ).pipe(Effect.map((envelope) => mutationSucceeded(envelope.data?.attachmentCreate))); + const persistToken = ( operation: LinearApiOperation, token: string, @@ -556,6 +989,15 @@ export const make = Effect.gen(function* () { probeAuth, searchIssues, fetchIssues, + listIssues, + listTeams, + listWorkflowStates, + listProjects, + listLabels, + listUsers, + updateIssueState, + createComment, + createAttachment, setToken, clearToken, }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 45d9257caf9..9777baa56a0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -303,6 +303,15 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.linearAuthStatus, AuthOrchestrationReadScope], [WS_METHODS.linearSearchIssues, AuthOrchestrationReadScope], [WS_METHODS.linearFetchIssues, AuthOrchestrationReadScope], + [WS_METHODS.linearListIssues, AuthOrchestrationReadScope], + [WS_METHODS.linearListTeams, AuthOrchestrationReadScope], + [WS_METHODS.linearListWorkflowStates, AuthOrchestrationReadScope], + [WS_METHODS.linearListProjects, AuthOrchestrationReadScope], + [WS_METHODS.linearListLabels, AuthOrchestrationReadScope], + [WS_METHODS.linearListUsers, AuthOrchestrationReadScope], + [WS_METHODS.linearUpdateIssueState, AuthOrchestrationOperateScope], + [WS_METHODS.linearCreateComment, AuthOrchestrationOperateScope], + [WS_METHODS.linearCreateAttachment, AuthOrchestrationOperateScope], [WS_METHODS.linearSetToken, AuthOrchestrationOperateScope], [WS_METHODS.linearClearToken, AuthOrchestrationOperateScope], [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], @@ -1343,6 +1352,52 @@ const makeWsRpcLayer = ( linear.fetchIssues(input).pipe(Effect.map((issues) => ({ issues }))), { "rpc.aggregate": "linear" }, ), + [WS_METHODS.linearListIssues]: (input) => + observeRpcEffect(WS_METHODS.linearListIssues, linear.listIssues(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearListTeams]: (_input) => + observeRpcEffect( + WS_METHODS.linearListTeams, + linear.listTeams.pipe(Effect.map((teams) => ({ teams }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearListWorkflowStates]: (input) => + observeRpcEffect( + WS_METHODS.linearListWorkflowStates, + linear.listWorkflowStates(input).pipe(Effect.map((states) => ({ states }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearListProjects]: (_input) => + observeRpcEffect( + WS_METHODS.linearListProjects, + linear.listProjects.pipe(Effect.map((projects) => ({ projects }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearListLabels]: (_input) => + observeRpcEffect( + WS_METHODS.linearListLabels, + linear.listLabels.pipe(Effect.map((labels) => ({ labels }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearListUsers]: (_input) => + observeRpcEffect( + WS_METHODS.linearListUsers, + linear.listUsers.pipe(Effect.map((users) => ({ users }))), + { "rpc.aggregate": "linear" }, + ), + [WS_METHODS.linearUpdateIssueState]: (input) => + observeRpcEffect(WS_METHODS.linearUpdateIssueState, linear.updateIssueState(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearCreateComment]: (input) => + observeRpcEffect(WS_METHODS.linearCreateComment, linear.createComment(input), { + "rpc.aggregate": "linear", + }), + [WS_METHODS.linearCreateAttachment]: (input) => + observeRpcEffect(WS_METHODS.linearCreateAttachment, linear.createAttachment(input), { + "rpc.aggregate": "linear", + }), [WS_METHODS.linearSetToken]: (input) => observeRpcEffect(WS_METHODS.linearSetToken, linear.setToken(input.token), { "rpc.aggregate": "linear", diff --git a/packages/client-runtime/src/state/linear.ts b/packages/client-runtime/src/state/linear.ts index 4e122ec1f97..a099e2b09d9 100644 --- a/packages/client-runtime/src/state/linear.ts +++ b/packages/client-runtime/src/state/linear.ts @@ -21,11 +21,50 @@ export function createLinearEnvironmentAtoms( label: "environment-data:linear:search-issues", tag: WS_METHODS.linearSearchIssues, }), + listIssues: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:list-issues", + tag: WS_METHODS.linearListIssues, + }), + teams: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:teams", + tag: WS_METHODS.linearListTeams, + }), + workflowStates: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:workflow-states", + tag: WS_METHODS.linearListWorkflowStates, + }), + projects: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:projects", + tag: WS_METHODS.linearListProjects, + }), + labels: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:labels", + tag: WS_METHODS.linearListLabels, + }), + users: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:linear:users", + tag: WS_METHODS.linearListUsers, + }), fetchIssues: createEnvironmentRpcCommand(runtime, { label: "environment-data:linear:fetch-issues", tag: WS_METHODS.linearFetchIssues, scheduler: commandScheduler, }), + updateIssueState: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:update-issue-state", + tag: WS_METHODS.linearUpdateIssueState, + scheduler: commandScheduler, + }), + createComment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:create-comment", + tag: WS_METHODS.linearCreateComment, + scheduler: commandScheduler, + }), + createAttachment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:create-attachment", + tag: WS_METHODS.linearCreateAttachment, + scheduler: commandScheduler, + }), setToken: createEnvironmentRpcCommand(runtime, { label: "environment-data:linear:set-token", tag: WS_METHODS.linearSetToken, diff --git a/packages/contracts/src/linear.ts b/packages/contracts/src/linear.ts index d8300364388..fba719e70f4 100644 --- a/packages/contracts/src/linear.ts +++ b/packages/contracts/src/linear.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; const LINEAR_SEARCH_MAX_LIMIT = 50; +const LINEAR_LIST_MAX_LIMIT = 100; const LINEAR_SEARCH_QUERY_MAX_LENGTH = 256; const LINEAR_TOKEN_MAX_LENGTH = 512; @@ -23,6 +24,19 @@ export const LinearAuthStatus = Schema.Struct({ }); export type LinearAuthStatus = typeof LinearAuthStatus.Type; +// ── Workflow states ────────────────────────────────────────────────── + +/** Linear workflow-state category. Stable across teams that rename states. */ +export const LinearWorkflowStateType = Schema.Literals([ + "backlog", + "unstarted", + "started", + "completed", + "canceled", + "triage", +]); +export type LinearWorkflowStateType = typeof LinearWorkflowStateType.Type; + // ── Issue shapes ───────────────────────────────────────────────────── export const LinearIssueSummary = Schema.Struct({ @@ -31,9 +45,11 @@ export const LinearIssueSummary = Schema.Struct({ title: TrimmedNonEmptyString, url: Schema.String, stateName: Schema.optional(TrimmedNonEmptyString), + stateType: Schema.optional(LinearWorkflowStateType), priorityLabel: Schema.optional(TrimmedNonEmptyString), assigneeName: Schema.optional(TrimmedNonEmptyString), teamKey: Schema.optional(TrimmedNonEmptyString), + teamId: Schema.optional(TrimmedNonEmptyString), }); export type LinearIssueSummary = typeof LinearIssueSummary.Type; @@ -69,9 +85,11 @@ export const LinearIssueDetail = Schema.Struct({ title: TrimmedNonEmptyString, url: Schema.String, stateName: Schema.optional(TrimmedNonEmptyString), + stateType: Schema.optional(LinearWorkflowStateType), priorityLabel: Schema.optional(TrimmedNonEmptyString), assigneeName: Schema.optional(TrimmedNonEmptyString), teamKey: Schema.optional(TrimmedNonEmptyString), + teamId: Schema.optional(TrimmedNonEmptyString), description: Schema.String, labels: Schema.Array(TrimmedNonEmptyString), subIssues: Schema.Array(LinearSubIssue), @@ -81,8 +99,139 @@ export const LinearIssueDetail = Schema.Struct({ }); export type LinearIssueDetail = typeof LinearIssueDetail.Type; +// ── Filter metadata (teams / states / projects / labels / users) ───── + +export const LinearTeam = Schema.Struct({ + id: TrimmedNonEmptyString, + key: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +export type LinearTeam = typeof LinearTeam.Type; + +export const LinearWorkflowState = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + type: LinearWorkflowStateType, + position: Schema.Number, + color: Schema.optional(TrimmedNonEmptyString), + teamId: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearWorkflowState = typeof LinearWorkflowState.Type; + +export const LinearProject = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +export type LinearProject = typeof LinearProject.Type; + +export const LinearLabel = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + color: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearLabel = typeof LinearLabel.Type; + +export const LinearUser = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + displayName: Schema.optional(TrimmedNonEmptyString), + email: Schema.optional(TrimmedNonEmptyString), + isMe: Schema.optional(Schema.Boolean), +}); +export type LinearUser = typeof LinearUser.Type; + +/** Persisted link from a T3 Code thread back to the Linear issue it came from. */ +export const LinearIssueLink = Schema.Struct({ + id: TrimmedNonEmptyString, + identifier: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + url: Schema.String, + teamId: Schema.optional(TrimmedNonEmptyString), + stateType: Schema.optional(LinearWorkflowStateType), + stateName: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearIssueLink = typeof LinearIssueLink.Type; + +// ── Filter + pagination ────────────────────────────────────────────── + +export const LinearIssueFilter = Schema.Struct({ + teamId: Schema.optional(TrimmedNonEmptyString), + assigneeId: Schema.optional(TrimmedNonEmptyString), + stateType: Schema.optional(LinearWorkflowStateType), + stateId: Schema.optional(TrimmedNonEmptyString), + projectId: Schema.optional(TrimmedNonEmptyString), + labelId: Schema.optional(TrimmedNonEmptyString), + priority: Schema.optional(Schema.Int), + query: Schema.optional(TrimmedString.check(Schema.isMaxLength(LINEAR_SEARCH_QUERY_MAX_LENGTH))), +}); +export type LinearIssueFilter = typeof LinearIssueFilter.Type; + +export const LinearPageInfo = Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearPageInfo = typeof LinearPageInfo.Type; + // ── RPC inputs / results ───────────────────────────────────────────── +export const LinearListIssuesInput = Schema.Struct({ + filter: Schema.optional(LinearIssueFilter), + first: PositiveInt.check(Schema.isLessThanOrEqualTo(LINEAR_LIST_MAX_LIMIT)), + after: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearListIssuesInput = typeof LinearListIssuesInput.Type; + +export const LinearListIssuesResult = Schema.Struct({ + issues: Schema.Array(LinearIssueSummary), + pageInfo: LinearPageInfo, +}); +export type LinearListIssuesResult = typeof LinearListIssuesResult.Type; + +export const LinearListTeamsResult = Schema.Struct({ teams: Schema.Array(LinearTeam) }); +export type LinearListTeamsResult = typeof LinearListTeamsResult.Type; + +export const LinearListWorkflowStatesInput = Schema.Struct({ teamId: TrimmedNonEmptyString }); +export type LinearListWorkflowStatesInput = typeof LinearListWorkflowStatesInput.Type; + +export const LinearListWorkflowStatesResult = Schema.Struct({ + states: Schema.Array(LinearWorkflowState), +}); +export type LinearListWorkflowStatesResult = typeof LinearListWorkflowStatesResult.Type; + +export const LinearListProjectsResult = Schema.Struct({ projects: Schema.Array(LinearProject) }); +export type LinearListProjectsResult = typeof LinearListProjectsResult.Type; + +export const LinearListLabelsResult = Schema.Struct({ labels: Schema.Array(LinearLabel) }); +export type LinearListLabelsResult = typeof LinearListLabelsResult.Type; + +export const LinearListUsersResult = Schema.Struct({ users: Schema.Array(LinearUser) }); +export type LinearListUsersResult = typeof LinearListUsersResult.Type; + +// ── Write mutations (Phase 3) ──────────────────────────────────────── + +export const LinearUpdateIssueStateInput = Schema.Struct({ + issueId: TrimmedNonEmptyString, + stateId: TrimmedNonEmptyString, +}); +export type LinearUpdateIssueStateInput = typeof LinearUpdateIssueStateInput.Type; + +export const LinearCreateCommentInput = Schema.Struct({ + issueId: TrimmedNonEmptyString, + body: Schema.String, +}); +export type LinearCreateCommentInput = typeof LinearCreateCommentInput.Type; + +export const LinearCreateAttachmentInput = Schema.Struct({ + issueId: TrimmedNonEmptyString, + url: Schema.String, + title: Schema.optional(TrimmedNonEmptyString), + subtitle: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearCreateAttachmentInput = typeof LinearCreateAttachmentInput.Type; + +export const LinearMutationResult = Schema.Struct({ success: Schema.Boolean }); +export type LinearMutationResult = typeof LinearMutationResult.Type; + export const LinearSearchIssuesInput = Schema.Struct({ query: TrimmedString.check(Schema.isMaxLength(LINEAR_SEARCH_QUERY_MAX_LENGTH)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(LINEAR_SEARCH_MAX_LIMIT)), @@ -116,6 +265,15 @@ export const LinearApiOperation = Schema.Literals([ "probeAuth", "searchIssues", "fetchIssues", + "listIssues", + "listTeams", + "listWorkflowStates", + "listProjects", + "listLabels", + "listUsers", + "updateIssueState", + "createComment", + "createAttachment", "setToken", "clearToken", ]); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 623fed0917b..94cf81b6821 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -21,6 +21,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { LinearIssueLink } from "./linear.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -352,6 +353,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -398,6 +400,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -503,6 +506,7 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), createdAt: IsoDateTime, }); @@ -532,6 +536,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), }); const ThreadRuntimeModeSetCommand = Schema.Struct({ @@ -558,6 +563,7 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), createdAt: IsoDateTime, }); @@ -847,6 +853,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -873,6 +880,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + linearIssue: Schema.optional(Schema.NullOr(LinearIssueLink)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b857425a755..a5b97d4d7f0 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -146,13 +146,25 @@ import { VcsError } from "./vcs.ts"; import { LinearAuthError, LinearAuthStatus, + LinearCreateAttachmentInput, + LinearCreateCommentInput, LinearFetchIssuesInput, LinearFetchIssuesResult, + LinearListIssuesInput, + LinearListIssuesResult, + LinearListLabelsResult, + LinearListProjectsResult, + LinearListTeamsResult, + LinearListUsersResult, + LinearListWorkflowStatesInput, + LinearListWorkflowStatesResult, + LinearMutationResult, LinearRequestError, LinearSearchIssuesInput, LinearSearchIssuesResult, LinearSetTokenInput, LinearTokenStoreError, + LinearUpdateIssueStateInput, } from "./linear.ts"; export const WS_METHODS = { @@ -238,6 +250,15 @@ export const WS_METHODS = { linearAuthStatus: "linear.authStatus", linearSearchIssues: "linear.searchIssues", linearFetchIssues: "linear.fetchIssues", + linearListIssues: "linear.listIssues", + linearListTeams: "linear.listTeams", + linearListWorkflowStates: "linear.listWorkflowStates", + linearListProjects: "linear.listProjects", + linearListLabels: "linear.listLabels", + linearListUsers: "linear.listUsers", + linearUpdateIssueState: "linear.updateIssueState", + linearCreateComment: "linear.createComment", + linearCreateAttachment: "linear.createAttachment", linearSetToken: "linear.setToken", linearClearToken: "linear.clearToken", @@ -412,6 +433,67 @@ export const WsLinearClearTokenRpc = Rpc.make(WS_METHODS.linearClearToken, { error: Schema.Union([LinearTokenStoreError, EnvironmentAuthorizationError]), }); +const LinearReadError = Schema.Union([ + LinearAuthError, + LinearRequestError, + LinearTokenStoreError, + EnvironmentAuthorizationError, +]); + +export const WsLinearListIssuesRpc = Rpc.make(WS_METHODS.linearListIssues, { + payload: LinearListIssuesInput, + success: LinearListIssuesResult, + error: LinearReadError, +}); + +export const WsLinearListTeamsRpc = Rpc.make(WS_METHODS.linearListTeams, { + payload: Schema.Struct({}), + success: LinearListTeamsResult, + error: LinearReadError, +}); + +export const WsLinearListWorkflowStatesRpc = Rpc.make(WS_METHODS.linearListWorkflowStates, { + payload: LinearListWorkflowStatesInput, + success: LinearListWorkflowStatesResult, + error: LinearReadError, +}); + +export const WsLinearListProjectsRpc = Rpc.make(WS_METHODS.linearListProjects, { + payload: Schema.Struct({}), + success: LinearListProjectsResult, + error: LinearReadError, +}); + +export const WsLinearListLabelsRpc = Rpc.make(WS_METHODS.linearListLabels, { + payload: Schema.Struct({}), + success: LinearListLabelsResult, + error: LinearReadError, +}); + +export const WsLinearListUsersRpc = Rpc.make(WS_METHODS.linearListUsers, { + payload: Schema.Struct({}), + success: LinearListUsersResult, + error: LinearReadError, +}); + +export const WsLinearUpdateIssueStateRpc = Rpc.make(WS_METHODS.linearUpdateIssueState, { + payload: LinearUpdateIssueStateInput, + success: LinearMutationResult, + error: LinearReadError, +}); + +export const WsLinearCreateCommentRpc = Rpc.make(WS_METHODS.linearCreateComment, { + payload: LinearCreateCommentInput, + success: LinearMutationResult, + error: LinearReadError, +}); + +export const WsLinearCreateAttachmentRpc = Rpc.make(WS_METHODS.linearCreateAttachment, { + payload: LinearCreateAttachmentInput, + success: LinearMutationResult, + error: LinearReadError, +}); + export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -760,6 +842,15 @@ export const WsRpcGroup = RpcGroup.make( WsLinearAuthStatusRpc, WsLinearSearchIssuesRpc, WsLinearFetchIssuesRpc, + WsLinearListIssuesRpc, + WsLinearListTeamsRpc, + WsLinearListWorkflowStatesRpc, + WsLinearListProjectsRpc, + WsLinearListLabelsRpc, + WsLinearListUsersRpc, + WsLinearUpdateIssueStateRpc, + WsLinearCreateCommentRpc, + WsLinearCreateAttachmentRpc, WsLinearSetTokenRpc, WsLinearClearTokenRpc, WsProjectsListEntriesRpc, From 342a1dabe79fe414e2cc3dd0772be2590cd53be7 Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Sun, 5 Jul 2026 22:35:28 +0530 Subject: [PATCH 03/16] =?UTF-8?q?feat(linear):=20Phase=201=20bulk=20browse?= =?UTF-8?q?r=20=E2=80=94=20filters,=20pagination,=20multi-select,=20import?= =?UTF-8?q?=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /linear full-page browser: team/status/assignee filters, cursor pagination (Load more), select-all + multi-select table - import control: one-thread-per-issue (real started threads, each linked) or combine (draft) + destination-folder picker - entry points: command-palette 'Browse Linear issues' + popover 'Browse all' - useLinearImport gains perIssue mode via startTurn bootstrap --- apps/web/src/components/CommandPalette.tsx | 13 +- .../src/components/LinearBrowsePopover.tsx | 29 +- .../src/components/linear/LinearBrowser.tsx | 438 ++++++++++++++++++ apps/web/src/hooks/useLinearImport.ts | 107 ++++- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.linear.tsx | 7 + 6 files changed, 589 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/components/linear/LinearBrowser.tsx create mode 100644 apps/web/src/routes/_chat.linear.tsx diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..eb888904946 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -108,7 +108,7 @@ import { } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, LinearIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -1030,6 +1030,17 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:linear-browse", + searchTerms: ["linear", "issues", "tickets", "browse", "import"], + title: "Browse Linear issues", + icon: , + run: async () => { + await navigate({ to: "/linear" }); + }, + }); + if (wslAddProjectEnvironmentOption) { actionItems.push({ kind: "action", diff --git a/apps/web/src/components/LinearBrowsePopover.tsx b/apps/web/src/components/LinearBrowsePopover.tsx index 52b2de274df..8abf7ecf25b 100644 --- a/apps/web/src/components/LinearBrowsePopover.tsx +++ b/apps/web/src/components/LinearBrowsePopover.tsx @@ -1,4 +1,5 @@ import type { EnvironmentId, LinearIssueSummary, ProjectId } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; import { SearchIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -71,6 +72,7 @@ export function LinearBrowsePopover({ const [importing, setImporting] = useState(false); const runImport = useLinearImport(); + const navigate = useNavigate(); useEffect(() => { const id = setTimeout(() => setDebounced(query), SEARCH_DEBOUNCE_MS); @@ -125,7 +127,7 @@ export function LinearBrowsePopover({ const result = await runImport({ target: { environmentId, projectId }, ids: [...selected], - mode: combine ? "combine" : "subtasks", + mode: combine ? "combine" : "perIssue", }); if (result.ok) { handleOpenChange(false); @@ -252,7 +254,18 @@ export function LinearBrowsePopover({ {selectedCount} selected - ) : null} + ) : ( + + )} {connected ? ( @@ -273,19 +286,13 @@ export function LinearBrowsePopover({ {connected ? (
-
+ {thread.linearIssue ? : null} {discoveredPorts.length > 0 && ( + event.stopPropagation()} + className={cn( + "inline-flex shrink-0 items-center gap-1 rounded-sm px-1 font-mono text-[10px] leading-4 transition-colors hover:bg-accent", + stateColorClass(issue.stateType), + className, + )} + aria-label={`Linear ${issue.identifier} — ${stateLabel}`} + > + + {issue.identifier} + + } + /> + + {issue.identifier} · {stateLabel} + + + ); +} From 34931ef42d114d77795f4f4737c2d1f8bc71d7f0 Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Sun, 5 Jul 2026 23:14:02 +0530 Subject: [PATCH 05/16] =?UTF-8?q?feat(linear):=20Phase=203=20=E2=80=94=20s?= =?UTF-8?q?tatus=20write-back=20(LinearSyncReactor=20+=20settings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServerSettings.linear: autoSync + per-transition toggles + comment/attachment toggles + per-team state-mapping override (+ patch) - linearStateMapping resolver: workflow-state type/name → target stateId, with per-team override (unit tested, 7 cases) - LinearSyncReactor: agent start → In Progress; PR open → In Review; merge → Done (via VcsStatusBroadcaster per-thread watch); dedupes per issue, posts optional comment/attachment, reflects state to the thread badge via thread.meta.update. Registered in OrchestrationReactor + server layer graph - Settings UI: Status write-back toggles on the Linear settings page --- .../OrchestrationEngineHarness.integration.ts | 6 + .../src/linear/linearStateMapping.test.ts | 52 +++++ apps/server/src/linear/linearStateMapping.ts | 39 ++++ .../orchestration/Layers/LinearSyncReactor.ts | 201 ++++++++++++++++++ .../Layers/OrchestrationReactor.test.ts | 10 + .../Layers/OrchestrationReactor.ts | 3 + .../Services/LinearSyncReactor.ts | 17 ++ apps/server/src/server.ts | 12 ++ .../components/settings/LinearSettings.tsx | 83 ++++++++ packages/contracts/src/settings.ts | 35 +++ 10 files changed, 458 insertions(+) create mode 100644 apps/server/src/linear/linearStateMapping.test.ts create mode 100644 apps/server/src/linear/linearStateMapping.ts create mode 100644 apps/server/src/orchestration/Layers/LinearSyncReactor.ts create mode 100644 apps/server/src/orchestration/Services/LinearSyncReactor.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ebc4f984b86..04d0a13cfe2 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -52,6 +52,7 @@ import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; +import { LinearSyncReactor } from "../src/orchestration/Services/LinearSyncReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; import { @@ -366,6 +367,11 @@ export const makeOrchestrationIntegrationHarness = ( drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(LinearSyncReactor, { + start: () => Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/linear/linearStateMapping.test.ts b/apps/server/src/linear/linearStateMapping.test.ts new file mode 100644 index 00000000000..9dbaf6cdbbf --- /dev/null +++ b/apps/server/src/linear/linearStateMapping.test.ts @@ -0,0 +1,52 @@ +import type { LinearWorkflowState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveTargetStateId } from "./linearStateMapping.ts"; + +const states: ReadonlyArray = [ + { id: "s-backlog", name: "Backlog", type: "backlog", position: 0 }, + { id: "s-todo", name: "Todo", type: "unstarted", position: 1 }, + { id: "s-progress", name: "In Progress", type: "started", position: 2 }, + { id: "s-review", name: "In Review", type: "started", position: 3 }, + { id: "s-done", name: "Done", type: "completed", position: 4 }, + { id: "s-canceled", name: "Canceled", type: "canceled", position: 5 }, +]; + +describe("resolveTargetStateId", () => { + it("maps started to the In Progress state", () => { + expect(resolveTargetStateId(states, undefined, "started")).toBe("s-progress"); + }); + + it("maps done to the completed state", () => { + expect(resolveTargetStateId(states, undefined, "done")).toBe("s-done"); + }); + + it("maps review to a state named In Review", () => { + expect(resolveTargetStateId(states, undefined, "review")).toBe("s-review"); + }); + + it("prefers a valid per-team override", () => { + expect(resolveTargetStateId(states, { started: "s-todo" }, "started")).toBe("s-todo"); + }); + + it("ignores an override that no longer exists", () => { + expect(resolveTargetStateId(states, { started: "s-missing" }, "started")).toBe("s-progress"); + }); + + it("falls back to the first started state when none is named In Progress", () => { + const renamed: ReadonlyArray = [ + { id: "s-doing", name: "Doing", type: "started", position: 1 }, + { id: "s-done", name: "Complete", type: "completed", position: 2 }, + ]; + expect(resolveTargetStateId(renamed, undefined, "started")).toBe("s-doing"); + expect(resolveTargetStateId(renamed, undefined, "done")).toBe("s-done"); + }); + + it("returns undefined for review when no review-like state exists", () => { + const noReview: ReadonlyArray = [ + { id: "s-progress", name: "In Progress", type: "started", position: 1 }, + { id: "s-done", name: "Done", type: "completed", position: 2 }, + ]; + expect(resolveTargetStateId(noReview, undefined, "review")).toBeUndefined(); + }); +}); diff --git a/apps/server/src/linear/linearStateMapping.ts b/apps/server/src/linear/linearStateMapping.ts new file mode 100644 index 00000000000..2818301e785 --- /dev/null +++ b/apps/server/src/linear/linearStateMapping.ts @@ -0,0 +1,39 @@ +import type { LinearTeamStateMapping, LinearWorkflowState } from "@t3tools/contracts"; + +export type LinearLifecycleStage = "started" | "review" | "done"; + +/** + * Resolve which Linear workflow-state id a lifecycle stage should move an issue + * to, for a given team. Precedence: + * 1. explicit per-team override (if it still exists in the team's states); + * 2. a sensibly-named state ("In Progress" / "In Review"); + * 3. the first state of the matching category `type`. + * Returns undefined when nothing sensible maps (the caller skips the transition). + */ +export function resolveTargetStateId( + states: ReadonlyArray, + mapping: LinearTeamStateMapping | undefined, + stage: LinearLifecycleStage, +): string | undefined { + const override = mapping?.[stage]; + if (override !== undefined && states.some((state) => state.id === override)) { + return override; + } + + const ordered = [...states].sort((a, b) => a.position - b.position); + const byType = (type: LinearWorkflowState["type"]) => + ordered.find((state) => state.type === type)?.id; + const byName = (needle: string) => + ordered.find((state) => state.name.toLowerCase().includes(needle))?.id; + + switch (stage) { + case "started": + return byName("in progress") ?? byType("started"); + case "review": + // Linear has no "review" category; teams model it as a custom started + // state (commonly "In Review"). Fall back to nothing so the caller skips. + return byName("in review") ?? byName("review"); + case "done": + return byType("completed"); + } +} diff --git a/apps/server/src/orchestration/Layers/LinearSyncReactor.ts b/apps/server/src/orchestration/Layers/LinearSyncReactor.ts new file mode 100644 index 00000000000..6dd20ee6c93 --- /dev/null +++ b/apps/server/src/orchestration/Layers/LinearSyncReactor.ts @@ -0,0 +1,201 @@ +import { + CommandId, + type LinearIssueLink, + type LinearWorkflowState, + type OrchestrationThreadShell, + type ServerSettings as ServerSettingsType, + type ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as LinearApi from "../../linear/LinearApi.ts"; +import { + resolveTargetStateId, + type LinearLifecycleStage, +} from "../../linear/linearStateMapping.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import * as VcsStatusBroadcaster from "../../vcs/VcsStatusBroadcaster.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../Services/ProjectionSnapshotQuery.ts"; +import { LinearSyncReactor, type LinearSyncReactorShape } from "../Services/LinearSyncReactor.ts"; + +const STAGE_RANK: Record = { started: 1, review: 2, done: 3 }; + +function transitionEnabled( + settings: ServerSettingsType["linear"], + stage: LinearLifecycleStage, +): boolean { + switch (stage) { + case "started": + return settings.transitionOnStart; + case "review": + return settings.transitionOnPrOpen; + case "done": + return settings.transitionOnMerge; + } +} + +const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const linear = yield* LinearApi.LinearApi; + const settingsService = yield* ServerSettings.ServerSettingsService; + const snapshot = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const vcs = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const crypto = yield* Crypto.Crypto; + + // Highest lifecycle stage already written per Linear issue id (never regress). + const appliedRank = new Map(); + // Threads whose PR status we're already watching. + const watchedThreads = new Set(); + // Cache team workflow states for the process lifetime. + const statesByTeam = new Map>(); + + const getTeamStates = (teamId: string) => + statesByTeam.has(teamId) + ? Effect.succeed(statesByTeam.get(teamId)!) + : linear.listWorkflowStates({ teamId }).pipe( + Effect.tap((states) => Effect.sync(() => statesByTeam.set(teamId, states))), + Effect.catchCause((cause) => + Effect.logDebug("linear sync could not load workflow states", { + teamId, + cause: Cause.pretty(cause), + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + + const reflectState = (threadId: ThreadId, issue: LinearIssueLink, state: LinearWorkflowState) => + crypto.randomUUIDv4.pipe( + Effect.flatMap((uuid) => + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make(uuid), + threadId, + linearIssue: { ...issue, stateName: state.name, stateType: state.type }, + }), + ), + Effect.catchCause((cause) => + Effect.logDebug("linear sync could not reflect state to thread", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ); + + const applyStage = (thread: OrchestrationThreadShell, stage: LinearLifecycleStage) => + Effect.gen(function* () { + const issue = thread.linearIssue; + if (issue === null || issue === undefined) return; + const teamId = issue.teamId; + if (teamId === undefined) return; + + const settings = (yield* settingsService.getSettings).linear; + if (!settings.autoSync || !transitionEnabled(settings, stage)) return; + + const rank = STAGE_RANK[stage]; + if ((appliedRank.get(issue.id) ?? 0) >= rank) return; + + const states = yield* getTeamStates(teamId); + const stateId = resolveTargetStateId(states, settings.stateMappingByTeam[teamId], stage); + if (stateId === undefined) return; + + const result = yield* linear.updateIssueState({ issueId: issue.id, stateId }); + if (!result.success) return; + appliedRank.set(issue.id, rank); + + if (stage === "started") { + if (settings.linkAttachment) { + yield* linear + .createAttachment({ issueId: issue.id, url: issue.url, title: "T3 Code" }) + .pipe(Effect.ignore); + } + if (settings.postComments) { + yield* linear + .createComment({ + issueId: issue.id, + body: `T3 Code started working on this issue in thread “${thread.title}”.`, + }) + .pipe(Effect.ignore); + } + } + + const nextState = states.find((state) => state.id === stateId); + if (nextState !== undefined) { + yield* reflectState(thread.id, issue, nextState); + } + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("linear sync failed to apply stage", { + threadId: thread.id, + stage, + cause: Cause.pretty(cause), + }), + ), + ); + + const watchPullRequest = (thread: OrchestrationThreadShell) => + Effect.gen(function* () { + const worktreePath = thread.worktreePath; + if (worktreePath === null || thread.linearIssue == null) return; + if (watchedThreads.has(thread.id)) return; + watchedThreads.add(thread.id); + + yield* Effect.forkScoped( + Stream.runForEach(vcs.streamStatus({ cwd: worktreePath }), (event) => { + const remote = event._tag === "localUpdated" ? null : event.remote; + const pr = remote?.pr ?? null; + if (pr === null) return Effect.void; + if (pr.state === "open") return applyStage(thread, "review"); + if (pr.state === "merged") return applyStage(thread, "done"); + return Effect.void; + }).pipe( + Effect.catchCause((cause) => + Effect.logDebug("linear sync PR watcher stopped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + ); + }); + + const onThreadActivity = (threadId: ThreadId, markStarted: boolean) => + snapshot.getThreadShellById(threadId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (thread) => + thread.linearIssue == null + ? Effect.void + : watchPullRequest(thread).pipe( + Effect.andThen(markStarted ? applyStage(thread, "started") : Effect.void), + ), + }), + ), + Effect.catchCause(() => Effect.void), + ); + + const start: LinearSyncReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(engine.streamDomainEvents, (event) => { + switch (event.type) { + case "thread.turn-start-requested": + return onThreadActivity(event.payload.threadId, true); + case "thread.created": + case "thread.meta-updated": + return onThreadActivity(event.payload.threadId, false); + default: + return Effect.void; + } + }), + ); + }); + + return { start } satisfies LinearSyncReactorShape; +}); + +export const LinearSyncReactorLive = Layer.effect(LinearSyncReactor, make); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 300d1526bb9..8b636a7bbef 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { LinearSyncReactor } from "../Services/LinearSyncReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -64,6 +65,14 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(LinearSyncReactor, { + start: () => { + started.push("linear-sync-reactor"); + return Effect.void; + }, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -85,6 +94,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "linear-sync-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fb7543e31af..1af015bb847 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { LinearSyncReactor } from "../Services/LinearSyncReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const linearSyncReactor = yield* LinearSyncReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* linearSyncReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Services/LinearSyncReactor.ts b/apps/server/src/orchestration/Services/LinearSyncReactor.ts new file mode 100644 index 00000000000..481b6845c7a --- /dev/null +++ b/apps/server/src/orchestration/Services/LinearSyncReactor.ts @@ -0,0 +1,17 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +/** + * LinearSyncReactor - background service that writes T3 Code thread progress + * back to linked Linear issues (In Progress on start → In Review on PR open → + * Done on merge). + */ +export interface LinearSyncReactorShape { + /** Start reacting to lifecycle + VCS signals. Must be run in a scope. */ + readonly start: () => Effect.Effect; +} + +export class LinearSyncReactor extends Context.Service()( + "t3/orchestration/Services/LinearSyncReactor", +) {} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c632d8486c..72dad534871 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -49,6 +49,8 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { LinearSyncReactorLive } from "./orchestration/Layers/LinearSyncReactor.ts"; +import * as LinearApi from "./linear/LinearApi.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -162,6 +164,16 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge( + LinearSyncReactorLive.pipe( + Layer.provide( + LinearApi.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(FetchHttpClient.layer), + ), + ), + ), + ), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/web/src/components/settings/LinearSettings.tsx b/apps/web/src/components/settings/LinearSettings.tsx index 549eebae212..4083789bffa 100644 --- a/apps/web/src/components/settings/LinearSettings.tsx +++ b/apps/web/src/components/settings/LinearSettings.tsx @@ -2,12 +2,14 @@ import { useCallback, useState } from "react"; import { SquareKanbanIcon } from "lucide-react"; import { usePrimaryEnvironmentId } from "../../state/environments"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { linearEnvironment } from "../../state/linear"; import { useEnvironmentQuery } from "../../state/query"; import { useAtomCommand } from "../../state/use-atom-command"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; @@ -27,6 +29,13 @@ export function LinearSettingsPanel() { const status = authQuery.data; const connected = status?.status === "authenticated"; + const linear = usePrimarySettings((settings) => settings.linear); + const updateSettings = useUpdatePrimarySettings(); + const setLinear = useCallback( + (patch: Partial) => updateSettings({ linear: { ...linear, ...patch } }), + [linear, updateSettings], + ); + const handleSave = useCallback(async () => { const trimmed = token.trim(); if (environmentId === null || trimmed.length === 0 || busy) return; @@ -142,6 +151,80 @@ export function LinearSettingsPanel() { )} + + + setLinear({ autoSync: checked })} + aria-label="Auto-sync issue status" + /> + } + /> + setLinear({ transitionOnStart: checked })} + aria-label="Move to In Progress on start" + /> + } + /> + setLinear({ transitionOnPrOpen: checked })} + aria-label="Move to In Review on pull request" + /> + } + /> + setLinear({ transitionOnMerge: checked })} + aria-label="Move to Done on merge" + /> + } + /> + setLinear({ linkAttachment: checked })} + aria-label="Link the thread on the issue" + /> + } + /> + setLinear({ postComments: checked })} + aria-label="Post progress comments" + /> + } + /> + ); } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..e34fe604ca4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -361,6 +361,27 @@ export const ObservabilitySettings = Schema.Struct({ }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; +/** Per-team override mapping lifecycle stages → specific Linear workflow-state ids. */ +export const LinearTeamStateMapping = Schema.Struct({ + started: Schema.optional(TrimmedNonEmptyString), + review: Schema.optional(TrimmedNonEmptyString), + done: Schema.optional(TrimmedNonEmptyString), +}); +export type LinearTeamStateMapping = typeof LinearTeamStateMapping.Type; + +export const LinearSyncSettings = Schema.Struct({ + autoSync: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + transitionOnStart: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + transitionOnPrOpen: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + transitionOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + postComments: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + linkAttachment: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + stateMappingByTeam: Schema.Record(TrimmedNonEmptyString, LinearTeamStateMapping).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), +}); +export type LinearSyncSettings = typeof LinearSyncSettings.Type; + export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const ServerSettings = Schema.Struct({ @@ -409,6 +430,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + linear: LinearSyncSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -530,6 +552,19 @@ export const ServerSettingsPatch = Schema.Struct({ // patches risk leaving driver-specific config in a half-merged state. // The web UI sends a fully-formed map every time it edits this field. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), + linear: Schema.optionalKey( + Schema.Struct({ + autoSync: Schema.optionalKey(Schema.Boolean), + transitionOnStart: Schema.optionalKey(Schema.Boolean), + transitionOnPrOpen: Schema.optionalKey(Schema.Boolean), + transitionOnMerge: Schema.optionalKey(Schema.Boolean), + postComments: Schema.optionalKey(Schema.Boolean), + linkAttachment: Schema.optionalKey(Schema.Boolean), + stateMappingByTeam: Schema.optionalKey( + Schema.Record(TrimmedNonEmptyString, LinearTeamStateMapping), + ), + }), + ), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; From 1f04d05776bd8e32dfa3724e89eb384d56ce7df4 Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Sun, 5 Jul 2026 23:40:28 +0530 Subject: [PATCH 06/16] fix(linear): address PR review (Macroscope + Bugbot) - LinearSyncReactor: inline the service interface in Context.Service per Effect conventions (drop standalone LinearSyncReactorShape) - reactor: key PR watchers by worktree so a later/changed worktree re-registers instead of being ignored (High); set appliedRank after the write + badge reflect so a failed issueUpdate can retry - drop the link-attachment write-back (+ setting/UI): it linked the issue to itself; a real thread URL isn't available server-side without OAuth/hosting - LinearBrowser: replace (not merge) rows when the filter changes so stale pages can't leak across filters; only offer 'Load more' when an endCursor exists - perIssue import: attempt all issues and report a summary instead of bailing mid-loop; surface issues that couldn't be loaded --- .../orchestration/Layers/LinearSyncReactor.ts | 38 +++++++++---------- .../Services/LinearSyncReactor.ts | 15 ++++---- .../src/components/linear/LinearBrowser.tsx | 28 +++++++++----- .../components/settings/LinearSettings.tsx | 12 ------ apps/web/src/hooks/useLinearImport.ts | 22 ++++++++++- packages/contracts/src/settings.ts | 2 - 6 files changed, 65 insertions(+), 52 deletions(-) diff --git a/apps/server/src/orchestration/Layers/LinearSyncReactor.ts b/apps/server/src/orchestration/Layers/LinearSyncReactor.ts index 6dd20ee6c93..5dda5ba93c4 100644 --- a/apps/server/src/orchestration/Layers/LinearSyncReactor.ts +++ b/apps/server/src/orchestration/Layers/LinearSyncReactor.ts @@ -22,7 +22,7 @@ import * as ServerSettings from "../../serverSettings.ts"; import * as VcsStatusBroadcaster from "../../vcs/VcsStatusBroadcaster.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../Services/ProjectionSnapshotQuery.ts"; -import { LinearSyncReactor, type LinearSyncReactorShape } from "../Services/LinearSyncReactor.ts"; +import { LinearSyncReactor } from "../Services/LinearSyncReactor.ts"; const STAGE_RANK: Record = { started: 1, review: 2, done: 3 }; @@ -105,28 +105,23 @@ const make = Effect.gen(function* () { const result = yield* linear.updateIssueState({ issueId: issue.id, stateId }); if (!result.success) return; - appliedRank.set(issue.id, rank); - if (stage === "started") { - if (settings.linkAttachment) { - yield* linear - .createAttachment({ issueId: issue.id, url: issue.url, title: "T3 Code" }) - .pipe(Effect.ignore); - } - if (settings.postComments) { - yield* linear - .createComment({ - issueId: issue.id, - body: `T3 Code started working on this issue in thread “${thread.title}”.`, - }) - .pipe(Effect.ignore); - } + if (stage === "started" && settings.postComments) { + yield* linear + .createComment({ + issueId: issue.id, + body: `T3 Code started working on this issue in thread “${thread.title}”.`, + }) + .pipe(Effect.ignore); } const nextState = states.find((state) => state.id === stateId); if (nextState !== undefined) { yield* reflectState(thread.id, issue, nextState); } + // Mark applied only after the write + best-effort badge reflect, so a + // failed issueUpdate can be retried on the next signal. + appliedRank.set(issue.id, rank); }).pipe( Effect.catchCause((cause) => Effect.logWarning("linear sync failed to apply stage", { @@ -141,8 +136,11 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const worktreePath = thread.worktreePath; if (worktreePath === null || thread.linearIssue == null) return; - if (watchedThreads.has(thread.id)) return; - watchedThreads.add(thread.id); + // Key by worktree so a thread that later moves/creates its worktree gets + // a fresh watcher instead of being ignored on the stale path. + const watchKey = `${thread.id}:${worktreePath}`; + if (watchedThreads.has(watchKey)) return; + watchedThreads.add(watchKey); yield* Effect.forkScoped( Stream.runForEach(vcs.streamStatus({ cwd: worktreePath }), (event) => { @@ -179,7 +177,7 @@ const make = Effect.gen(function* () { Effect.catchCause(() => Effect.void), ); - const start: LinearSyncReactorShape["start"] = Effect.fn("start")(function* () { + const start: LinearSyncReactor["Service"]["start"] = Effect.fn("start")(function* () { yield* Effect.forkScoped( Stream.runForEach(engine.streamDomainEvents, (event) => { switch (event.type) { @@ -195,7 +193,7 @@ const make = Effect.gen(function* () { ); }); - return { start } satisfies LinearSyncReactorShape; + return { start } satisfies LinearSyncReactor["Service"]; }); export const LinearSyncReactorLive = Layer.effect(LinearSyncReactor, make); diff --git a/apps/server/src/orchestration/Services/LinearSyncReactor.ts b/apps/server/src/orchestration/Services/LinearSyncReactor.ts index 481b6845c7a..8d7a3cb3e11 100644 --- a/apps/server/src/orchestration/Services/LinearSyncReactor.ts +++ b/apps/server/src/orchestration/Services/LinearSyncReactor.ts @@ -7,11 +7,10 @@ import type * as Scope from "effect/Scope"; * back to linked Linear issues (In Progress on start → In Review on PR open → * Done on merge). */ -export interface LinearSyncReactorShape { - /** Start reacting to lifecycle + VCS signals. Must be run in a scope. */ - readonly start: () => Effect.Effect; -} - -export class LinearSyncReactor extends Context.Service()( - "t3/orchestration/Services/LinearSyncReactor", -) {} +export class LinearSyncReactor extends Context.Service< + LinearSyncReactor, + { + /** Start reacting to lifecycle + VCS signals. Must be run in a scope. */ + readonly start: () => Effect.Effect; + } +>()("t3/orchestration/Services/LinearSyncReactor") {} diff --git a/apps/web/src/components/linear/LinearBrowser.tsx b/apps/web/src/components/linear/LinearBrowser.tsx index b06130bbfb4..b76ba994b7c 100644 --- a/apps/web/src/components/linear/LinearBrowser.tsx +++ b/apps/web/src/components/linear/LinearBrowser.tsx @@ -6,7 +6,7 @@ import type { } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import { SearchIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useLinearImport } from "../../hooks/useLinearImport"; import { usePrimaryEnvironmentId } from "../../state/environments"; @@ -159,25 +159,32 @@ export function LinearBrowser() { : null, ); - // Reset accumulation whenever the filter changes. + // Which filter the accumulated rows belong to, so a page fetched under a + // previous filter can never merge into results for the current one. + const accumFilterKey = useRef(null); + + // Reset paging + selection whenever the filter changes. useEffect(() => { - setRows([]); setCursor(undefined); setSelected(new Set()); }, [filterKey]); - // Accumulate pages (dedupe by id). + // Accumulate pages (dedupe by id); replace instead of append when the filter + // changed since the last accumulated page. useEffect(() => { const data = listQuery.data; if (!data) return; setRows((prev) => { - const seen = new Set(prev.map((issue) => issue.id)); - const merged = [...prev]; + const base = accumFilterKey.current === filterKey ? prev : []; + const seen = new Set(base.map((issue) => issue.id)); + const merged = [...base]; for (const issue of data.issues) if (!seen.has(issue.id)) merged.push(issue); return merged; }); - setHasNext(data.pageInfo.hasNextPage); - }, [listQuery.data]); + accumFilterKey.current = filterKey; + // Only offer "load more" when the API actually returned a cursor to advance. + setHasNext(data.pageInfo.hasNextPage && data.pageInfo.endCursor != null); + }, [listQuery.data, filterKey]); useEffect(() => { if (targetProjectId === null && projects.length > 0) { @@ -335,7 +342,10 @@ export function LinearBrowser() { size="sm" variant="outline" disabled={listQuery.isPending} - onClick={() => setCursor(listQuery.data?.pageInfo.endCursor)} + onClick={() => { + const next = listQuery.data?.pageInfo.endCursor; + if (next != null) setCursor(next); + }} > {listQuery.isPending ? "Loading…" : "Load more"} diff --git a/apps/web/src/components/settings/LinearSettings.tsx b/apps/web/src/components/settings/LinearSettings.tsx index 4083789bffa..fd6fd44d9c6 100644 --- a/apps/web/src/components/settings/LinearSettings.tsx +++ b/apps/web/src/components/settings/LinearSettings.tsx @@ -200,18 +200,6 @@ export function LinearSettingsPanel() { /> } /> - setLinear({ linkAttachment: checked })} - aria-label="Link the thread on the issue" - /> - } - /> 0) { + return { + ok: false, + error: `Created ${createdCount} of ${issues.length} threads; failed: ${failed.join(", ")}.`, + }; + } + const missing = input.ids.length - issues.length; + if (missing > 0) { + return { + ok: false, + error: `Imported ${issues.length}; ${missing} selected issue(s) could not be loaded.`, + }; + } return { ok: true }; } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e34fe604ca4..088d9fc87aa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -375,7 +375,6 @@ export const LinearSyncSettings = Schema.Struct({ transitionOnPrOpen: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), transitionOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), postComments: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - linkAttachment: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), stateMappingByTeam: Schema.Record(TrimmedNonEmptyString, LinearTeamStateMapping).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), @@ -559,7 +558,6 @@ export const ServerSettingsPatch = Schema.Struct({ transitionOnPrOpen: Schema.optionalKey(Schema.Boolean), transitionOnMerge: Schema.optionalKey(Schema.Boolean), postComments: Schema.optionalKey(Schema.Boolean), - linkAttachment: Schema.optionalKey(Schema.Boolean), stateMappingByTeam: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, LinearTeamStateMapping), ), From e184aa7cf81a9e819fc82aef304ea7cef4b84347 Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Sun, 5 Jul 2026 23:49:34 +0530 Subject: [PATCH 07/16] fix(linear): treat partial import as success with a warning Partial perIssue imports (some threads created, or some issues not loaded) now return ok:true with a `warning`, so consumers navigate and show a non-blocking warning toast instead of a full-failure error. Only a total failure returns ok:false. (addresses Bugbot follow-up) --- .../web/src/components/LinearBrowsePopover.tsx | 7 +++++++ .../src/components/linear/LinearBrowser.tsx | 18 +++++++++++------- apps/web/src/hooks/useLinearImport.ts | 13 +++++++++---- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/LinearBrowsePopover.tsx b/apps/web/src/components/LinearBrowsePopover.tsx index 8abf7ecf25b..0f052a78d4d 100644 --- a/apps/web/src/components/LinearBrowsePopover.tsx +++ b/apps/web/src/components/LinearBrowsePopover.tsx @@ -130,6 +130,13 @@ export function LinearBrowsePopover({ mode: combine ? "combine" : "perIssue", }); if (result.ok) { + if (result.warning) { + toastManager.add({ + type: "warning", + title: "Imported with issues", + description: result.warning, + }); + } handleOpenChange(false); } else { toastManager.add({ diff --git a/apps/web/src/components/linear/LinearBrowser.tsx b/apps/web/src/components/linear/LinearBrowser.tsx index b76ba994b7c..627233ba078 100644 --- a/apps/web/src/components/linear/LinearBrowser.tsx +++ b/apps/web/src/components/linear/LinearBrowser.tsx @@ -246,13 +246,17 @@ export function LinearBrowser() { mode: perIssue ? "perIssue" : "combine", }); if (result.ok) { - toastManager.add({ - type: "success", - title: perIssue ? "Threads created" : "Draft ready", - description: perIssue - ? `Started ${selected.size} thread${selected.size === 1 ? "" : "s"} from Linear.` - : "Review the pre-filled composer and send.", - }); + toastManager.add( + result.warning + ? { type: "warning", title: "Imported with issues", description: result.warning } + : { + type: "success", + title: perIssue ? "Threads created" : "Draft ready", + description: perIssue + ? `Started ${selected.size} thread${selected.size === 1 ? "" : "s"} from Linear.` + : "Review the pre-filled composer and send.", + }, + ); setSelected(new Set()); void navigate({ to: "/" }); } else { diff --git a/apps/web/src/hooks/useLinearImport.ts b/apps/web/src/hooks/useLinearImport.ts index 59e56dacaf8..db1d94b153e 100644 --- a/apps/web/src/hooks/useLinearImport.ts +++ b/apps/web/src/hooks/useLinearImport.ts @@ -35,8 +35,12 @@ export interface LinearImportTarget { export type LinearBulkImportMode = "combine" | "perIssue"; export interface LinearImportResult { + /** True when the import proceeded (at least one thread was created). */ readonly ok: boolean; + /** Hard failure detail (shown as an error toast); only set when `ok` is false. */ readonly error?: string; + /** Soft/partial detail (shown as a non-blocking notice) when `ok` is true. */ + readonly warning?: string; } function issueTitle(issue: LinearIssueDetail): string { @@ -146,17 +150,18 @@ export function useLinearImport() { if (createdCount === 0) { return { ok: false, error: "Failed to create any threads from Linear." }; } + // Partial success still created threads: succeed, but surface a notice. if (failed.length > 0) { return { - ok: false, - error: `Created ${createdCount} of ${issues.length} threads; failed: ${failed.join(", ")}.`, + ok: true, + warning: `Created ${createdCount} of ${issues.length} threads; failed: ${failed.join(", ")}.`, }; } const missing = input.ids.length - issues.length; if (missing > 0) { return { - ok: false, - error: `Imported ${issues.length}; ${missing} selected issue(s) could not be loaded.`, + ok: true, + warning: `Imported ${issues.length}; ${missing} selected issue(s) could not be loaded.`, }; } return { ok: true }; From 13aba65b347be568254c3df184eeebd2ac8258af Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Sun, 5 Jul 2026 23:55:19 +0530 Subject: [PATCH 08/16] fix(linear): make stateMappingByTeam a whole-map replacement on patch Server settings updates deepMerge (never delete missing keys), so a nested patch could add but not remove team state-mapping overrides. applyServerSettingsPatch now replaces linear.stateMappingByTeam wholesale (like providerInstances), so an empty map clears stale overrides. (addresses Macroscope review) --- packages/shared/src/serverSettings.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1bbf466f60b..74fbaa2532b 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -83,6 +83,11 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + // Whole-map replacement so sending an empty map clears stale team overrides + // (deepMerge would otherwise keep removed keys). + ...(patch.linear?.stateMappingByTeam !== undefined + ? { linear: { ...next.linear, stateMappingByTeam: patch.linear.stateMappingByTeam } } + : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), }; if (!selectionPatch) { From 511410b8ad432b435f1130f8979f1205aa2654be Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Mon, 6 Jul 2026 00:00:01 +0530 Subject: [PATCH 09/16] fix(linear): don't mask connectivity errors; retain failed rows on partial import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - probeAuth now only swallows LinearAuthError (rejected token → unauthenticated) and lets LinearRequestError propagate, so a transient outage/misconfig isn't shown as 'Not connected' (Settings surfaces the error). Widened setToken/ clearToken error channels + clearToken RPC union accordingly. - useLinearImport returns failedIds; LinearBrowser/LinearBrowsePopover keep just the failed issues selected (and don't navigate/close) on a partial import so they can be retried without re-running the ones that already succeeded. (addresses Macroscope + Bugbot follow-ups) --- apps/server/src/linear/LinearApi.ts | 26 ++++++++++++------ .../src/components/LinearBrowsePopover.tsx | 9 ++++++- .../src/components/linear/LinearBrowser.tsx | 12 +++++++-- apps/web/src/hooks/useLinearImport.ts | 27 ++++++++++++------- packages/contracts/src/rpc.ts | 2 +- 5 files changed, 55 insertions(+), 21 deletions(-) diff --git a/apps/server/src/linear/LinearApi.ts b/apps/server/src/linear/LinearApi.ts index 63bd16cf4a2..52e4a57da16 100644 --- a/apps/server/src/linear/LinearApi.ts +++ b/apps/server/src/linear/LinearApi.ts @@ -536,7 +536,7 @@ function isAuthMessage(message: string | undefined): boolean { export class LinearApi extends Context.Service< LinearApi, { - readonly probeAuth: Effect.Effect; + readonly probeAuth: Effect.Effect; readonly searchIssues: (input: { readonly query: string; readonly limit: number; @@ -603,8 +603,13 @@ export class LinearApi extends Context.Service< LinearMutationResult, LinearAuthError | LinearRequestError | LinearTokenStoreError >; - readonly setToken: (token: string) => Effect.Effect; - readonly clearToken: Effect.Effect; + readonly setToken: ( + token: string, + ) => Effect.Effect; + readonly clearToken: Effect.Effect< + LinearAuthStatus, + LinearTokenStoreError | LinearRequestError + >; } >()("t3/linear/LinearApi") {} @@ -749,11 +754,16 @@ export const make = Effect.gen(function* () { }, }; }), - Effect.orElseSucceed( - (): LinearAuthStatus => ({ - status: "unauthenticated", - detail: "The stored Linear token was rejected.", - }), + // A rejected token → "unauthenticated"; genuine connectivity/API + // errors propagate so a transient outage isn't shown as "not + // connected" (which would tempt users to replace a valid token). + Effect.catch((error) => + error._tag === "LinearAuthError" + ? Effect.succeed({ + status: "unauthenticated", + detail: "The stored Linear token was rejected.", + }) + : Effect.fail(error), ), ), }), diff --git a/apps/web/src/components/LinearBrowsePopover.tsx b/apps/web/src/components/LinearBrowsePopover.tsx index 0f052a78d4d..936b11f8c30 100644 --- a/apps/web/src/components/LinearBrowsePopover.tsx +++ b/apps/web/src/components/LinearBrowsePopover.tsx @@ -129,6 +129,7 @@ export function LinearBrowsePopover({ ids: [...selected], mode: combine ? "combine" : "perIssue", }); + const failedIds = result.failedIds ?? []; if (result.ok) { if (result.warning) { toastManager.add({ @@ -137,13 +138,19 @@ export function LinearBrowsePopover({ description: result.warning, }); } - handleOpenChange(false); + if (failedIds.length > 0) { + // Keep the failed issues selected so the user can retry them. + setSelected(new Set(failedIds)); + } else { + handleOpenChange(false); + } } else { toastManager.add({ type: "error", title: "Linear import failed", description: result.error ?? "The issues could not be imported.", }); + if (failedIds.length > 0) setSelected(new Set(failedIds)); } } finally { setImporting(false); diff --git a/apps/web/src/components/linear/LinearBrowser.tsx b/apps/web/src/components/linear/LinearBrowser.tsx index 627233ba078..a6f4b743f6b 100644 --- a/apps/web/src/components/linear/LinearBrowser.tsx +++ b/apps/web/src/components/linear/LinearBrowser.tsx @@ -245,6 +245,7 @@ export function LinearBrowser() { ids: [...selected], mode: perIssue ? "perIssue" : "combine", }); + const failedIds = result.failedIds ?? []; if (result.ok) { toastManager.add( result.warning @@ -257,14 +258,21 @@ export function LinearBrowser() { : "Review the pre-filled composer and send.", }, ); - setSelected(new Set()); - void navigate({ to: "/" }); + if (failedIds.length > 0) { + // Keep only the failed issues selected so a retry re-imports just + // those; stay on the browser rather than navigating away. + setSelected(new Set(failedIds)); + } else { + setSelected(new Set()); + void navigate({ to: "/" }); + } } else { toastManager.add({ type: "error", title: "Linear import failed", description: result.error ?? "The issues could not be imported.", }); + if (failedIds.length > 0) setSelected(new Set(failedIds)); } } finally { setImporting(false); diff --git a/apps/web/src/hooks/useLinearImport.ts b/apps/web/src/hooks/useLinearImport.ts index db1d94b153e..bdf76beafa6 100644 --- a/apps/web/src/hooks/useLinearImport.ts +++ b/apps/web/src/hooks/useLinearImport.ts @@ -41,6 +41,8 @@ export interface LinearImportResult { readonly error?: string; /** Soft/partial detail (shown as a non-blocking notice) when `ok` is true. */ readonly warning?: string; + /** Issue ids that failed to import or load, so the UI can keep them selected. */ + readonly failedIds?: ReadonlyArray; } function issueTitle(issue: LinearIssueDetail): string { @@ -109,6 +111,7 @@ export function useLinearImport() { // Attempt every issue; report a summary rather than bailing mid-loop and // leaving the caller unsure which threads were actually created. const failed: string[] = []; + const failedIds: string[] = []; for (const issue of issues) { const createdAt = new Date().toISOString(); const title = issueTitle(issue); @@ -144,24 +147,30 @@ export function useLinearImport() { }); if (startResult._tag !== "Success") { failed.push(issue.identifier); + failedIds.push(issue.id); } } + // Selected ids Linear never returned (couldn't be loaded). + const returnedIds = new Set(issues.map((issue) => issue.id)); + const missingIds = input.ids.filter((id) => !returnedIds.has(id)); const createdCount = issues.length - failed.length; if (createdCount === 0) { - return { ok: false, error: "Failed to create any threads from Linear." }; - } - // Partial success still created threads: succeed, but surface a notice. - if (failed.length > 0) { return { - ok: true, - warning: `Created ${createdCount} of ${issues.length} threads; failed: ${failed.join(", ")}.`, + ok: false, + error: "Failed to create any threads from Linear.", + failedIds: [...failedIds, ...missingIds], }; } - const missing = input.ids.length - issues.length; - if (missing > 0) { + const problems: string[] = []; + if (failed.length > 0) problems.push(`failed: ${failed.join(", ")}`); + if (missingIds.length > 0) problems.push(`${missingIds.length} couldn't be loaded`); + // Partial success still created threads: succeed, but surface a notice + // and hand back the failed ids so the UI keeps them selected for retry. + if (problems.length > 0) { return { ok: true, - warning: `Imported ${issues.length}; ${missing} selected issue(s) could not be loaded.`, + warning: `Created ${createdCount} of ${input.ids.length} threads (${problems.join("; ")}).`, + failedIds: [...failedIds, ...missingIds], }; } return { ok: true }; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a5b97d4d7f0..1484c95d95d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -430,7 +430,7 @@ export const WsLinearSetTokenRpc = Rpc.make(WS_METHODS.linearSetToken, { export const WsLinearClearTokenRpc = Rpc.make(WS_METHODS.linearClearToken, { payload: Schema.Struct({}), success: LinearAuthStatus, - error: Schema.Union([LinearTokenStoreError, EnvironmentAuthorizationError]), + error: Schema.Union([LinearRequestError, LinearTokenStoreError, EnvironmentAuthorizationError]), }); const LinearReadError = Schema.Union([ From f948fce7d181bbcacc8a044f07ab9d12177e5fc4 Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Mon, 6 Jul 2026 00:19:18 +0530 Subject: [PATCH 10/16] fix(linear): distinguish outage from disconnected; stop mapping wipes on toggle - setToken/clearToken no longer fail on a post-write probe outage (probeAuth stays strict for the status query; the writes swallow LinearRequestError and report a best-effort status). Reverted their error channels + clearToken RPC. - LinearBrowsePopover surfaces auth query errors ('Couldn't reach Linear') before the 'not connected' prompt, and prunes selections that leave the current result set so Import can't act on invisible issues. - Linear settings toggles send only the changed key via the server settings command, so stateMappingByTeam is no longer carried (and wiped) on every toggle; explicit map edits still replace wholesale. (addresses Macroscope + Bugbot follow-ups on the prior fixes) --- apps/server/src/linear/LinearApi.ts | 27 ++++++++++++------- .../src/components/LinearBrowsePopover.tsx | 22 +++++++++++++++ .../components/settings/LinearSettings.tsx | 24 ++++++++++++++--- packages/contracts/src/rpc.ts | 2 +- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/server/src/linear/LinearApi.ts b/apps/server/src/linear/LinearApi.ts index 52e4a57da16..575919c3c36 100644 --- a/apps/server/src/linear/LinearApi.ts +++ b/apps/server/src/linear/LinearApi.ts @@ -603,13 +603,8 @@ export class LinearApi extends Context.Service< LinearMutationResult, LinearAuthError | LinearRequestError | LinearTokenStoreError >; - readonly setToken: ( - token: string, - ) => Effect.Effect; - readonly clearToken: Effect.Effect< - LinearAuthStatus, - LinearTokenStoreError | LinearRequestError - >; + readonly setToken: (token: string) => Effect.Effect; + readonly clearToken: Effect.Effect; } >()("t3/linear/LinearApi") {} @@ -978,8 +973,22 @@ export const make = Effect.gen(function* () { ), ); + // After a token write we surface the resulting auth status, but a probe that + // can't reach Linear (outage) must not turn the write itself into a failure — + // the standalone `probeAuth` RPC still reports connectivity errors. + const probeAuthLenient: Effect.Effect = probeAuth.pipe( + Effect.catch((error) => + error._tag === "LinearRequestError" + ? Effect.succeed({ + status: "unauthenticated", + detail: "Saved, but couldn't reach Linear to verify the token.", + }) + : Effect.fail(error), + ), + ); + const setToken: LinearApi["Service"]["setToken"] = (token) => - persistToken("setToken", token.trim()).pipe(Effect.flatMap(() => probeAuth)); + persistToken("setToken", token.trim()).pipe(Effect.flatMap(() => probeAuthLenient)); const clearToken: LinearApi["Service"]["clearToken"] = secrets .remove(LINEAR_API_TOKEN_SECRET) @@ -992,7 +1001,7 @@ export const make = Effect.gen(function* () { cause, }), ), - Effect.flatMap(() => probeAuth), + Effect.flatMap(() => probeAuthLenient), ); return LinearApi.of({ diff --git a/apps/web/src/components/LinearBrowsePopover.tsx b/apps/web/src/components/LinearBrowsePopover.tsx index 936b11f8c30..81ecd5d40ba 100644 --- a/apps/web/src/components/LinearBrowsePopover.tsx +++ b/apps/web/src/components/LinearBrowsePopover.tsx @@ -96,6 +96,19 @@ export function LinearBrowsePopover({ const issues = searchQuery.data?.issues ?? []; const selectedCount = selected.size; + // When a new result set arrives, drop selections that are no longer visible + // so Import can't act on issues that scrolled out of the current search. + useEffect(() => { + const data = searchQuery.data; + if (!data) return; + setSelected((current) => { + if (current.size === 0) return current; + const visible = new Set(data.issues.map((issue) => issue.id)); + const filtered = [...current].filter((id) => visible.has(id)); + return filtered.length === current.size ? current : new Set(filtered); + }); + }, [searchQuery.data]); + const toggle = useCallback((id: string) => { setSelected((current) => { const next = new Set(current); @@ -174,6 +187,14 @@ export function LinearBrowsePopover({
); } + if (authQuery.error !== null) { + return ( + +

Couldn’t reach Linear

+

{authQuery.error}

+
+ ); + } if (!connected) { return ( @@ -227,6 +248,7 @@ export function LinearBrowsePopover({ ); }, [ authQuery.data, + authQuery.error, authQuery.isPending, connected, debounced, diff --git a/apps/web/src/components/settings/LinearSettings.tsx b/apps/web/src/components/settings/LinearSettings.tsx index fd6fd44d9c6..05f9a2fdc84 100644 --- a/apps/web/src/components/settings/LinearSettings.tsx +++ b/apps/web/src/components/settings/LinearSettings.tsx @@ -2,8 +2,9 @@ import { useCallback, useState } from "react"; import { SquareKanbanIcon } from "lucide-react"; import { usePrimaryEnvironmentId } from "../../state/environments"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { usePrimarySettings } from "../../hooks/useSettings"; import { linearEnvironment } from "../../state/linear"; +import { serverEnvironment } from "../../state/server"; import { useEnvironmentQuery } from "../../state/query"; import { useAtomCommand } from "../../state/use-atom-command"; import { Badge } from "../ui/badge"; @@ -30,10 +31,25 @@ export function LinearSettingsPanel() { const connected = status?.status === "authenticated"; const linear = usePrimarySettings((settings) => settings.linear); - const updateSettings = useUpdatePrimarySettings(); + const updateServerSettings = useAtomCommand( + serverEnvironment.updateSettings, + "linear settings update", + ); + // Send only the changed key(s). Sending the whole `linear` object would carry + // `stateMappingByTeam` on every toggle, which the whole-map replacement would + // then overwrite (wiping server-side per-team overrides). const setLinear = useCallback( - (patch: Partial) => updateSettings({ linear: { ...linear, ...patch } }), - [linear, updateSettings], + (patch: { + autoSync?: boolean; + transitionOnStart?: boolean; + transitionOnPrOpen?: boolean; + transitionOnMerge?: boolean; + postComments?: boolean; + }) => { + if (environmentId === null) return; + void updateServerSettings({ environmentId, input: { patch: { linear: patch } } }); + }, + [environmentId, updateServerSettings], ); const handleSave = useCallback(async () => { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1484c95d95d..a5b97d4d7f0 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -430,7 +430,7 @@ export const WsLinearSetTokenRpc = Rpc.make(WS_METHODS.linearSetToken, { export const WsLinearClearTokenRpc = Rpc.make(WS_METHODS.linearClearToken, { payload: Schema.Struct({}), success: LinearAuthStatus, - error: Schema.Union([LinearRequestError, LinearTokenStoreError, EnvironmentAuthorizationError]), + error: Schema.Union([LinearTokenStoreError, EnvironmentAuthorizationError]), }); const LinearReadError = Schema.Union([ From a037ddbec1a6f0a02da9f7fdb71209193d0a411e Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Mon, 6 Jul 2026 00:25:27 +0530 Subject: [PATCH 11/16] docs(linear): document bulk browser + status write-back settings --- docs/integrations/linear.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/integrations/linear.md b/docs/integrations/linear.md index e1c8a7af519..bb3f59bdbb7 100644 --- a/docs/integrations/linear.md +++ b/docs/integrations/linear.md @@ -13,6 +13,13 @@ sidebar, select one or more, and a new thread opens with its full context alread into the composer as markdown. Review and edit before you send. - **Combine or split** – When you select more than one issue, choose whether to **combine** them into one task or treat them as **related subtasks**. +- **Browse in bulk** – Open the full Linear browser to filter by team, status, and assignee, page + through large backlogs, and multi-select issues to import as one thread per issue or a single + combined thread. +- **Status write-back** – As work progresses, T3 Code moves the linked issue through your workflow: + **In Progress** when the agent starts, **In Review** when a pull request opens, and **Done** when + it merges. States are mapped per team by their category (so renamed states still work). Toggle + each transition (and optional progress comments) in **Settings → Linear → Status write-back**. ## Getting Started From 766d42ec295ec1bf10ed16545f7cde2ff62b7465 Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Mon, 6 Jul 2026 00:40:20 +0530 Subject: [PATCH 12/16] fix(linear): catchTags convention + non-auth probe errors + retry-selection polish - probeAuth/probeAuthLenient use Effect.catchTags to recover specific error tags (Effect Service Conventions) instead of Effect.catch + manual _tag - probeAuth only treats auth-message GraphQL errors as a rejected token; other 200-with-errors responses propagate as LinearRequestError (outage) - Settings save: a stored-but-unverified token (outage) is a saved success with a warning, not a connect failure - import retry-selection: keep only failed issues still visible in the current results/rows selected, so no un-clearable off-screen selection and the popover prune can't drop them --- apps/server/src/linear/LinearApi.ts | 58 +++++++++++-------- .../src/components/LinearBrowsePopover.tsx | 13 +++-- .../src/components/linear/LinearBrowser.tsx | 16 ++--- .../components/settings/LinearSettings.tsx | 26 ++++++--- 4 files changed, 68 insertions(+), 45 deletions(-) diff --git a/apps/server/src/linear/LinearApi.ts b/apps/server/src/linear/LinearApi.ts index 575919c3c36..4ce86e9ad91 100644 --- a/apps/server/src/linear/LinearApi.ts +++ b/apps/server/src/linear/LinearApi.ts @@ -732,34 +732,43 @@ export const make = Effect.gen(function* () { }), onSome: (token) => runGraphql("probeAuth", token, VIEWER_DOCUMENT, {}, viewerEnvelope).pipe( - Effect.map((envelope): LinearAuthStatus => { + Effect.flatMap((envelope): Effect.Effect => { if (envelope.errors !== undefined && envelope.errors.length > 0) { - return { - status: "unauthenticated", - detail: "The stored Linear token was rejected.", - }; + const message = firstGraphQlErrorMessage(envelope.errors); + // Only auth-related GraphQL errors mean a rejected token; + // other errors are real API/outage failures and propagate. + return isAuthMessage(message) + ? Effect.succeed({ + status: "unauthenticated", + detail: "The stored Linear token was rejected.", + }) + : Effect.fail( + new LinearRequestError({ + operation: "probeAuth", + detail: message ?? "Linear reported an error for the request.", + }), + ); } const viewer = envelope.data?.viewer ?? undefined; const name = clean(viewer?.name); - return { + return Effect.succeed({ status: "authenticated", account: { name: name ?? "Linear account", ...(clean(viewer?.email) ? { email: clean(viewer?.email) } : {}), }, - }; + }); }), // A rejected token → "unauthenticated"; genuine connectivity/API - // errors propagate so a transient outage isn't shown as "not - // connected" (which would tempt users to replace a valid token). - Effect.catch((error) => - error._tag === "LinearAuthError" - ? Effect.succeed({ - status: "unauthenticated", - detail: "The stored Linear token was rejected.", - }) - : Effect.fail(error), - ), + // errors (LinearRequestError) stay in the channel so a transient + // outage isn't shown as "not connected". + Effect.catchTags({ + LinearAuthError: () => + Effect.succeed({ + status: "unauthenticated", + detail: "The stored Linear token was rejected.", + }), + }), ), }), ), @@ -977,14 +986,13 @@ export const make = Effect.gen(function* () { // can't reach Linear (outage) must not turn the write itself into a failure — // the standalone `probeAuth` RPC still reports connectivity errors. const probeAuthLenient: Effect.Effect = probeAuth.pipe( - Effect.catch((error) => - error._tag === "LinearRequestError" - ? Effect.succeed({ - status: "unauthenticated", - detail: "Saved, but couldn't reach Linear to verify the token.", - }) - : Effect.fail(error), - ), + Effect.catchTags({ + LinearRequestError: () => + Effect.succeed({ + status: "unauthenticated", + detail: "Saved, but couldn't reach Linear to verify the token.", + }), + }), ); const setToken: LinearApi["Service"]["setToken"] = (token) => diff --git a/apps/web/src/components/LinearBrowsePopover.tsx b/apps/web/src/components/LinearBrowsePopover.tsx index 81ecd5d40ba..f9874c827c4 100644 --- a/apps/web/src/components/LinearBrowsePopover.tsx +++ b/apps/web/src/components/LinearBrowsePopover.tsx @@ -142,7 +142,10 @@ export function LinearBrowsePopover({ ids: [...selected], mode: combine ? "combine" : "perIssue", }); - const failedIds = result.failedIds ?? []; + // Only keep failed issues that are still visible in the current results, + // so we never leave an un-clearable selection of off-screen rows. + const visibleIds = new Set(issues.map((issue) => issue.id)); + const retryable = (result.failedIds ?? []).filter((id) => visibleIds.has(id)); if (result.ok) { if (result.warning) { toastManager.add({ @@ -151,9 +154,8 @@ export function LinearBrowsePopover({ description: result.warning, }); } - if (failedIds.length > 0) { - // Keep the failed issues selected so the user can retry them. - setSelected(new Set(failedIds)); + if (retryable.length > 0) { + setSelected(new Set(retryable)); } else { handleOpenChange(false); } @@ -163,7 +165,7 @@ export function LinearBrowsePopover({ title: "Linear import failed", description: result.error ?? "The issues could not be imported.", }); - if (failedIds.length > 0) setSelected(new Set(failedIds)); + setSelected(new Set(retryable)); } } finally { setImporting(false); @@ -173,6 +175,7 @@ export function LinearBrowsePopover({ environmentId, handleOpenChange, importing, + issues, projectId, runImport, selected, diff --git a/apps/web/src/components/linear/LinearBrowser.tsx b/apps/web/src/components/linear/LinearBrowser.tsx index a6f4b743f6b..0fbc65c23cf 100644 --- a/apps/web/src/components/linear/LinearBrowser.tsx +++ b/apps/web/src/components/linear/LinearBrowser.tsx @@ -245,7 +245,10 @@ export function LinearBrowser() { ids: [...selected], mode: perIssue ? "perIssue" : "combine", }); - const failedIds = result.failedIds ?? []; + // Keep only failed issues still visible in the loaded rows selected, so a + // retry re-imports just those without stranding off-screen selections. + const visibleIds = new Set(rows.map((issue) => issue.id)); + const retryable = (result.failedIds ?? []).filter((id) => visibleIds.has(id)); if (result.ok) { toastManager.add( result.warning @@ -258,10 +261,9 @@ export function LinearBrowser() { : "Review the pre-filled composer and send.", }, ); - if (failedIds.length > 0) { - // Keep only the failed issues selected so a retry re-imports just - // those; stay on the browser rather than navigating away. - setSelected(new Set(failedIds)); + if (retryable.length > 0) { + // Stay on the browser with the failed rows selected for retry. + setSelected(new Set(retryable)); } else { setSelected(new Set()); void navigate({ to: "/" }); @@ -272,12 +274,12 @@ export function LinearBrowser() { title: "Linear import failed", description: result.error ?? "The issues could not be imported.", }); - if (failedIds.length > 0) setSelected(new Set(failedIds)); + setSelected(new Set(retryable)); } } finally { setImporting(false); } - }, [environmentId, importing, navigate, perIssue, runImport, selected, targetProject]); + }, [environmentId, importing, navigate, perIssue, rows, runImport, selected, targetProject]); let body: ReactNode; if (!environmentId || (authQuery.isPending && authQuery.data === null)) { diff --git a/apps/web/src/components/settings/LinearSettings.tsx b/apps/web/src/components/settings/LinearSettings.tsx index 05f9a2fdc84..5d0aedc168e 100644 --- a/apps/web/src/components/settings/LinearSettings.tsx +++ b/apps/web/src/components/settings/LinearSettings.tsx @@ -58,9 +58,19 @@ export function LinearSettingsPanel() { setBusy(true); try { const result = await setToken({ environmentId, input: { token: trimmed } }); - if (result._tag === "Success" && result.value.status === "authenticated") { - setTokenValue(""); - authQuery.refresh(); + if (result._tag !== "Success") { + toastManager.add({ + type: "error", + title: "Could not save token", + description: "The token could not be saved.", + }); + return; + } + // The token was stored. Verification is a separate concern — a transient + // outage shouldn't read as "connect failed". + setTokenValue(""); + authQuery.refresh(); + if (result.value.status === "authenticated") { toastManager.add({ type: "success", title: "Linear connected", @@ -69,11 +79,11 @@ export function LinearSettingsPanel() { : "Linear is now connected.", }); } else { - const detail = - result._tag === "Success" - ? (result.value.detail ?? "Linear rejected the token.") - : "The token could not be saved."; - toastManager.add({ type: "error", title: "Could not connect Linear", description: detail }); + toastManager.add({ + type: "warning", + title: "Token saved", + description: result.value.detail ?? "Saved, but Linear couldn’t verify it right now.", + }); } } finally { setBusy(false); From 43560020d9a4b050f69199ee63f2675ce8ab0a30 Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Mon, 6 Jul 2026 00:46:00 +0530 Subject: [PATCH 13/16] fix(linear): preserve selection on blanket import failure Only narrow the selection to failed issues when the hook actually reports failedIds; a total transient failure (e.g. fetchIssues failed, nothing imported) now keeps the current selection so it can be retried as-is. --- apps/web/src/components/LinearBrowsePopover.tsx | 5 ++++- apps/web/src/components/linear/LinearBrowser.tsx | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/LinearBrowsePopover.tsx b/apps/web/src/components/LinearBrowsePopover.tsx index f9874c827c4..6f917e5f1e4 100644 --- a/apps/web/src/components/LinearBrowsePopover.tsx +++ b/apps/web/src/components/LinearBrowsePopover.tsx @@ -165,7 +165,10 @@ export function LinearBrowsePopover({ title: "Linear import failed", description: result.error ?? "The issues could not be imported.", }); - setSelected(new Set(retryable)); + // Only narrow the selection when the hook reported specific failures; + // a blanket failure (nothing imported) keeps the current selection so + // the user can retry it as-is. + if (result.failedIds !== undefined) setSelected(new Set(retryable)); } } finally { setImporting(false); diff --git a/apps/web/src/components/linear/LinearBrowser.tsx b/apps/web/src/components/linear/LinearBrowser.tsx index 0fbc65c23cf..b504eecb7b5 100644 --- a/apps/web/src/components/linear/LinearBrowser.tsx +++ b/apps/web/src/components/linear/LinearBrowser.tsx @@ -274,7 +274,9 @@ export function LinearBrowser() { title: "Linear import failed", description: result.error ?? "The issues could not be imported.", }); - setSelected(new Set(retryable)); + // Keep the current selection on a blanket failure (nothing imported); + // only narrow it when specific issues were reported as failed. + if (result.failedIds !== undefined) setSelected(new Set(retryable)); } } finally { setImporting(false); From 08fa28b41b4bf174e39961bbd3b71c60a2538b8c Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Mon, 6 Jul 2026 03:25:11 +0530 Subject: [PATCH 14/16] feat(linear): default imports to Claude Opus 4.8 + right-click 'mark issue done' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - perIssue imports now start threads on Claude Opus 4.8 (claudeAgent / claude-opus-4-8) instead of the project/Codex default. - New linear.completeThreadIssue RPC: resolves the team's completed state, writes it back via issueUpdate, and reflects it on the thread (badge → Done) via thread.meta.update. Surfaced as a 'Mark done in Linear' item in the thread right-click menu, shown only when the thread has a linked issue. --- apps/server/src/ws.ts | 82 +++++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 41 +++++++++++ apps/web/src/hooks/useLinearImport.ts | 9 ++- packages/client-runtime/src/state/linear.ts | 5 ++ packages/contracts/src/linear.ts | 6 +- packages/contracts/src/rpc.ts | 9 +++ 6 files changed, 147 insertions(+), 5 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8452129f83e..21cf24e85d6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,7 +56,9 @@ import { type TerminalMetadataStreamEvent, WS_METHODS, WsRpcGroup, + LinearRequestError, } from "@t3tools/contracts"; +import { resolveTargetStateId } from "./linear/linearStateMapping.ts"; import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -312,6 +314,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.linearUpdateIssueState, AuthOrchestrationOperateScope], [WS_METHODS.linearCreateComment, AuthOrchestrationOperateScope], [WS_METHODS.linearCreateAttachment, AuthOrchestrationOperateScope], + [WS_METHODS.linearCompleteThreadIssue, AuthOrchestrationOperateScope], [WS_METHODS.linearSetToken, AuthOrchestrationOperateScope], [WS_METHODS.linearClearToken, AuthOrchestrationOperateScope], [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], @@ -526,6 +529,81 @@ const makeWsRpcLayer = ( const serverCommandId = (tag: string) => randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); + // Mark the Linear issue linked to a thread as done: resolve the team's + // completed state, write it back, and reflect the new state on the thread + // so its badge updates live. Non-Linear failures map to LinearRequestError. + const completeLinearThreadIssue = (input: { readonly threadId: ThreadId }) => + Effect.gen(function* () { + const shell = yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation: "updateIssueState", + detail: "Failed to read the thread.", + cause, + }), + ), + ); + const issue = Option.isSome(shell) ? shell.value.linearIssue : null; + if (issue === null || issue === undefined || issue.teamId === undefined) { + return { success: false }; + } + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation: "updateIssueState", + detail: "Failed to read settings.", + cause, + }), + ), + ); + const states = yield* linear.listWorkflowStates({ teamId: issue.teamId }); + const stateId = resolveTargetStateId( + states, + settings.linear.stateMappingByTeam[issue.teamId], + "done", + ); + if (stateId === undefined) { + return { success: false }; + } + const result = yield* linear.updateIssueState({ issueId: issue.id, stateId }); + if (!result.success) { + return { success: false }; + } + const nextState = states.find((state) => state.id === stateId); + if (nextState !== undefined) { + const commandId = yield* serverCommandId("linear-complete").pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation: "updateIssueState", + detail: "Failed to reflect the completed state.", + cause, + }), + ), + ); + yield* orchestrationEngine + .dispatch({ + type: "thread.meta.update", + commandId, + threadId: input.threadId, + linearIssue: { ...issue, stateName: nextState.name, stateType: nextState.type }, + }) + .pipe( + Effect.mapError( + (cause) => + new LinearRequestError({ + operation: "updateIssueState", + detail: "Failed to reflect the completed state.", + cause, + }), + ), + ); + } + return { success: true }; + }); + const loadAuthAccessSnapshot = () => Effect.all({ pairingLinks: serverAuth.listPairingLinks(), @@ -1401,6 +1479,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.linearCreateAttachment, linear.createAttachment(input), { "rpc.aggregate": "linear", }), + [WS_METHODS.linearCompleteThreadIssue]: (input) => + observeRpcEffect(WS_METHODS.linearCompleteThreadIssue, completeLinearThreadIssue(input), { + "rpc.aggregate": "linear", + }), [WS_METHODS.linearSetToken]: (input) => observeRpcEffect(WS_METHODS.linearSetToken, linear.setToken(input.token), { "rpc.aggregate": "linear", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 821c4a1c07c..cf614cb8a40 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -24,6 +24,7 @@ import { import { ProjectFavicon } from "./ProjectFavicon"; import { LinearBrowsePopover } from "./LinearBrowsePopover"; import { LinearIssueBadge } from "./linear/LinearIssueBadge"; +import { linearEnvironment } from "../state/linear"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -1122,6 +1123,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const completeLinearIssue = useAtomCommand(linearEnvironment.completeThreadIssue, { + reportFailure: false, + }); const updateSettings = useUpdateClientSettings(); const sidebarThreadPreviewCount = useClientSettings( (settings) => settings.sidebarThreadPreviewCount, @@ -2132,6 +2136,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, + ...(thread.linearIssue + ? [ + { + id: "linear-complete", + label: `Mark ${thread.linearIssue.identifier} done in Linear`, + }, + ] + : []), { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, @@ -2164,6 +2176,34 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; } + if (clicked === "linear-complete") { + const linearIssue = thread.linearIssue; + if (!linearIssue) return; + const result = await completeLinearIssue({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + const succeeded = result._tag === "Success" ? result.value.success : false; + toastManager.add( + stackedThreadToast( + succeeded + ? { + type: "success", + title: "Marked done in Linear", + description: `${linearIssue.identifier} moved to your team's completed state.`, + } + : { + type: "error", + title: "Couldn't complete the issue", + description: + result._tag === "Failure" + ? "Linear rejected the update — check the connection in Settings." + : "No completed state is configured for this team.", + }, + ), + ); + return; + } if (clicked !== "delete") return; if (appSettingsConfirmThreadDelete) { const confirmed = await api.dialogs.confirm( @@ -2190,6 +2230,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, [ appSettingsConfirmThreadDelete, + completeLinearIssue, copyPathToClipboard, copyThreadIdToClipboard, deleteThread, diff --git a/apps/web/src/hooks/useLinearImport.ts b/apps/web/src/hooks/useLinearImport.ts index bdf76beafa6..3fad669c602 100644 --- a/apps/web/src/hooks/useLinearImport.ts +++ b/apps/web/src/hooks/useLinearImport.ts @@ -1,6 +1,5 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { - DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, ProviderInstanceId, @@ -104,9 +103,11 @@ export function useLinearImport() { } if (input.mode === "perIssue") { - const modelSelection: ModelSelection = project?.defaultModelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, + // Imported issues default to Claude Opus 4.8 — the strongest coding + // model — rather than the project/Codex default. + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-8", }; // Attempt every issue; report a summary rather than bailing mid-loop and // leaving the caller unsure which threads were actually created. diff --git a/packages/client-runtime/src/state/linear.ts b/packages/client-runtime/src/state/linear.ts index a099e2b09d9..6c6a0add283 100644 --- a/packages/client-runtime/src/state/linear.ts +++ b/packages/client-runtime/src/state/linear.ts @@ -65,6 +65,11 @@ export function createLinearEnvironmentAtoms( tag: WS_METHODS.linearCreateAttachment, scheduler: commandScheduler, }), + completeThreadIssue: createEnvironmentRpcCommand(runtime, { + label: "environment-data:linear:complete-thread-issue", + tag: WS_METHODS.linearCompleteThreadIssue, + scheduler: commandScheduler, + }), setToken: createEnvironmentRpcCommand(runtime, { label: "environment-data:linear:set-token", tag: WS_METHODS.linearSetToken, diff --git a/packages/contracts/src/linear.ts b/packages/contracts/src/linear.ts index fba719e70f4..be7db956fa3 100644 --- a/packages/contracts/src/linear.ts +++ b/packages/contracts/src/linear.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { PositiveInt, ThreadId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; const LINEAR_SEARCH_MAX_LIMIT = 50; const LINEAR_LIST_MAX_LIMIT = 100; @@ -232,6 +232,10 @@ export type LinearCreateAttachmentInput = typeof LinearCreateAttachmentInput.Typ export const LinearMutationResult = Schema.Struct({ success: Schema.Boolean }); export type LinearMutationResult = typeof LinearMutationResult.Type; +/** Mark the Linear issue linked to a thread as done (right-click → thread menu). */ +export const LinearCompleteIssueInput = Schema.Struct({ threadId: ThreadId }); +export type LinearCompleteIssueInput = typeof LinearCompleteIssueInput.Type; + export const LinearSearchIssuesInput = Schema.Struct({ query: TrimmedString.check(Schema.isMaxLength(LINEAR_SEARCH_QUERY_MAX_LENGTH)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(LINEAR_SEARCH_MAX_LIMIT)), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a5b97d4d7f0..fdd3d33ab5c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -146,6 +146,7 @@ import { VcsError } from "./vcs.ts"; import { LinearAuthError, LinearAuthStatus, + LinearCompleteIssueInput, LinearCreateAttachmentInput, LinearCreateCommentInput, LinearFetchIssuesInput, @@ -259,6 +260,7 @@ export const WS_METHODS = { linearUpdateIssueState: "linear.updateIssueState", linearCreateComment: "linear.createComment", linearCreateAttachment: "linear.createAttachment", + linearCompleteThreadIssue: "linear.completeThreadIssue", linearSetToken: "linear.setToken", linearClearToken: "linear.clearToken", @@ -494,6 +496,12 @@ export const WsLinearCreateAttachmentRpc = Rpc.make(WS_METHODS.linearCreateAttac error: LinearReadError, }); +export const WsLinearCompleteThreadIssueRpc = Rpc.make(WS_METHODS.linearCompleteThreadIssue, { + payload: LinearCompleteIssueInput, + success: LinearMutationResult, + error: LinearReadError, +}); + export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -851,6 +859,7 @@ export const WsRpcGroup = RpcGroup.make( WsLinearUpdateIssueStateRpc, WsLinearCreateCommentRpc, WsLinearCreateAttachmentRpc, + WsLinearCompleteThreadIssueRpc, WsLinearSetTokenRpc, WsLinearClearTokenRpc, WsProjectsListEntriesRpc, From 2638ae9f3e504ef633e5cfcad5aa6bcb578a57fc Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Mon, 6 Jul 2026 11:21:58 +0530 Subject: [PATCH 15/16] feat(linear): bring the Linear integration to the mobile app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - share formatLinearIssues via @t3tools/client-runtime/linear-format (web re-exports) - mobile: linearEnvironment atoms; Settings → Linear (connect token, status, write-back toggles); Linear import sheet (search, multi-select, per-issue / combine, Opus 4.8, linked threads); thread-row Linear badge + long-press 'Mark done' via linear.completeThreadIssue; LinearIcon; screens wired into the native-stack navigator + settings sheet - projectThreadStartTurn carries linearIssue through the bootstrap --- apps/mobile/src/Stack.tsx | 19 ++ apps/mobile/src/components/LinearIcon.tsx | 14 + .../linear/LinearImportRouteScreen.tsx | 286 ++++++++++++++++++ .../settings/SettingsLinearRouteScreen.tsx | 184 +++++++++++ .../features/settings/SettingsRouteScreen.tsx | 8 + .../components/settings-sheet-targets.ts | 6 +- .../features/threads/thread-list-items.tsx | 45 ++- apps/mobile/src/lib/projectThreadStartTurn.ts | 4 + apps/mobile/src/state/linear.ts | 5 + apps/web/src/lib/linearFormat.ts | 120 +------- packages/client-runtime/package.json | 4 + packages/client-runtime/src/linearFormat.ts | 118 ++++++++ 12 files changed, 692 insertions(+), 121 deletions(-) create mode 100644 apps/mobile/src/components/LinearIcon.tsx create mode 100644 apps/mobile/src/features/linear/LinearImportRouteScreen.tsx create mode 100644 apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx create mode 100644 apps/mobile/src/state/linear.ts create mode 100644 packages/client-runtime/src/linearFormat.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 29cf29da90a..b4702b5d29a 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -38,7 +38,9 @@ import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; +import { LinearImportRouteScreen } from "./features/linear/LinearImportRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; +import { SettingsLinearRouteScreen } from "./features/settings/SettingsLinearRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; @@ -145,6 +147,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Appearance", }, }), + SettingsLinear: createNativeStackScreen({ + screen: SettingsLinearRouteScreen, + linking: "linear", + options: { + title: "Linear", + }, + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", @@ -431,6 +440,16 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: true, }, }), + LinearImport: createNativeStackScreen({ + screen: LinearImportRouteScreen, + linking: "linear-import", + options: { + title: "Import from Linear", + presentation: "formSheet", + sheetAllowedDetents: [0.7, 0.92], + sheetGrabberVisible: true, + }, + }), NewTaskSheet: createNativeStackScreen({ screen: NewTaskSheetStack, linking: "new", diff --git a/apps/mobile/src/components/LinearIcon.tsx b/apps/mobile/src/components/LinearIcon.tsx new file mode 100644 index 00000000000..ce52b507000 --- /dev/null +++ b/apps/mobile/src/components/LinearIcon.tsx @@ -0,0 +1,14 @@ +import Svg, { Path } from "react-native-svg"; + +/** Linear brand mark. Defaults to the current-color tint when no color given. */ +export function LinearIcon(props: { readonly size?: number; readonly color?: string }) { + const size = props.size ?? 18; + return ( + + + + ); +} diff --git a/apps/mobile/src/features/linear/LinearImportRouteScreen.tsx b/apps/mobile/src/features/linear/LinearImportRouteScreen.tsx new file mode 100644 index 00000000000..c26bfc28db6 --- /dev/null +++ b/apps/mobile/src/features/linear/LinearImportRouteScreen.tsx @@ -0,0 +1,286 @@ +import { LegendList, type LegendListRenderItemProps } from "@legendapp/list/react-native"; +import { useNavigation } from "@react-navigation/native"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + ProviderInstanceId, + type LinearIssueDetail, + type LinearIssueLink, + type LinearIssueSummary, + type ModelSelection, +} from "@t3tools/contracts"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { formatLinearIssues } from "@t3tools/client-runtime/linear-format"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { LinearIcon } from "../../components/LinearIcon"; +import { cn } from "../../lib/cn"; +import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; +import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useEnvironments } from "../../state/environments"; +import { useProjects } from "../../state/entities"; +import { linearEnvironment } from "../../state/linear"; +import { useEnvironmentQuery } from "../../state/query"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ConnectionSheetButton } from "../connection/ConnectionSheetButton"; + +const SEARCH_LIMIT = 30; +const IMPORT_MODEL: ModelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-8", +}; + +function issueLink(issue: LinearIssueDetail): LinearIssueLink { + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + ...(issue.teamId ? { teamId: issue.teamId } : {}), + ...(issue.stateType ? { stateType: issue.stateType } : {}), + ...(issue.stateName ? { stateName: issue.stateName } : {}), + }; +} + +export function LinearImportRouteScreen() { + const navigation = useNavigation(); + const { environments } = useEnvironments(); + const environmentId = environments[0]?.environmentId ?? null; + const projects = useProjects(); + const envProjects = useMemo( + () => projects.filter((project) => project.environmentId === environmentId), + [projects, environmentId], + ); + + const [projectId, setProjectId] = useState(null); + const activeProject: EnvironmentProject | null = + envProjects.find((project) => project.id === projectId) ?? envProjects[0] ?? null; + + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + const [selected, setSelected] = useState>(() => new Set()); + const [perIssue, setPerIssue] = useState(true); + const [importing, setImporting] = useState(false); + + const iconColor = useThemeColor("--color-primary"); + const placeholderColor = useThemeColor("--color-icon-subtle"); + + const fetchIssues = useAtomCommand(linearEnvironment.fetchIssues, { reportFailure: false }); + const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + + useEffect(() => { + const id = setTimeout(() => setDebounced(query), 220); + return () => clearTimeout(id); + }, [query]); + + const search = useEnvironmentQuery( + environmentId === null + ? null + : linearEnvironment.searchIssues({ + environmentId, + input: { query: debounced, limit: SEARCH_LIMIT }, + }), + ); + const issues = search.data?.issues ?? []; + + const toggle = useCallback((id: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const handleImport = useCallback(async () => { + if (environmentId === null || activeProject === null || selected.size === 0 || importing) + return; + setImporting(true); + try { + const result = await fetchIssues({ environmentId, input: { ids: [...selected] } }); + if (result._tag !== "Success" || result.value.issues.length === 0) return; + const details = result.value.issues; + + const start = (text: string, linearIssue: LinearIssueLink | null) => { + const metadata = makeTurnCommandMetadata(); + return startTurn({ + environmentId, + input: buildProjectThreadStartTurnInput({ + projectId: activeProject.id, + projectCwd: activeProject.workspaceRoot, + threadId: metadata.threadId, + commandId: metadata.commandId, + messageId: metadata.messageId, + createdAt: metadata.createdAt, + text, + attachments: [], + modelSelection: IMPORT_MODEL, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + workspaceMode: "local", + branch: null, + worktreePath: null, + startFromOrigin: false, + worktreeBranchName: "", + linearIssue, + }), + }); + }; + + if (perIssue) { + for (const detail of details) { + await start(formatLinearIssues([detail], "combine"), issueLink(detail)); + } + } else { + await start(formatLinearIssues(details, "combine"), null); + } + navigation.goBack(); + } finally { + setImporting(false); + } + }, [ + activeProject, + environmentId, + fetchIssues, + importing, + navigation, + perIssue, + selected, + startTurn, + ]); + + const renderItem = useCallback( + ({ item }: LegendListRenderItemProps) => { + const isSelected = selected.has(item.id); + return ( + toggle(item.id)} + > + + + + {item.identifier} + + + {item.title} + + + {item.stateName || item.assigneeName ? ( + + {[item.stateName, item.assigneeName].filter(Boolean).join(" · ")} + + ) : null} + + + + ); + }, + [iconColor, placeholderColor, selected, toggle], + ); + + const connected = search.error === null; + + return ( + + + {envProjects.length > 1 ? ( + + {envProjects.map((project) => { + const isActive = (activeProject?.id ?? null) === project.id; + return ( + setProjectId(project.id)} + > + + {project.title} + + + ); + })} + + ) : null} + + + + + {search.isPending && issues.length === 0 ? ( + + + + ) : issues.length === 0 ? ( + + + + {connected ? "No issues found." : "Connect Linear in Settings to import issues."} + + + ) : ( + item.id} + renderItem={renderItem} + contentContainerStyle={{ paddingVertical: 8 }} + /> + )} + + + setPerIssue((value) => !value)} + > + + {perIssue ? "One thread per issue" : "Combine into one thread"} + + + + void handleImport()} + tone="primary" + /> + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx new file mode 100644 index 00000000000..d143fe6aa56 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsLinearRouteScreen.tsx @@ -0,0 +1,184 @@ +import { useNavigation } from "@react-navigation/native"; +import { useCallback, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { LinearIcon } from "../../components/LinearIcon"; +import { useEnvironments } from "../../state/environments"; +import { useEnvironmentServerConfig } from "../../state/entities"; +import { linearEnvironment } from "../../state/linear"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ConnectionSheetButton } from "../connection/ConnectionSheetButton"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; + +const LINEAR_KEY_HELP = "Linear → Settings → Security & access → Personal API keys"; + +export function SettingsLinearRouteScreen() { + const navigation = useNavigation(); + const { environments } = useEnvironments(); + const environmentId = environments[0]?.environmentId ?? null; + + const authQuery = useEnvironmentQuery( + environmentId === null ? null : linearEnvironment.authStatus({ environmentId, input: {} }), + ); + const setToken = useAtomCommand(linearEnvironment.setToken, { reportFailure: false }); + const clearToken = useAtomCommand(linearEnvironment.clearToken, { reportFailure: false }); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + + const config = useEnvironmentServerConfig(environmentId); + const linear = config?.settings?.linear; + + const [token, setTokenValue] = useState(""); + const [busy, setBusy] = useState(false); + + const connected = authQuery.data?.status === "authenticated"; + const account = authQuery.data?.account; + + const handleConnect = useCallback(async () => { + const trimmed = token.trim(); + if (environmentId === null || trimmed.length === 0 || busy) return; + setBusy(true); + try { + const result = await setToken({ environmentId, input: { token: trimmed } }); + if (result._tag === "Success" && result.value.status === "authenticated") { + setTokenValue(""); + authQuery.refresh(); + } + } finally { + setBusy(false); + } + }, [authQuery, busy, environmentId, setToken, token]); + + const handleDisconnect = useCallback(async () => { + if (environmentId === null || busy) return; + setBusy(true); + try { + await clearToken({ environmentId, input: {} }); + authQuery.refresh(); + } finally { + setBusy(false); + } + }, [authQuery, busy, clearToken, environmentId]); + + const setSyncFlag = useCallback( + (patch: Record) => { + if (environmentId === null) return; + void updateSettings({ environmentId, input: { patch: { linear: patch } } }); + }, + [environmentId, updateSettings], + ); + + return ( + + + + + + + + {connected + ? `Connected as ${account?.name ?? "Linear account"}${ + account?.email ? ` (${account.email})` : "" + }` + : "Not connected"} + + + {connected ? ( + void handleDisconnect()} + tone="danger" + /> + ) : ( + <> + + void handleConnect()} + tone="primary" + /> + + Create a personal API key in {LINEAR_KEY_HELP}. The key is stored securely on the + server. + + + )} + + + + {connected ? ( + + navigation.navigate("LinearImport")} + > + + Browse & import issues + + + + ) : null} + + + setSyncFlag({ autoSync: value })} + value={linear?.autoSync ?? true} + /> + setSyncFlag({ transitionOnStart: value })} + value={linear?.transitionOnStart ?? true} + /> + setSyncFlag({ transitionOnPrOpen: value })} + value={linear?.transitionOnPrOpen ?? true} + /> + setSyncFlag({ transitionOnMerge: value })} + value={linear?.transitionOnMerge ?? true} + /> + setSyncFlag({ postComments: value })} + value={linear?.postComments ?? false} + /> + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 11d363dbf69..fd59afcd376 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -94,6 +94,10 @@ function LocalSettingsRouteScreen() { + + + + @@ -411,6 +415,10 @@ function ConfiguredSettingsRouteScreen() { + + + + diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 7de54bceee5..0493392fcce 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -1 +1,5 @@ -export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance"; +export type SettingsSheetTarget = + | "SettingsEnvironments" + | "SettingsArchive" + | "SettingsAppearance" + | "SettingsLinear"; diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 654be473b98..9efa1646c05 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -16,6 +16,8 @@ import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; +import { linearEnvironment } from "../../state/linear"; +import { useAtomCommand } from "../../state/use-atom-command"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadStatus } from "./threadPresentation"; @@ -446,6 +448,24 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const completeLinearIssue = useAtomCommand(linearEnvironment.completeThreadIssue, { + reportFailure: false, + }); + const linearIssue = thread.linearIssue ?? null; + const menuActions = useMemo( + () => + linearIssue + ? [ + { + id: "linear-done", + title: `Mark ${linearIssue.identifier} done`, + image: "checkmark.circle", + }, + ...THREAD_ROW_MENU_ACTIONS, + ] + : THREAD_ROW_MENU_ACTIONS, + [linearIssue], + ); const primaryAction = useMemo( () => ({ accessibilityLabel: `Archive ${thread.title}`, @@ -459,8 +479,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); + if (nativeEvent.event === "linear-done") { + void completeLinearIssue({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + } }, - [handleArchive, handleDelete], + [handleArchive, handleDelete, completeLinearIssue, thread.environmentId, thread.id], ); const statusPill = effectiveStatus ? ( @@ -474,6 +500,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null; + const linearBadge = linearIssue ? ( + + + {linearIssue.identifier} + + + ) : null; + const subtitleRow = subtitleParts.length > 0 || pr !== null ? ( @@ -539,6 +578,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {thread.title} + {linearBadge} {statusPill} + {linearBadge} {statusPill} diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index e3c2f744ada..fff1f3da5c8 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -2,6 +2,7 @@ import { CommandId, MessageId, ThreadId, + type LinearIssueLink, type ModelSelection, type ProjectId, type ProviderInteractionMode, @@ -38,6 +39,8 @@ export interface ProjectThreadStartTurnSpec { readonly startFromOrigin: boolean; /** Generated temp branch for worktree mode; unused for local mode. */ readonly worktreeBranchName: string; + /** Linear issue this thread is imported from, if any. */ + readonly linearIssue?: LinearIssueLink | null; } /** @@ -70,6 +73,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe interactionMode: spec.interactionMode, branch: spec.branch, worktreePath: isWorktree ? null : spec.worktreePath, + ...(spec.linearIssue ? { linearIssue: spec.linearIssue } : {}), createdAt: spec.createdAt, }, ...(isWorktree diff --git a/apps/mobile/src/state/linear.ts b/apps/mobile/src/state/linear.ts new file mode 100644 index 00000000000..8861cdcd3ea --- /dev/null +++ b/apps/mobile/src/state/linear.ts @@ -0,0 +1,5 @@ +import { createLinearEnvironmentAtoms } from "@t3tools/client-runtime/state/linear"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const linearEnvironment = createLinearEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/lib/linearFormat.ts b/apps/web/src/lib/linearFormat.ts index 1abd10cf4b9..60cd1161ab1 100644 --- a/apps/web/src/lib/linearFormat.ts +++ b/apps/web/src/lib/linearFormat.ts @@ -1,118 +1,2 @@ -import type { LinearIssueDetail } from "@t3tools/contracts"; - -/** - * How multiple imported Linear issues are laid out in the composer prompt. - * - `combine`: merge every issue into one comprehensive task. - * - `subtasks`: present each issue as an independent, ordered subtask. - */ -export type LinearImportMode = "combine" | "subtasks"; - -function metadataLine(issue: LinearIssueDetail): string | null { - const parts: Array = []; - if (issue.stateName) parts.push(`Status: ${issue.stateName}`); - if (issue.priorityLabel) parts.push(`Priority: ${issue.priorityLabel}`); - if (issue.assigneeName) parts.push(`Assignee: ${issue.assigneeName}`); - if (issue.teamKey) parts.push(`Team: ${issue.teamKey}`); - if (issue.labels.length > 0) parts.push(`Labels: ${issue.labels.join(", ")}`); - return parts.length > 0 ? parts.join(" · ") : null; -} - -function sectionList(title: string, lines: ReadonlyArray): ReadonlyArray { - if (lines.length === 0) return []; - return [`**${title}**`, ...lines.map((line) => `- ${line}`), ""]; -} - -/** Render a single issue as a self-contained markdown block under `heading`. */ -function formatIssueBlock(issue: LinearIssueDetail, heading: string): string { - const lines: Array = [heading, ""]; - - const meta = metadataLine(issue); - if (meta) { - lines.push(meta, ""); - } - if (issue.url) { - lines.push(`Linear: ${issue.url}`, ""); - } - - const description = issue.description.trim(); - if (description.length > 0) { - // The description carries any acceptance criteria / checklists inline as - // markdown; preserve it verbatim so checkboxes survive the import. - lines.push("**Description**", "", description, ""); - } - - lines.push( - ...sectionList( - "Sub-issues", - issue.subIssues.map((sub) => - sub.stateName - ? `${sub.identifier}: ${sub.title} (${sub.stateName})` - : `${sub.identifier}: ${sub.title}`, - ), - ), - ); - - lines.push( - ...sectionList( - "Linked pull requests", - issue.linkedPullRequests.map((pr) => (pr.title ? `[${pr.title}](${pr.url})` : pr.url)), - ), - ); - - lines.push( - ...sectionList( - "Attachments", - issue.attachments.map((attachment) => - attachment.title ? `[${attachment.title}](${attachment.url})` : attachment.url, - ), - ), - ); - - if (issue.comments.length > 0) { - lines.push("**Comments**", ""); - for (const comment of issue.comments) { - const author = comment.author ?? "Unknown"; - const when = comment.createdAt ? ` (${comment.createdAt})` : ""; - lines.push(`> **${author}**${when}:`); - for (const bodyLine of comment.body.trim().split("\n")) { - lines.push(`> ${bodyLine}`); - } - lines.push(""); - } - } - - return lines.join("\n").trimEnd(); -} - -/** - * Build the composer prompt for one or more imported Linear issues. - * Pure and deterministic — safe to unit test. - */ -export function formatLinearIssues( - issues: ReadonlyArray, - mode: LinearImportMode, -): string { - if (issues.length === 0) return ""; - - if (issues.length === 1) { - const issue = issues[0]!; - return `Work on this Linear issue:\n\n${formatIssueBlock( - issue, - `## ${issue.identifier}: ${issue.title}`, - )}\n`; - } - - if (mode === "subtasks") { - const blocks = issues.map((issue, index) => - formatIssueBlock(issue, `## Subtask ${index + 1} — ${issue.identifier}: ${issue.title}`), - ); - return `These related Linear issues should be implemented as subtasks:\n\n${blocks.join( - "\n\n", - )}\n`; - } - - const blocks = issues.map((issue) => - formatIssueBlock(issue, `## ${issue.identifier}: ${issue.title}`), - ); - return `Work on these Linear issues together as one task:\n\n${blocks.join("\n\n")}\n`; -} +// Re-exported from the shared package so web and mobile format issues identically. +export * from "@t3tools/client-runtime/linear-format"; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 6b3eb254c78..f30f4ededf6 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -31,6 +31,10 @@ "types": "./src/operations/projects.ts", "default": "./src/operations/projects.ts" }, + "./linear-format": { + "types": "./src/linearFormat.ts", + "default": "./src/linearFormat.ts" + }, "./platform": { "types": "./src/platform/index.ts", "default": "./src/platform/index.ts" diff --git a/packages/client-runtime/src/linearFormat.ts b/packages/client-runtime/src/linearFormat.ts new file mode 100644 index 00000000000..1abd10cf4b9 --- /dev/null +++ b/packages/client-runtime/src/linearFormat.ts @@ -0,0 +1,118 @@ +import type { LinearIssueDetail } from "@t3tools/contracts"; + +/** + * How multiple imported Linear issues are laid out in the composer prompt. + * - `combine`: merge every issue into one comprehensive task. + * - `subtasks`: present each issue as an independent, ordered subtask. + */ +export type LinearImportMode = "combine" | "subtasks"; + +function metadataLine(issue: LinearIssueDetail): string | null { + const parts: Array = []; + if (issue.stateName) parts.push(`Status: ${issue.stateName}`); + if (issue.priorityLabel) parts.push(`Priority: ${issue.priorityLabel}`); + if (issue.assigneeName) parts.push(`Assignee: ${issue.assigneeName}`); + if (issue.teamKey) parts.push(`Team: ${issue.teamKey}`); + if (issue.labels.length > 0) parts.push(`Labels: ${issue.labels.join(", ")}`); + return parts.length > 0 ? parts.join(" · ") : null; +} + +function sectionList(title: string, lines: ReadonlyArray): ReadonlyArray { + if (lines.length === 0) return []; + return [`**${title}**`, ...lines.map((line) => `- ${line}`), ""]; +} + +/** Render a single issue as a self-contained markdown block under `heading`. */ +function formatIssueBlock(issue: LinearIssueDetail, heading: string): string { + const lines: Array = [heading, ""]; + + const meta = metadataLine(issue); + if (meta) { + lines.push(meta, ""); + } + if (issue.url) { + lines.push(`Linear: ${issue.url}`, ""); + } + + const description = issue.description.trim(); + if (description.length > 0) { + // The description carries any acceptance criteria / checklists inline as + // markdown; preserve it verbatim so checkboxes survive the import. + lines.push("**Description**", "", description, ""); + } + + lines.push( + ...sectionList( + "Sub-issues", + issue.subIssues.map((sub) => + sub.stateName + ? `${sub.identifier}: ${sub.title} (${sub.stateName})` + : `${sub.identifier}: ${sub.title}`, + ), + ), + ); + + lines.push( + ...sectionList( + "Linked pull requests", + issue.linkedPullRequests.map((pr) => (pr.title ? `[${pr.title}](${pr.url})` : pr.url)), + ), + ); + + lines.push( + ...sectionList( + "Attachments", + issue.attachments.map((attachment) => + attachment.title ? `[${attachment.title}](${attachment.url})` : attachment.url, + ), + ), + ); + + if (issue.comments.length > 0) { + lines.push("**Comments**", ""); + for (const comment of issue.comments) { + const author = comment.author ?? "Unknown"; + const when = comment.createdAt ? ` (${comment.createdAt})` : ""; + lines.push(`> **${author}**${when}:`); + for (const bodyLine of comment.body.trim().split("\n")) { + lines.push(`> ${bodyLine}`); + } + lines.push(""); + } + } + + return lines.join("\n").trimEnd(); +} + +/** + * Build the composer prompt for one or more imported Linear issues. + * Pure and deterministic — safe to unit test. + */ +export function formatLinearIssues( + issues: ReadonlyArray, + mode: LinearImportMode, +): string { + if (issues.length === 0) return ""; + + if (issues.length === 1) { + const issue = issues[0]!; + return `Work on this Linear issue:\n\n${formatIssueBlock( + issue, + `## ${issue.identifier}: ${issue.title}`, + )}\n`; + } + + if (mode === "subtasks") { + const blocks = issues.map((issue, index) => + formatIssueBlock(issue, `## Subtask ${index + 1} — ${issue.identifier}: ${issue.title}`), + ); + return `These related Linear issues should be implemented as subtasks:\n\n${blocks.join( + "\n\n", + )}\n`; + } + + const blocks = issues.map((issue) => + formatIssueBlock(issue, `## ${issue.identifier}: ${issue.title}`), + ); + return `Work on these Linear issues together as one task:\n\n${blocks.join("\n\n")}\n`; +} From 5ab69b55663dc104a1546fac915b662defa1630c Mon Sep 17 00:00:00 2001 From: maslinedwin Date: Mon, 6 Jul 2026 11:27:35 +0530 Subject: [PATCH 16/16] chore(mobile): allow fork builds under a different Apple/Expo account app.config.ts honors EXPO_OWNER, EAS_PROJECT_ID, APPLE_TEAM_ID, and T3CODE_IOS_BUNDLE_ID env overrides (all defaulting to the upstream values), so a fork can build and ship to its own TestFlight without editing the config. --- apps/mobile/app.config.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 192153316b0..36457fe4141 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -59,6 +59,14 @@ function resolveAppVariant(value: string | undefined): AppVariant { const variant = VARIANT_CONFIG[APP_VARIANT]; +// Account-specific overrides so a fork can build/submit under its own Apple + +// Expo account (e.g. for a personal TestFlight) without editing this file. +// All default to the upstream T3 Tools values. +const iosBundleIdentifier = process.env.T3CODE_IOS_BUNDLE_ID ?? variant.iosBundleIdentifier; +const easProjectId = process.env.EAS_PROJECT_ID ?? "d763fcb8-d37c-41ea-a773-b54a0ab4a454"; +const appleTeamId = process.env.APPLE_TEAM_ID ?? "ARK85ZXQ4Z"; +const expoOwner = process.env.EXPO_OWNER ?? "pingdotgg"; + const config: ExpoConfig = { name: variant.appName, slug: "t3-code", @@ -73,18 +81,19 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", updates: { enabled: true, - url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", + url: `https://u.expo.dev/${easProjectId}`, checkAutomatically: "ON_LOAD", fallbackToCacheTimeout: 0, }, ios: { icon: variant.iosIcon, supportsTablet: true, - bundleIdentifier: variant.iosBundleIdentifier, + bundleIdentifier: iosBundleIdentifier, // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, - // Sign in with Apple, or push notification entitlements). - appleTeamId: "ARK85ZXQ4Z", + // Sign in with Apple, or push notification entitlements). Override with + // APPLE_TEAM_ID for a fork build under a different Apple account. + appleTeamId, associatedDomains: [ `applinks:${variant.relyingParty}`, `webcredentials:${variant.relyingParty}`, @@ -154,8 +163,8 @@ const config: ExpoConfig = { [ "expo-widgets", { - bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, - groupIdentifier: `group.${variant.iosBundleIdentifier}`, + bundleIdentifier: `${iosBundleIdentifier}.widgets`, + groupIdentifier: `group.${iosBundleIdentifier}`, enablePushNotifications: true, widgets: [ { @@ -185,10 +194,10 @@ const config: ExpoConfig = { tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, eas: { - projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", + projectId: easProjectId, }, }, - owner: "pingdotgg", + owner: expoOwner, }; export default config;