Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "lts",
"pnpm": "latest"
"pnpm": "10.15.0"
}
},
"customizations": {
Expand All @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 0 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,5 @@
"ts-jest": "^29.4.0",
"typescript": "^5.8.3",
"vite": "^6.3.6"
},
"pnpm": {
"overrides": {
"tmp": "^0.2.4"
}
}
}
12 changes: 12 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
packages:
- "."

overrides:
tmp: "^0.2.4"

onlyBuiltDependencies:
- "@parcel/watcher"
- "@sentry/cli"
- "@swc/core"
- core-js
- esbuild
12 changes: 12 additions & 0 deletions src/components/IssueItem/IssueItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -31,6 +32,7 @@ const IssueItem: FC<Props> = ({ 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<HTMLAnchorElement> = useCallback((e) => {
Expand Down Expand Up @@ -110,6 +112,16 @@ const IssueItem: FC<Props> = ({ issue, onClickTitle }) => {
}
/>
)}
{releases?.length > 0 && (
<Property
label='Releases'
text={
<div style={{display: 'flex', flexDirection: 'column'}}>
{releases.map(release => <ReleaseItem key={release.id} release={release} />)}
</div>
}
/>
)}
</>
);
};
Expand Down
17 changes: 17 additions & 0 deletions src/components/IssueItem/__tests__/IssueItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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();
});
});
19 changes: 19 additions & 0 deletions src/components/ReleaseItem/ReleaseItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<P5 style={{ display: 'flex' }}>
{`${release.name}${release.version ? ` (${release.version})` : ''} `}
<ExternalIconLink href={release.url} />
</P5>
<P5>{`Stage: ${release.stage.name}`}</P5>
</>
);
};
7 changes: 7 additions & 0 deletions src/components/ViewIssue/ViewIssue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Issue>,
Expand Down Expand Up @@ -46,6 +47,12 @@ const ViewIssue: FC<Props> = ({

<HorizontalDivider />

<Container>
<Releases releases={issue?.releases || []} />
</Container>

<HorizontalDivider />

<Container>
<Comments
comments={issue?.comments || []}
Expand Down
20 changes: 20 additions & 0 deletions src/components/ViewIssue/blocks/Releases.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Title } from '@deskpro/app-sdk';
import { P5 } from '@deskpro/deskpro-ui';
import { ReleaseItem } from '../../ReleaseItem/ReleaseItem';
import { Release } from '../../../services/linear/types';

interface Releases {
releases: Release[];
};

export function Releases({ releases }: Releases) {
return (
<>
<Title title={`Releases (${releases.length})`} />
{releases.length === 0
? <P5>No releases found</P5>
: releases.map(release => <ReleaseItem key={release.id} release={release} />)
}
</>
);
};
42 changes: 42 additions & 0 deletions src/services/linear/__tests__/getIssueService.test.ts
Original file line number Diff line number Diff line change
@@ -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 } } }",
);
});
});
59 changes: 59 additions & 0 deletions src/services/linear/__tests__/getIssuesService.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions src/services/linear/getIssueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ const getIssueService = (
}
}
}
releases {
nodes {
id
name
version
url
stage {
id
name
type
color
}
}
}
}
}
${issueFullInfoFragment}
Expand Down
Loading
Loading