Skip to content

Commit 69c1496

Browse files
Merge pull request #101 from shrimalmadhur/feat-issues-add-slack-thread-issue-transport
feat(issues): add Slack thread issue transport
2 parents 36a945e + 8d116bc commit 69c1496

File tree

15 files changed

+956
-58
lines changed

15 files changed

+956
-58
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE `issues` ADD `slack_channel_id` text;
2+
ALTER TABLE `issues` ADD `slack_thread_ts` text;
3+
ALTER TABLE `issue_messages` ADD `slack_message_ts` text;
4+
CREATE INDEX `idx_issues_slack_thread` ON `issues` (`slack_channel_id`,`slack_thread_ts`);
5+
CREATE INDEX `idx_issue_messages_slack_message_ts` ON `issue_messages` (`slack_message_ts`);

drizzle/meta/_journal.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@
7878
"when": 1774574276077,
7979
"tag": "0010_complex_doomsday",
8080
"breakpoints": true
81+
},
82+
{
83+
"idx": 11,
84+
"version": "6",
85+
"when": 1774600000000,
86+
"tag": "0011_nice_slack_threads",
87+
"breakpoints": true
8188
}
8289
]
83-
}
90+
}

scripts/issue-pipeline.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { loadEnv } from "./lib/load-env";
22
loadEnv();
33

4+
import { db } from "../src/lib/db";
5+
import { issues } from "../src/lib/db/schema";
46
import { runIssuePipeline } from "../src/lib/issues/pipeline";
57
import { getIssuesTelegramConfig } from "../src/lib/issues/telegram-poller";
8+
import { getIssuesSlackConfig } from "../src/lib/issues/slack";
9+
import { eq } from "drizzle-orm";
610

711
async function main() {
812
const args = process.argv.slice(2);
@@ -13,14 +17,39 @@ async function main() {
1317
}
1418
const issueId = args[issueIdx + 1];
1519

20+
const [issue] = await db.select({
21+
slackChannelId: issues.slackChannelId,
22+
slackThreadTs: issues.slackThreadTs,
23+
}).from(issues).where(eq(issues.id, issueId)).limit(1);
24+
25+
if (!issue) {
26+
console.error(`Issue not found: ${issueId}`);
27+
process.exit(1);
28+
}
29+
30+
const useSlack = Boolean(issue.slackChannelId && issue.slackThreadTs);
31+
32+
if (useSlack) {
33+
const slackConfig = await getIssuesSlackConfig();
34+
if (!slackConfig) {
35+
console.error("No Slack issues app configured. Set it up via Issues > Config in the UI.");
36+
process.exit(1);
37+
}
38+
39+
console.log(`Running pipeline for issue: ${issueId}`);
40+
await runIssuePipeline(issueId, { kind: "slack", ...slackConfig });
41+
console.log("Pipeline complete.");
42+
return;
43+
}
44+
1645
const config = await getIssuesTelegramConfig();
1746
if (!config) {
1847
console.error("No Telegram issues bot configured. Set up via Issues > Config in the UI.");
1948
process.exit(1);
2049
}
2150

2251
console.log(`Running pipeline for issue: ${issueId}`);
23-
await runIssuePipeline(issueId, config);
52+
await runIssuePipeline(issueId, { kind: "telegram", ...config });
2453
console.log("Pipeline complete.");
2554
}
2655

src/__tests__/instrumentation.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ describe("instrumentation register()", () => {
1212
// Reset the globalThis poller state so ensurePollerRunning is callable
1313
const g = globalThis as unknown as { _issuePoller?: { running: boolean; starting: boolean } };
1414
g._issuePoller = { running: false, starting: false };
15+
const slack = globalThis as unknown as { _slackIssueSocket?: { running: boolean; starting: boolean } };
16+
slack._slackIssueSocket = { running: false, starting: false };
1517
});
1618

