@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.
Files changed (95) hide show
  1. package/dist/lib/jobs/job-scheduler.js +17 -0
  2. package/dist/lib/slack/slack-service.d.ts +89 -0
  3. package/dist/lib/slack/slack-service.js +504 -0
  4. package/dist/routes/api/v1/git/index.js +17 -0
  5. package/dist/routes/api/v1/proposals/[id]/route.js +9 -0
  6. package/dist/routes/api/v1/proposals/route.js +8 -0
  7. package/dist/routes/api/v1/resources/generate/route.js +24 -3
  8. package/dist/routes/api/v1/slack/index.d.ts +3 -0
  9. package/dist/routes/api/v1/slack/index.js +15 -0
  10. package/dist/routes/api/v1/slack/route.d.ts +122 -0
  11. package/dist/routes/api/v1/slack/route.js +189 -0
  12. package/dist/server-with-static.js +2 -0
  13. package/dist/server.js +2 -0
  14. package/package.json +5 -4
  15. package/static/assets/{AgentDetailPage-CZ2tz-Ol.js → AgentDetailPage-CS4l_2nY.js} +1 -1
  16. package/static/assets/{AgentEditPage-BiAoWU1z.js → AgentEditPage-mvchfGaI.js} +1 -1
  17. package/static/assets/{AgentsPage-D_HMA-40.js → AgentsPage-D4JsS7kC.js} +1 -1
  18. package/static/assets/{AgentsSettingsPage-C5ZsOVSL.js → AgentsSettingsPage-CPZm3ECa.js} +1 -1
  19. package/static/assets/{ApiKeysSettingsPage-C7Xlzj-X.js → ApiKeysSettingsPage-BmGRl-Cr.js} +1 -1
  20. package/static/assets/{ArchitectureEditPage-B9nVQn0B.js → ArchitectureEditPage-CyAu4lkT.js} +1 -1
  21. package/static/assets/{ArchitecturePage-CChIC6Qa.js → ArchitecturePage-BEVC5igc.js} +1 -1
  22. package/static/assets/{AuthSettingsPage-CaeV2cQ4.js → AuthSettingsPage-BfxPusvb.js} +1 -1
  23. package/static/assets/{CallbackPage-DmeStBSx.js → CallbackPage-C7QN2xcl.js} +1 -1
  24. package/static/assets/{CodePage-BtKkipWC.js → CodePage-DoP5IiuI.js} +1 -1
  25. package/static/assets/{CollapsibleSection-Cb80fCCb.js → CollapsibleSection-DpcF5jIr.js} +1 -1
  26. package/static/assets/{DashboardPage-DdApq_B-.js → DashboardPage-D0EoXiL2.js} +1 -1
  27. package/static/assets/{GitPage-D2aJfzTq.js → GitPage-DVCWmjb1.js} +1 -1
  28. package/static/assets/{GitSettingsPage-Bz17VWrK.js → GitSettingsPage-CA-NVPgC.js} +1 -1
  29. package/static/assets/{IdentityPage-O7o2b4JB.js → IdentityPage-yBi2meP8.js} +1 -1
  30. package/static/assets/{ImplementationStepsEditor-BkJjQBc-.js → ImplementationStepsEditor-CdFjqsQp.js} +1 -1
  31. package/static/assets/{IntegrationsSettingsPage-brFTXUBL.js → IntegrationsSettingsPage-4NWwhHlm.js} +1 -1
  32. package/static/assets/{KnowledgeDetailPage-e5OpRrZ2.js → KnowledgeDetailPage-mBBrz-_N.js} +1 -1
  33. package/static/assets/{KnowledgeEditPage-DgyPoIJv.js → KnowledgeEditPage-DqTY1Y3C.js} +1 -1
  34. package/static/assets/{KnowledgePage-Ck_FeruK.js → KnowledgePage-DonVTg_6.js} +1 -1
  35. package/static/assets/{LoginPage-IqcnIlMj.js → LoginPage-DFpCcG6v.js} +1 -1
  36. package/static/assets/{McpSettingsPage-KJBLTelP.js → McpSettingsPage-jXN9kO3i.js} +1 -1
  37. package/static/assets/{NewAgentPage-DLIvDpeZ.js → NewAgentPage-BNojru16.js} +1 -1
  38. package/static/assets/{NewKnowledgePage-xBm9PTKB.js → NewKnowledgePage-D93ERPBo.js} +1 -1
  39. package/static/assets/{NewProposalPage-BlocVyGv.js → NewProposalPage-BBmHfu-z.js} +1 -1
  40. package/static/assets/NotificationsSettingsPage-D6oOIvep.js +1 -0
  41. package/static/assets/{ProjectEditPage-BlA-908F.js → ProjectEditPage-kXy3gxS3.js} +1 -1
  42. package/static/assets/{ProjectPage-Dj-tc-ZE.js → ProjectPage-D3vvfNxI.js} +1 -1
  43. package/static/assets/{PromptsSettingsPage-BvU01T7L.js → PromptsSettingsPage-BNQrbBn5.js} +1 -1
  44. package/static/assets/{ProposalDetailPage-_8Wn6NJt.js → ProposalDetailPage-CMgZUcxP.js} +1 -1
  45. package/static/assets/{ProposalEditPage-BbbWkAuu.js → ProposalEditPage-Bn_1OJ11.js} +1 -1
  46. package/static/assets/{ProposalsPage-erDBdH7Z.js → ProposalsPage-CysWDXyl.js} +1 -1
  47. package/static/assets/ResourcesPage-QD68AoOk.js +71 -0
  48. package/static/assets/{RulesSettingsPage-DurFFm98.js → RulesSettingsPage-DQvhq7o0.js} +1 -1
  49. package/static/assets/{SchedulePage-BAnM4Lu5.js → SchedulePage-CghrKKJB.js} +1 -1
  50. package/static/assets/{SourceInput-7lTShZ6_.js → SourceInput-BU8xQMbc.js} +1 -1
  51. package/static/assets/{TagInput-2_jYxO52.js → TagInput-C1XF1z9l.js} +1 -1
  52. package/static/assets/{TerminalPage-CRVEtFXr.js → TerminalPage-BfNa9oLV.js} +1 -1
  53. package/static/assets/{TerminalSessionPage-BjvQegW1.js → TerminalSessionPage-BkuO3Dlc.js} +3 -8
  54. package/static/assets/{UserPreferencesPage-C5ueVUwG.js → UserPreferencesPage-DWsDPJWa.js} +1 -1
  55. package/static/assets/{UserSettingsPage-DhOu8xZ3.js → UserSettingsPage-Czyu--ZE.js} +1 -1
  56. package/static/assets/{UtilitiesPage-olcchfEX.js → UtilitiesPage-BL3r6jt4.js} +1 -1
  57. package/static/assets/{alert-bP1PxakB.js → alert-yDv7BoVY.js} +1 -1
  58. package/static/assets/{arrow-down-BoBPr1Sn.js → arrow-down-B6mDQzhv.js} +1 -1
  59. package/static/assets/{arrow-left-orDiJ2Pw.js → arrow-left-oYBMYRNP.js} +1 -1
  60. package/static/assets/{arrow-up-CCloQpeV.js → arrow-up-BtNT59gW.js} +1 -1
  61. package/static/assets/{badge-D7styiB7.js → badge-A8sRXZ1m.js} +1 -1
  62. package/static/assets/{browser-modal-n0MeSpgA.js → browser-modal-DcxLC6CX.js} +1 -1
  63. package/static/assets/{calendar-w3geg78-.js → calendar-C6_Zaz3s.js} +1 -1
  64. package/static/assets/{card-pzUJtmwJ.js → card-Brl-P7qu.js} +1 -1
  65. package/static/assets/{chevron-left-B6nXpDLi.js → chevron-left-BsJmB9OE.js} +1 -1
  66. package/static/assets/{circle-alert-BcFpY-ZU.js → circle-alert-BYXOF-Yd.js} +1 -1
  67. package/static/assets/{circle-check-HRharHjy.js → circle-check-BISWYf84.js} +1 -1
  68. package/static/assets/{circle-check-big-B2CFEks4.js → circle-check-big-GdURlCCT.js} +1 -1
  69. package/static/assets/{circle-play-CfrNAC9J.js → circle-play-D14kkelg.js} +1 -1
  70. package/static/assets/{circle-x-CNn7_0Ew.js → circle-x-CWvfwx75.js} +1 -1
  71. package/static/assets/{clipboard-LaXihY2m.js → clipboard-CQ1MeyX5.js} +1 -1
  72. package/static/assets/{clock-By2Dd4u0.js → clock-C7sCX_DE.js} +1 -1
  73. package/static/assets/{download--HiFU7TR.js → download-BjE8JxlU.js} +1 -1
  74. package/static/assets/{eye-DorygWtP.js → eye-2hl27c7R.js} +1 -1
  75. package/static/assets/{folder-git-2-B8bjLoxc.js → folder-git-2-BBlVIDmr.js} +1 -1
  76. package/static/assets/index-BJVvhEWY.css +2 -0
  77. package/static/assets/{index-BfJaT17z.js → index-D_2ZhRwL.js} +91 -86
  78. package/static/assets/{label-CTlQtJaU.js → label-DA737D6X.js} +1 -1
  79. package/static/assets/{markdown-editor-BOcltq2r.js → markdown-editor-BhYm394v.js} +1 -1
  80. package/static/assets/message-square-DV1_7h3v.js +6 -0
  81. package/static/assets/{pause-D33aM-oa.js → pause-DS_ZgX9w.js} +1 -1
  82. package/static/assets/{play-B6IdXHPj.js → play-Cgu7MRJC.js} +1 -1
  83. package/static/assets/{plus-DhYjijTS.js → plus-RZwwEJRO.js} +1 -1
  84. package/static/assets/{radio-group-B-Rc5x_L.js → radio-group-CzZoufFM.js} +1 -1
  85. package/static/assets/{refresh-cw-BuuX9h4Z.js → refresh-cw-BJ2GIM1Z.js} +1 -1
  86. package/static/assets/{search-BiMN-o92.js → search-B2ODQzPd.js} +1 -1
  87. package/static/assets/{switch-vSV_roZ2.js → switch-AdqXRjK1.js} +1 -1
  88. package/static/assets/{tabs-ChuwGq16.js → tabs-DnIPixK3.js} +1 -1
  89. package/static/assets/{tag-DERlGH35.js → tag-C4UgL0Z_.js} +1 -1
  90. package/static/assets/{terminal-preview-BFedy4-J.js → terminal-preview-PYoJzFpP.js} +1 -1
  91. package/static/assets/{use-terminal-qzkum-B5.js → use-terminal-qsHkIOJb.js} +1 -1
  92. package/static/assets/{zap-DwFz_ltU.js → zap-CpNZOBUL.js} +1 -1
  93. package/static/index.html +2 -2
  94. package/static/assets/ResourcesPage-B1uF-EA-.js +0 -71
  95. 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,