diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 17898af..459caa6 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": { @@ -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" } 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/package.json b/package.json index ce4f45e..2ed75f2 100644 --- a/package.json +++ b/package.json @@ -78,10 +78,5 @@ "ts-jest": "^29.4.0", "typescript": "^5.8.3", "vite": "^6.3.6" - }, - "pnpm": { - "overrides": { - "tmp": "^0.2.4" - } } } \ 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 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 }), }); }; }