Skip to content

Commit 3ff5947

Browse files
authored
migrate linear find issues (#65)
* migrate linear find issues * fixes
1 parent 81c6955 commit 3ff5947

File tree

6 files changed

+253
-97
lines changed

6 files changed

+253
-97
lines changed

app/workflows/[workflowId]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,7 +711,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => {
711711
{/* Hover indicator */}
712712
<div className="absolute inset-y-0 left-0 w-1 bg-transparent transition-colors group-hover:bg-blue-500 group-active:bg-blue-600" />
713713
{/* Collapse button - hidden while resizing */}
714-
{!isDraggingResize && !panelCollapsed && (
714+
{!(isDraggingResize || panelCollapsed) && (
715715
<button
716716
className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-0 flex size-6 items-center justify-center rounded-full border bg-background opacity-0 shadow-sm transition-opacity hover:bg-muted group-hover:opacity-100"
717717
onClick={(e) => {

components/workflow/config/action-config.tsx

Lines changed: 4 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -26,80 +26,6 @@ type ActionConfigProps = {
2626
disabled: boolean;
2727
};
2828

29-
// Find Issues fields component (kept hardcoded - Linear plugin incomplete)
30-
function FindIssuesFields({
31-
config,
32-
onUpdateConfig,
33-
disabled,
34-
}: {
35-
config: Record<string, unknown>;
36-
onUpdateConfig: (key: string, value: string) => void;
37-
disabled: boolean;
38-
}) {
39-
return (
40-
<>
41-
<div className="space-y-2">
42-
<Label className="ml-1" htmlFor="linearAssigneeId">
43-
Assignee (User ID)
44-
</Label>
45-
<TemplateBadgeInput
46-
disabled={disabled}
47-
id="linearAssigneeId"
48-
onChange={(value) => onUpdateConfig("linearAssigneeId", value)}
49-
placeholder="user-id-123 or {{NodeName.userId}}"
50-
value={(config?.linearAssigneeId as string) || ""}
51-
/>
52-
</div>
53-
<div className="space-y-2">
54-
<Label className="ml-1" htmlFor="linearTeamId">
55-
Team ID (optional)
56-
</Label>
57-
<TemplateBadgeInput
58-
disabled={disabled}
59-
id="linearTeamId"
60-
onChange={(value) => onUpdateConfig("linearTeamId", value)}
61-
placeholder="team-id-456 or {{NodeName.teamId}}"
62-
value={(config?.linearTeamId as string) || ""}
63-
/>
64-
</div>
65-
<div className="space-y-2">
66-
<Label className="ml-1" htmlFor="linearStatus">
67-
Status (optional)
68-
</Label>
69-
<Select
70-
disabled={disabled}
71-
onValueChange={(value) => onUpdateConfig("linearStatus", value)}
72-
value={(config?.linearStatus as string) || "any"}
73-
>
74-
<SelectTrigger className="w-full" id="linearStatus">
75-
<SelectValue placeholder="Any status" />
76-
</SelectTrigger>
77-
<SelectContent>
78-
<SelectItem value="any">Any</SelectItem>
79-
<SelectItem value="backlog">Backlog</SelectItem>
80-
<SelectItem value="todo">Todo</SelectItem>
81-
<SelectItem value="in_progress">In Progress</SelectItem>
82-
<SelectItem value="done">Done</SelectItem>
83-
<SelectItem value="canceled">Canceled</SelectItem>
84-
</SelectContent>
85-
</Select>
86-
</div>
87-
<div className="space-y-2">
88-
<Label className="ml-1" htmlFor="linearLabel">
89-
Label (optional)
90-
</Label>
91-
<TemplateBadgeInput
92-
disabled={disabled}
93-
id="linearLabel"
94-
onChange={(value) => onUpdateConfig("linearLabel", value)}
95-
placeholder="bug, feature, etc. or {{NodeName.label}}"
96-
value={(config?.linearLabel as string) || ""}
97-
/>
98-
</div>
99-
</>
100-
);
101-
}
102-
10329
// Database Query fields component
10430
function DatabaseQueryFields({
10531
config,
@@ -435,25 +361,14 @@ export function ActionConfig({
435361
/>
436362
)}
437363

438-
{/* Find Issues - kept hardcoded (Linear plugin incomplete) */}
439-
{config?.actionType === "Find Issues" && (
440-
<FindIssuesFields
364+
{/* Plugin actions - dynamic config fields */}
365+
{pluginAction && !SYSTEM_ACTIONS.includes(actionType) && (
366+
<pluginAction.configFields
441367
config={config}
442368
disabled={disabled}
443-
onUpdateConfig={onUpdateConfig}
369+
onUpdateConfig={handlePluginUpdateConfig}
444370
/>
445371
)}
446-
447-
{/* Plugin actions - dynamic config fields */}
448-
{pluginAction &&
449-
!SYSTEM_ACTIONS.includes(actionType) &&
450-
actionType !== "Find Issues" && (
451-
<pluginAction.configFields
452-
config={config}
453-
disabled={disabled}
454-
onUpdateConfig={handlePluginUpdateConfig}
455-
/>
456-
)}
457372
</>
458373
);
459374
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Code generation template for Find Issues action
3+
* Used when exporting workflows to standalone Next.js projects
4+
*/
5+
export const findIssuesCodegenTemplate = `import { LinearClient } from '@linear/sdk';
6+
7+
export async function findIssuesStep(input: {
8+
linearAssigneeId?: string;
9+
linearTeamId?: string;
10+
linearStatus?: string;
11+
linearLabel?: string;
12+
}) {
13+
"use step";
14+
15+
const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
16+
17+
const filter: Record<string, unknown> = {};
18+
19+
if (input.linearAssigneeId) {
20+
filter.assignee = { id: { eq: input.linearAssigneeId } };
21+
}
22+
23+
if (input.linearTeamId) {
24+
filter.team = { id: { eq: input.linearTeamId } };
25+
}
26+
27+
if (input.linearStatus && input.linearStatus !== 'any') {
28+
filter.state = { name: { eqIgnoreCase: input.linearStatus } };
29+
}
30+
31+
if (input.linearLabel) {
32+
filter.labels = { name: { eqIgnoreCase: input.linearLabel } };
33+
}
34+
35+
const issues = await linear.issues({ filter });
36+
37+
const mappedIssues = await Promise.all(
38+
issues.nodes.map(async (issue) => {
39+
const state = await issue.state;
40+
return {
41+
id: issue.id,
42+
title: issue.title,
43+
url: issue.url,
44+
state: state?.name || 'Unknown',
45+
priority: issue.priority,
46+
assigneeId: issue.assigneeId || undefined,
47+
};
48+
})
49+
);
50+
51+
return {
52+
issues: mappedIssues,
53+
count: mappedIssues.length,
54+
};
55+
}`;
56+

plugins/linear/index.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { Ticket } from "lucide-react";
1+
import { Search, Ticket } from "lucide-react";
22
import type { IntegrationPlugin } from "../registry";
33
import { registerIntegration } from "../registry";
44
import { createTicketCodegenTemplate } from "./codegen/create-ticket";
5+
import { findIssuesCodegenTemplate } from "./codegen/find-issues";
56
import { LinearSettings } from "./settings";
67
import { CreateTicketConfigFields } from "./steps/create-ticket/config";
8+
import { FindIssuesConfigFields } from "./steps/find-issues/config";
79

810
const linearPlugin: IntegrationPlugin = {
911
type: "linear",
@@ -70,17 +72,16 @@ const linearPlugin: IntegrationPlugin = {
7072
configFields: CreateTicketConfigFields,
7173
codegenTemplate: createTicketCodegenTemplate,
7274
},
73-
// TODO: Add Find Issues action
7475
{
7576
id: "Find Issues",
7677
label: "Find Issues",
7778
description: "Search for issues in Linear",
7879
category: "Linear",
79-
icon: Ticket,
80-
stepFunction: "createTicketStep", // TODO: Implement separate findIssuesStep
81-
stepImportPath: "create-ticket",
82-
configFields: CreateTicketConfigFields,
83-
codegenTemplate: createTicketCodegenTemplate,
80+
icon: Search,
81+
stepFunction: "findIssuesStep",
82+
stepImportPath: "find-issues",
83+
configFields: FindIssuesConfigFields,
84+
codegenTemplate: findIssuesCodegenTemplate,
8485
},
8586
],
8687
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Label } from "@/components/ui/label";
2+
import {
3+
Select,
4+
SelectContent,
5+
SelectItem,
6+
SelectTrigger,
7+
SelectValue,
8+
} from "@/components/ui/select";
9+
import { TemplateBadgeInput } from "@/components/ui/template-badge-input";
10+
11+
/**
12+
* Find Issues Config Fields Component
13+
* UI for configuring the find issues action
14+
*/
15+
export function FindIssuesConfigFields({
16+
config,
17+
onUpdateConfig,
18+
disabled,
19+
}: {
20+
config: Record<string, unknown>;
21+
onUpdateConfig: (key: string, value: unknown) => void;
22+
disabled?: boolean;
23+
}) {
24+
return (
25+
<>
26+
<div className="space-y-2">
27+
<Label className="ml-1" htmlFor="linearAssigneeId">
28+
Assignee (User ID)
29+
</Label>
30+
<TemplateBadgeInput
31+
disabled={disabled}
32+
id="linearAssigneeId"
33+
onChange={(value) => onUpdateConfig("linearAssigneeId", value)}
34+
placeholder="user-id-123 or {{NodeName.userId}}"
35+
value={(config?.linearAssigneeId as string) || ""}
36+
/>
37+
</div>
38+
<div className="space-y-2">
39+
<Label className="ml-1" htmlFor="linearTeamId">
40+
Team ID (optional)
41+
</Label>
42+
<TemplateBadgeInput
43+
disabled={disabled}
44+
id="linearTeamId"
45+
onChange={(value) => onUpdateConfig("linearTeamId", value)}
46+
placeholder="team-id-456 or {{NodeName.teamId}}"
47+
value={(config?.linearTeamId as string) || ""}
48+
/>
49+
</div>
50+
<div className="space-y-2">
51+
<Label className="ml-1" htmlFor="linearStatus">
52+
Status (optional)
53+
</Label>
54+
<Select
55+
disabled={disabled}
56+
onValueChange={(value) => onUpdateConfig("linearStatus", value)}
57+
value={(config?.linearStatus as string) || "any"}
58+
>
59+
<SelectTrigger className="w-full" id="linearStatus">
60+
<SelectValue placeholder="Any status" />
61+
</SelectTrigger>
62+
<SelectContent>
63+
<SelectItem value="any">Any</SelectItem>
64+
<SelectItem value="backlog">Backlog</SelectItem>
65+
<SelectItem value="todo">Todo</SelectItem>
66+
<SelectItem value="in_progress">In Progress</SelectItem>
67+
<SelectItem value="done">Done</SelectItem>
68+
<SelectItem value="canceled">Canceled</SelectItem>
69+
</SelectContent>
70+
</Select>
71+
</div>
72+
<div className="space-y-2">
73+
<Label className="ml-1" htmlFor="linearLabel">
74+
Label (optional)
75+
</Label>
76+
<TemplateBadgeInput
77+
disabled={disabled}
78+
id="linearLabel"
79+
onChange={(value) => onUpdateConfig("linearLabel", value)}
80+
placeholder="bug, feature, etc. or {{NodeName.label}}"
81+
value={(config?.linearLabel as string) || ""}
82+
/>
83+
</div>
84+
</>
85+
);
86+
}
87+
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import "server-only";
2+
3+
import { LinearClient } from "@linear/sdk";
4+
import { fetchCredentials } from "@/lib/credential-fetcher";
5+
import { getErrorMessage } from "@/lib/utils";
6+
7+
type LinearIssue = {
8+
id: string;
9+
title: string;
10+
url: string;
11+
state: string;
12+
priority: number;
13+
assigneeId?: string;
14+
};
15+
16+
type FindIssuesResult =
17+
| { success: true; issues: LinearIssue[]; count: number }
18+
| { success: false; error: string };
19+
20+
/**
21+
* Find Issues Step
22+
* Searches for issues in Linear based on filters
23+
*/
24+
export async function findIssuesStep(input: {
25+
integrationId?: string;
26+
linearAssigneeId?: string;
27+
linearTeamId?: string;
28+
linearStatus?: string;
29+
linearLabel?: string;
30+
}): Promise<FindIssuesResult> {
31+
"use step";
32+
33+
const credentials = input.integrationId
34+
? await fetchCredentials(input.integrationId)
35+
: {};
36+
37+
const apiKey = credentials.LINEAR_API_KEY;
38+
39+
if (!apiKey) {
40+
return {
41+
success: false,
42+
error:
43+
"LINEAR_API_KEY is not configured. Please add it in Project Integrations.",
44+
};
45+
}
46+
47+
try {
48+
const linear = new LinearClient({ apiKey });
49+
50+
// Build filter object
51+
const filter: Record<string, unknown> = {};
52+
53+
if (input.linearAssigneeId) {
54+
filter.assignee = { id: { eq: input.linearAssigneeId } };
55+
}
56+
57+
if (input.linearTeamId) {
58+
filter.team = { id: { eq: input.linearTeamId } };
59+
}
60+
61+
if (input.linearStatus && input.linearStatus !== "any") {
62+
filter.state = { name: { eqIgnoreCase: input.linearStatus } };
63+
}
64+
65+
if (input.linearLabel) {
66+
filter.labels = { name: { eqIgnoreCase: input.linearLabel } };
67+
}
68+
69+
const issues = await linear.issues({ filter });
70+
71+
const mappedIssues: LinearIssue[] = await Promise.all(
72+
issues.nodes.map(async (issue) => {
73+
const state = await issue.state;
74+
return {
75+
id: issue.id,
76+
title: issue.title,
77+
url: issue.url,
78+
state: state?.name || "Unknown",
79+
priority: issue.priority,
80+
assigneeId: issue.assigneeId || undefined,
81+
};
82+
})
83+
);
84+
85+
return {
86+
success: true,
87+
issues: mappedIssues,
88+
count: mappedIssues.length,
89+
};
90+
} catch (error) {
91+
return {
92+
success: false,
93+
error: `Failed to find issues: ${getErrorMessage(error)}`,
94+
};
95+
}
96+
}
97+

0 commit comments

Comments
 (0)