Skip to content
Merged
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
55 changes: 53 additions & 2 deletions src/components/settings/WebhooksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { useState } from 'react'
import {
Globe, MessageSquare, Send, Hash, Plus, Trash2, Power, PowerOff,
CheckCircle2, XCircle, ChevronDown, ChevronUp, Loader2, Zap, CheckSquare, Pencil, Columns3, BookOpen, Mail, Server,
CheckCircle2, XCircle, ChevronDown, ChevronUp, Loader2, Zap, CheckSquare, Pencil, Columns3, BookOpen, Mail, Server, Layers,
} from 'lucide-react'
import { useWorkspace } from '@/hooks/useWorkspace'
import { useWebhooks, useCreateWebhook, useUpdateWebhook, useDeleteWebhook, useWebhookLogs } from '@/hooks/useWebhooks'
Expand All @@ -23,7 +23,7 @@ function GitHubIcon({ className }: { className?: string }) {
)
}

type DestinationType = 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' | 'github' | 'email' | 'smtp'
type DestinationType = 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' | 'github' | 'email' | 'smtp' | 'linear'

const DESTINATION_META: Record<DestinationType, { label: string; icon: typeof Globe; color: string; bgColor: string }> = {
http: { label: 'HTTP Webhook', icon: Globe, color: 'text-blue-400', bgColor: 'bg-blue-500/10' },
Expand All @@ -37,6 +37,7 @@ const DESTINATION_META: Record<DestinationType, { label: string; icon: typeof Gl
github: { label: 'GitHub Issues', icon: GitHubIcon as unknown as typeof Globe, color: 'text-gray-200', bgColor: 'bg-gray-600/10' },
email: { label: 'Email (Resend)', icon: Mail, color: 'text-amber-400', bgColor: 'bg-amber-500/10' },
smtp: { label: 'SMTP Email', icon: Server, color: 'text-orange-400', bgColor: 'bg-orange-500/10' },
linear: { label: 'Linear', icon: Layers, color: 'text-violet-300', bgColor: 'bg-violet-500/10' },
}

const EVENT_OPTIONS = [
Expand Down Expand Up @@ -733,6 +734,56 @@ function DestinationConfigFields({
</div>
</div>
)

case 'linear':
return (
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-400">API Key</label>
<input
type="password"
value={config.api_key ?? ''}
onChange={(e) => updateField('api_key', e.target.value)}
placeholder="lin_api_xxxxxxxxxxxx"
className={inputClass}
/>
<p className="mt-1 text-xs text-gray-600">
Create one at{' '}
<a href="https://linear.app/settings/api" target="_blank" rel="noopener noreferrer" className="text-violet-300 hover:underline">
linear.app/settings/api
</a>
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-400">Team ID</label>
<input
type="text"
value={config.team_id ?? ''}
onChange={(e) => updateField('team_id', e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
className={inputClass}
/>
<p className="mt-1 text-xs text-gray-600">
Find it in team settings or use the Linear API to list teams.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-400">
Labels <span className="text-gray-600">(optional, comma-separated)</span>
</label>
<input
type="text"
value={config.labels ?? ''}
onChange={(e) => updateField('labels', e.target.value)}
placeholder="crewform, ai-output"
className={inputClass}
/>
<p className="mt-1 text-xs text-gray-600">
Label names are matched against existing labels in your team.
</p>
</div>
</div>
)
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/components/shared/ChannelSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 CrewForm

import { Globe, MessageSquare, Hash, Send, Users, CheckSquare, Radio, Columns3, BookOpen, Mail, Server } from 'lucide-react'
import { Globe, MessageSquare, Hash, Send, Users, CheckSquare, Radio, Columns3, BookOpen, Mail, Server, Layers } from 'lucide-react'
import { useOutputRoutes } from '@/hooks/useChannels'
import { cn } from '@/lib/utils'

Expand All @@ -26,6 +26,7 @@ const DESTINATION_ICONS: Record<string, typeof Globe> = {
github: GitHubIcon as unknown as typeof Globe,
email: Mail,
smtp: Server,
linear: Layers,
}

const DESTINATION_COLORS: Record<string, { text: string; bg: string }> = {
Expand All @@ -40,6 +41,7 @@ const DESTINATION_COLORS: Record<string, { text: string; bg: string }> = {
github: { text: 'text-gray-200', bg: 'bg-gray-600/10' },
email: { text: 'text-amber-400', bg: 'bg-amber-500/10' },
smtp: { text: 'text-orange-400', bg: 'bg-orange-500/10' },
linear: { text: 'text-violet-300', bg: 'bg-violet-500/10' },
}

interface OutputRouteSelectorProps {
Expand Down
4 changes: 2 additions & 2 deletions src/db/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface OutputRoute {
id: string
workspace_id: string
name: string
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' | 'github' | 'email' | 'smtp'
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' | 'github' | 'email' | 'smtp' | 'linear'
config: Record<string, unknown>
events: string[]
is_active: boolean
Expand All @@ -34,7 +34,7 @@ export interface WebhookLog {
export interface CreateRouteInput {
workspace_id: string
name: string
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' | 'github' | 'email' | 'smtp'
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' | 'github' | 'email' | 'smtp' | 'linear'
config: Record<string, unknown>
events: string[]
is_active?: boolean
Expand Down
128 changes: 127 additions & 1 deletion task-runner/src/webhookDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface OutputRoute {
id: string;
workspace_id: string;
name: string;
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' | 'github' | 'email' | 'smtp';
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion' | 'github' | 'email' | 'smtp' | 'linear';
config: Record<string, unknown>;
events: string[];
is_active: boolean;
Expand Down Expand Up @@ -387,6 +387,8 @@ async function deliver(
return deliverEmail(route, payload);
case 'smtp':
return deliverSmtp(route, payload);
case 'linear':
return deliverLinear(route, payload);
default:
throw new Error(`Unknown destination type: ${route.destination_type}`);
}
Expand Down Expand Up @@ -1465,6 +1467,130 @@ ${escapeHtml(truncated)}
</div>`;
}

// ── Linear ──────────────────────────────────────────────────────────────────

/**
* Deliver a webhook payload to Linear by creating an issue via GraphQL.
*
* Config fields:
* - api_key: Linear API key (personal or workspace)
* - team_id: Linear team UUID to create the issue in
* - labels: Comma-separated label names (optional)
*
* Creates an issue with:
* - Title: task title with status emoji
* - Description: metadata + full result as markdown
* - Labels: matched by name against existing team labels
*/
async function deliverLinear(
route: OutputRoute,
payload: WebhookPayload,
): Promise<{ ok: boolean; statusCode: number }> {
const apiKey = route.config.api_key as string;
const teamId = route.config.team_id as string;
const labelsCsv = (route.config.labels as string) ?? '';

if (!apiKey || !teamId) {
throw new Error('Linear integration requires api_key and team_id');
}

const emoji = payload.status === 'completed' ? 'βœ…' : '❌';
const output = payload.result_full || payload.error || 'No output';
const isTeamRun = !!payload.team_run_id;

const title = `${emoji} ${payload.task_title}`.substring(0, 256);

const description = [
`## ${emoji} ${isTeamRun ? 'Team Run' : 'Task'} ${payload.status}`,
'',
`| Field | Value |`,
`|-------|-------|`,
`| **${isTeamRun ? 'Team' : 'Agent'}** | ${payload.agent_name} |`,
`| **Status** | ${payload.status} |`,
`| **Event** | \`${payload.event}\` |`,
`| **Timestamp** | ${payload.timestamp} |`,
'',
'---',
'',
output.length > 60000
? `${output.substring(0, 60000)}\n\n> _Output truncated (${output.length} chars)_`
: output,
'',
'---',
'_Created by [CrewForm](https://crewform.tech)_',
].join('\n');

// Resolve label IDs by name if labels are specified
let labelIds: string[] | undefined;
if (labelsCsv.trim()) {
const labelNames = labelsCsv.split(',').map(l => l.trim().toLowerCase()).filter(Boolean);
try {
const labelsResp = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': apiKey,
},
body: JSON.stringify({
query: `query { issueLabels(filter: { team: { id: { eq: "${teamId}" } } }) { nodes { id name } } }`,
}),
signal: AbortSignal.timeout(10000),
});
if (labelsResp.ok) {
const labelsData = await labelsResp.json() as { data?: { issueLabels?: { nodes?: { id: string; name: string }[] } } };
const allLabels = labelsData.data?.issueLabels?.nodes ?? [];
labelIds = allLabels
.filter(l => labelNames.includes(l.name.toLowerCase()))
.map(l => l.id);
}
} catch {
// Label resolution failure is non-fatal β€” create issue without labels
console.warn('[Linear] Failed to resolve labels, creating issue without them');
}
}

const mutation = `mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue { id identifier url }
}
}`;

const variables: Record<string, unknown> = {
input: {
teamId,
title,
description,
...(labelIds && labelIds.length > 0 ? { labelIds } : {}),
},
};

const resp = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': apiKey,
},
body: JSON.stringify({ query: mutation, variables }),
signal: AbortSignal.timeout(15000),
});

if (!resp.ok) {
const errBody = await resp.text().catch(() => '');
console.error(`[Linear] API error ${resp.status}: ${errBody.substring(0, 500)}`);
return { ok: false, statusCode: resp.status };
}

const result = await resp.json() as { data?: { issueCreate?: { success: boolean } }; errors?: { message: string }[] };
if (result.errors?.length) {
console.error(`[Linear] GraphQL errors: ${result.errors.map(e => e.message).join(', ')}`);
return { ok: false, statusCode: 422 };
}

const success = result.data?.issueCreate?.success ?? false;
return { ok: success, statusCode: success ? 200 : 422 };
}

// ─── Logging ────────────────────────────────────────────────────────────────

async function logDelivery(
Expand Down
Loading