@snagset/server 0.0.3 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/app.js +167 -30
- package/dist/app.js.map +1 -1
- package/dist/bin.js +46 -9
- package/dist/bin.js.map +1 -1
- package/dist/boot.js +105 -54
- package/dist/boot.js.map +1 -1
- package/dist/config.generated.js +5 -2
- package/dist/config.generated.js.map +1 -1
- package/dist/db/connect.js +7 -0
- package/dist/db/connect.js.map +1 -1
- package/dist/db/migrations/0005_studio_reviewers.sql +45 -0
- package/dist/db/migrations/0006_instance_settings.sql +69 -0
- package/dist/db/migrations/0007_jira_provider.no-txn.sql +13 -0
- package/dist/domain.js +129 -0
- package/dist/domain.js.map +1 -0
- package/dist/instance/settings.js +54 -0
- package/dist/instance/settings.js.map +1 -0
- package/dist/integrations/github.js +112 -0
- package/dist/integrations/github.js.map +1 -0
- package/dist/integrations/inbound.js +88 -0
- package/dist/integrations/inbound.js.map +1 -0
- package/dist/integrations/jira.js +129 -0
- package/dist/integrations/jira.js.map +1 -0
- package/dist/integrations/linear.js +111 -0
- package/dist/integrations/linear.js.map +1 -0
- package/dist/integrations/outbox.js +305 -0
- package/dist/integrations/outbox.js.map +1 -0
- package/dist/integrations/slack.js +135 -0
- package/dist/integrations/slack.js.map +1 -0
- package/dist/integrations/store.js +101 -0
- package/dist/integrations/store.js.map +1 -0
- package/dist/mail/index.js +21 -11
- package/dist/mail/index.js.map +1 -1
- package/dist/mail/resolve.js +80 -0
- package/dist/mail/resolve.js.map +1 -0
- package/dist/projectCache.js +3 -2
- package/dist/projectCache.js.map +1 -1
- package/dist/projects.js +8 -2
- package/dist/projects.js.map +1 -1
- package/dist/routes/account.js +59 -0
- package/dist/routes/account.js.map +1 -0
- package/dist/routes/activity.js +111 -0
- package/dist/routes/activity.js.map +1 -0
- package/dist/routes/anchorReports.js +196 -0
- package/dist/routes/anchorReports.js.map +1 -0
- package/dist/routes/assignees.js +139 -0
- package/dist/routes/assignees.js.map +1 -0
- package/dist/routes/attachments.js +2 -2
- package/dist/routes/attachments.js.map +1 -1
- package/dist/routes/bodyLimit.js +60 -0
- package/dist/routes/bodyLimit.js.map +1 -0
- package/dist/routes/comments.js +27 -17
- package/dist/routes/comments.js.map +1 -1
- package/dist/routes/cors.js +10 -1
- package/dist/routes/cors.js.map +1 -1
- package/dist/routes/instance.js +164 -0
- package/dist/routes/instance.js.map +1 -0
- package/dist/routes/integrations.js +610 -0
- package/dist/routes/integrations.js.map +1 -0
- package/dist/routes/jobs.js +15 -3
- package/dist/routes/jobs.js.map +1 -1
- package/dist/routes/mcpTokens.js +119 -0
- package/dist/routes/mcpTokens.js.map +1 -0
- package/dist/routes/members.js +177 -0
- package/dist/routes/members.js.map +1 -0
- package/dist/routes/owner.js +64 -7
- package/dist/routes/owner.js.map +1 -1
- package/dist/routes/sites.js +181 -8
- package/dist/routes/sites.js.map +1 -1
- package/dist/routes/threads.js +276 -9
- package/dist/routes/threads.js.map +1 -1
- package/dist/serverless.js +39 -6
- package/dist/serverless.js.map +1 -1
- package/dist/studioReviewers.js +67 -0
- package/dist/studioReviewers.js.map +1 -0
- package/dist/vendor/adapters/outbound/sanitise.js +18 -3
- package/dist/vendor/types/brief.js +121 -0
- package/package.json +1 -1
- package/static/dashboard/assets/CompositeItem-CoPuxIvr.js +1 -0
- package/static/dashboard/assets/ConfirmDialog-DkCcJwll.js +1 -0
- package/static/dashboard/assets/CopyButton-BNa2pLLv.js +1 -0
- package/static/dashboard/assets/FieldItemContext-DQXjpFtW.js +1 -0
- package/static/dashboard/assets/InstallTag-SOlWl7Hx.js +1 -0
- package/static/dashboard/assets/SaveState-CmxB4XyX.js +6 -0
- package/static/dashboard/assets/_app-DNkv3HBi.js +1 -0
- package/static/dashboard/assets/_app-xVQe811c.js +56 -0
- package/static/dashboard/assets/activity-DsMa5Ja6.js +1 -0
- package/static/dashboard/assets/arrow-left-vQV4xhAD.js +6 -0
- package/static/dashboard/assets/avatar-DuPL6QD_.js +1 -0
- package/static/dashboard/assets/badge-BWjiIc8T.js +1 -0
- package/static/dashboard/assets/card-BIBfCno8.js +1 -0
- package/static/dashboard/assets/check-Dpx6GTdT.js +6 -0
- package/static/dashboard/assets/chevron-right-DKIlBUGN.js +6 -0
- package/static/dashboard/assets/copy-DCbhEe2L.js +6 -0
- package/static/dashboard/assets/createBaseUIEventDetails-C_ZVkrgp.js +1 -0
- package/static/dashboard/assets/createLucideIcon-C_IGE4WD.js +36 -0
- package/static/dashboard/assets/dialog-BT2uWf4e.js +6 -0
- package/static/dashboard/assets/dropdown-menu-DSXzPAwB.js +11 -0
- package/static/dashboard/assets/image-off-DGWejLDG.js +6 -0
- package/static/dashboard/assets/index-BRTBtV91.css +1 -0
- package/static/dashboard/assets/index-D6FWZoJT.js +129 -0
- package/static/dashboard/assets/label-9QsSpLMA.js +1 -0
- package/static/dashboard/assets/labels--UUyt9uB.js +1 -0
- package/static/dashboard/assets/layout-grid-UPGEmEw-.js +6 -0
- package/static/dashboard/assets/me-0HQsBXLU.js +1 -0
- package/static/dashboard/assets/parts-C29uZ5zo.js +1 -0
- package/static/dashboard/assets/plus-BFNS5LcY.js +6 -0
- package/static/dashboard/assets/s._site-D6Ep2tdc.js +1 -0
- package/static/dashboard/assets/s._site.comments-BRVI5gpJ.js +39 -0
- package/static/dashboard/assets/s._site.reviewers-CiEkZnnp.js +1 -0
- package/static/dashboard/assets/s._site.settings-BqLGyfEd.js +1 -0
- package/static/dashboard/assets/setup-dUMCPWdf.js +1 -0
- package/static/dashboard/assets/sign-in-CdRa-bio.js +1 -0
- package/static/dashboard/assets/sites-CmbtFKoo.js +1 -0
- package/static/dashboard/assets/studio-BHuVXWZh.js +1 -0
- package/static/dashboard/assets/studio-BjeTOKda.js +1 -0
- package/static/dashboard/assets/studio.index--kuzOs7w.js +1 -0
- package/static/dashboard/assets/studio.integrations-Bq6HP80a.js +1 -0
- package/static/dashboard/assets/studio.members-8AB1KYsw.js +1 -0
- package/static/dashboard/assets/sun-BCiTcgnh.js +16 -0
- package/static/dashboard/assets/table-BXmeI_yA.js +1 -0
- package/static/dashboard/assets/update-duXzIzFS.js +1 -0
- package/static/dashboard/assets/useBaseQuery-Cp9wivVR.js +1 -0
- package/static/dashboard/assets/useQuery-BZXZlOqO.js +1 -0
- package/static/dashboard/assets/useRender-CO8aPW06.js +1 -0
- package/static/dashboard/assets/users-Dvw2FCKM.js +6 -0
- package/static/dashboard/assets/welcome-BHKjw20b.js +11 -0
- package/static/dashboard/assets/you-RBpAfUMU.js +1 -0
- package/static/dashboard/assets/zod-BpyjQIvb.js +1 -0
- package/static/dashboard/index.html +17 -2
- package/static/widget/snag.capture.js +1 -1
- package/static/widget/snag.dock.js +1 -1
- package/static/widget/snag.panels.js +1 -1
- package/static/widget/snag.runtime.js +2 -2
- package/static/widget/snag.text.js +1 -1
- package/vercel.json +4 -0
- package/static/dashboard/assets/index-DsUJ96yc.css +0 -1
- package/static/dashboard/assets/index-DultqpS7.js +0 -1
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { ID_PREFIX } from "../vendor/types/names.js";
|
|
3
|
+
import { open } from "../crypto.js";
|
|
4
|
+
import { githubClient } from "../integrations/github.js";
|
|
5
|
+
import { linearClient, statusForLinearState, verifyLinearSignature } from "../integrations/linear.js";
|
|
6
|
+
import { jiraClient, statusForJiraCategory, verifyJiraSignature } from "../integrations/jira.js";
|
|
7
|
+
import { slackClient, slackManifest } from "../integrations/slack.js";
|
|
8
|
+
import { getIntegration, isTracker, listIntegrations, markIntegration, mintWebhookSecret, putIntegration, removeIntegration, trackerSecrets, REF_KIND, } from "../integrations/store.js";
|
|
9
|
+
import { applyGitHubIssueEvent, applyIssueState, verifyGitHubSignature } from "../integrations/inbound.js";
|
|
10
|
+
import { queueResolvedNotice } from "../mail/notify.js";
|
|
11
|
+
import { drainNow, queueIntegrationEvent } from "../integrations/outbox.js";
|
|
12
|
+
function fail(c, status, code, message, details) {
|
|
13
|
+
return c.json({ error: { code, message, ...(details ? { details } : {}), requestId: c.get("requestId") } }, status);
|
|
14
|
+
}
|
|
15
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
const NAME = { github: "GitHub", linear: "Linear", jira: "Jira", slack: "Slack" };
|
|
17
|
+
export function integrationRoutes(deps) {
|
|
18
|
+
const { sql, keys, projects } = deps;
|
|
19
|
+
const app = new Hono();
|
|
20
|
+
const webhookUrl = (provider) => `${deps.baseUrl.replace(/\/+$/, "")}/api/v1/integrations/${provider}/inbound`;
|
|
21
|
+
const ghOpts = () => ({ ...(deps.push.githubApiBase ? { apiBase: deps.push.githubApiBase } : {}), ...(deps.push.fetch ? { fetch: deps.push.fetch } : {}) });
|
|
22
|
+
const lnOpts = () => ({ ...(deps.push.linearApiBase ? { apiBase: deps.push.linearApiBase } : {}), ...(deps.push.fetch ? { fetch: deps.push.fetch } : {}) });
|
|
23
|
+
const mustBeOwner = (c) => {
|
|
24
|
+
const owner = c.get("owner");
|
|
25
|
+
if (!owner)
|
|
26
|
+
return fail(c, 401, "unauthorized", "Sign in to the dashboard first.");
|
|
27
|
+
if (owner.role !== "owner")
|
|
28
|
+
return fail(c, 403, "forbidden", "Only an owner can change what a site is connected to.");
|
|
29
|
+
return owner;
|
|
30
|
+
};
|
|
31
|
+
const site = async (c, owner) => {
|
|
32
|
+
const p = await projects.get(c.req.param("uid") ?? "");
|
|
33
|
+
if (!p || p.org_id !== owner.orgId)
|
|
34
|
+
return null;
|
|
35
|
+
return p;
|
|
36
|
+
};
|
|
37
|
+
const provider = (c) => {
|
|
38
|
+
const v = c.req.param("provider") ?? "";
|
|
39
|
+
return isTracker(v) ? v : fail(c, 404, "integration_not_configured", `No tracker called ${JSON.stringify(v)}. This build speaks: github, linear, jira, slack.`);
|
|
40
|
+
};
|
|
41
|
+
/** The card's view of a connection — never a token. */
|
|
42
|
+
const shape = (i, projectUid) => {
|
|
43
|
+
const base = {
|
|
44
|
+
site: projectUid,
|
|
45
|
+
createOn: (i.config.createOn === "thread.created" ? "thread.created" : "manual"),
|
|
46
|
+
health: i.unreadable ? "needs_reauth" : i.installState,
|
|
47
|
+
lastError: i.lastError,
|
|
48
|
+
updatedAt: i.updatedAt.toISOString(),
|
|
49
|
+
hasWebhookSecret: !!trackerSecrets(i.secret).webhookSecret,
|
|
50
|
+
};
|
|
51
|
+
if (i.provider === "linear") {
|
|
52
|
+
const cfg = i.config;
|
|
53
|
+
return { provider: "linear", ...base, teamKey: cfg.teamKey, teamName: cfg.teamName };
|
|
54
|
+
}
|
|
55
|
+
if (i.provider === "jira") {
|
|
56
|
+
const cfg = i.config;
|
|
57
|
+
return { provider: "jira", ...base, siteUrl: cfg.siteUrl, projectKey: cfg.projectKey, projectName: cfg.projectName, issueType: cfg.issueTypeName, email: cfg.email };
|
|
58
|
+
}
|
|
59
|
+
if (i.provider === "slack") {
|
|
60
|
+
const cfg = i.config;
|
|
61
|
+
return { provider: "slack", ...base, teamName: cfg.teamName, teamUrl: cfg.teamUrl, channelId: cfg.channelId, channelName: cfg.channelName };
|
|
62
|
+
}
|
|
63
|
+
const cfg = i.config;
|
|
64
|
+
return { provider: "github", ...base, owner: cfg.owner, repo: cfg.repo, labels: cfg.labels ?? [] };
|
|
65
|
+
};
|
|
66
|
+
const sites = async (orgId) => {
|
|
67
|
+
const rows = await sql `
|
|
68
|
+
SELECT id, uid, name FROM projects WHERE org_id = ${orgId}::uuid AND deleted_at IS NULL
|
|
69
|
+
`;
|
|
70
|
+
return new Map(rows.map((p) => [p.id, p]));
|
|
71
|
+
};
|
|
72
|
+
app.get("/api/v1/integrations", async (c) => {
|
|
73
|
+
const owner = c.get("owner");
|
|
74
|
+
if (!owner)
|
|
75
|
+
return fail(c, 401, "unauthorized", "Sign in to the dashboard first.");
|
|
76
|
+
const byId = await sites(owner.orgId);
|
|
77
|
+
const rows = await listIntegrations(sql, keys, owner.orgId);
|
|
78
|
+
return c.json({
|
|
79
|
+
data: rows
|
|
80
|
+
.filter((r) => isTracker(r.provider) && byId.has(r.projectId))
|
|
81
|
+
.map((r) => ({ ...shape(r, byId.get(r.projectId).uid), siteName: byId.get(r.projectId).name })),
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
app.get("/api/v1/projects/:uid/integrations", async (c) => {
|
|
85
|
+
const owner = c.get("owner");
|
|
86
|
+
if (!owner)
|
|
87
|
+
return fail(c, 401, "unauthorized", "Sign in to the dashboard first.");
|
|
88
|
+
const p = await site(c, owner);
|
|
89
|
+
if (!p)
|
|
90
|
+
return fail(c, 404, "project_not_found", "No project with that uid.");
|
|
91
|
+
const rows = (await listIntegrations(sql, keys, owner.orgId)).filter((r) => r.projectId === p.id && isTracker(r.provider));
|
|
92
|
+
return c.json({ data: rows.map((r) => shape(r, p.uid)) });
|
|
93
|
+
});
|
|
94
|
+
/** 07 §6.2: the manifest the operator creates the Slack app from — one link, scopes pre-filled. */
|
|
95
|
+
app.get("/api/v1/integrations/slack/manifest", async (c) => {
|
|
96
|
+
const owner = c.get("owner");
|
|
97
|
+
if (!owner)
|
|
98
|
+
return fail(c, 401, "unauthorized", "Sign in to the dashboard first.");
|
|
99
|
+
const [org] = await sql `SELECT name FROM orgs WHERE id = ${owner.orgId}::uuid`;
|
|
100
|
+
const manifest = slackManifest(org?.name ?? "Snagset");
|
|
101
|
+
return c.json({ manifest, url: `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(JSON.stringify(manifest))}` });
|
|
102
|
+
});
|
|
103
|
+
app.get("/api/v1/projects/:uid/integrations/:provider", async (c) => {
|
|
104
|
+
const owner = c.get("owner");
|
|
105
|
+
if (!owner)
|
|
106
|
+
return fail(c, 401, "unauthorized", "Sign in to the dashboard first.");
|
|
107
|
+
const pv = provider(c);
|
|
108
|
+
if (pv instanceof Response)
|
|
109
|
+
return pv;
|
|
110
|
+
const p = await site(c, owner);
|
|
111
|
+
if (!p)
|
|
112
|
+
return fail(c, 404, "project_not_found", "No project with that uid.");
|
|
113
|
+
const i = await getIntegration(sql, keys, p.org_id, p.id, pv);
|
|
114
|
+
return c.json({ integration: i ? shape(i, p.uid) : null });
|
|
115
|
+
});
|
|
116
|
+
app.put("/api/v1/projects/:uid/integrations/:provider", async (c) => {
|
|
117
|
+
const owner = mustBeOwner(c);
|
|
118
|
+
if (owner instanceof Response)
|
|
119
|
+
return owner;
|
|
120
|
+
const pv = provider(c);
|
|
121
|
+
if (pv instanceof Response)
|
|
122
|
+
return pv;
|
|
123
|
+
const p = await site(c, owner);
|
|
124
|
+
if (!p)
|
|
125
|
+
return fail(c, 404, "project_not_found", "No project with that uid.");
|
|
126
|
+
const body = (await c.req.json().catch(() => null));
|
|
127
|
+
const str = (k) => (typeof body?.[k] === "string" ? body[k].trim() : "");
|
|
128
|
+
// Slack announces: every new comment by default. The trackers ask first.
|
|
129
|
+
const createOn = body?.["createOn"] === "thread.created" ? "thread.created" : body?.["createOn"] === "manual" ? "manual" : pv === "slack" ? "thread.created" : "manual";
|
|
130
|
+
const existing = await getIntegration(sql, keys, p.org_id, p.id, pv);
|
|
131
|
+
const had = trackerSecrets(existing?.secret ?? null);
|
|
132
|
+
if (pv === "slack") {
|
|
133
|
+
// Slack (D99): a bot token from the app the operator created from our
|
|
134
|
+
// manifest, and a channel by #name or id. Probed — the token, then the
|
|
135
|
+
// channel — before it is stored.
|
|
136
|
+
const token = str("token") || had.token || "";
|
|
137
|
+
if (!/^xox[bp]-/.test(token))
|
|
138
|
+
return fail(c, 422, "validation_failed", "Paste the app's Bot User OAuth Token — it starts with xoxb-.", { field: "token" });
|
|
139
|
+
const channel = str("channel") || existing?.config?.channelId || "";
|
|
140
|
+
if (!channel)
|
|
141
|
+
return fail(c, 422, "validation_failed", "Give the channel, like #reviews.", { field: "channel" });
|
|
142
|
+
const sc = slackClient({ token, ...(deps.push.slackApiBase ? { apiBase: deps.push.slackApiBase } : {}), ...(deps.push.fetch ? { fetch: deps.push.fetch } : {}) });
|
|
143
|
+
let who;
|
|
144
|
+
let ch;
|
|
145
|
+
try {
|
|
146
|
+
who = await sc.whoami();
|
|
147
|
+
ch = await sc.resolveChannel(channel);
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
return fail(c, 422, "validation_failed", e instanceof Error ? e.message : String(e), { field: "token" });
|
|
151
|
+
}
|
|
152
|
+
await putIntegration(sql, keys, {
|
|
153
|
+
orgId: p.org_id,
|
|
154
|
+
projectId: p.id,
|
|
155
|
+
provider: "slack",
|
|
156
|
+
config: { teamName: who.team, teamUrl: who.url, channelId: ch.id, channelName: ch.name, createOn },
|
|
157
|
+
secret: JSON.stringify({ token, webhookSecret: null }),
|
|
158
|
+
});
|
|
159
|
+
const i = await getIntegration(sql, keys, p.org_id, p.id, "slack");
|
|
160
|
+
return c.json({ integration: i ? shape(i, p.uid) : null });
|
|
161
|
+
}
|
|
162
|
+
if (pv === "github") {
|
|
163
|
+
// `owner/repo` in one box is what people paste; two boxes are accepted too.
|
|
164
|
+
let ownerName = str("owner");
|
|
165
|
+
let repo = str("repo");
|
|
166
|
+
const full = str("repository");
|
|
167
|
+
if (full && !ownerName && !repo) {
|
|
168
|
+
const m = /^(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(full);
|
|
169
|
+
if (!m)
|
|
170
|
+
return fail(c, 422, "validation_failed", "Give the repository as owner/name, like kilnwork/site.", { field: "repository" });
|
|
171
|
+
ownerName = m[1];
|
|
172
|
+
repo = m[2];
|
|
173
|
+
}
|
|
174
|
+
if (!ownerName || !repo)
|
|
175
|
+
return fail(c, 422, "validation_failed", "Give the repository as owner/name, like kilnwork/site.", { field: "repository" });
|
|
176
|
+
const token = str("token") || had.token || "";
|
|
177
|
+
if (!token)
|
|
178
|
+
return fail(c, 422, "validation_failed", "Paste a fine-grained token with Issues: read and write on that repository.", { field: "token" });
|
|
179
|
+
// D96: the webhook secret is minted on the first connect and kept across
|
|
180
|
+
// replacements — the operator registered it on the repository once.
|
|
181
|
+
const minted = had.webhookSecret ? null : mintWebhookSecret();
|
|
182
|
+
const webhookSecret = had.webhookSecret ?? minted;
|
|
183
|
+
const prior = existing?.config?.labels ?? [];
|
|
184
|
+
const labels = Array.isArray(body?.["labels"])
|
|
185
|
+
? body["labels"].map(String).map((s) => s.trim()).filter(Boolean).slice(0, 20)
|
|
186
|
+
: typeof body?.["labels"] === "string"
|
|
187
|
+
? body["labels"].split(",").map((s) => s.trim()).filter(Boolean).slice(0, 20)
|
|
188
|
+
: prior;
|
|
189
|
+
// Probed before it is stored: a token that is merely well-formed is the
|
|
190
|
+
// one that fails on the first issue, in front of the client.
|
|
191
|
+
const probe = await githubClient({ token, ...ghOpts() }).probe({ owner: ownerName, repo });
|
|
192
|
+
if (!probe.ok)
|
|
193
|
+
return fail(c, 422, "validation_failed", probe.detail, { field: "token" });
|
|
194
|
+
if (!probe.canWrite) {
|
|
195
|
+
return fail(c, 422, "validation_failed", `The token can see ${probe.fullName} but cannot write to it. It needs Issues: read and write.`, { field: "token" });
|
|
196
|
+
}
|
|
197
|
+
await putIntegration(sql, keys, {
|
|
198
|
+
orgId: p.org_id,
|
|
199
|
+
projectId: p.id,
|
|
200
|
+
provider: "github",
|
|
201
|
+
config: { owner: ownerName, repo, labels, createOn },
|
|
202
|
+
secret: JSON.stringify({ token, webhookSecret }),
|
|
203
|
+
});
|
|
204
|
+
const i = await getIntegration(sql, keys, p.org_id, p.id, "github");
|
|
205
|
+
return c.json({
|
|
206
|
+
integration: i ? shape(i, p.uid) : null,
|
|
207
|
+
// Shown ONCE, like a review link: the URL to paste and the secret GitHub signs with.
|
|
208
|
+
...(minted ? { webhook: { url: webhookUrl("github"), secret: minted } } : {}),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (pv === "jira") {
|
|
212
|
+
// Jira Cloud (D98): the site, the project key, the account's email and
|
|
213
|
+
// an API token — basic auth, what Atlassian gives a self-hoster — and,
|
|
214
|
+
// for the way back, the secret the operator typed into Jira's webhook.
|
|
215
|
+
const siteUrl = str("siteUrl").replace(/\/+$/, "");
|
|
216
|
+
if (!/^https?:\/\/[^\s/]+$/.test(siteUrl))
|
|
217
|
+
return fail(c, 422, "validation_failed", "Give the site's address, like https://kilnwork.atlassian.net.", { field: "siteUrl" });
|
|
218
|
+
const projectKey = str("project").toUpperCase();
|
|
219
|
+
if (!/^[A-Z][A-Z0-9_]{0,9}$/.test(projectKey))
|
|
220
|
+
return fail(c, 422, "validation_failed", "Give the project's key, like KAN.", { field: "project" });
|
|
221
|
+
const prior = existing?.config;
|
|
222
|
+
const email = str("email").toLowerCase() || prior?.email || "";
|
|
223
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
|
224
|
+
return fail(c, 422, "validation_failed", "Give the Atlassian account's email — the token belongs to it.", { field: "email" });
|
|
225
|
+
const apiToken = str("apiToken") || had.token || "";
|
|
226
|
+
if (!apiToken)
|
|
227
|
+
return fail(c, 422, "validation_failed", "Paste an API token from id.atlassian.com › Security › API tokens.", { field: "apiToken" });
|
|
228
|
+
const webhookSecret = str("webhookSecret") || had.webhookSecret || null;
|
|
229
|
+
const probe = await jiraClient({ siteUrl, email, apiToken, ...(deps.push.fetch ? { fetch: deps.push.fetch } : {}) }).probe(projectKey);
|
|
230
|
+
if (!probe.ok)
|
|
231
|
+
return fail(c, 422, "validation_failed", probe.detail, { field: "apiToken" });
|
|
232
|
+
await putIntegration(sql, keys, {
|
|
233
|
+
orgId: p.org_id,
|
|
234
|
+
projectId: p.id,
|
|
235
|
+
provider: "jira",
|
|
236
|
+
config: {
|
|
237
|
+
siteUrl,
|
|
238
|
+
email,
|
|
239
|
+
projectKey: probe.project.key,
|
|
240
|
+
projectId: probe.project.id,
|
|
241
|
+
projectName: probe.project.name,
|
|
242
|
+
issueTypeId: probe.issueType.id,
|
|
243
|
+
issueTypeName: probe.issueType.name,
|
|
244
|
+
createOn,
|
|
245
|
+
},
|
|
246
|
+
secret: JSON.stringify({ token: apiToken, webhookSecret }),
|
|
247
|
+
});
|
|
248
|
+
const i = await getIntegration(sql, keys, p.org_id, p.id, "jira");
|
|
249
|
+
return c.json({ integration: i ? shape(i, p.uid) : null, webhook: { url: webhookUrl("jira") } });
|
|
250
|
+
}
|
|
251
|
+
// Linear (D97): an API key belonging to a member of the team, the team's
|
|
252
|
+
// key, and — for the way back — the signing secret Linear shows when the
|
|
253
|
+
// operator creates the webhook, pasted here.
|
|
254
|
+
const teamKey = str("team").toUpperCase();
|
|
255
|
+
if (!/^[A-Z][A-Z0-9]{0,7}$/.test(teamKey))
|
|
256
|
+
return fail(c, 422, "validation_failed", "Give the team's key, like ENG.", { field: "team" });
|
|
257
|
+
const apiKey = str("apiKey") || had.token || "";
|
|
258
|
+
if (!apiKey)
|
|
259
|
+
return fail(c, 422, "validation_failed", "Paste a personal API key from Linear › Settings › API.", { field: "apiKey" });
|
|
260
|
+
const webhookSecret = str("webhookSecret") || had.webhookSecret || null;
|
|
261
|
+
const probe = await linearClient({ apiKey, ...lnOpts() }).probe(teamKey);
|
|
262
|
+
if (!probe.ok)
|
|
263
|
+
return fail(c, 422, "validation_failed", probe.detail, { field: "apiKey" });
|
|
264
|
+
await putIntegration(sql, keys, {
|
|
265
|
+
orgId: p.org_id,
|
|
266
|
+
projectId: p.id,
|
|
267
|
+
provider: "linear",
|
|
268
|
+
config: { teamId: probe.team.id, teamKey: probe.team.key, teamName: probe.team.name, states: probe.states, createOn },
|
|
269
|
+
secret: JSON.stringify({ token: apiKey, webhookSecret }),
|
|
270
|
+
});
|
|
271
|
+
const i = await getIntegration(sql, keys, p.org_id, p.id, "linear");
|
|
272
|
+
return c.json({ integration: i ? shape(i, p.uid) : null, webhook: { url: webhookUrl("linear") } });
|
|
273
|
+
});
|
|
274
|
+
app.delete("/api/v1/projects/:uid/integrations/:provider", async (c) => {
|
|
275
|
+
const owner = mustBeOwner(c);
|
|
276
|
+
if (owner instanceof Response)
|
|
277
|
+
return owner;
|
|
278
|
+
const pv = provider(c);
|
|
279
|
+
if (pv instanceof Response)
|
|
280
|
+
return pv;
|
|
281
|
+
const p = await site(c, owner);
|
|
282
|
+
if (!p)
|
|
283
|
+
return fail(c, 404, "project_not_found", "No project with that uid.");
|
|
284
|
+
await removeIntegration(sql, p.org_id, p.id, pv);
|
|
285
|
+
return c.json({ integration: null });
|
|
286
|
+
});
|
|
287
|
+
app.post("/api/v1/projects/:uid/integrations/:provider/test", async (c) => {
|
|
288
|
+
const owner = mustBeOwner(c);
|
|
289
|
+
if (owner instanceof Response)
|
|
290
|
+
return owner;
|
|
291
|
+
const pv = provider(c);
|
|
292
|
+
if (pv instanceof Response)
|
|
293
|
+
return pv;
|
|
294
|
+
const p = await site(c, owner);
|
|
295
|
+
if (!p)
|
|
296
|
+
return fail(c, 404, "project_not_found", "No project with that uid.");
|
|
297
|
+
const i = await getIntegration(sql, keys, p.org_id, p.id, pv);
|
|
298
|
+
const token = trackerSecrets(i?.secret ?? null).token;
|
|
299
|
+
if (!i || !token)
|
|
300
|
+
return fail(c, 409, "integration_not_configured", `${NAME[pv]} is not connected on this site.`);
|
|
301
|
+
let ok;
|
|
302
|
+
if (pv === "github") {
|
|
303
|
+
const cfg = i.config;
|
|
304
|
+
const r = await githubClient({ token, ...ghOpts() }).probe({ owner: cfg.owner, repo: cfg.repo });
|
|
305
|
+
ok = r.ok ? { ok: true, target: r.fullName } : r;
|
|
306
|
+
}
|
|
307
|
+
else if (pv === "jira") {
|
|
308
|
+
const cfg = i.config;
|
|
309
|
+
const r = await jiraClient({ siteUrl: cfg.siteUrl, email: cfg.email, apiToken: token, ...(deps.push.fetch ? { fetch: deps.push.fetch } : {}) }).probe(cfg.projectKey);
|
|
310
|
+
ok = r.ok ? { ok: true, target: `${r.project.key} · ${r.project.name}` } : r;
|
|
311
|
+
}
|
|
312
|
+
else if (pv === "slack") {
|
|
313
|
+
const cfg = i.config;
|
|
314
|
+
try {
|
|
315
|
+
const sc = slackClient({ token, ...(deps.push.slackApiBase ? { apiBase: deps.push.slackApiBase } : {}), ...(deps.push.fetch ? { fetch: deps.push.fetch } : {}) });
|
|
316
|
+
const who = await sc.whoami();
|
|
317
|
+
const ch = await sc.resolveChannel(cfg.channelId);
|
|
318
|
+
ok = { ok: true, target: `#${ch.name} in ${who.team}` };
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
ok = { ok: false, detail: e instanceof Error ? e.message : String(e) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
const cfg = i.config;
|
|
326
|
+
const r = await linearClient({ apiKey: token, ...lnOpts() }).probe(cfg.teamKey);
|
|
327
|
+
ok = r.ok ? { ok: true, target: `${r.team.key} · ${r.team.name}` } : r;
|
|
328
|
+
}
|
|
329
|
+
await markIntegration(sql, p.org_id, i.id, ok.ok ? "ok" : "needs_reauth", ok.ok ? null : ok.detail);
|
|
330
|
+
return c.json(ok.ok ? { ok: true, repository: ok.target, target: ok.target } : { ok: false, detail: ok.detail });
|
|
331
|
+
});
|
|
332
|
+
/** GitHub only: a new minted webhook secret, shown once; the old one stops working the moment this answers. */
|
|
333
|
+
app.post("/api/v1/projects/:uid/integrations/:provider/webhook-secret", async (c) => {
|
|
334
|
+
const owner = mustBeOwner(c);
|
|
335
|
+
if (owner instanceof Response)
|
|
336
|
+
return owner;
|
|
337
|
+
const pv = provider(c);
|
|
338
|
+
if (pv instanceof Response)
|
|
339
|
+
return pv;
|
|
340
|
+
const p = await site(c, owner);
|
|
341
|
+
if (!p)
|
|
342
|
+
return fail(c, 404, "project_not_found", "No project with that uid.");
|
|
343
|
+
if (pv === "slack")
|
|
344
|
+
return fail(c, 409, "integration_not_configured", "Slack sends nothing back in this version; there is no webhook to secure.");
|
|
345
|
+
if (pv !== "github") {
|
|
346
|
+
return fail(c, 409, "integration_not_configured", `${NAME[pv]}'s webhook secret is typed into ${NAME[pv]}'s own webhook form: paste the same secret when connecting.`);
|
|
347
|
+
}
|
|
348
|
+
const i = await getIntegration(sql, keys, p.org_id, p.id, "github");
|
|
349
|
+
const had = trackerSecrets(i?.secret ?? null);
|
|
350
|
+
if (!i || !had.token)
|
|
351
|
+
return fail(c, 409, "integration_not_configured", "GitHub is not connected on this site.");
|
|
352
|
+
const secret = mintWebhookSecret();
|
|
353
|
+
await putIntegration(sql, keys, {
|
|
354
|
+
orgId: p.org_id,
|
|
355
|
+
projectId: p.id,
|
|
356
|
+
provider: "github",
|
|
357
|
+
config: i.config,
|
|
358
|
+
secret: JSON.stringify({ token: had.token, webhookSecret: secret }),
|
|
359
|
+
});
|
|
360
|
+
return c.json({ webhook: { url: webhookUrl("github"), secret } });
|
|
361
|
+
});
|
|
362
|
+
/** After an inbound resolve, the same mail a dashboard resolve sends, signed by the tracker. */
|
|
363
|
+
const noticeIfResolved = async (applied, orgId, projectId, actorName) => {
|
|
364
|
+
if (applied.kind !== "resolved" || !(await deps.notifications()))
|
|
365
|
+
return;
|
|
366
|
+
await sql.begin(async (tx) => {
|
|
367
|
+
await tx `SELECT set_config('app.org_id', ${orgId}, true)`;
|
|
368
|
+
await queueResolvedNotice(tx, {
|
|
369
|
+
orgId,
|
|
370
|
+
projectId,
|
|
371
|
+
threadId: applied.threadId,
|
|
372
|
+
threadKey: applied.threadKey,
|
|
373
|
+
version: applied.version,
|
|
374
|
+
actorId: applied.threadId,
|
|
375
|
+
actorName,
|
|
376
|
+
pageUrl: applied.pageUrl,
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
};
|
|
380
|
+
const candidatesFor = (pv, where) => sql.begin(async (tx) => {
|
|
381
|
+
// Read under the maintenance claim: the tracker holds no org, and the
|
|
382
|
+
// org is what the row tells us.
|
|
383
|
+
await tx `SET LOCAL app.maintenance = 'on'`;
|
|
384
|
+
return where(tx);
|
|
385
|
+
});
|
|
386
|
+
const secretOf = (r) => trackerSecrets(r.credentials && r.credentials_iv ? open(keys, { ciphertext: r.credentials, iv: r.credentials_iv }) : null).webhookSecret;
|
|
387
|
+
/**
|
|
388
|
+
* The tracker → us (05 endpoint 25c). Unauthenticated in the session
|
|
389
|
+
* sense; authenticated by the provider's signature over the RAW body with
|
|
390
|
+
* the secret this connection holds — minted by us for GitHub (D96),
|
|
391
|
+
* pasted from Linear's form for Linear (D97). The target named in the
|
|
392
|
+
* payload picks the candidate connections; the signature picks the one.
|
|
393
|
+
*/
|
|
394
|
+
app.post("/api/v1/integrations/:provider/inbound", async (c) => {
|
|
395
|
+
const pv = provider(c);
|
|
396
|
+
if (pv instanceof Response)
|
|
397
|
+
return pv;
|
|
398
|
+
if (pv === "slack")
|
|
399
|
+
return fail(c, 409, "integration_not_configured", "Slack sends nothing back in this version.");
|
|
400
|
+
const raw = await c.req.text();
|
|
401
|
+
let payload;
|
|
402
|
+
try {
|
|
403
|
+
payload = JSON.parse(raw);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return fail(c, 400, "validation_failed", "The body is not JSON.");
|
|
407
|
+
}
|
|
408
|
+
if (pv === "github") {
|
|
409
|
+
const signature = c.req.header("x-hub-signature-256");
|
|
410
|
+
const event = c.req.header("x-github-event") ?? "";
|
|
411
|
+
const delivery = c.req.header("x-github-delivery") ?? "";
|
|
412
|
+
if (event === "ping")
|
|
413
|
+
return c.json({ ok: true, pong: true });
|
|
414
|
+
const fullName = String(payload?.repository?.full_name ?? "").toLowerCase();
|
|
415
|
+
if (!fullName.includes("/"))
|
|
416
|
+
return fail(c, 400, "validation_failed", "The payload names no repository.");
|
|
417
|
+
const [ownerName, repo] = fullName.split("/");
|
|
418
|
+
const candidates = await candidatesFor("github", (tx) => tx `
|
|
419
|
+
SELECT id, org_id, project_id, credentials, credentials_iv FROM integrations
|
|
420
|
+
WHERE provider = 'github' AND deleted_at IS NULL AND enabled
|
|
421
|
+
AND lower(config->>'owner') = ${ownerName} AND lower(config->>'repo') = ${repo}
|
|
422
|
+
`);
|
|
423
|
+
const match = candidates.find((r) => {
|
|
424
|
+
const s = secretOf(r);
|
|
425
|
+
return !!s && verifyGitHubSignature(raw, signature, s);
|
|
426
|
+
});
|
|
427
|
+
if (!match) {
|
|
428
|
+
return candidates.length
|
|
429
|
+
? fail(c, 401, "provider_signature_invalid", "The signature does not match this repository's webhook secret.")
|
|
430
|
+
: fail(c, 409, "integration_not_configured", `No site here is connected to ${fullName}.`);
|
|
431
|
+
}
|
|
432
|
+
if (event !== "issues")
|
|
433
|
+
return c.json({ ok: true, ignored: event });
|
|
434
|
+
const number = Number(payload?.issue?.number);
|
|
435
|
+
if (!Number.isInteger(number))
|
|
436
|
+
return fail(c, 400, "validation_failed", "The payload names no issue.");
|
|
437
|
+
const applied = await applyGitHubIssueEvent(sql, {
|
|
438
|
+
orgId: match.org_id,
|
|
439
|
+
projectId: match.project_id,
|
|
440
|
+
deliveryId: delivery || `${fullName}#${number}:${String(payload?.action)}:${Date.now()}`,
|
|
441
|
+
action: String(payload?.action ?? ""),
|
|
442
|
+
issueNumber: number,
|
|
443
|
+
});
|
|
444
|
+
await noticeIfResolved(applied, match.org_id, match.project_id, "GitHub");
|
|
445
|
+
return c.json({ ok: true, applied: applied.kind, ...("why" in applied ? { why: applied.why } : {}) });
|
|
446
|
+
}
|
|
447
|
+
if (pv === "jira") {
|
|
448
|
+
// Jira Cloud: X-Hub-Signature over the body with the secret typed into
|
|
449
|
+
// the webhook; X-Atlassian-Webhook-Identifier is the replay key. The
|
|
450
|
+
// project and the site in the payload pick the candidates.
|
|
451
|
+
const signature = c.req.header("x-hub-signature");
|
|
452
|
+
const delivery = c.req.header("x-atlassian-webhook-identifier") ?? "";
|
|
453
|
+
const event = String(payload?.webhookEvent ?? "");
|
|
454
|
+
const issue = payload?.issue;
|
|
455
|
+
const projectId = String(issue?.fields?.project?.id ?? "");
|
|
456
|
+
const issueKey = String(issue?.key ?? "");
|
|
457
|
+
if (!projectId || !issueKey)
|
|
458
|
+
return fail(c, 400, "validation_failed", "The payload names no issue in a project.");
|
|
459
|
+
let host = "";
|
|
460
|
+
try {
|
|
461
|
+
host = new URL(String(issue?.self ?? "")).host.toLowerCase();
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
/* no self link — the project id alone will have to do */
|
|
465
|
+
}
|
|
466
|
+
const candidates = await candidatesFor("jira", (tx) => tx `
|
|
467
|
+
SELECT id, org_id, project_id, credentials, credentials_iv FROM integrations
|
|
468
|
+
WHERE provider = 'jira' AND deleted_at IS NULL AND enabled AND config->>'projectId' = ${projectId}
|
|
469
|
+
AND (${host} = '' OR lower(config->>'siteUrl') LIKE ${"%//" + host + "%"})
|
|
470
|
+
`);
|
|
471
|
+
const match = candidates.find((r) => {
|
|
472
|
+
const s = secretOf(r);
|
|
473
|
+
return !!s && verifyJiraSignature(raw, signature, s);
|
|
474
|
+
});
|
|
475
|
+
if (!match) {
|
|
476
|
+
return candidates.length
|
|
477
|
+
? fail(c, 401, "provider_signature_invalid", "The signature does not match this project's webhook secret — type the same secret into Jira's webhook and when connecting.")
|
|
478
|
+
: fail(c, 409, "integration_not_configured", "No site here is connected to that Jira project.");
|
|
479
|
+
}
|
|
480
|
+
let status = null;
|
|
481
|
+
if (event === "jira:issue_deleted")
|
|
482
|
+
status = "detach";
|
|
483
|
+
else if (event === "jira:issue_updated" || event === "jira:issue_created") {
|
|
484
|
+
const category = String(issue?.fields?.status?.statusCategory?.key ?? "");
|
|
485
|
+
if (category)
|
|
486
|
+
status = statusForJiraCategory(category);
|
|
487
|
+
}
|
|
488
|
+
if (!status)
|
|
489
|
+
return c.json({ ok: true, applied: "ignored", why: "action" });
|
|
490
|
+
const applied = await applyIssueState(sql, {
|
|
491
|
+
provider: "jira",
|
|
492
|
+
orgId: match.org_id,
|
|
493
|
+
projectId: match.project_id,
|
|
494
|
+
deliveryId: delivery || `${issueKey}:${event}:${String(issue?.fields?.updated ?? Date.now())}`,
|
|
495
|
+
externalId: issueKey,
|
|
496
|
+
status,
|
|
497
|
+
});
|
|
498
|
+
await noticeIfResolved(applied, match.org_id, match.project_id, "Jira");
|
|
499
|
+
return c.json({ ok: true, applied: applied.kind, ...("why" in applied ? { why: applied.why } : {}) });
|
|
500
|
+
}
|
|
501
|
+
// Linear (07 §5.3): Linear-Signature is the hex HMAC over the raw body
|
|
502
|
+
// with the webhook's signing secret; Linear-Delivery is the replay key;
|
|
503
|
+
// webhookTimestamp guards a captured body replayed much later.
|
|
504
|
+
const signature = c.req.header("linear-signature");
|
|
505
|
+
const delivery = c.req.header("linear-delivery") ?? "";
|
|
506
|
+
const stamp = Number(payload?.webhookTimestamp);
|
|
507
|
+
if (Number.isFinite(stamp) && Math.abs(Date.now() - stamp) > 5 * 60_000) {
|
|
508
|
+
return fail(c, 401, "provider_signature_invalid", "The delivery's timestamp is more than five minutes off.");
|
|
509
|
+
}
|
|
510
|
+
const teamId = String(payload?.data?.teamId ?? payload?.data?.team?.id ?? "");
|
|
511
|
+
if (!teamId)
|
|
512
|
+
return fail(c, 400, "validation_failed", "The payload names no team.");
|
|
513
|
+
const candidates = await candidatesFor("linear", (tx) => tx `
|
|
514
|
+
SELECT id, org_id, project_id, credentials, credentials_iv FROM integrations
|
|
515
|
+
WHERE provider = 'linear' AND deleted_at IS NULL AND enabled AND config->>'teamId' = ${teamId}
|
|
516
|
+
`);
|
|
517
|
+
const match = candidates.find((r) => {
|
|
518
|
+
const s = secretOf(r);
|
|
519
|
+
return !!s && verifyLinearSignature(raw, signature, s);
|
|
520
|
+
});
|
|
521
|
+
if (!match) {
|
|
522
|
+
return candidates.length
|
|
523
|
+
? fail(c, 401, "provider_signature_invalid", "The signature does not match this team's webhook secret — paste the signing secret from Linear's webhook when connecting.")
|
|
524
|
+
: fail(c, 409, "integration_not_configured", "No site here is connected to that Linear team.");
|
|
525
|
+
}
|
|
526
|
+
if (payload?.type !== "Issue")
|
|
527
|
+
return c.json({ ok: true, ignored: String(payload?.type ?? "") });
|
|
528
|
+
const issueId = String(payload?.data?.id ?? "");
|
|
529
|
+
if (!issueId)
|
|
530
|
+
return fail(c, 400, "validation_failed", "The payload names no issue.");
|
|
531
|
+
const action = String(payload?.action ?? "");
|
|
532
|
+
const stateType = typeof payload?.data?.state?.type === "string" ? payload.data.state.type : null;
|
|
533
|
+
let status = null;
|
|
534
|
+
if (action === "remove")
|
|
535
|
+
status = "detach";
|
|
536
|
+
else if ((action === "update" || action === "create") && stateType)
|
|
537
|
+
status = statusForLinearState(stateType);
|
|
538
|
+
if (!status)
|
|
539
|
+
return c.json({ ok: true, applied: "ignored", why: "action" });
|
|
540
|
+
const applied = await applyIssueState(sql, {
|
|
541
|
+
provider: "linear",
|
|
542
|
+
orgId: match.org_id,
|
|
543
|
+
projectId: match.project_id,
|
|
544
|
+
deliveryId: delivery || `${issueId}:${action}:${Date.now()}`,
|
|
545
|
+
externalId: issueId,
|
|
546
|
+
status,
|
|
547
|
+
});
|
|
548
|
+
await noticeIfResolved(applied, match.org_id, match.project_id, "Linear");
|
|
549
|
+
return c.json({ ok: true, applied: applied.kind, ...("why" in applied ? { why: applied.why } : {}) });
|
|
550
|
+
});
|
|
551
|
+
/** "Create … issue" on the Brief — queued in a transaction, drained now, the link comes back. */
|
|
552
|
+
app.post("/api/v1/projects/:uid/threads/:id/integrations/:provider", async (c) => {
|
|
553
|
+
const owner = mustBeOwner(c);
|
|
554
|
+
if (owner instanceof Response)
|
|
555
|
+
return owner;
|
|
556
|
+
const pv = provider(c);
|
|
557
|
+
if (pv instanceof Response)
|
|
558
|
+
return pv;
|
|
559
|
+
const p = await site(c, owner);
|
|
560
|
+
if (!p)
|
|
561
|
+
return fail(c, 404, "project_not_found", "No project with that uid.");
|
|
562
|
+
const raw = c.req.param("id") ?? "";
|
|
563
|
+
const threadRow = raw.startsWith(ID_PREFIX.thread) ? raw.slice(ID_PREFIX.thread.length) : raw;
|
|
564
|
+
if (!UUID.test(threadRow))
|
|
565
|
+
return fail(c, 404, "thread_not_found", "No such thread in this project.");
|
|
566
|
+
const i = await getIntegration(sql, keys, p.org_id, p.id, pv);
|
|
567
|
+
if (!i || !i.enabled)
|
|
568
|
+
return fail(c, 409, "integration_not_configured", `${NAME[pv]} is not connected on this site.`);
|
|
569
|
+
const queued = await sql.begin(async (tx) => {
|
|
570
|
+
await tx `SELECT set_config('app.org_id', ${p.org_id}, true)`;
|
|
571
|
+
const [t] = await tx `
|
|
572
|
+
SELECT version FROM threads WHERE id = ${threadRow}::uuid AND project_id = ${p.id}::uuid AND deleted_at IS NULL
|
|
573
|
+
`;
|
|
574
|
+
if (!t)
|
|
575
|
+
return false;
|
|
576
|
+
// The dedupe key counts the issues this thread has LOST: the first
|
|
577
|
+
// create is row 0, a second click while that issue lives hits the same
|
|
578
|
+
// key and queues nothing (the live ref answers), and a new issue after
|
|
579
|
+
// the tracker deleted the old one is row 1. The worker still refuses
|
|
580
|
+
// to create beside a live ref, so the key can never make a duplicate.
|
|
581
|
+
const [had] = await tx `
|
|
582
|
+
SELECT count(*)::int AS n FROM external_refs
|
|
583
|
+
WHERE thread_id = ${threadRow}::uuid AND provider = ${pv}::snag_provider AND kind = ${REF_KIND[pv]}::snag_ref_kind AND status = 'detached'
|
|
584
|
+
`;
|
|
585
|
+
await queueIntegrationEvent(tx, {
|
|
586
|
+
orgId: p.org_id,
|
|
587
|
+
projectId: p.id,
|
|
588
|
+
integrationId: i.id,
|
|
589
|
+
threadId: threadRow,
|
|
590
|
+
provider: pv,
|
|
591
|
+
topic: "issue.create",
|
|
592
|
+
version: had?.n ?? 0,
|
|
593
|
+
});
|
|
594
|
+
return true;
|
|
595
|
+
});
|
|
596
|
+
if (!queued)
|
|
597
|
+
return fail(c, 404, "thread_not_found", "No such thread in this project.");
|
|
598
|
+
await drainNow(sql, deps.push);
|
|
599
|
+
const [ref] = await sql `
|
|
600
|
+
SELECT external_id, url, status FROM external_refs
|
|
601
|
+
WHERE thread_id = ${threadRow}::uuid AND provider = ${pv}::snag_provider AND kind = ${REF_KIND[pv]}::snag_ref_kind AND status <> 'detached'
|
|
602
|
+
`;
|
|
603
|
+
if (ref)
|
|
604
|
+
return c.json({ ref: { provider: pv, kind: REF_KIND[pv], externalId: ref.external_id, url: ref.url, status: ref.status } });
|
|
605
|
+
const fresh = await getIntegration(sql, keys, p.org_id, p.id, pv);
|
|
606
|
+
return fail(c, 502, "integration_push_failed", fresh?.lastError ?? `${NAME[pv]} did not take the ${REF_KIND[pv]}. The card on Studio › Integrations says why.`);
|
|
607
|
+
});
|
|
608
|
+
return app;
|
|
609
|
+
}
|
|
610
|
+
//# sourceMappingURL=integrations.js.map
|