@lovelybunch/api 1.0.71 → 1.0.72-alpha.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/dist/lib/jobs/job-scheduler.js +17 -0
- package/dist/lib/slack/slack-service.d.ts +89 -0
- package/dist/lib/slack/slack-service.js +504 -0
- package/dist/routes/api/v1/git/index.js +17 -0
- package/dist/routes/api/v1/proposals/[id]/route.js +9 -0
- package/dist/routes/api/v1/proposals/route.js +8 -0
- package/dist/routes/api/v1/resources/generate/route.js +24 -3
- package/dist/routes/api/v1/slack/index.d.ts +3 -0
- package/dist/routes/api/v1/slack/index.js +15 -0
- package/dist/routes/api/v1/slack/route.d.ts +122 -0
- package/dist/routes/api/v1/slack/route.js +189 -0
- package/dist/server-with-static.js +2 -0
- package/dist/server.js +2 -0
- package/package.json +5 -4
- package/static/assets/{AgentDetailPage-CZ2tz-Ol.js → AgentDetailPage-CS4l_2nY.js} +1 -1
- package/static/assets/{AgentEditPage-BiAoWU1z.js → AgentEditPage-mvchfGaI.js} +1 -1
- package/static/assets/{AgentsPage-D_HMA-40.js → AgentsPage-D4JsS7kC.js} +1 -1
- package/static/assets/{AgentsSettingsPage-C5ZsOVSL.js → AgentsSettingsPage-CPZm3ECa.js} +1 -1
- package/static/assets/{ApiKeysSettingsPage-C7Xlzj-X.js → ApiKeysSettingsPage-BmGRl-Cr.js} +1 -1
- package/static/assets/{ArchitectureEditPage-B9nVQn0B.js → ArchitectureEditPage-CyAu4lkT.js} +1 -1
- package/static/assets/{ArchitecturePage-CChIC6Qa.js → ArchitecturePage-BEVC5igc.js} +1 -1
- package/static/assets/{AuthSettingsPage-CaeV2cQ4.js → AuthSettingsPage-BfxPusvb.js} +1 -1
- package/static/assets/{CallbackPage-DmeStBSx.js → CallbackPage-C7QN2xcl.js} +1 -1
- package/static/assets/{CodePage-BtKkipWC.js → CodePage-DoP5IiuI.js} +1 -1
- package/static/assets/{CollapsibleSection-Cb80fCCb.js → CollapsibleSection-DpcF5jIr.js} +1 -1
- package/static/assets/{DashboardPage-DdApq_B-.js → DashboardPage-D0EoXiL2.js} +1 -1
- package/static/assets/{GitPage-D2aJfzTq.js → GitPage-DVCWmjb1.js} +1 -1
- package/static/assets/{GitSettingsPage-Bz17VWrK.js → GitSettingsPage-CA-NVPgC.js} +1 -1
- package/static/assets/{IdentityPage-O7o2b4JB.js → IdentityPage-yBi2meP8.js} +1 -1
- package/static/assets/{ImplementationStepsEditor-BkJjQBc-.js → ImplementationStepsEditor-CdFjqsQp.js} +1 -1
- package/static/assets/{IntegrationsSettingsPage-brFTXUBL.js → IntegrationsSettingsPage-4NWwhHlm.js} +1 -1
- package/static/assets/{KnowledgeDetailPage-e5OpRrZ2.js → KnowledgeDetailPage-mBBrz-_N.js} +1 -1
- package/static/assets/{KnowledgeEditPage-DgyPoIJv.js → KnowledgeEditPage-DqTY1Y3C.js} +1 -1
- package/static/assets/{KnowledgePage-Ck_FeruK.js → KnowledgePage-DonVTg_6.js} +1 -1
- package/static/assets/{LoginPage-IqcnIlMj.js → LoginPage-DFpCcG6v.js} +1 -1
- package/static/assets/{McpSettingsPage-KJBLTelP.js → McpSettingsPage-jXN9kO3i.js} +1 -1
- package/static/assets/{NewAgentPage-DLIvDpeZ.js → NewAgentPage-BNojru16.js} +1 -1
- package/static/assets/{NewKnowledgePage-xBm9PTKB.js → NewKnowledgePage-D93ERPBo.js} +1 -1
- package/static/assets/{NewProposalPage-BlocVyGv.js → NewProposalPage-BBmHfu-z.js} +1 -1
- package/static/assets/NotificationsSettingsPage-D6oOIvep.js +1 -0
- package/static/assets/{ProjectEditPage-BlA-908F.js → ProjectEditPage-kXy3gxS3.js} +1 -1
- package/static/assets/{ProjectPage-Dj-tc-ZE.js → ProjectPage-D3vvfNxI.js} +1 -1
- package/static/assets/{PromptsSettingsPage-BvU01T7L.js → PromptsSettingsPage-BNQrbBn5.js} +1 -1
- package/static/assets/{ProposalDetailPage-_8Wn6NJt.js → ProposalDetailPage-CMgZUcxP.js} +1 -1
- package/static/assets/{ProposalEditPage-BbbWkAuu.js → ProposalEditPage-Bn_1OJ11.js} +1 -1
- package/static/assets/{ProposalsPage-erDBdH7Z.js → ProposalsPage-CysWDXyl.js} +1 -1
- package/static/assets/ResourcesPage-QD68AoOk.js +71 -0
- package/static/assets/{RulesSettingsPage-DurFFm98.js → RulesSettingsPage-DQvhq7o0.js} +1 -1
- package/static/assets/{SchedulePage-BAnM4Lu5.js → SchedulePage-CghrKKJB.js} +1 -1
- package/static/assets/{SourceInput-7lTShZ6_.js → SourceInput-BU8xQMbc.js} +1 -1
- package/static/assets/{TagInput-2_jYxO52.js → TagInput-C1XF1z9l.js} +1 -1
- package/static/assets/{TerminalPage-CRVEtFXr.js → TerminalPage-BfNa9oLV.js} +1 -1
- package/static/assets/{TerminalSessionPage-BjvQegW1.js → TerminalSessionPage-BkuO3Dlc.js} +3 -8
- package/static/assets/{UserPreferencesPage-C5ueVUwG.js → UserPreferencesPage-DWsDPJWa.js} +1 -1
- package/static/assets/{UserSettingsPage-DhOu8xZ3.js → UserSettingsPage-Czyu--ZE.js} +1 -1
- package/static/assets/{UtilitiesPage-olcchfEX.js → UtilitiesPage-BL3r6jt4.js} +1 -1
- package/static/assets/{alert-bP1PxakB.js → alert-yDv7BoVY.js} +1 -1
- package/static/assets/{arrow-down-BoBPr1Sn.js → arrow-down-B6mDQzhv.js} +1 -1
- package/static/assets/{arrow-left-orDiJ2Pw.js → arrow-left-oYBMYRNP.js} +1 -1
- package/static/assets/{arrow-up-CCloQpeV.js → arrow-up-BtNT59gW.js} +1 -1
- package/static/assets/{badge-D7styiB7.js → badge-A8sRXZ1m.js} +1 -1
- package/static/assets/{browser-modal-n0MeSpgA.js → browser-modal-DcxLC6CX.js} +1 -1
- package/static/assets/{calendar-w3geg78-.js → calendar-C6_Zaz3s.js} +1 -1
- package/static/assets/{card-pzUJtmwJ.js → card-Brl-P7qu.js} +1 -1
- package/static/assets/{chevron-left-B6nXpDLi.js → chevron-left-BsJmB9OE.js} +1 -1
- package/static/assets/{circle-alert-BcFpY-ZU.js → circle-alert-BYXOF-Yd.js} +1 -1
- package/static/assets/{circle-check-HRharHjy.js → circle-check-BISWYf84.js} +1 -1
- package/static/assets/{circle-check-big-B2CFEks4.js → circle-check-big-GdURlCCT.js} +1 -1
- package/static/assets/{circle-play-CfrNAC9J.js → circle-play-D14kkelg.js} +1 -1
- package/static/assets/{circle-x-CNn7_0Ew.js → circle-x-CWvfwx75.js} +1 -1
- package/static/assets/{clipboard-LaXihY2m.js → clipboard-CQ1MeyX5.js} +1 -1
- package/static/assets/{clock-By2Dd4u0.js → clock-C7sCX_DE.js} +1 -1
- package/static/assets/{download--HiFU7TR.js → download-BjE8JxlU.js} +1 -1
- package/static/assets/{eye-DorygWtP.js → eye-2hl27c7R.js} +1 -1
- package/static/assets/{folder-git-2-B8bjLoxc.js → folder-git-2-BBlVIDmr.js} +1 -1
- package/static/assets/index-BJVvhEWY.css +2 -0
- package/static/assets/{index-BfJaT17z.js → index-D_2ZhRwL.js} +91 -86
- package/static/assets/{label-CTlQtJaU.js → label-DA737D6X.js} +1 -1
- package/static/assets/{markdown-editor-BOcltq2r.js → markdown-editor-BhYm394v.js} +1 -1
- package/static/assets/message-square-DV1_7h3v.js +6 -0
- package/static/assets/{pause-D33aM-oa.js → pause-DS_ZgX9w.js} +1 -1
- package/static/assets/{play-B6IdXHPj.js → play-Cgu7MRJC.js} +1 -1
- package/static/assets/{plus-DhYjijTS.js → plus-RZwwEJRO.js} +1 -1
- package/static/assets/{radio-group-B-Rc5x_L.js → radio-group-CzZoufFM.js} +1 -1
- package/static/assets/{refresh-cw-BuuX9h4Z.js → refresh-cw-BJ2GIM1Z.js} +1 -1
- package/static/assets/{search-BiMN-o92.js → search-B2ODQzPd.js} +1 -1
- package/static/assets/{switch-vSV_roZ2.js → switch-AdqXRjK1.js} +1 -1
- package/static/assets/{tabs-ChuwGq16.js → tabs-DnIPixK3.js} +1 -1
- package/static/assets/{tag-DERlGH35.js → tag-C4UgL0Z_.js} +1 -1
- package/static/assets/{terminal-preview-BFedy4-J.js → terminal-preview-PYoJzFpP.js} +1 -1
- package/static/assets/{use-terminal-qzkum-B5.js → use-terminal-qsHkIOJb.js} +1 -1
- package/static/assets/{zap-DwFz_ltU.js → zap-CpNZOBUL.js} +1 -1
- package/static/index.html +2 -2
- package/static/assets/ResourcesPage-B1uF-EA-.js +0 -71
- package/static/assets/index-B5SwW-PH.css +0 -2
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
|
|
|
2
2
|
import { JobStore } from './job-store.js';
|
|
3
3
|
import { JobRunner } from './job-runner.js';
|
|
4
4
|
import { getLogger, JobKinds } from '@lovelybunch/core/logging';
|
|
5
|
+
import { getSlackService } from '../slack/slack-service.js';
|
|
5
6
|
const DAY_TO_INDEX = {
|
|
6
7
|
sunday: 0,
|
|
7
8
|
monday: 1,
|
|
@@ -191,6 +192,15 @@ export class JobScheduler {
|
|
|
191
192
|
logPath: result.outputPath,
|
|
192
193
|
}
|
|
193
194
|
});
|
|
195
|
+
// Send Slack notification (non-blocking)
|
|
196
|
+
const notificationType = result.status === 'succeeded' ? 'job.completed' : 'job.failed';
|
|
197
|
+
getSlackService().sendNotification({
|
|
198
|
+
type: notificationType,
|
|
199
|
+
jobId: job.id,
|
|
200
|
+
jobName: job.name || job.id,
|
|
201
|
+
duration,
|
|
202
|
+
error: result.error,
|
|
203
|
+
}).catch(err => console.warn('[jobs] Slack notification failed:', err));
|
|
194
204
|
}
|
|
195
205
|
catch (logError) {
|
|
196
206
|
console.error('Error logging job run end:', logError);
|
|
@@ -219,6 +229,13 @@ export class JobScheduler {
|
|
|
219
229
|
stack: error?.stack,
|
|
220
230
|
}
|
|
221
231
|
});
|
|
232
|
+
// Send Slack notification for job failure (non-blocking)
|
|
233
|
+
getSlackService().sendNotification({
|
|
234
|
+
type: 'job.failed',
|
|
235
|
+
jobId: job.id,
|
|
236
|
+
jobName: job.name || job.id,
|
|
237
|
+
error: error?.message || 'Unknown error',
|
|
238
|
+
}).catch(err => console.warn('[jobs] Slack notification failed:', err));
|
|
222
239
|
}
|
|
223
240
|
catch (logError) {
|
|
224
241
|
console.error('Error logging job run error:', logError);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export interface SlackNotificationSettings {
|
|
2
|
+
proposals: {
|
|
3
|
+
created: boolean;
|
|
4
|
+
statusChange: boolean;
|
|
5
|
+
};
|
|
6
|
+
jobs: {
|
|
7
|
+
completed: boolean;
|
|
8
|
+
failed: boolean;
|
|
9
|
+
};
|
|
10
|
+
git: {
|
|
11
|
+
push: boolean;
|
|
12
|
+
merge: boolean;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface SlackConfig {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
botToken: string;
|
|
18
|
+
signingSecret: string;
|
|
19
|
+
channelId: string;
|
|
20
|
+
channelName: string;
|
|
21
|
+
notifications: SlackNotificationSettings;
|
|
22
|
+
}
|
|
23
|
+
export interface SlackChannel {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
isPrivate: boolean;
|
|
27
|
+
isMember: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface ProposalNotificationPayload {
|
|
30
|
+
type: 'proposal.created' | 'proposal.statusChange';
|
|
31
|
+
proposalId: string;
|
|
32
|
+
intent: string;
|
|
33
|
+
status?: string;
|
|
34
|
+
previousStatus?: string;
|
|
35
|
+
author?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface JobNotificationPayload {
|
|
38
|
+
type: 'job.completed' | 'job.failed';
|
|
39
|
+
jobId: string;
|
|
40
|
+
jobName: string;
|
|
41
|
+
duration?: number;
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface GitNotificationPayload {
|
|
45
|
+
type: 'git.push' | 'git.merge';
|
|
46
|
+
branch: string;
|
|
47
|
+
targetBranch?: string;
|
|
48
|
+
commitCount?: number;
|
|
49
|
+
message?: string;
|
|
50
|
+
}
|
|
51
|
+
export type NotificationPayload = ProposalNotificationPayload | JobNotificationPayload | GitNotificationPayload;
|
|
52
|
+
export type NotificationType = 'proposal.created' | 'proposal.statusChange' | 'job.completed' | 'job.failed' | 'git.push' | 'git.merge';
|
|
53
|
+
declare class SlackService {
|
|
54
|
+
private client;
|
|
55
|
+
private config;
|
|
56
|
+
private getSettingsPath;
|
|
57
|
+
loadConfig(): Promise<SlackConfig>;
|
|
58
|
+
saveConfig(updates: Partial<SlackConfig>): Promise<SlackConfig>;
|
|
59
|
+
getConfigForDisplay(): Promise<Omit<SlackConfig, 'botToken' | 'signingSecret'> & {
|
|
60
|
+
hasBotToken: boolean;
|
|
61
|
+
hasSigningSecret: boolean;
|
|
62
|
+
}>;
|
|
63
|
+
private getClient;
|
|
64
|
+
testConnection(): Promise<{
|
|
65
|
+
success: boolean;
|
|
66
|
+
teamName?: string;
|
|
67
|
+
error?: string;
|
|
68
|
+
}>;
|
|
69
|
+
listChannels(): Promise<SlackChannel[]>;
|
|
70
|
+
sendNotification(payload: NotificationPayload): Promise<boolean>;
|
|
71
|
+
/**
|
|
72
|
+
* Send an example notification for testing a specific notification type.
|
|
73
|
+
* Bypasses the enabled check so users can test before enabling.
|
|
74
|
+
*/
|
|
75
|
+
sendExampleNotification(notificationType: NotificationType): Promise<boolean>;
|
|
76
|
+
private getExamplePayload;
|
|
77
|
+
private isNotificationEnabled;
|
|
78
|
+
private formatNotification;
|
|
79
|
+
private formatProposalCreated;
|
|
80
|
+
private formatProposalStatusChange;
|
|
81
|
+
private formatJobCompleted;
|
|
82
|
+
private formatJobFailed;
|
|
83
|
+
private formatGitPush;
|
|
84
|
+
private formatGitMerge;
|
|
85
|
+
private getStatusEmoji;
|
|
86
|
+
clearCache(): void;
|
|
87
|
+
}
|
|
88
|
+
export declare function getSlackService(): SlackService;
|
|
89
|
+
export { SlackService };
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { WebClient, LogLevel } from '@slack/web-api';
|
|
2
|
+
import { promises as fs, existsSync, mkdirSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { getAppDataDir } from '@lovelybunch/core';
|
|
5
|
+
const DEFAULT_CONFIG = {
|
|
6
|
+
enabled: false,
|
|
7
|
+
botToken: '',
|
|
8
|
+
signingSecret: '',
|
|
9
|
+
channelId: '',
|
|
10
|
+
channelName: '',
|
|
11
|
+
notifications: {
|
|
12
|
+
proposals: { created: true, statusChange: true },
|
|
13
|
+
jobs: { completed: true, failed: true },
|
|
14
|
+
git: { push: false, merge: true },
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
class SlackService {
|
|
18
|
+
client = null;
|
|
19
|
+
config = null;
|
|
20
|
+
getSettingsPath() {
|
|
21
|
+
const appDataDir = getAppDataDir();
|
|
22
|
+
// Ensure directory exists
|
|
23
|
+
if (!existsSync(appDataDir)) {
|
|
24
|
+
mkdirSync(appDataDir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
return path.join(appDataDir, 'slack.json');
|
|
27
|
+
}
|
|
28
|
+
async loadConfig() {
|
|
29
|
+
if (this.config) {
|
|
30
|
+
return this.config;
|
|
31
|
+
}
|
|
32
|
+
const settingsPath = this.getSettingsPath();
|
|
33
|
+
try {
|
|
34
|
+
const raw = await fs.readFile(settingsPath, 'utf-8');
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
this.config = { ...DEFAULT_CONFIG, ...parsed };
|
|
37
|
+
return this.config;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error?.code === 'ENOENT') {
|
|
41
|
+
this.config = { ...DEFAULT_CONFIG };
|
|
42
|
+
return this.config;
|
|
43
|
+
}
|
|
44
|
+
console.warn('[slack-service] Failed to read slack.json:', error);
|
|
45
|
+
this.config = { ...DEFAULT_CONFIG };
|
|
46
|
+
return this.config;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async saveConfig(updates) {
|
|
50
|
+
const current = await this.loadConfig();
|
|
51
|
+
const updated = {
|
|
52
|
+
...current,
|
|
53
|
+
...updates,
|
|
54
|
+
notifications: {
|
|
55
|
+
...current.notifications,
|
|
56
|
+
...(updates.notifications || {}),
|
|
57
|
+
proposals: {
|
|
58
|
+
...current.notifications.proposals,
|
|
59
|
+
...(updates.notifications?.proposals || {}),
|
|
60
|
+
},
|
|
61
|
+
jobs: {
|
|
62
|
+
...current.notifications.jobs,
|
|
63
|
+
...(updates.notifications?.jobs || {}),
|
|
64
|
+
},
|
|
65
|
+
git: {
|
|
66
|
+
...current.notifications.git,
|
|
67
|
+
...(updates.notifications?.git || {}),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const settingsPath = this.getSettingsPath();
|
|
72
|
+
await fs.writeFile(settingsPath, JSON.stringify(updated, null, 2), 'utf-8');
|
|
73
|
+
// Reset cached config and client
|
|
74
|
+
this.config = updated;
|
|
75
|
+
this.client = null;
|
|
76
|
+
return updated;
|
|
77
|
+
}
|
|
78
|
+
async getConfigForDisplay() {
|
|
79
|
+
const config = await this.loadConfig();
|
|
80
|
+
return {
|
|
81
|
+
enabled: config.enabled,
|
|
82
|
+
channelId: config.channelId,
|
|
83
|
+
channelName: config.channelName,
|
|
84
|
+
notifications: config.notifications,
|
|
85
|
+
hasBotToken: !!config.botToken && config.botToken.length > 0,
|
|
86
|
+
hasSigningSecret: !!config.signingSecret && config.signingSecret.length > 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
getClient() {
|
|
90
|
+
if (this.client) {
|
|
91
|
+
return this.client;
|
|
92
|
+
}
|
|
93
|
+
if (!this.config?.botToken) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
this.client = new WebClient(this.config.botToken, {
|
|
97
|
+
logLevel: LogLevel.WARN,
|
|
98
|
+
});
|
|
99
|
+
return this.client;
|
|
100
|
+
}
|
|
101
|
+
async testConnection() {
|
|
102
|
+
try {
|
|
103
|
+
await this.loadConfig();
|
|
104
|
+
const client = this.getClient();
|
|
105
|
+
if (!client) {
|
|
106
|
+
return { success: false, error: 'Bot token not configured' };
|
|
107
|
+
}
|
|
108
|
+
const result = await client.auth.test();
|
|
109
|
+
if (result.ok) {
|
|
110
|
+
return {
|
|
111
|
+
success: true,
|
|
112
|
+
teamName: result.team
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
return { success: false, error: result.error || 'Unknown error' };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return {
|
|
121
|
+
success: false,
|
|
122
|
+
error: error.message || 'Failed to connect to Slack'
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async listChannels() {
|
|
127
|
+
try {
|
|
128
|
+
await this.loadConfig();
|
|
129
|
+
const client = this.getClient();
|
|
130
|
+
if (!client) {
|
|
131
|
+
throw new Error('Bot token not configured');
|
|
132
|
+
}
|
|
133
|
+
const channels = [];
|
|
134
|
+
let cursor;
|
|
135
|
+
// First try to fetch both public and private channels
|
|
136
|
+
// Fall back to public only if groups:read scope is missing
|
|
137
|
+
let channelTypes = 'public_channel,private_channel';
|
|
138
|
+
try {
|
|
139
|
+
// Test if we can list private channels
|
|
140
|
+
await client.conversations.list({ types: 'private_channel', limit: 1 });
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error.data?.error === 'missing_scope' && error.data?.needed === 'groups:read') {
|
|
144
|
+
// Fall back to public channels only
|
|
145
|
+
channelTypes = 'public_channel';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Fetch channels
|
|
149
|
+
do {
|
|
150
|
+
const result = await client.conversations.list({
|
|
151
|
+
types: channelTypes,
|
|
152
|
+
limit: 200,
|
|
153
|
+
cursor,
|
|
154
|
+
});
|
|
155
|
+
if (result.channels) {
|
|
156
|
+
for (const channel of result.channels) {
|
|
157
|
+
if (channel.id && channel.name) {
|
|
158
|
+
channels.push({
|
|
159
|
+
id: channel.id,
|
|
160
|
+
name: channel.name,
|
|
161
|
+
isPrivate: channel.is_private || false,
|
|
162
|
+
isMember: channel.is_member || false,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
cursor = result.response_metadata?.next_cursor;
|
|
168
|
+
} while (cursor);
|
|
169
|
+
// Sort by name
|
|
170
|
+
channels.sort((a, b) => a.name.localeCompare(b.name));
|
|
171
|
+
return channels;
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
console.error('[slack-service] Failed to list channels:', error);
|
|
175
|
+
// Provide helpful error messages for common Slack API errors
|
|
176
|
+
if (error.data?.error === 'missing_scope' || error.message?.includes('missing_scope')) {
|
|
177
|
+
throw new Error('Missing required Slack scopes. Please add "channels:read" (and optionally "groups:read" for private channels) to your Slack app under OAuth & Permissions.');
|
|
178
|
+
}
|
|
179
|
+
if (error.data?.error === 'invalid_auth' || error.message?.includes('invalid_auth')) {
|
|
180
|
+
throw new Error('Invalid bot token. Please check your Bot User OAuth Token.');
|
|
181
|
+
}
|
|
182
|
+
if (error.data?.error === 'token_revoked' || error.message?.includes('token_revoked')) {
|
|
183
|
+
throw new Error('Bot token has been revoked. Please reinstall the Slack app to your workspace.');
|
|
184
|
+
}
|
|
185
|
+
throw new Error(error.message || 'Failed to list channels');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async sendNotification(payload) {
|
|
189
|
+
try {
|
|
190
|
+
const config = await this.loadConfig();
|
|
191
|
+
if (!config.enabled || !config.channelId) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
// Check if this notification type is enabled
|
|
195
|
+
if (!this.isNotificationEnabled(config, payload)) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
const client = this.getClient();
|
|
199
|
+
if (!client) {
|
|
200
|
+
console.warn('[slack-service] Cannot send notification: bot token not configured');
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
const message = this.formatNotification(payload);
|
|
204
|
+
await client.chat.postMessage({
|
|
205
|
+
channel: config.channelId,
|
|
206
|
+
...message,
|
|
207
|
+
});
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
console.error('[slack-service] Failed to send notification:', error);
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Send an example notification for testing a specific notification type.
|
|
217
|
+
* Bypasses the enabled check so users can test before enabling.
|
|
218
|
+
*/
|
|
219
|
+
async sendExampleNotification(notificationType) {
|
|
220
|
+
try {
|
|
221
|
+
const config = await this.loadConfig();
|
|
222
|
+
if (!config.channelId) {
|
|
223
|
+
throw new Error('No channel configured. Please select a channel first.');
|
|
224
|
+
}
|
|
225
|
+
const client = this.getClient();
|
|
226
|
+
if (!client) {
|
|
227
|
+
throw new Error('Bot token not configured');
|
|
228
|
+
}
|
|
229
|
+
const payload = this.getExamplePayload(notificationType);
|
|
230
|
+
const message = this.formatNotification(payload);
|
|
231
|
+
await client.chat.postMessage({
|
|
232
|
+
channel: config.channelId,
|
|
233
|
+
...message,
|
|
234
|
+
});
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
console.error('[slack-service] Failed to send example notification:', error);
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
getExamplePayload(type) {
|
|
243
|
+
switch (type) {
|
|
244
|
+
case 'proposal.created':
|
|
245
|
+
return {
|
|
246
|
+
type: 'proposal.created',
|
|
247
|
+
proposalId: 'cp-example-123',
|
|
248
|
+
intent: 'Add user authentication flow',
|
|
249
|
+
author: 'Example User',
|
|
250
|
+
};
|
|
251
|
+
case 'proposal.statusChange':
|
|
252
|
+
return {
|
|
253
|
+
type: 'proposal.statusChange',
|
|
254
|
+
proposalId: 'cp-example-123',
|
|
255
|
+
intent: 'Add user authentication flow',
|
|
256
|
+
status: 'proposed',
|
|
257
|
+
previousStatus: 'draft',
|
|
258
|
+
};
|
|
259
|
+
case 'job.completed':
|
|
260
|
+
return {
|
|
261
|
+
type: 'job.completed',
|
|
262
|
+
jobId: 'job-example-456',
|
|
263
|
+
jobName: 'Daily code review sync',
|
|
264
|
+
duration: 45000,
|
|
265
|
+
};
|
|
266
|
+
case 'job.failed':
|
|
267
|
+
return {
|
|
268
|
+
type: 'job.failed',
|
|
269
|
+
jobId: 'job-example-456',
|
|
270
|
+
jobName: 'Daily code review sync',
|
|
271
|
+
error: 'Connection timeout after 30 seconds',
|
|
272
|
+
};
|
|
273
|
+
case 'git.push':
|
|
274
|
+
return {
|
|
275
|
+
type: 'git.push',
|
|
276
|
+
branch: 'feature/example-branch',
|
|
277
|
+
commitCount: 3,
|
|
278
|
+
};
|
|
279
|
+
case 'git.merge':
|
|
280
|
+
return {
|
|
281
|
+
type: 'git.merge',
|
|
282
|
+
branch: 'feature/example-branch',
|
|
283
|
+
targetBranch: 'main',
|
|
284
|
+
message: 'Merged feature branch into main',
|
|
285
|
+
};
|
|
286
|
+
default:
|
|
287
|
+
throw new Error(`Unknown notification type: ${type}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
isNotificationEnabled(config, payload) {
|
|
291
|
+
switch (payload.type) {
|
|
292
|
+
case 'proposal.created':
|
|
293
|
+
return config.notifications.proposals.created;
|
|
294
|
+
case 'proposal.statusChange':
|
|
295
|
+
return config.notifications.proposals.statusChange;
|
|
296
|
+
case 'job.completed':
|
|
297
|
+
return config.notifications.jobs.completed;
|
|
298
|
+
case 'job.failed':
|
|
299
|
+
return config.notifications.jobs.failed;
|
|
300
|
+
case 'git.push':
|
|
301
|
+
return config.notifications.git.push;
|
|
302
|
+
case 'git.merge':
|
|
303
|
+
return config.notifications.git.merge;
|
|
304
|
+
default:
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
formatNotification(payload) {
|
|
309
|
+
switch (payload.type) {
|
|
310
|
+
case 'proposal.created':
|
|
311
|
+
return this.formatProposalCreated(payload);
|
|
312
|
+
case 'proposal.statusChange':
|
|
313
|
+
return this.formatProposalStatusChange(payload);
|
|
314
|
+
case 'job.completed':
|
|
315
|
+
return this.formatJobCompleted(payload);
|
|
316
|
+
case 'job.failed':
|
|
317
|
+
return this.formatJobFailed(payload);
|
|
318
|
+
case 'git.push':
|
|
319
|
+
return this.formatGitPush(payload);
|
|
320
|
+
case 'git.merge':
|
|
321
|
+
return this.formatGitMerge(payload);
|
|
322
|
+
default:
|
|
323
|
+
return { text: 'Unknown notification type' };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
formatProposalCreated(payload) {
|
|
327
|
+
const text = `New proposal created: ${payload.intent}`;
|
|
328
|
+
return {
|
|
329
|
+
text,
|
|
330
|
+
blocks: [
|
|
331
|
+
{
|
|
332
|
+
type: 'section',
|
|
333
|
+
text: {
|
|
334
|
+
type: 'mrkdwn',
|
|
335
|
+
text: `:memo: *New Proposal Created*\n*${payload.intent}*\nA new change proposal has been submitted for review.`,
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
type: 'context',
|
|
340
|
+
elements: [
|
|
341
|
+
{
|
|
342
|
+
type: 'mrkdwn',
|
|
343
|
+
text: `ID: \`${payload.proposalId}\`${payload.author ? ` | Author: ${payload.author}` : ''}`,
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
formatProposalStatusChange(payload) {
|
|
351
|
+
const statusEmoji = this.getStatusEmoji(payload.status);
|
|
352
|
+
const text = `Proposal status changed: ${payload.intent} → ${payload.status}`;
|
|
353
|
+
return {
|
|
354
|
+
text,
|
|
355
|
+
blocks: [
|
|
356
|
+
{
|
|
357
|
+
type: 'section',
|
|
358
|
+
text: {
|
|
359
|
+
type: 'mrkdwn',
|
|
360
|
+
text: `${statusEmoji} *Proposal Status Changed*\n*${payload.intent}*\nThe proposal has moved to a new stage in the review process.`,
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
type: 'context',
|
|
365
|
+
elements: [
|
|
366
|
+
{
|
|
367
|
+
type: 'mrkdwn',
|
|
368
|
+
text: `\`${payload.previousStatus || 'unknown'}\` → \`${payload.status}\` | ID: \`${payload.proposalId}\``,
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
formatJobCompleted(payload) {
|
|
376
|
+
const duration = payload.duration ? `${Math.round(payload.duration / 1000)}s` : 'unknown';
|
|
377
|
+
const text = `Job completed: ${payload.jobName}`;
|
|
378
|
+
return {
|
|
379
|
+
text,
|
|
380
|
+
blocks: [
|
|
381
|
+
{
|
|
382
|
+
type: 'section',
|
|
383
|
+
text: {
|
|
384
|
+
type: 'mrkdwn',
|
|
385
|
+
text: `:white_check_mark: *Job Completed*\n*${payload.jobName}*\nThe scheduled job finished successfully.`,
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
type: 'context',
|
|
390
|
+
elements: [
|
|
391
|
+
{
|
|
392
|
+
type: 'mrkdwn',
|
|
393
|
+
text: `Duration: ${duration} | ID: \`${payload.jobId}\``,
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
formatJobFailed(payload) {
|
|
401
|
+
const text = `Job failed: ${payload.jobName}`;
|
|
402
|
+
return {
|
|
403
|
+
text,
|
|
404
|
+
blocks: [
|
|
405
|
+
{
|
|
406
|
+
type: 'section',
|
|
407
|
+
text: {
|
|
408
|
+
type: 'mrkdwn',
|
|
409
|
+
text: `:x: *Job Failed*\n*${payload.jobName}*\nThe scheduled job encountered an error and did not complete.`,
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
type: 'context',
|
|
414
|
+
elements: [
|
|
415
|
+
{
|
|
416
|
+
type: 'mrkdwn',
|
|
417
|
+
text: `ID: \`${payload.jobId}\`${payload.error ? `\nError: ${payload.error.slice(0, 200)}` : ''}`,
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
formatGitPush(payload) {
|
|
425
|
+
const text = `Pushed to ${payload.branch}`;
|
|
426
|
+
return {
|
|
427
|
+
text,
|
|
428
|
+
blocks: [
|
|
429
|
+
{
|
|
430
|
+
type: 'section',
|
|
431
|
+
text: {
|
|
432
|
+
type: 'mrkdwn',
|
|
433
|
+
text: `:arrow_up: *Git Push*\nPushed to \`${payload.branch}\`\nCode changes have been pushed to the remote repository.`,
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
type: 'context',
|
|
438
|
+
elements: [
|
|
439
|
+
{
|
|
440
|
+
type: 'mrkdwn',
|
|
441
|
+
text: payload.commitCount ? `${payload.commitCount} commit(s)` : 'Commits pushed',
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
},
|
|
445
|
+
],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
formatGitMerge(payload) {
|
|
449
|
+
const text = `Merged ${payload.branch} into ${payload.targetBranch}`;
|
|
450
|
+
return {
|
|
451
|
+
text,
|
|
452
|
+
blocks: [
|
|
453
|
+
{
|
|
454
|
+
type: 'section',
|
|
455
|
+
text: {
|
|
456
|
+
type: 'mrkdwn',
|
|
457
|
+
text: `:twisted_rightwards_arrows: *Git Merge*\nMerged \`${payload.branch}\` into \`${payload.targetBranch || 'target'}\`\nBranches have been combined successfully.`,
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
type: 'context',
|
|
462
|
+
elements: [
|
|
463
|
+
{
|
|
464
|
+
type: 'mrkdwn',
|
|
465
|
+
text: payload.message || 'Merge completed',
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
getStatusEmoji(status) {
|
|
473
|
+
switch (status) {
|
|
474
|
+
case 'approved':
|
|
475
|
+
return ':white_check_mark:';
|
|
476
|
+
case 'merged':
|
|
477
|
+
return ':tada:';
|
|
478
|
+
case 'rejected':
|
|
479
|
+
return ':no_entry:';
|
|
480
|
+
case 'in-review':
|
|
481
|
+
return ':eyes:';
|
|
482
|
+
case 'proposed':
|
|
483
|
+
return ':raising_hand:';
|
|
484
|
+
case 'draft':
|
|
485
|
+
return ':pencil2:';
|
|
486
|
+
default:
|
|
487
|
+
return ':arrows_counterclockwise:';
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// Clear cached state (useful for testing or config changes)
|
|
491
|
+
clearCache() {
|
|
492
|
+
this.config = null;
|
|
493
|
+
this.client = null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Singleton instance
|
|
497
|
+
let slackServiceInstance = null;
|
|
498
|
+
export function getSlackService() {
|
|
499
|
+
if (!slackServiceInstance) {
|
|
500
|
+
slackServiceInstance = new SlackService();
|
|
501
|
+
}
|
|
502
|
+
return slackServiceInstance;
|
|
503
|
+
}
|
|
504
|
+
export { SlackService };
|
|
@@ -7,6 +7,7 @@ import { resolveCoconutId, resolveControlPlaneUrl } from '../../../../lib/coconu
|
|
|
7
7
|
import { loadGitSettings, saveGitSettings, isGitAuthMode } from '../../../../lib/git-settings.js';
|
|
8
8
|
import { getLogger, CodeKinds } from '@lovelybunch/core/logging';
|
|
9
9
|
import { requireAuth } from '../../../../middleware/auth.js';
|
|
10
|
+
import { getSlackService } from '../../../../lib/slack/slack-service.js';
|
|
10
11
|
const app = new Hono();
|
|
11
12
|
// Settings
|
|
12
13
|
app.get('/settings', async (c) => {
|
|
@@ -330,7 +331,18 @@ app.post('/branches/:branch/merge', async (c) => {
|
|
|
330
331
|
}
|
|
331
332
|
catch { }
|
|
332
333
|
const mergeStrategy = strategy?.strategy === 'squash' || strategy?.strategy === 'rebase' ? strategy.strategy : 'merge';
|
|
334
|
+
// Get current branch (target) before merge
|
|
335
|
+
const { runGit } = await import('../../../../lib/git.js');
|
|
336
|
+
const { stdout: branchOutput } = await runGit(['branch', '--show-current']);
|
|
337
|
+
const targetBranch = branchOutput.trim();
|
|
333
338
|
const result = await mergeBranch(name, mergeStrategy);
|
|
339
|
+
// Send Slack notification (non-blocking)
|
|
340
|
+
getSlackService().sendNotification({
|
|
341
|
+
type: 'git.merge',
|
|
342
|
+
branch: name,
|
|
343
|
+
targetBranch,
|
|
344
|
+
message: `Merged ${name} into ${targetBranch} using ${mergeStrategy}`,
|
|
345
|
+
}).catch(err => console.warn('[git] Slack notification failed:', err));
|
|
334
346
|
return c.json({ success: true, data: { branch: name, strategy: mergeStrategy, result } });
|
|
335
347
|
}
|
|
336
348
|
catch (e) {
|
|
@@ -466,6 +478,11 @@ app.post('/push', async (c) => {
|
|
|
466
478
|
remote: remoteName,
|
|
467
479
|
}
|
|
468
480
|
});
|
|
481
|
+
// Send Slack notification (non-blocking)
|
|
482
|
+
getSlackService().sendNotification({
|
|
483
|
+
type: 'git.push',
|
|
484
|
+
branch: currentBranch,
|
|
485
|
+
}).catch(err => console.warn('[git] Slack notification failed:', err));
|
|
469
486
|
}
|
|
470
487
|
catch (logError) {
|
|
471
488
|
console.error('Error logging push:', logError);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FileStorageAdapter } from '../../../../../lib/storage/file-storage.js';
|
|
2
2
|
import { getLogger } from '@lovelybunch/core/logging';
|
|
3
|
+
import { getSlackService } from '../../../../../lib/slack/slack-service.js';
|
|
3
4
|
const storage = new FileStorageAdapter();
|
|
4
5
|
// Logger is lazily initialized inside handlers to use server config
|
|
5
6
|
export async function GET(c) {
|
|
@@ -90,6 +91,14 @@ export async function PATCH(c) {
|
|
|
90
91
|
reason: null
|
|
91
92
|
}
|
|
92
93
|
});
|
|
94
|
+
// Send Slack notification for status change (non-blocking)
|
|
95
|
+
getSlackService().sendNotification({
|
|
96
|
+
type: 'proposal.statusChange',
|
|
97
|
+
proposalId: id,
|
|
98
|
+
intent: updatedProposal?.intent || existing.intent,
|
|
99
|
+
status: newStatus,
|
|
100
|
+
previousStatus: oldStatus,
|
|
101
|
+
}).catch(err => console.warn('[proposals] Slack notification failed:', err));
|
|
93
102
|
}
|
|
94
103
|
return c.json({
|
|
95
104
|
success: true,
|