From 3e2b4354d5585e0893575ec96dac4bae3d19355c Mon Sep 17 00:00:00 2001 From: dsayerdp Date: Thu, 2 Jul 2026 08:17:03 +0100 Subject: [PATCH 1/5] (SR-779) Add search-by-ID and release display to Linear app Search: replace the title containsIgnoreCase filter with Linear's searchIssues(term:) query so issues can be found by identifier (e.g. ENG-123) as well as title. gql() now omits `variables` from the POST body when empty, which Linear otherwise rejects. Releases: request releases { ... } in the issue queries, add Release/ReleaseStage types, and render releases via a new ReleaseItem component on issue list items and a Releases block on the issue view. Co-Authored-By: Claude Fable 5 --- manifest.json | 2 +- src/components/IssueItem/IssueItem.tsx | 12 ++ .../IssueItem/__tests__/IssueItem.test.tsx | 17 +++ src/components/ReleaseItem/ReleaseItem.tsx | 19 ++++ src/components/ViewIssue/ViewIssue.tsx | 7 ++ src/components/ViewIssue/blocks/Releases.tsx | 20 ++++ .../linear/__tests__/getIssueService.test.ts | 42 +++++++ .../linear/__tests__/getIssuesService.test.ts | 59 ++++++++++ src/services/linear/getIssueService.ts | 14 +++ src/services/linear/getIssuesService.ts | 106 +++++++++++++----- src/services/linear/types.ts | 26 ++++- src/utils/__tests__/gql.test.ts | 12 ++ src/utils/gql.ts | 6 +- 13 files changed, 307 insertions(+), 35 deletions(-) create mode 100644 src/components/ReleaseItem/ReleaseItem.tsx create mode 100644 src/components/ViewIssue/blocks/Releases.tsx create mode 100644 src/services/linear/__tests__/getIssueService.test.ts create mode 100644 src/services/linear/__tests__/getIssuesService.test.ts diff --git a/manifest.json b/manifest.json index 612468b..674b568 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "name": "@deskpro-apps/linear", "title": "Linear", "description": "View your Linear issues linked with Deskpro tickets to streamline communication with users.", - "version": "1.0.24", + "version": "1.0.25", "scope": "agent", "isSingleInstall": false, "hasDevMode": true, diff --git a/src/components/IssueItem/IssueItem.tsx b/src/components/IssueItem/IssueItem.tsx index 04ec40f..8da365e 100644 --- a/src/components/IssueItem/IssueItem.tsx +++ b/src/components/IssueItem/IssueItem.tsx @@ -17,6 +17,7 @@ import { import type { FC, MouseEventHandler } from "react"; import type { Issue } from "../../services/linear/types"; import { RelationshipItem } from '../RelationshipItem/RelationshipItem'; +import { ReleaseItem } from '../ReleaseItem/ReleaseItem'; import { useIssueRelationships } from '../../hooks/useIssueRelationships'; export type Props = { @@ -31,6 +32,7 @@ const IssueItem: FC = ({ issue, onClickTitle }) => { || get(issue, ["assignee", "email"]); }, [issue]); const labels = useMemo(() => get(issue, ["labels"], []) || [], [issue]); + const releases = useMemo(() => get(issue, ["releases"], []) || [], [issue]); const { relationships, error } = useIssueRelationships(issue.relations, issue.id); const onClick: MouseEventHandler = useCallback((e) => { @@ -110,6 +112,16 @@ const IssueItem: FC = ({ issue, onClickTitle }) => { } /> )} + {releases?.length > 0 && ( + + {releases.map(release => )} + + } + /> + )} ); }; diff --git a/src/components/IssueItem/__tests__/IssueItem.test.tsx b/src/components/IssueItem/__tests__/IssueItem.test.tsx index af29ce7..f705b73 100644 --- a/src/components/IssueItem/__tests__/IssueItem.test.tsx +++ b/src/components/IssueItem/__tests__/IssueItem.test.tsx @@ -35,4 +35,21 @@ describe("DeskproTickets", () => { expect(await findAllByText(/Wall/i)).toHaveLength(2); expect(await findAllByText(/Defense/i)).toHaveLength(2); }); + + test("renders linked releases", async () => { + const issue = normalize(mockIssue.data.issue) as Record; + issue.releases = [{ + id: "rel-1", + name: "Hotfix", + version: "2026.2.9", + url: "https://linear.app/deskpro/release/1", + stage: { id: "s1", name: "Next patch release", type: "started", color: "#0f783c" }, + }]; + + const { findByText } = renderIssueItem({ issue: issue as never }); + + expect(await findByText("Releases")).toBeInTheDocument(); + expect(await findByText(/Hotfix \(2026\.2\.9\)/i)).toBeInTheDocument(); + expect(await findByText(/Stage: Next patch release/i)).toBeInTheDocument(); + }); }); diff --git a/src/components/ReleaseItem/ReleaseItem.tsx b/src/components/ReleaseItem/ReleaseItem.tsx new file mode 100644 index 0000000..9c1d85e --- /dev/null +++ b/src/components/ReleaseItem/ReleaseItem.tsx @@ -0,0 +1,19 @@ +import { ExternalIconLink } from '@deskpro/app-sdk'; +import { P5 } from '@deskpro/deskpro-ui'; +import { Release } from '../../services/linear/types'; + +interface ReleaseItem { + release: Release; +}; + +export function ReleaseItem({ release }: ReleaseItem) { + return ( + <> + + {`${release.name}${release.version ? ` (${release.version})` : ''} `} + + + {`Stage: ${release.stage.name}`} + + ); +}; diff --git a/src/components/ViewIssue/ViewIssue.tsx b/src/components/ViewIssue/ViewIssue.tsx index 78c81c7..c39d6cf 100644 --- a/src/components/ViewIssue/ViewIssue.tsx +++ b/src/components/ViewIssue/ViewIssue.tsx @@ -5,6 +5,7 @@ import type { FC } from "react"; import type { Maybe } from "../../types"; import type { Issue, WorkflowState } from "../../services/linear/types"; import { Relationships } from './blocks/Relationships'; +import { Releases } from './blocks/Releases'; type Props = { issue: Maybe, @@ -46,6 +47,12 @@ const ViewIssue: FC = ({ + + + + + + + + {releases.length === 0 + ? <P5>No releases found</P5> + : releases.map(release => <ReleaseItem key={release.id} release={release} />) + } + </> + ); +}; diff --git a/src/services/linear/__tests__/getIssueService.test.ts b/src/services/linear/__tests__/getIssueService.test.ts new file mode 100644 index 0000000..7a174d0 --- /dev/null +++ b/src/services/linear/__tests__/getIssueService.test.ts @@ -0,0 +1,42 @@ +import { getIssueService } from "../getIssueService"; +import { baseRequest } from "../baseRequest"; +import type { IDeskproClient } from "@deskpro/app-sdk"; + +jest.mock("../baseRequest", () => ({ + baseRequest: jest.fn(() => Promise.resolve({ data: {} })), +})); + +const client = {} as IDeskproClient; + +const getSentBody = (): string => { + const mock = baseRequest as jest.Mock; + return JSON.parse(mock.mock.calls[0][1].data).query; +}; + +const getSentVariables = () => { + const mock = baseRequest as jest.Mock; + return JSON.parse(mock.mock.calls[0][1].data).variables; +}; + +describe("getIssueService", () => { + beforeEach(() => { + (baseRequest as jest.Mock).mockClear(); + }); + + test("queries the issue by id", async () => { + await getIssueService(client, "issue-1"); + + expect(getSentBody()).toContain("query Issue($issueId: String!)"); + expect(getSentVariables()).toEqual({ issueId: "issue-1" }); + }); + + test("requests the releases associated with the issue", async () => { + await getIssueService(client, "issue-1"); + + // Releases is a Business+ feature; the API returns an empty connection on + // lower tiers, so the field is always safe to request. + expect(getSentBody()).toContain( + "releases { nodes { id name version url stage { id name type color } } }", + ); + }); +}); diff --git a/src/services/linear/__tests__/getIssuesService.test.ts b/src/services/linear/__tests__/getIssuesService.test.ts new file mode 100644 index 0000000..8919697 --- /dev/null +++ b/src/services/linear/__tests__/getIssuesService.test.ts @@ -0,0 +1,59 @@ +import { getIssuesService } from "../getIssuesService"; +import { baseRequest } from "../baseRequest"; +import type { IDeskproClient } from "@deskpro/app-sdk"; + +jest.mock("../baseRequest", () => ({ + baseRequest: jest.fn(() => Promise.resolve({ data: {} })), +})); + +const client = {} as IDeskproClient; + +const getSentBody = (): string => { + const mock = baseRequest as jest.Mock; + return JSON.parse(mock.mock.calls[0][1].data).query; +}; + +const getSentVariables = () => { + const mock = baseRequest as jest.Mock; + return JSON.parse(mock.mock.calls[0][1].data).variables; +}; + +describe("getIssuesService", () => { + beforeEach(() => { + (baseRequest as jest.Mock).mockClear(); + }); + + test("searches by term (matches identifier + title) when given a query", async () => { + await getIssuesService(client, { q: "ENG-123" }); + + const query = getSentBody(); + expect(query).toContain("searchIssues(term: $term)"); + expect(query).not.toContain("containsIgnoreCase"); + expect(getSentVariables()).toEqual({ term: "ENG-123" }); + // searchIssues returns IssueSearchResult, not Issue, so the top-level node + // must inline the scalar fields rather than spread the `on Issue` fragment. + expect(query).toContain("searchIssues(term: $term) { nodes { id identifier title"); + expect(query).not.toContain("searchIssues(term: $term) { nodes { ...issueInfo"); + }); + + test("filters by id list when given ids", async () => { + await getIssuesService(client, { ids: ["uuid-1", "uuid-2"] }); + + const query = getSentBody(); + expect(query).toContain("issues(filter: $filter)"); + expect(getSentVariables()).toEqual({ filter: { id: { in: ["uuid-1", "uuid-2"] } } }); + }); + + test("requests linked releases on listed issues (both query shapes)", async () => { + const releasesSelection = + "releases { nodes { id name version url stage { id name type color } } }"; + + await getIssuesService(client, { ids: ["uuid-1"] }); + expect(getSentBody()).toContain(releasesSelection); + + (baseRequest as jest.Mock).mockClear(); + + await getIssuesService(client, { q: "ENG-123" }); + expect(getSentBody()).toContain(releasesSelection); + }); +}); diff --git a/src/services/linear/getIssueService.ts b/src/services/linear/getIssueService.ts index 18a7a94..8935f55 100644 --- a/src/services/linear/getIssueService.ts +++ b/src/services/linear/getIssueService.ts @@ -55,6 +55,20 @@ const getIssueService = ( } } } + releases { + nodes { + id + name + version + url + stage { + id + name + type + color + } + } + } } } ${issueFullInfoFragment} diff --git a/src/services/linear/getIssuesService.ts b/src/services/linear/getIssuesService.ts index 822f637..b06a855 100644 --- a/src/services/linear/getIssuesService.ts +++ b/src/services/linear/getIssuesService.ts @@ -17,15 +17,84 @@ type Params = { ids?: Array<Issue["id"]>, }; -const getIssuesService = (client: IDeskproClient, params?: Params) => { - let variables = {}; +// The scalar fields of `issueInfo`, inlined for use on `IssueSearchResult` +// nodes (which are not of type `Issue`, so the fragment cannot be spread). +const issueScalarFields = ` + id + identifier + title + priority + priorityLabel + url + dueDate +`; + +// Shared sub-selection for an issue node beyond its own scalar fields. The +// nested relatedIssue is always of type `Issue`, so spreading `issueInfo` +// there is valid for both queries. +const issueNodeFields = ` + state { ...stateInfo } + team { ...teamInfo } + labels { nodes { ...labelInfo } } + assignee { ...userInfo } + relations { + nodes { + type + relatedIssue { + ...issueInfo + state { ...stateInfo } + team { ...teamInfo } + labels { nodes { ...labelInfo } } + assignee { ...userInfo } + } + } + } + releases { + nodes { + id + name + version + url + stage { + id + name + type + color + } + } + } +`; +const getIssuesService = (client: IDeskproClient, params?: Params) => { + // A free-text query is run through Linear's full-text search so that it + // matches the human identifier (e.g. "ENG-123") as well as the title — the + // `issues` filter has no identifier comparator. if (params?.q) { - variables = set(variables, ["filter", "title", "containsIgnoreCase"], params?.q); + const query = gql({ term: params.q })` + query SearchIssues($term: String!) { + searchIssues(term: $term) { + nodes { + ${issueScalarFields} + ${issueNodeFields} + } + } + } + ${issueFragment} + ${stateFragment} + ${teamFragment} + ${labelFragment} + ${userFragment} + `; + + return baseRequest<GQL<Issue[]>>(client, { data: query }) + .then(normalize) + .then((res) => get(res, ["data", "searchIssues"]) || []); } + let variables = {}; + if (params?.ids) { - variables = set(variables, ["filter", "id", "in"], params?.ids); + variables = set(variables, ["filter", "id", "in"], params.ids); } const query = gql(variables)` @@ -33,32 +102,7 @@ const getIssuesService = (client: IDeskproClient, params?: Params) => { issues(filter: $filter) { nodes { ...issueInfo - state { ...stateInfo } - team { ...teamInfo } - labels { nodes { ...labelInfo } } - assignee { ...userInfo } - relations { - nodes { - type - relatedIssue { - ...issueInfo - state { - ...stateInfo - } - team { - ...teamInfo - } - labels { - nodes { - ...labelInfo - } - } - assignee { - ...userInfo - } - } - } - } + ${issueNodeFields} } } } @@ -74,4 +118,4 @@ const getIssuesService = (client: IDeskproClient, params?: Params) => { .then((res) => get(res, ["data", "issues"]) || []) }; -export { getIssuesService }; \ No newline at end of file +export { getIssuesService }; diff --git a/src/services/linear/types.ts b/src/services/linear/types.ts index 7ba74c9..076c679 100644 --- a/src/services/linear/types.ts +++ b/src/services/linear/types.ts @@ -82,7 +82,8 @@ export type Issue = Omit<IssueGQL, "labels"|"children"|"comments"|"team"> & { children: Issue[], comments: IssueComment[], team: Team, - relations: Relation[] + relations: Relation[], + releases: Release[], }; export type WorkflowState = WorkflowStateGQL; @@ -108,4 +109,25 @@ export type IssueEditInput = Pick< export type IssueLabelInput = Pick<IssueLabelCreateInputGQL, "teamId"|"name"|"color"> -export type Relation = IssueRelationGQL; \ No newline at end of file +export type Relation = IssueRelationGQL; + +// Linear's Releases feature is not present in the vendored GraphQL schema +// (Linear-API@current.graphql predates it), so these are hand-rolled to mirror +// the live API rather than generated. See Release/ReleaseStage in Linear's +// public schema. +export type ReleaseStageType = "planned"|"started"|"completed"|"canceled"; + +export type ReleaseStage = { + id: string, + name: string, + type: ReleaseStageType, + color: string, +}; + +export type Release = { + id: string, + name: string, + version: string|null, + url: string, + stage: ReleaseStage, +}; \ No newline at end of file diff --git a/src/utils/__tests__/gql.test.ts b/src/utils/__tests__/gql.test.ts index 26a05db..6713f03 100644 --- a/src/utils/__tests__/gql.test.ts +++ b/src/utils/__tests__/gql.test.ts @@ -44,5 +44,17 @@ describe("gql", () => { expect(query).toBe(result); }); + test("should omit variables when the variables object is empty", () => { + const query = gql({})` + query Issues($filter: IssueFilter) { + issues(filter: $filter) { nodes { id } } + } + `; + + const result = "{\"query\":\"query Issues($filter: IssueFilter) { issues(filter: $filter) { nodes { id } } }\"}"; + + expect(query).toBe(result); + }); + test.todo("pass wrong values"); }); diff --git a/src/utils/gql.ts b/src/utils/gql.ts index b30e6da..c9bf1d0 100644 --- a/src/utils/gql.ts +++ b/src/utils/gql.ts @@ -1,6 +1,7 @@ import { removeUnnecessarySpaces } from "./removeUnnecessarySpaces"; import type { Dict } from "../types"; import reduce from "lodash/reduce"; +import isEmpty from "lodash/isEmpty"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GqlParams = Dict<any>; @@ -35,7 +36,10 @@ const gql: GQL = (params: GqlParams|TemplateStringsArray, ...placeholders: any[] const query = constructGqlString(strings, placeholders); return JSON.stringify({ query: removeUnnecessarySpaces(query), - variables: params, + // Linear's GraphQL API rejects a POST body whose `variables` is an + // empty object ("`variables` in a POST body must be an object if + // provided."), so only include it when there are variables to send. + ...(isEmpty(params) ? {} : { variables: params }), }); }; } From 8f10974bfba0d9969fd7bef4671addf346eb2fe4 Mon Sep 17 00:00:00 2001 From: dsayerdp <dan.sayer@deskpro.com> Date: Thu, 2 Jul 2026 09:50:50 +0100 Subject: [PATCH 2/5] Fix CI: approve build scripts for pnpm Newer pnpm versions fail install with ERR_PNPM_IGNORED_BUILDS instead of warning when native build scripts aren't explicitly approved. Adds onlyBuiltDependencies so pnpm install succeeds in CI (devcontainer pins pnpm to "latest"). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- package.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ce4f45e..f39f542 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,13 @@ "pnpm": { "overrides": { "tmp": "^0.2.4" - } + }, + "onlyBuiltDependencies": [ + "@parcel/watcher", + "@sentry/cli", + "@swc/core", + "core-js", + "esbuild" + ] } } \ No newline at end of file From 30f7b2941b467e611fabc8abf8af2eef341cba07 Mon Sep 17 00:00:00 2001 From: dsayerdp <dan.sayer@deskpro.com> Date: Thu, 2 Jul 2026 10:00:14 +0100 Subject: [PATCH 3/5] Move pnpm config to pnpm-workspace.yaml CI's pnpm (pinned to "latest" in devcontainer.json) no longer reads the "pnpm" field in package.json at all, so the onlyBuiltDependencies fix from the previous commit was silently ignored. pnpm now expects overrides/onlyBuiltDependencies in pnpm-workspace.yaml instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- package.json | 12 ------------ pnpm-workspace.yaml | 12 ++++++++++++ 2 files changed, 12 insertions(+), 12 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/package.json b/package.json index f39f542..2ed75f2 100644 --- a/package.json +++ b/package.json @@ -78,17 +78,5 @@ "ts-jest": "^29.4.0", "typescript": "^5.8.3", "vite": "^6.3.6" - }, - "pnpm": { - "overrides": { - "tmp": "^0.2.4" - }, - "onlyBuiltDependencies": [ - "@parcel/watcher", - "@sentry/cli", - "@swc/core", - "core-js", - "esbuild" - ] } } \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..53531de --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - "." + +overrides: + tmp: "^0.2.4" + +onlyBuiltDependencies: + - "@parcel/watcher" + - "@sentry/cli" + - "@swc/core" + - core-js + - esbuild From 7fe5efa3968fae2ce8c75ac8bf6dba1bc3f62b40 Mon Sep 17 00:00:00 2001 From: dsayerdp <dan.sayer@deskpro.com> Date: Thu, 2 Jul 2026 10:04:00 +0100 Subject: [PATCH 4/5] Pin pnpm version in devcontainer instead of "latest" CI still failed after moving config to pnpm-workspace.yaml: the devcontainer feature installs pnpm "latest" fresh on every run, and whatever version CI resolved to (with lockfile supply-chain scanning, newer than local 10.15.0) still hard-failed with ERR_PNPM_IGNORED_BUILDS despite the onlyBuiltDependencies config. Pinning to 10.15.0 (verified locally: install/typecheck/lint/tests all pass, no ignored-builds warning) removes the version-drift root cause instead of chasing each release's config format. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 17898af..28f6f6c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "features": { "ghcr.io/devcontainers/features/node:1": { "version": "lts", - "pnpm": "latest" + "pnpm": "10.15.0" } }, "customizations": { From 1f2d5260e1e59ea66ba778c29a4ba2a601dfc6a9 Mon Sep 17 00:00:00 2001 From: dsayerdp <dan.sayer@deskpro.com> Date: Thu, 2 Jul 2026 10:07:31 +0100 Subject: [PATCH 5/5] Activate pinned pnpm via corepack at container start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The devcontainer feature's pnpm-version input is baked into a cached Docker layer that CI keeps reusing, so pinning "10.15.0" in the feature config never actually took effect — every run still installed the same bleeding-edge pnpm (visible via its "supply-chain policies" lockfile check, present even in the very first failing run before any of these fixes). Activating the version via corepack in postCreateCommand runs at container start, not image build time, so it can't be short-circuited by the cached feature layer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 28f6f6c..459caa6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,7 @@ "source=${localEnv:HOME}/.gitconfig,target=/home/node/.gitconfig,type=bind,consistency=cached", "source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,consistency=cached" ], - "postCreateCommand": "pnpm install", + "postCreateCommand": "corepack enable && corepack prepare pnpm@10.15.0 --activate && pnpm install", "containerEnv": { "npm_config_store_dir": "/home/node/.local/share/pnpm/store" }