Complete Jira Linear Trello ClickUp integration workflows
swadhinbiswas committed 8 months ago
f5c388b
1 parent fea3bac
commit f5c388b
6 files changed
+740 -6
Showing 6 changed files+740-6
▼doc/feature_audit.md
+6-6
| @@ -12,14 +12,14 @@ | ||
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,10 +120,10 @@ | ||
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,6 +112,12 @@ const repo = { | ||
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,6 +109,12 @@ const repo = { | ||
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
| @@ -0,0 +1,449 @@ | ||
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
| @@ -0,0 +1,115 @@ | ||
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
| @@ -0,0 +1,158 @@ | ||
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 | |