Skip to content
Merged
6 changes: 3 additions & 3 deletions 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 Expand Up @@ -90,12 +90,12 @@
"proxy": {
"whitelist": [
{
"url": "https://api.linear.app/oauth/.*",
"url": "https://api\\.linear\\.app/oauth/.*",
"methods": ["POST"],
"timeout": 20
},
{
"url": "https://api.linear.app/graphql",
"url": "https://api\\.linear\\.app/graphql",
"methods": ["POST"],
"timeout": 20
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/IssueItem/IssueItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const IssueItem: FC<Props> = ({ issue, onClickTitle }) => {
|| get(issue, ["assignee", "email"]);
}, [issue]);
const labels = useMemo(() => get(issue, ["labels"], []) || [], [issue]);
const { relationships, error } = useIssueRelationships(issue.relations, issue.id);
const { relationships, error } = useIssueRelationships(issue.relations, issue.inverseRelations ?? []);

const onClick: MouseEventHandler<HTMLAnchorElement> = useCallback((e) => {
e.preventDefault();
Expand Down
2 changes: 1 addition & 1 deletion src/components/ViewIssue/ViewIssue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const ViewIssue: FC<Props> = ({
<HorizontalDivider/>

<Container>
<Relationships relationships={issue?.relations || []} issueID={issue?.id || ''} />
<Relationships relationships={issue?.relations || []} inverseRelations={issue?.inverseRelations || []} issueID={issue?.id || ''} />
</Container>

<HorizontalDivider />
Expand Down
7 changes: 4 additions & 3 deletions src/components/ViewIssue/blocks/Relationships.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { Relation } from '../../../services/linear/types';

interface Relationships {
relationships: Relation[];
inverseRelations: Relation[];
issueID: string;
};

export function Relationships({ relationships, issueID }: Relationships) {
export function Relationships({ relationships, inverseRelations, issueID }: Relationships) {
const navigate = useNavigate();
const { relationships: trueRelationships, error } = useIssueRelationships(relationships, issueID);
const { relationships: trueRelationships, error } = useIssueRelationships(relationships, inverseRelations);

return (
<>
Expand All @@ -25,4 +26,4 @@ export function Relationships({ relationships, issueID }: Relationships) {
}
</>
);
};
};
82 changes: 82 additions & 0 deletions src/hooks/__tests__/useIssueRelationships.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { renderHook } from "@testing-library/react";
import { useIssueRelationships } from "../useIssueRelationships";
import type { Relation } from "../../services/linear/types";

const makeRelation = (overrides: Partial<Relation>): Relation =>
({ id: "rel-1", type: "related", relatedIssue: { id: "issue-2" }, issue: null, ...overrides } as unknown as Relation);

describe("useIssueRelationships", () => {
test("returns forward relations unchanged", () => {
const forward: Relation[] = [makeRelation({ id: "rel-1", type: "blocks" })];
const { result } = renderHook(() => useIssueRelationships(forward, []));
expect(result.current.relationships).toHaveLength(1);
expect(result.current.relationships[0].type).toBe("blocks");
});

test("maps inverse relation types: blocks→blocked, duplicate→duplicated, related→related", () => {
const inverseRelations: Relation[] = [
makeRelation({ id: "rel-a", type: "blocks", issue: { id: "issue-b", title: "B" } as Relation["issue"] }),
makeRelation({ id: "rel-b", type: "duplicate", issue: { id: "issue-c", title: "C" } as Relation["issue"] }),
makeRelation({ id: "rel-c", type: "related", issue: { id: "issue-d", title: "D" } as Relation["issue"] }),
];
const { result } = renderHook(() => useIssueRelationships([], inverseRelations));
const types = result.current.relationships.map(r => r.type);
expect(types).toEqual(["blocked", "duplicated", "related"]);
});

test("maps the symmetric 'similar' inverse relation to itself", () => {
const inverseRelations: Relation[] = [
makeRelation({ id: "rel-s", type: "similar", issue: { id: "issue-s", title: "S" } as Relation["issue"] }),
];
const { result } = renderHook(() => useIssueRelationships([], inverseRelations));
expect(result.current.relationships[0].type).toBe("similar");
});

test("uses issue field as relatedIssue for inverse relations", () => {
const otherIssue = { id: "issue-b", title: "Issue B" };
const inv: Relation[] = [
makeRelation({ id: "rel-1", type: "blocks", issue: otherIssue as Relation["issue"] }),
];
const { result } = renderHook(() => useIssueRelationships([], inv));
expect(result.current.relationships[0].relatedIssue).toBe(otherIssue);
});

test("deduplicates when a relation appears in both forward and inverse", () => {
const forward: Relation[] = [makeRelation({ id: "rel-shared", type: "related" })];
const inverse: Relation[] = [makeRelation({ id: "rel-shared", type: "related" })];
const { result } = renderHook(() => useIssueRelationships(forward, inverse));
expect(result.current.relationships).toHaveLength(1);
});

test("merges forward and inverse relations", () => {
const forward: Relation[] = [makeRelation({ id: "rel-1", type: "blocks" })];
const inverse: Relation[] = [makeRelation({ id: "rel-2", type: "related" })];
const { result } = renderHook(() => useIssueRelationships(forward, inverse));
expect(result.current.relationships).toHaveLength(2);
});

test("handles empty arrays gracefully", () => {
const { result } = renderHook(() => useIssueRelationships([], []));
expect(result.current.relationships).toEqual([]);
expect(result.current.error).toBeNull();
});

test("handles undefined arguments gracefully", () => {
const { result } = renderHook(() =>
useIssueRelationships(undefined as unknown as Relation[], undefined as unknown as Relation[])
);
expect(result.current.relationships).toEqual([]);
});

test("relationships array is stable across rerenders when inputs are unchanged", () => {
const forward: Relation[] = [makeRelation({ id: "rel-1", type: "blocks" })];
const inverse: Relation[] = [];
const { result, rerender } = renderHook(
({ f, i }) => useIssueRelationships(f, i),
{ initialProps: { f: forward, i: inverse } }
);
const firstRelationships = result.current.relationships;
rerender({ f: forward, i: inverse });
expect(result.current.relationships).toBe(firstRelationships);
});
});
80 changes: 29 additions & 51 deletions src/hooks/useIssueRelationships.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,33 @@
import { useState } from 'react';
import { useDeskproAppClient, useInitialisedDeskproAppClient } from '@deskpro/app-sdk';
import { getIssuesService } from '../services/linear';
import { Issue, Relation } from '../services/linear/types';
import { useMemo } from 'react';
import { Relation } from '../services/linear/types';

export function useIssueRelationships(originalRelationships: Relation[], issueID: string) {
const { client } = useDeskproAppClient();
const [relationships, setRelationships] = useState<Relation[]>(originalRelationships);
const [error, setError] = useState<string | null>(null);
const INVERSE_TYPE: Record<string, string> = {
Comment thread
dane-h marked this conversation as resolved.
blocks: 'blocked',
duplicate: 'duplicated',
related: 'related',
similar: 'similar',
};

useInitialisedDeskproAppClient(client => {
getIssuesService(client, {})
.then((issues: Issue[]) => {
const relatedIssues = issues
.filter(issue => issue.relations.some(relation => relation.type === 'related' && relation.relatedIssue.id === issueID))
.map(relatedIssue => ({
id: relatedIssue.id,
type: 'related',
relatedIssue: relatedIssue
}));
const blockedIssues = issues
.filter(issue => issue.relations.some(relation => relation.type === 'blocks' && relation.relatedIssue.id === issueID))
.map(blockingIssue => ({
id: blockingIssue.id,
type: 'blocked',
relatedIssue: blockingIssue
}));
const duplicatedIssues = issues
.filter(issue => issue.relations.some(relation => relation.type === 'duplicate' && relation.relatedIssue.id === issueID))
.map(duplicateIssue => ({
id: duplicateIssue.id,
type: 'duplicated',
relatedIssue: duplicateIssue
}));
const allRelationships = [
...originalRelationships,
...relatedIssues,
...blockedIssues,
...duplicatedIssues
];
const uniqueRelationships = Array.from(
new Map(
allRelationships.map(relationship => [relationship.id, relationship] as [string, Relation])
).values()
);
export function useIssueRelationships(
relations: Relation[] = [],
inverseRelations: Relation[] = [],
) {
const relationships = useMemo(() => {
const inverseMapped = inverseRelations.map(rel => ({
...rel,
type: INVERSE_TYPE[rel.type] ?? rel.type,
relatedIssue: rel.issue,
}));

setRelationships(uniqueRelationships);
})
.catch(error => {
setError(`error getting relationships: ${error.message}`);
});
}, [client, issueID, originalRelationships]);
const all = [...relations, ...inverseMapped];
const seen = new Set<string>();
return all.filter(r => {
const key = r.id ?? (r.relatedIssue as { id?: string })?.id;
Comment thread
dane-h marked this conversation as resolved.
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}, [relations, inverseRelations]);

return { relationships, error };
};
return { relationships, error: null };
}
24 changes: 24 additions & 0 deletions src/services/linear/getIssueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const getIssueService = (
}
relations {
nodes {
id
type
relatedIssue {
...issueInfo
Expand All @@ -55,6 +56,29 @@ const getIssueService = (
}
}
}
inverseRelations {
Comment thread
dane-h marked this conversation as resolved.
nodes {
id
type
issue {
...issueInfo
state {
...stateInfo
}
team {
...teamInfo
}
labels {
nodes {
...labelInfo
}
}
assignee {
...userInfo
}
}
}
}
}
}
${issueFullInfoFragment}
Expand Down
24 changes: 24 additions & 0 deletions src/services/linear/getIssuesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const getIssuesService = (client: IDeskproClient, params?: Params) => {
assignee { ...userInfo }
relations {
nodes {
id
type
relatedIssue {
...issueInfo
Expand All @@ -59,6 +60,29 @@ const getIssuesService = (client: IDeskproClient, params?: Params) => {
}
}
}
inverseRelations {
nodes {
id
type
issue {
...issueInfo
state {
...stateInfo
}
team {
...teamInfo
}
labels {
nodes {
...labelInfo
}
}
assignee {
...userInfo
}
}
}
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/services/linear/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ export type Issue = Omit<IssueGQL, "labels"|"children"|"comments"|"team"> & {
children: Issue[],
comments: IssueComment[],
team: Team,
relations: Relation[]
relations: Relation[],
inverseRelations: Relation[],
};

export type WorkflowState = WorkflowStateGQL;
Expand Down
5 changes: 3 additions & 2 deletions src/types/relationships.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
export type RelationshipType = 'related' | 'blocks' | 'duplicate' | 'blocked' | 'duplicated';
export type RelationshipType = 'related' | 'blocks' | 'duplicate' | 'blocked' | 'duplicated' | 'similar';

export const RELATIONSHIP_LABELS: Record<RelationshipType, string> = {
related: 'Related to',
blocks: 'Blocking',
duplicate: 'Duplicates',
blocked: 'Blocked by',
duplicated: 'Duplicated by'
duplicated: 'Duplicated by',
similar: 'Similar to'
};

export const RELATIONSHIP_OPTIONS = [
Expand Down