1719
afterEach(() => {
@@ -29,16 +31,18 @@ describe("instrumentation register()", () => {
2931
}
3032
});
3133

32-
test("calls ensurePollerRunning in bun runtime", async () => {
34+
test("starts issue transports in bun runtime", async () => {
3335
// We're running under bun, so process.versions.bun is already set
3436
expect(process.versions.bun).toBeTruthy();
3537

3638
const { register } = await import("../instrumentation");
3739
await register();
3840

39-
// ensurePollerRunning sets starting=true on the global state
41+
// Both transport managers set starting=true on the global state
4042
const g = globalThis as unknown as { _issuePoller?: { running: boolean; starting: boolean } };
4143
expect(g._issuePoller!.starting).toBe(true);
44+
const slack = globalThis as unknown as { _slackIssueSocket?: { running: boolean; starting: boolean } };
45+
expect(slack._slackIssueSocket!.starting).toBe(true);
4246
});
4347

4448
test("skips in non-bun runtime", async () => {
@@ -52,8 +56,10 @@ describe("instrumentation register()", () => {
5256
const { register } = await import("../instrumentation");
5357
await register();
5458

55-
// ensurePollerRunning should NOT have been called
59+
// Transport managers should NOT have been called
5660
const g = globalThis as unknown as { _issuePoller?: { running: boolean; starting: boolean } };
5761
expect(g._issuePoller!.starting).toBe(false);
62+
const slack = globalThis as unknown as { _slackIssueSocket?: { running: boolean; starting: boolean } };
63+
expect(slack._slackIssueSocket!.starting).toBe(false);
5864
});
5965
});

src/app/(app)/issues/config/page.tsx

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,18 @@ interface TelegramConfig {
3131
chatId: string | null;
3232
}
3333

34+
interface SlackConfig {
35+
configured: boolean;
36+
enabled: boolean;
37+
botToken: string | null;
38+
appToken: string | null;
39+
channelId: string | null;
40+
}
41+
3442
export default function IssuesConfigPage() {
3543
const [repos, setRepos] = useState<Repository[]>([]);
3644
const [telegram, setTelegram] = useState<TelegramConfig | null>(null);
45+
const [slack, setSlack] = useState<SlackConfig | null>(null);
3746
const [loading, setLoading] = useState(true);
3847
const [error, setError] = useState<string | null>(null);
3948
const [success, setSuccess] = useState<string | null>(null);
@@ -66,11 +75,17 @@ export default function IssuesConfigPage() {
6675
const [tgChatTitle, setTgChatTitle] = useState("");
6776
const [tgManualChatId, setTgManualChatId] = useState("");
6877

78+
const [slackBotToken, setSlackBotToken] = useState("");
79+
const [slackAppToken, setSlackAppToken] = useState("");
80+
const [slackChannelId, setSlackChannelId] = useState("");
81+
const [savingSlack, setSavingSlack] = useState(false);
82+
6983
async function fetchAll() {
7084
try {
71-
const [repoRes, tgRes] = await Promise.all([
85+
const [repoRes, tgRes, slackRes] = await Promise.all([
7286
fetch("/api/issues/projects"),
7387
fetch("/api/issues/telegram"),
88+
fetch("/api/issues/slack"),
7489
]);
7590
if (repoRes.ok) {
7691
const data = await repoRes.json();
@@ -80,6 +95,10 @@ export default function IssuesConfigPage() {
8095
const data = await tgRes.json();
8196
setTelegram(data);
8297
}
98+
if (slackRes.ok) {
99+
const data = await slackRes.json();
100+
setSlack(data);
101+
}
83102
} catch {
84103
setError("Could not connect to server");
85104
} finally {
@@ -285,6 +304,52 @@ export default function IssuesConfigPage() {
285304
}
286305
}
287306

307+
async function handleSaveSlack() {
308+
setSavingSlack(true);
309+
setError(null);
310+
try {
311+
const res = await fetch("/api/issues/slack", {
312+
method: "POST",
313+
headers: { "Content-Type": "application/json" },
314+
body: JSON.stringify({
315+
botToken: slackBotToken.trim(),
316+
appToken: slackAppToken.trim(),
317+
channelId: slackChannelId.trim() || undefined,
318+
test: true,
319+
}),
320+
});
321+
if (!res.ok) {
322+
const data = await res.json();
323+
showError(data.error || "Failed to save Slack config");
324+
return;
325+
}
326+
327+
setSlackBotToken("");
328+
setSlackAppToken("");
329+
setSlackChannelId("");
330+
showSuccess("Slack app configured and tested");
331+
await fetchAll();
332+
} catch {
333+
showError("Failed to save Slack config");
334+
} finally {
335+
setSavingSlack(false);
336+
}
337+
}
338+
339+
async function handleDeleteSlack() {
340+
try {
341+
const res = await fetch("/api/issues/slack", { method: "DELETE" });
342+
if (!res.ok) {
343+
showError("Failed to remove Slack config");
344+
return;
345+
}
346+
showSuccess("Slack config removed");
347+
await fetchAll();
348+
} catch {
349+
showError("Failed to remove Slack config");
350+
}
351+
}
352+
288353
if (loading) {
289354
return (
290355
<div className="flex items-center justify-center h-full">
@@ -332,6 +397,100 @@ export default function IssuesConfigPage() {
332397
</div>
333398
)}
334399

400+
<section className="space-y-3">
401+
<h2 className="text-[14px] font-mono font-bold text-accent uppercase tracking-widest">
402+
&gt; Slack Issue App
403+
</h2>
404+
<p className="text-[13px] font-mono text-muted-foreground ml-4">
405+
Uses Slack Socket Mode so issue creation and all follow-up replies stay inside the Slack thread.
406+
</p>
407+
408+
{slack?.configured ? (
409+
<div className="term-card p-4 space-y-2">
410+
<div className="grid grid-cols-3 gap-4">
411+
<div className="space-y-1">
412+
<div className="text-[12px] font-mono text-muted uppercase">bot token</div>
413+
<div className="text-[14px] font-mono text-foreground">{slack.botToken}</div>
414+
</div>
415+
<div className="space-y-1">
416+
<div className="text-[12px] font-mono text-muted uppercase">app token</div>
417+
<div className="text-[14px] font-mono text-foreground">{slack.appToken}</div>
418+
</div>
419+
<div className="space-y-1 text-right">
420+
<div className="text-[12px] font-mono text-muted uppercase">channel id</div>
421+
<div className="text-[14px] font-mono text-foreground">{slack.channelId || "any joined channel"}</div>
422+
</div>
423+
</div>
424+
<div className="flex items-center justify-between pt-2 border-t border-border/40">
425+
<span className="flex items-center gap-1.5 text-[12px] font-mono text-green">
426+
<span className="h-1.5 w-1.5 rounded-full bg-green" />
427+
configured
428+
</span>
429+
<button
430+
onClick={handleDeleteSlack}
431+
className="text-[12px] font-mono text-muted-foreground hover:text-red transition-colors"
432+
>
433+
remove
434+
</button>
435+
</div>
436+
</div>
437+
) : (
438+
<div className="term-card p-4 space-y-3">
439+
<div className="space-y-1">
440+
<label className="text-[12px] font-mono text-muted uppercase tracking-wider">bot token</label>
441+
<input
442+
type="password"
443+
value={slackBotToken}
444+
onChange={(e) => setSlackBotToken(e.target.value)}
445+
placeholder="xoxb-..."
446+
className="w-full border border-border bg-background px-3 py-2 text-[14px] font-mono text-foreground placeholder:text-muted/50 outline-none focus:border-accent"
447+
/>
448+
</div>
449+
<div className="space-y-1">
450+
<label className="text-[12px] font-mono text-muted uppercase tracking-wider">app token</label>
451+
<input
452+
type="password"
453+
value={slackAppToken}
454+
onChange={(e) => setSlackAppToken(e.target.value)}
455+
placeholder="xapp-..."
456+
className="w-full border border-border bg-background px-3 py-2 text-[14px] font-mono text-foreground placeholder:text-muted/50 outline-none focus:border-accent"
457+
/>
458+
<p className="text-[12px] font-mono text-muted">
459+
Enable Socket Mode in your Slack app and use an app-level token with the <span className="text-foreground">connections:write</span> scope.
460+
</p>
461+
</div>
462+
<div className="space-y-1">
463+
<label className="text-[12px] font-mono text-muted uppercase tracking-wider">channel id (optional)</label>
464+
<input
465+
type="text"
466+
value={slackChannelId}
467+
onChange={(e) => setSlackChannelId(e.target.value)}
468+
placeholder="C0123456789"
469+
className="w-full border border-border bg-background px-3 py-2 text-[14px] font-mono text-foreground placeholder:text-muted/50 outline-none focus:border-accent"
470+
/>
471+
<p className="text-[12px] font-mono text-muted">
472+
Restrict issue creation to one Slack channel, or leave blank to accept mentions in any channel the bot has joined.
473+
</p>
474+
</div>
475+
<div className="border border-border/50 bg-background/40 px-3 py-3 text-[12px] font-mono text-muted space-y-1">
476+
<div>Required bot scopes: <span className="text-foreground">app_mentions:read</span>, <span className="text-foreground">channels:history</span>, <span className="text-foreground">groups:history</span>, <span className="text-foreground">chat:write</span></div>
477+
<div>Usage: mention the bot with <span className="text-foreground">@bot repo-name: description</span>. All replies should stay in that Slack thread.</div>
478+
</div>
479+
<button
480+
onClick={handleSaveSlack}
481+
disabled={!slackBotToken.trim() || !slackAppToken.trim() || savingSlack}
482+
className="flex items-center gap-1.5 border border-accent bg-accent/10 px-4 py-1.5 text-[13px] font-mono font-bold text-accent uppercase tracking-wider transition-all hover:bg-accent/20 disabled:opacity-40"
483+
>
484+
{savingSlack ? (
485+
<><Loader2 className="h-3 w-3 animate-spin" /> saving...</>
486+
) : (
487+
<><Send className="h-3 w-3" /> save &amp; test</>
488+
)}
489+
</button>
490+
</div>
491+
)}
492+
</section>
493+
335494
{/* Telegram Bot Config */}
336495
<section className="space-y-3">
337496
<h2 className="text-[14px] font-mono font-bold text-accent uppercase tracking-widest">

src/app/api/issues/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import { db } from "@/lib/db";
33
import { issues, repositories } from "@/lib/db/schema";
44
import { eq, desc, and, isNull, isNotNull, count, type SQL } from "drizzle-orm";
55
import { ensurePollerRunning } from "@/lib/issues/poller-manager";
6+
import { ensureSlackIssuesSocketRunning } from "@/lib/issues/slack-socket";
67
import { createIssueSchema } from "@/lib/validations/issue";
78
import { withErrorHandler } from "@/lib/api/utils";
89

910
export const runtime = "nodejs";
1011

1112
export const GET = withErrorHandler(async (request: Request) => {
12-
// Start the in-process Telegram poller on first access (lazy init)
13+
// Start the in-process issue transports on first access (lazy init)
1314
ensurePollerRunning();
15+
ensureSlackIssuesSocketRunning();
1416

1517
const { searchParams } = new URL(request.url);
1618
const repositoryId = searchParams.get("repositoryId");

0 commit comments

Comments
 (0)