Complete Jira Linear Trello ClickUp integration workflows

swadhinbiswas committed 8 months ago
f5c388b
1 parent fea3bac
commit f5c388b
6 files changed
+740 -6
▼doc/feature_audit.md
+6-6
12
12
| **Code Review** | 9 | 1 | 0 | 10 |
13
13
| **Issues & Planning** | 10 | 0 | 0 | 10 |
14
14
| **CI/CD & Automation** | 7 | 1 | 0 | 8 |
15
-| **Third-Party Integrations** | 12 | 10 | 0 | 22 |
15
+| **Third-Party Integrations** | 16 | 6 | 0 | 22 |
16
16
| **Dependency & Impact Awareness** | 1 | 4 | 0 | 5 |
17
17
| **Security** | 9 | 3 | 0 | 12 |
18
18
| **Analytics & Insights** | 6 | 2 | 0 | 8 |
19
19
| **Notifications & Collaboration** | 5 | 3 | 0 | 8 |
20
20
| **Interfaces & Extensibility** | 5 | 2 | 0 | 7 |
21
21
| **Self-Hosted & Deployment** | 4 | 3 | 0 | 7 |
22
-| **Total** | **86** | **39** | **0** | **125** |
22
+| **Total** | **90** | **35** | **0** | **125** |
23
23
24
24
---
25
25
120
120
### 6.3 Issue Tracking
121
121
| Feature | Status | Notes |
122
122
| :--- | :---: | :--- |
123
-| Jira | ⚠️ | Basic integration implemented (`jira.ts`) |
124
-| Linear | ⚠️ | Provider integration + repo config/webhook APIs implemented; workflow/UI depth can expand |
125
-| Trello | ⚠️ | Provider integration + repo config/webhook APIs implemented; workflow/UI depth can expand |
126
-| ClickUp | ⚠️ | Provider integration + repo config/webhook APIs implemented; workflow/UI depth can expand |
123
+| Jira | ✅ | Provider integration, repo config CRUD/webhook APIs, and repository settings automation UX are implemented (`integrations/issue-trackers`, `settings/issue-trackers`) with Jira-specific API URL/project/token controls, webhook secret visibility, and recent sync-link surfacing |
124
+| Linear | ✅ | Provider integration, repo config CRUD/webhook APIs, and repository settings automation UX are implemented (`integrations/issue-trackers`, `settings/issue-trackers`) with team/token controls, webhook secret visibility, and recent sync-link surfacing |
125
+| Trello | ✅ | Provider integration, repo config CRUD/webhook APIs, and repository settings automation UX are implemented (`integrations/issue-trackers`, `settings/issue-trackers`) with board/list + `key:token` controls, webhook secret visibility, and recent sync-link surfacing |
126
+| ClickUp | ✅ | Provider integration, repo config CRUD/webhook APIs, and repository settings automation UX are implemented (`integrations/issue-trackers`, `settings/issue-trackers`) with list/token controls, webhook secret visibility, and recent sync-link surfacing |
127
127
128
128
### 6.4 Chat & Notifications
129
129
| Feature | Status | Notes |
130
130
▼src/pages/[owner]/[repo]/settings/external-ci.astro
+6-0
112
112
>
113
113
External CI
114
114
</a>
115
+ <a
116
+ href={`/${repo.owner}/${repo.name}/settings/issue-trackers`}
117
+ class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
118
+ >
119
+ Issue Trackers
120
+ </a>
115
121
<a
116
122
href={`/${repo.owner}/${repo.name}/settings/reviews`}
117
123
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
118
124
▼src/pages/[owner]/[repo]/settings/index.astro
+6-0
109
109
>
110
110
External CI
111
111
</a>
112
+ <a
113
+ href={`/${repo.owner}/${repo.name}/settings/issue-trackers`}
114
+ class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
115
+ >
116
+ Issue Trackers
117
+ </a>
112
118
<a
113
119
href={`/${repo.owner}/${repo.name}/settings/reviews`}
114
120
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
115
121
▼src/pages/[owner]/[repo]/settings/issue-trackers.astro
+449-0
1
+---
2
+import BaseLayout from "@/layouts/BaseLayout.astro";
3
+import { Button } from "@/components/ui/button";
4
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+import { getDatabase, schema } from "@/db";
6
+import { eq, and } from "drizzle-orm";
7
+import { canAdminRepo } from "@/lib/permissions";
8
+import RepoHeader from "@/components/repo/RepoHeader.astro";
9
+import { Link2 } from "lucide-react";
10
+
11
+const { owner: ownerName, repo: repoName } = Astro.params;
12
+const db = getDatabase();
13
+
14
+const user = await db.query.users.findFirst({
15
+ where: eq(schema.users.username, ownerName!),
16
+});
17
+
18
+if (!user) return Astro.redirect("/404");
19
+
20
+const repoData = await db.query.repositories.findFirst({
21
+ where: and(
22
+ eq(schema.repositories.ownerId, user.id),
23
+ eq(schema.repositories.name, repoName!),
24
+ ),
25
+ with: { owner: true },
26
+});
27
+
28
+if (!repoData) return Astro.redirect("/404");
29
+
30
+const currentUser = Astro.locals.user;
31
+const hasAccess = await canAdminRepo(currentUser?.id, repoData, {
32
+ isAdmin: currentUser?.isAdmin ?? undefined,
33
+});
34
+
35
+if (!hasAccess) {
36
+ return Astro.redirect(`/${ownerName}/${repoName}`);
37
+}
38
+
39
+const trackerConfigs = await db.query.issueTrackerConfigs.findMany({
40
+ where: eq(schema.issueTrackerConfigs.repositoryId, repoData.id),
41
+});
42
+const jiraConfig = trackerConfigs.find((config) => config.provider === "jira");
43
+const linearConfig = trackerConfigs.find((config) => config.provider === "linear");
44
+const trelloConfig = trackerConfigs.find((config) => config.provider === "trello");
45
+const clickupConfig = trackerConfigs.find((config) => config.provider === "clickup");
46
+
47
+const siteUrl = process.env.SITE_URL || "http://localhost:4321";
48
+const jiraWebhookUrl = `${siteUrl}/api/repos/${repoData.owner.username}/${repoData.name}/integrations/issue-trackers/jira/webhook`;
49
+const linearWebhookUrl = `${siteUrl}/api/repos/${repoData.owner.username}/${repoData.name}/integrations/issue-trackers/linear/webhook`;
50
+const trelloWebhookUrl = `${siteUrl}/api/repos/${repoData.owner.username}/${repoData.name}/integrations/issue-trackers/trello/webhook`;
51
+const clickupWebhookUrl = `${siteUrl}/api/repos/${repoData.owner.username}/${repoData.name}/integrations/issue-trackers/clickup/webhook`;
52
+
53
+const repo = {
54
+ owner: repoData.owner.username,
55
+ name: repoData.name,
56
+ description: repoData.description,
57
+ visibility: repoData.visibility,
58
+ defaultBranch: repoData.defaultBranch,
59
+ stars: repoData.starCount,
60
+ forks: repoData.forkCount,
61
+ watchers: repoData.watchCount,
62
+ language: repoData.language || "Unknown",
63
+ openIssueCount: repoData.openIssueCount,
64
+ openPrCount: repoData.openPrCount,
65
+ hasIssues: repoData.hasIssues,
66
+ hasWiki: repoData.hasWiki,
67
+ hasActions: repoData.hasActions,
68
+};
69
+---
70
+
71
+<BaseLayout title={`Issue Trackers · ${repo.owner}/${repo.name}`}>
72
+ <div class="container py-6">
73
+ <RepoHeader repo={repo} activeTab="settings" />
74
+
75
+ <div class="grid gap-6 md:grid-cols-[240px_1fr]">
76
+ <nav class="space-y-1">
77
+ <a href={`/${repo.owner}/${repo.name}/settings`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">General</a>
78
+ <a href={`/${repo.owner}/${repo.name}/settings/collaborators`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Collaborators</a>
79
+ <a href={`/${repo.owner}/${repo.name}/settings/webhooks`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Webhooks</a>
80
+ <a href={`/${repo.owner}/${repo.name}/settings/external-ci`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">External CI</a>
81
+ <a href={`/${repo.owner}/${repo.name}/settings/issue-trackers`} class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground">Issue Trackers</a>
82
+ <a href={`/${repo.owner}/${repo.name}/settings/reviews`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Reviews</a>
83
+ <a href={`/${repo.owner}/${repo.name}/settings/actions`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Actions</a>
84
+ <a href={`/${repo.owner}/${repo.name}/settings/deploy-keys`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Deploy keys</a>
85
+ <a href={`/${repo.owner}/${repo.name}/settings/workflow`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Workflow</a>
86
+ </nav>
87
+
88
+ <div class="space-y-6">
89
+ <Card>
90
+ <CardHeader>
91
+ <CardTitle className="flex items-center gap-2">
92
+ <Link2 className="h-5 w-5" />
93
+ Issue Tracker Integrations
94
+ </CardTitle>
95
+ <CardDescription>
96
+ Configure Jira, Linear, Trello, and ClickUp sync settings, webhook endpoints, and issue-linking controls.
97
+ </CardDescription>
98
+ </CardHeader>
99
+ </Card>
100
+
101
+ <Card>
102
+ <CardHeader>
103
+ <CardTitle>Jira Integration</CardTitle>
104
+ <CardDescription>Configure Jira project sync and webhook processing.</CardDescription>
105
+ </CardHeader>
106
+ <CardContent className="space-y-4">
107
+ <div class="rounded-md border border-dashed border-white/10 bg-white/5 p-3 text-sm text-muted-foreground">
108
+ <div class="text-xs uppercase tracking-wide text-muted-foreground">Jira Webhook Endpoint</div>
109
+ <div class="mt-1 font-mono text-xs break-all">{jiraWebhookUrl}</div>
110
+ </div>
111
+ <div class="grid gap-3 md:grid-cols-3">
112
+ <div>
113
+ <label for="jira-api-url" class="block text-xs text-muted-foreground mb-1">Jira API URL</label>
114
+ <input id="jira-api-url" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={jiraConfig?.apiUrl || "https://your-domain.atlassian.net/rest/api/3"} />
115
+ </div>
116
+ <div>
117
+ <label for="jira-project-key" class="block text-xs text-muted-foreground mb-1">Project Key</label>
118
+ <input id="jira-project-key" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={jiraConfig?.projectKey || ""} placeholder="PROJ" />
119
+ </div>
120
+ <div>
121
+ <label for="jira-api-token" class="block text-xs text-muted-foreground mb-1">API Token</label>
122
+ <input id="jira-api-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder={jiraConfig ? "•••••••• (leave empty to keep)" : "Jira token"} />
123
+ </div>
124
+ </div>
125
+ <div class="grid gap-3 md:grid-cols-3">
126
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="jira-sync-to" type="checkbox" checked={jiraConfig?.syncToExternal ?? true} />Sync to provider</label>
127
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="jira-sync-from" type="checkbox" checked={jiraConfig?.syncFromExternal ?? true} />Sync from provider</label>
128
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="jira-enabled" type="checkbox" checked={jiraConfig?.isEnabled ?? true} />Enabled</label>
129
+ </div>
130
+ <div class="text-xs text-muted-foreground">Webhook secret: <span id="jira-webhook-secret" class="font-mono">{jiraConfig?.webhookSecret || "Not configured yet"}</span></div>
131
+ <div class="flex items-center gap-2">
132
+ <Button id="save-jira" variant="outline">Save Jira Config</Button>
133
+ <span id="jira-save-msg" class="text-xs text-muted-foreground"></span>
134
+ </div>
135
+ <div class="pt-2 border-t border-white/10">
136
+ <div class="text-xs font-medium text-muted-foreground mb-2">Recent Jira Links</div>
137
+ <div id="jira-links" class="space-y-1 text-xs text-muted-foreground">Loading links...</div>
138
+ </div>
139
+ </CardContent>
140
+ </Card>
141
+
142
+ <Card>
143
+ <CardHeader>
144
+ <CardTitle>Linear Integration</CardTitle>
145
+ <CardDescription>Configure Linear team sync and webhook processing.</CardDescription>
146
+ </CardHeader>
147
+ <CardContent className="space-y-4">
148
+ <div class="rounded-md border border-dashed border-white/10 bg-white/5 p-3 text-sm text-muted-foreground">
149
+ <div class="text-xs uppercase tracking-wide text-muted-foreground">Linear Webhook Endpoint</div>
150
+ <div class="mt-1 font-mono text-xs break-all">{linearWebhookUrl}</div>
151
+ </div>
152
+ <div class="grid gap-3 md:grid-cols-2">
153
+ <div>
154
+ <label for="linear-project-key" class="block text-xs text-muted-foreground mb-1">Team ID</label>
155
+ <input id="linear-project-key" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={linearConfig?.projectKey || ""} placeholder="team-id" />
156
+ </div>
157
+ <div>
158
+ <label for="linear-api-token" class="block text-xs text-muted-foreground mb-1">API Token</label>
159
+ <input id="linear-api-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder={linearConfig ? "•••••••• (leave empty to keep)" : "Linear token"} />
160
+ </div>
161
+ </div>
162
+ <div class="grid gap-3 md:grid-cols-3">
163
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="linear-sync-to" type="checkbox" checked={linearConfig?.syncToExternal ?? true} />Sync to provider</label>
164
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="linear-sync-from" type="checkbox" checked={linearConfig?.syncFromExternal ?? true} />Sync from provider</label>
165
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="linear-enabled" type="checkbox" checked={linearConfig?.isEnabled ?? true} />Enabled</label>
166
+ </div>
167
+ <div class="text-xs text-muted-foreground">Webhook secret: <span id="linear-webhook-secret" class="font-mono">{linearConfig?.webhookSecret || "Not configured yet"}</span></div>
168
+ <div class="flex items-center gap-2"><Button id="save-linear" variant="outline">Save Linear Config</Button><span id="linear-save-msg" class="text-xs text-muted-foreground"></span></div>
169
+ <div class="pt-2 border-t border-white/10"><div class="text-xs font-medium text-muted-foreground mb-2">Recent Linear Links</div><div id="linear-links" class="space-y-1 text-xs text-muted-foreground">Loading links...</div></div>
170
+ </CardContent>
171
+ </Card>
172
+
173
+ <Card>
174
+ <CardHeader>
175
+ <CardTitle>Trello Integration</CardTitle>
176
+ <CardDescription>Configure Trello board/list sync and webhook processing.</CardDescription>
177
+ </CardHeader>
178
+ <CardContent className="space-y-4">
179
+ <div class="rounded-md border border-dashed border-white/10 bg-white/5 p-3 text-sm text-muted-foreground"><div class="text-xs uppercase tracking-wide text-muted-foreground">Trello Webhook Endpoint</div><div class="mt-1 font-mono text-xs break-all">{trelloWebhookUrl}</div></div>
180
+ <div class="grid gap-3 md:grid-cols-2">
181
+ <div><label for="trello-project-key" class="block text-xs text-muted-foreground mb-1">Board/List ID</label><input id="trello-project-key" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={trelloConfig?.projectKey || ""} placeholder="list-id" /></div>
182
+ <div><label for="trello-api-token" class="block text-xs text-muted-foreground mb-1">API Token (`key:token`)</label><input id="trello-api-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder={trelloConfig ? "•••••••• (leave empty to keep)" : "key:token"} /></div>
183
+ </div>
184
+ <div class="grid gap-3 md:grid-cols-3">
185
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="trello-sync-to" type="checkbox" checked={trelloConfig?.syncToExternal ?? true} />Sync to provider</label>
186
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="trello-sync-from" type="checkbox" checked={trelloConfig?.syncFromExternal ?? true} />Sync from provider</label>
187
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="trello-enabled" type="checkbox" checked={trelloConfig?.isEnabled ?? true} />Enabled</label>
188
+ </div>
189
+ <div class="text-xs text-muted-foreground">Webhook secret: <span id="trello-webhook-secret" class="font-mono">{trelloConfig?.webhookSecret || "Not configured yet"}</span></div>
190
+ <div class="flex items-center gap-2"><Button id="save-trello" variant="outline">Save Trello Config</Button><span id="trello-save-msg" class="text-xs text-muted-foreground"></span></div>
191
+ <div class="pt-2 border-t border-white/10"><div class="text-xs font-medium text-muted-foreground mb-2">Recent Trello Links</div><div id="trello-links" class="space-y-1 text-xs text-muted-foreground">Loading links...</div></div>
192
+ </CardContent>
193
+ </Card>
194
+
195
+ <Card>
196
+ <CardHeader>
197
+ <CardTitle>ClickUp Integration</CardTitle>
198
+ <CardDescription>Configure ClickUp list sync and webhook processing.</CardDescription>
199
+ </CardHeader>
200
+ <CardContent className="space-y-4">
201
+ <div class="rounded-md border border-dashed border-white/10 bg-white/5 p-3 text-sm text-muted-foreground"><div class="text-xs uppercase tracking-wide text-muted-foreground">ClickUp Webhook Endpoint</div><div class="mt-1 font-mono text-xs break-all">{clickupWebhookUrl}</div></div>
202
+ <div class="grid gap-3 md:grid-cols-2">
203
+ <div><label for="clickup-project-key" class="block text-xs text-muted-foreground mb-1">List ID</label><input id="clickup-project-key" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={clickupConfig?.projectKey || ""} placeholder="list-id" /></div>
204
+ <div><label for="clickup-api-token" class="block text-xs text-muted-foreground mb-1">API Token</label><input id="clickup-api-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder={clickupConfig ? "•••••••• (leave empty to keep)" : "ClickUp token"} /></div>
205
+ </div>
206
+ <div class="grid gap-3 md:grid-cols-3">
207
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="clickup-sync-to" type="checkbox" checked={clickupConfig?.syncToExternal ?? true} />Sync to provider</label>
208
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="clickup-sync-from" type="checkbox" checked={clickupConfig?.syncFromExternal ?? true} />Sync from provider</label>
209
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="clickup-enabled" type="checkbox" checked={clickupConfig?.isEnabled ?? true} />Enabled</label>
210
+ </div>
211
+ <div class="text-xs text-muted-foreground">Webhook secret: <span id="clickup-webhook-secret" class="font-mono">{clickupConfig?.webhookSecret || "Not configured yet"}</span></div>
212
+ <div class="flex items-center gap-2"><Button id="save-clickup" variant="outline">Save ClickUp Config</Button><span id="clickup-save-msg" class="text-xs text-muted-foreground"></span></div>
213
+ <div class="pt-2 border-t border-white/10"><div class="text-xs font-medium text-muted-foreground mb-2">Recent ClickUp Links</div><div id="clickup-links" class="space-y-1 text-xs text-muted-foreground">Loading links...</div></div>
214
+ </CardContent>
215
+ </Card>
216
+ </div>
217
+ </div>
218
+ </div>
219
+</BaseLayout>
220
+
221
+<script define:vars={{ repoOwner: repo.owner, repoName: repo.name, initialJiraConfigId: jiraConfig?.id || null, initialLinearConfigId: linearConfig?.id || null, initialTrelloConfigId: trelloConfig?.id || null, initialClickupConfigId: clickupConfig?.id || null }}>
222
+ const saveJiraBtn = document.getElementById("save-jira");
223
+ const saveLinearBtn = document.getElementById("save-linear");
224
+ const saveTrelloBtn = document.getElementById("save-trello");
225
+ const saveClickupBtn = document.getElementById("save-clickup");
226
+
227
+ const jiraSaveMsg = document.getElementById("jira-save-msg");
228
+ const linearSaveMsg = document.getElementById("linear-save-msg");
229
+ const trelloSaveMsg = document.getElementById("trello-save-msg");
230
+ const clickupSaveMsg = document.getElementById("clickup-save-msg");
231
+
232
+ const jiraApiUrl = document.getElementById("jira-api-url");
233
+ const jiraProjectKey = document.getElementById("jira-project-key");
234
+ const jiraApiToken = document.getElementById("jira-api-token");
235
+ const jiraSyncTo = document.getElementById("jira-sync-to");
236
+ const jiraSyncFrom = document.getElementById("jira-sync-from");
237
+ const jiraEnabled = document.getElementById("jira-enabled");
238
+
239
+ const linearProjectKey = document.getElementById("linear-project-key");
240
+ const linearApiToken = document.getElementById("linear-api-token");
241
+ const linearSyncTo = document.getElementById("linear-sync-to");
242
+ const linearSyncFrom = document.getElementById("linear-sync-from");
243
+ const linearEnabled = document.getElementById("linear-enabled");
244
+
245
+ const trelloProjectKey = document.getElementById("trello-project-key");
246
+ const trelloApiToken = document.getElementById("trello-api-token");
247
+ const trelloSyncTo = document.getElementById("trello-sync-to");
248
+ const trelloSyncFrom = document.getElementById("trello-sync-from");
249
+ const trelloEnabled = document.getElementById("trello-enabled");
250
+
251
+ const clickupProjectKey = document.getElementById("clickup-project-key");
252
+ const clickupApiToken = document.getElementById("clickup-api-token");
253
+ const clickupSyncTo = document.getElementById("clickup-sync-to");
254
+ const clickupSyncFrom = document.getElementById("clickup-sync-from");
255
+ const clickupEnabled = document.getElementById("clickup-enabled");
256
+
257
+ const jiraWebhookSecret = document.getElementById("jira-webhook-secret");
258
+ const linearWebhookSecret = document.getElementById("linear-webhook-secret");
259
+ const trelloWebhookSecret = document.getElementById("trello-webhook-secret");
260
+ const clickupWebhookSecret = document.getElementById("clickup-webhook-secret");
261
+
262
+ const jiraLinks = document.getElementById("jira-links");
263
+ const linearLinks = document.getElementById("linear-links");
264
+ const trelloLinks = document.getElementById("trello-links");
265
+ const clickupLinks = document.getElementById("clickup-links");
266
+
267
+ let jiraConfigId = initialJiraConfigId;
268
+ let linearConfigId = initialLinearConfigId;
269
+ let trelloConfigId = initialTrelloConfigId;
270
+ let clickupConfigId = initialClickupConfigId;
271
+
272
+ function renderLinks(target, links, emptyMessage) {
273
+ if (!target) return;
274
+ target.innerHTML = links.length === 0
275
+ ? emptyMessage
276
+ : links.map((item) => `
277
+ <div class="rounded border border-white/10 bg-white/5 px-2 py-1.5 flex items-center justify-between gap-2">
278
+ <span>${item.externalKey || item.externalId || "linked issue"}</span>
279
+ <span class="text-[11px] text-muted-foreground">${item.lastSyncAt ? new Date(item.lastSyncAt).toLocaleString() : "pending"}</span>
280
+ </div>
281
+ `).join("");
282
+ }
283
+
284
+ async function loadSummary() {
285
+ try {
286
+ const res = await fetch(`/api/repos/${repoOwner}/${repoName}/integrations/issue-trackers?summary=1`);
287
+ const body = await res.json();
288
+ if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to load issue tracker summary");
289
+
290
+ const data = body?.data || body;
291
+ const configs = data?.configs || [];
292
+ const links = data?.links || [];
293
+ const byProvider = {
294
+ jira: configs.find((cfg) => cfg.provider === "jira"),
295
+ linear: configs.find((cfg) => cfg.provider === "linear"),
296
+ trello: configs.find((cfg) => cfg.provider === "trello"),
297
+ clickup: configs.find((cfg) => cfg.provider === "clickup"),
298
+ };
299
+
300
+ if (byProvider.jira) {
301
+ jiraConfigId = byProvider.jira.id;
302
+ if (jiraWebhookSecret) jiraWebhookSecret.textContent = byProvider.jira.webhookSecret || "Not configured";
303
+ }
304
+ if (byProvider.linear) {
305
+ linearConfigId = byProvider.linear.id;
306
+ if (linearWebhookSecret) linearWebhookSecret.textContent = byProvider.linear.webhookSecret || "Not configured";
307
+ }
308
+ if (byProvider.trello) {
309
+ trelloConfigId = byProvider.trello.id;
310
+ if (trelloWebhookSecret) trelloWebhookSecret.textContent = byProvider.trello.webhookSecret || "Not configured";
311
+ }
312
+ if (byProvider.clickup) {
313
+ clickupConfigId = byProvider.clickup.id;
314
+ if (clickupWebhookSecret) clickupWebhookSecret.textContent = byProvider.clickup.webhookSecret || "Not configured";
315
+ }
316
+
317
+ renderLinks(jiraLinks, links.filter((item) => item.configId === byProvider.jira?.id).slice(0, 5), "No Jira links synced yet.");
318
+ renderLinks(linearLinks, links.filter((item) => item.configId === byProvider.linear?.id).slice(0, 5), "No Linear links synced yet.");
319
+ renderLinks(trelloLinks, links.filter((item) => item.configId === byProvider.trello?.id).slice(0, 5), "No Trello links synced yet.");
320
+ renderLinks(clickupLinks, links.filter((item) => item.configId === byProvider.clickup?.id).slice(0, 5), "No ClickUp links synced yet.");
321
+ } catch (error) {
322
+ if (jiraLinks) jiraLinks.textContent = error.message || "Failed to load links";
323
+ if (linearLinks) linearLinks.textContent = error.message || "Failed to load links";
324
+ if (trelloLinks) trelloLinks.textContent = error.message || "Failed to load links";
325
+ if (clickupLinks) clickupLinks.textContent = error.message || "Failed to load links";
326
+ }
327
+ }
328
+
329
+ async function saveTracker(options) {
330
+ const res = await fetch(
331
+ options.configId
332
+ ? `/api/repos/${repoOwner}/${repoName}/integrations/issue-trackers/${options.configId}`
333
+ : `/api/repos/${repoOwner}/${repoName}/integrations/issue-trackers`,
334
+ {
335
+ method: options.configId ? "PATCH" : "POST",
336
+ headers: { "Content-Type": "application/json" },
337
+ body: JSON.stringify(options.payload),
338
+ },
339
+ );
340
+ const body = await res.json();
341
+ if (!res.ok) {
342
+ throw new Error(body?.error?.message || body?.message || "Failed to save issue tracker config");
343
+ }
344
+ return body?.data || body;
345
+ }
346
+
347
+ saveJiraBtn?.addEventListener("click", async () => {
348
+ if (!jiraSaveMsg) return;
349
+ jiraSaveMsg.textContent = "Saving Jira config...";
350
+ try {
351
+ const saved = await saveTracker({
352
+ configId: jiraConfigId,
353
+ payload: {
354
+ ...(jiraConfigId ? {} : { provider: "jira", name: "Jira" }),
355
+ ...(jiraConfigId ? { name: "Jira" } : {}),
356
+ apiUrl: jiraApiUrl?.value?.trim() || undefined,
357
+ projectKey: jiraProjectKey?.value?.trim() || undefined,
358
+ apiToken: jiraApiToken?.value?.trim() || undefined,
359
+ syncToExternal: !!jiraSyncTo?.checked,
360
+ syncFromExternal: !!jiraSyncFrom?.checked,
361
+ isEnabled: !!jiraEnabled?.checked,
362
+ },
363
+ });
364
+ if (saved?.id) jiraConfigId = saved.id;
365
+ if (jiraApiToken) jiraApiToken.value = "";
366
+ jiraSaveMsg.textContent = "Jira configuration saved.";
367
+ loadSummary();
368
+ } catch (error) {
369
+ jiraSaveMsg.textContent = error.message || "Failed to save Jira config";
370
+ }
371
+ });
372
+
373
+ saveLinearBtn?.addEventListener("click", async () => {
374
+ if (!linearSaveMsg) return;
375
+ linearSaveMsg.textContent = "Saving Linear config...";
376
+ try {
377
+ const saved = await saveTracker({
378
+ configId: linearConfigId,
379
+ payload: {
380
+ ...(linearConfigId ? {} : { provider: "linear", name: "Linear" }),
381
+ ...(linearConfigId ? { name: "Linear" } : {}),
382
+ projectKey: linearProjectKey?.value?.trim() || undefined,
383
+ apiToken: linearApiToken?.value?.trim() || undefined,
384
+ syncToExternal: !!linearSyncTo?.checked,
385
+ syncFromExternal: !!linearSyncFrom?.checked,
386
+ isEnabled: !!linearEnabled?.checked,
387
+ },
388
+ });
389
+ if (saved?.id) linearConfigId = saved.id;
390
+ if (linearApiToken) linearApiToken.value = "";
391
+ linearSaveMsg.textContent = "Linear configuration saved.";
392
+ loadSummary();
393
+ } catch (error) {
394
+ linearSaveMsg.textContent = error.message || "Failed to save Linear config";
395
+ }
396
+ });
397
+
398
+ saveTrelloBtn?.addEventListener("click", async () => {
399
+ if (!trelloSaveMsg) return;
400
+ trelloSaveMsg.textContent = "Saving Trello config...";
401
+ try {
402
+ const saved = await saveTracker({
403
+ configId: trelloConfigId,
404
+ payload: {
405
+ ...(trelloConfigId ? {} : { provider: "trello", name: "Trello" }),
406
+ ...(trelloConfigId ? { name: "Trello" } : {}),
407
+ projectKey: trelloProjectKey?.value?.trim() || undefined,
408
+ apiToken: trelloApiToken?.value?.trim() || undefined,
409
+ syncToExternal: !!trelloSyncTo?.checked,
410
+ syncFromExternal: !!trelloSyncFrom?.checked,
411
+ isEnabled: !!trelloEnabled?.checked,
412
+ },
413
+ });
414
+ if (saved?.id) trelloConfigId = saved.id;
415
+ if (trelloApiToken) trelloApiToken.value = "";
416
+ trelloSaveMsg.textContent = "Trello configuration saved.";
417
+ loadSummary();
418
+ } catch (error) {
419
+ trelloSaveMsg.textContent = error.message || "Failed to save Trello config";
420
+ }
421
+ });
422
+
423
+ saveClickupBtn?.addEventListener("click", async () => {
424
+ if (!clickupSaveMsg) return;
425
+ clickupSaveMsg.textContent = "Saving ClickUp config...";
426
+ try {
427
+ const saved = await saveTracker({
428
+ configId: clickupConfigId,
429
+ payload: {
430
+ ...(clickupConfigId ? {} : { provider: "clickup", name: "ClickUp" }),
431
+ ...(clickupConfigId ? { name: "ClickUp" } : {}),
432
+ projectKey: clickupProjectKey?.value?.trim() || undefined,
433
+ apiToken: clickupApiToken?.value?.trim() || undefined,
434
+ syncToExternal: !!clickupSyncTo?.checked,
435
+ syncFromExternal: !!clickupSyncFrom?.checked,
436
+ isEnabled: !!clickupEnabled?.checked,
437
+ },
438
+ });
439
+ if (saved?.id) clickupConfigId = saved.id;
440
+ if (clickupApiToken) clickupApiToken.value = "";
441
+ clickupSaveMsg.textContent = "ClickUp configuration saved.";
442
+ loadSummary();
443
+ } catch (error) {
444
+ clickupSaveMsg.textContent = error.message || "Failed to save ClickUp config";
445
+ }
446
+ });
447
+
448
+ loadSummary();
449
+</script>
0
450
▼src/pages/api/repos/[owner]/[repo]/integrations/issue-trackers.ts
+115-0
1
+import type { APIRoute } from "astro";
2
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+import { and, desc, eq, inArray } from "drizzle-orm";
4
+import { z } from "zod";
5
+import { getDatabase, schema } from "@/db";
6
+import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api";
7
+import { withErrorHandler } from "@/lib/errors";
8
+import { getUserFromRequest } from "@/lib/auth";
9
+import { canReadRepo, canWriteRepo } from "@/lib/permissions";
10
+import { configureIssueTracker, getIssueTrackerConfigs, ISSUE_PROVIDERS } from "@/lib/issue-trackers";
11
+
12
+const createSchema = z.object({
13
+ provider: z.enum(["jira", "linear", "trello", "clickup"]),
14
+ name: z.string().min(1).max(120),
15
+ apiUrl: z.string().url().optional(),
16
+ apiToken: z.string().min(1),
17
+ projectKey: z.string().min(1).max(200),
18
+});
19
+
20
+async function resolveRepository(owner: string, repoName: string) {
21
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
22
+ const repoOwner = await db.query.users.findFirst({
23
+ where: eq(schema.users.username, owner),
24
+ });
25
+ if (!repoOwner) return null;
26
+
27
+ return db.query.repositories.findFirst({
28
+ where: and(
29
+ eq(schema.repositories.ownerId, repoOwner.id),
30
+ eq(schema.repositories.name, repoName)
31
+ ),
32
+ });
33
+}
34
+
35
+function redactConfig(config: any) {
36
+ return {
37
+ ...config,
38
+ apiToken: undefined,
39
+ };
40
+}
41
+
42
+export const GET: APIRoute = withErrorHandler(async ({ params, request }) => {
43
+ const owner = params.owner;
44
+ const repoName = params.repo;
45
+ if (!owner || !repoName) return badRequest("Missing route parameters");
46
+
47
+ const user = await getUserFromRequest(request);
48
+ const repository = await resolveRepository(owner, repoName);
49
+ if (!repository) return notFound("Repository not found");
50
+
51
+ if (!(await canReadRepo(user?.userId, repository, { isAdmin: user?.isAdmin }))) {
52
+ return notFound("Repository not found");
53
+ }
54
+
55
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
56
+ const url = new URL(request.url);
57
+ const includeSummary = url.searchParams.get("summary") === "1";
58
+ const configs = await getIssueTrackerConfigs(repository.id);
59
+ const providers = Object.entries(ISSUE_PROVIDERS).map(([key, value]) => ({
60
+ id: key,
61
+ ...value,
62
+ }));
63
+
64
+ if (!includeSummary) {
65
+ return success({
66
+ providers,
67
+ configs: configs.map(redactConfig),
68
+ });
69
+ }
70
+
71
+ const configIds = configs.map((config) => config.id);
72
+ const links = configIds.length
73
+ ? await db.query.issueTrackerLinks.findMany({
74
+ where: inArray(schema.issueTrackerLinks.configId, configIds),
75
+ orderBy: [desc(schema.issueTrackerLinks.createdAt)],
76
+ limit: 50,
77
+ })
78
+ : [];
79
+
80
+ return success({
81
+ providers,
82
+ configs: configs.map(redactConfig),
83
+ links,
84
+ });
85
+});
86
+
87
+export const POST: APIRoute = withErrorHandler(async ({ params, request }) => {
88
+ const owner = params.owner;
89
+ const repoName = params.repo;
90
+ if (!owner || !repoName) return badRequest("Missing route parameters");
91
+
92
+ const user = await getUserFromRequest(request);
93
+ if (!user) return unauthorized();
94
+
95
+ const repository = await resolveRepository(owner, repoName);
96
+ if (!repository) return notFound("Repository not found");
97
+
98
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
99
+ return forbidden();
100
+ }
101
+
102
+ const parsed = await parseBody(request, createSchema);
103
+ if ("error" in parsed) return parsed.error;
104
+
105
+ const config = await configureIssueTracker({
106
+ repositoryId: repository.id,
107
+ provider: parsed.data.provider,
108
+ name: parsed.data.name,
109
+ apiUrl: parsed.data.apiUrl,
110
+ apiToken: parsed.data.apiToken,
111
+ projectKey: parsed.data.projectKey,
112
+ });
113
+
114
+ return success(redactConfig(config));
115
+});
0
116
▼tests/integration/issue-trackers-route.test.ts
+158-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ getUserFromRequestMock,
5
+ canReadRepoMock,
6
+ canWriteRepoMock,
7
+ configureIssueTrackerMock,
8
+ getIssueTrackerConfigsMock,
9
+ fakeSchema,
10
+} = vi.hoisted(() => ({
11
+ getUserFromRequestMock: vi.fn(async () => ({ userId: "user-1", isAdmin: false })),
12
+ canReadRepoMock: vi.fn(async () => true),
13
+ canWriteRepoMock: vi.fn(async () => true),
14
+ configureIssueTrackerMock: vi.fn(async () => ({
15
+ id: "cfg-1",
16
+ repositoryId: "repo-1",
17
+ provider: "jira",
18
+ name: "Jira",
19
+ projectKey: "PROJ",
20
+ webhookSecret: "secret-1",
21
+ apiToken: "hidden",
22
+ })),
23
+ getIssueTrackerConfigsMock: vi.fn(async () => ([])),
24
+ fakeSchema: {
25
+ users: { username: {} },
26
+ repositories: { ownerId: {}, name: {}, id: {} },
27
+ issueTrackerLinks: { configId: {}, createdAt: {} },
28
+ } as any,
29
+}));
30
+
31
+let mockDb: any;
32
+
33
+vi.mock("@/db", () => ({
34
+ getDatabase: () => mockDb,
35
+ schema: fakeSchema,
36
+}));
37
+
38
+vi.mock("@/lib/auth", () => ({
39
+ getUserFromRequest: getUserFromRequestMock,
40
+}));
41
+
42
+vi.mock("@/lib/permissions", () => ({
43
+ canReadRepo: canReadRepoMock,
44
+ canWriteRepo: canWriteRepoMock,
45
+}));
46
+
47
+vi.mock("@/lib/issue-trackers", () => ({
48
+ ISSUE_PROVIDERS: {
49
+ jira: { name: "Jira" },
50
+ linear: { name: "Linear" },
51
+ trello: { name: "Trello" },
52
+ clickup: { name: "ClickUp" },
53
+ },
54
+ configureIssueTracker: configureIssueTrackerMock,
55
+ getIssueTrackerConfigs: getIssueTrackerConfigsMock,
56
+}));
57
+
58
+import { GET as issueTrackersGet, POST as issueTrackersPost } from "@/pages/api/repos/[owner]/[repo]/integrations/issue-trackers";
59
+
60
+function makeDb() {
61
+ return {
62
+ query: {
63
+ users: {
64
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
65
+ },
66
+ repositories: {
67
+ findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })),
68
+ },
69
+ issueTrackerLinks: {
70
+ findMany: vi.fn(async () => ([
71
+ {
72
+ id: "link-1",
73
+ configId: "cfg-jira",
74
+ externalId: "10001",
75
+ externalKey: "PROJ-1",
76
+ externalUrl: "https://jira.example.com/browse/PROJ-1",
77
+ lastSyncAt: new Date("2026-02-19T00:00:00Z"),
78
+ createdAt: new Date("2026-02-19T00:00:00Z"),
79
+ },
80
+ ])),
81
+ },
82
+ },
83
+ };
84
+}
85
+
86
+async function readJson(response: Response): Promise<any> {
87
+ return response.json();
88
+}
89
+
90
+describe("issue trackers route", () => {
91
+ beforeEach(() => {
92
+ vi.clearAllMocks();
93
+ mockDb = makeDb();
94
+ getIssueTrackerConfigsMock.mockResolvedValue([
95
+ {
96
+ id: "cfg-jira",
97
+ repositoryId: "repo-1",
98
+ provider: "jira",
99
+ name: "Jira",
100
+ projectKey: "PROJ",
101
+ apiToken: "secret",
102
+ webhookSecret: "secret-1",
103
+ isEnabled: true,
104
+ syncToExternal: true,
105
+ syncFromExternal: true,
106
+ },
107
+ ]);
108
+ });
109
+
110
+ it("returns providers and configs", async () => {
111
+ const response = await issueTrackersGet({
112
+ params: { owner: "owner-1", repo: "demo" },
113
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/issue-trackers"),
114
+ } as any);
115
+
116
+ const body = await readJson(response);
117
+ expect(response.status).toBe(200);
118
+ expect(body?.data?.providers?.length).toBe(4);
119
+ expect(body?.data?.configs?.[0]?.provider).toBe("jira");
120
+ expect(body?.data?.configs?.[0]?.apiToken).toBeUndefined();
121
+ expect(body?.data?.links).toBeUndefined();
122
+ });
123
+
124
+ it("returns summary links when summary=1", async () => {
125
+ const response = await issueTrackersGet({
126
+ params: { owner: "owner-1", repo: "demo" },
127
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/issue-trackers?summary=1"),
128
+ } as any);
129
+
130
+ const body = await readJson(response);
131
+ expect(response.status).toBe(200);
132
+ expect(body?.data?.links?.length).toBe(1);
133
+ expect(body?.data?.links?.[0]?.externalKey).toBe("PROJ-1");
134
+ });
135
+
136
+ it("creates provider configs for all issue-tracker providers", async () => {
137
+ for (const provider of ["jira", "linear", "trello", "clickup"] as const) {
138
+ const response = await issueTrackersPost({
139
+ params: { owner: "owner-1", repo: "demo" },
140
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/issue-trackers", {
141
+ method: "POST",
142
+ headers: { "content-type": "application/json" },
143
+ body: JSON.stringify({
144
+ provider,
145
+ name: provider,
146
+ apiToken: "token-1",
147
+ projectKey: "proj-1",
148
+ ...(provider === "jira" ? { apiUrl: "https://jira.example.com/rest/api/3" } : {}),
149
+ }),
150
+ }),
151
+ } as any);
152
+
153
+ expect(response.status).toBe(200);
154
+ }
155
+
156
+ expect(configureIssueTrackerMock).toHaveBeenCalledTimes(4);
157
+ });
158
+});
0
159