@@ -32,6 +32,26 @@ const (
3232 // AnnotationGitHubReportPhase records the last Task phase that was
3333 // reported to GitHub, preventing duplicate API calls on re-list.
3434 AnnotationGitHubReportPhase = "kelos.dev/github-report-phase"
35+
36+ // AnnotationSlackReporting indicates that Slack reporting is enabled
37+ // for this Task.
38+ AnnotationSlackReporting = "kelos.dev/slack-reporting"
39+
40+ // AnnotationSlackChannel records the Slack channel ID where the
41+ // originating message was posted.
42+ AnnotationSlackChannel = "kelos.dev/slack-channel"
43+
44+ // AnnotationSlackThreadTS records the originating message timestamp,
45+ // used as thread_ts for posting replies.
46+ AnnotationSlackThreadTS = "kelos.dev/slack-thread-ts"
47+
48+ // AnnotationSlackReplyTS stores the message timestamp of the status
49+ // reply so subsequent updates edit the same message.
50+ AnnotationSlackReplyTS = "kelos.dev/slack-reply-ts"
51+
52+ // AnnotationSlackReportPhase records the last Task phase that was
53+ // reported to Slack, preventing duplicate API calls on re-list.
54+ AnnotationSlackReportPhase = "kelos.dev/slack-report-phase"
3555)
3656
3757// TaskReporter watches Tasks and reports status changes to GitHub.
@@ -156,3 +176,107 @@ func (tr *TaskReporter) persistReportingState(ctx context.Context, task *kelosv1
156176
157177 return nil
158178}
179+
180+ // SlackTaskReporter watches Tasks and reports status changes to Slack
181+ // as thread replies on the originating message.
182+ type SlackTaskReporter struct {
183+ Client client.Client
184+ Reporter * SlackReporter
185+ }
186+
187+ // ReportTaskStatus checks a Task's current phase against its last reported
188+ // phase and creates or updates the Slack thread reply as needed.
189+ func (tr * SlackTaskReporter ) ReportTaskStatus (ctx context.Context , task * kelosv1alpha1.Task ) error {
190+ log := ctrl .Log .WithName ("slack-reporter" )
191+
192+ annotations := task .Annotations
193+ if annotations == nil {
194+ return nil
195+ }
196+
197+ if annotations [AnnotationSlackReporting ] != "enabled" {
198+ return nil
199+ }
200+
201+ channel := annotations [AnnotationSlackChannel ]
202+ threadTS := annotations [AnnotationSlackThreadTS ]
203+ if channel == "" || threadTS == "" {
204+ return nil
205+ }
206+
207+ var desiredPhase string
208+ switch task .Status .Phase {
209+ case kelosv1alpha1 .TaskPhasePending , kelosv1alpha1 .TaskPhaseRunning , kelosv1alpha1 .TaskPhaseWaiting :
210+ desiredPhase = "accepted"
211+ case kelosv1alpha1 .TaskPhaseSucceeded :
212+ desiredPhase = "succeeded"
213+ case kelosv1alpha1 .TaskPhaseFailed :
214+ desiredPhase = "failed"
215+ default :
216+ return nil
217+ }
218+
219+ if annotations [AnnotationSlackReportPhase ] == desiredPhase {
220+ return nil
221+ }
222+
223+ var body string
224+ switch desiredPhase {
225+ case "accepted" :
226+ body = FormatSlackAccepted (task .Name )
227+ case "succeeded" :
228+ body = FormatSlackSucceeded (task .Name )
229+ case "failed" :
230+ body = FormatSlackFailed (task .Name )
231+ }
232+
233+ replyTS := annotations [AnnotationSlackReplyTS ]
234+ if replyTS == "" {
235+ log .Info ("Posting Slack thread reply" , "task" , task .Name , "channel" , channel , "phase" , desiredPhase )
236+ newTS , err := tr .Reporter .PostThreadReply (ctx , channel , threadTS , body )
237+ if err != nil {
238+ return fmt .Errorf ("posting Slack reply for task %s: %w" , task .Name , err )
239+ }
240+ replyTS = newTS
241+ } else {
242+ log .Info ("Updating Slack thread reply" , "task" , task .Name , "channel" , channel , "phase" , desiredPhase )
243+ if err := tr .Reporter .UpdateMessage (ctx , channel , replyTS , body ); err != nil {
244+ return fmt .Errorf ("updating Slack reply for task %s: %w" , task .Name , err )
245+ }
246+ }
247+
248+ if err := tr .persistSlackReportingState (ctx , task , replyTS , desiredPhase ); err != nil {
249+ return err
250+ }
251+
252+ return nil
253+ }
254+
255+ func (tr * SlackTaskReporter ) persistSlackReportingState (ctx context.Context , task * kelosv1alpha1.Task , replyTS , desiredPhase string ) error {
256+ if err := retry .RetryOnConflict (retry .DefaultRetry , func () error {
257+ var current kelosv1alpha1.Task
258+ if err := tr .Client .Get (ctx , client .ObjectKeyFromObject (task ), & current ); err != nil {
259+ return err
260+ }
261+
262+ if current .Annotations == nil {
263+ current .Annotations = make (map [string ]string )
264+ }
265+ current .Annotations [AnnotationSlackReplyTS ] = replyTS
266+ current .Annotations [AnnotationSlackReportPhase ] = desiredPhase
267+
268+ if err := tr .Client .Update (ctx , & current ); err != nil {
269+ return err
270+ }
271+
272+ task .Annotations = current .Annotations
273+ return nil
274+ }); err != nil {
275+ if apierrors .IsNotFound (err ) {
276+ return fmt .Errorf ("persisting Slack reporting annotations on task %s: task no longer exists" , task .Name )
277+ }
278+ return fmt .Errorf ("persisting Slack reporting annotations on task %s: %w" , task .Name , err )
279+ }
280+
281+ return nil
282+ }
0 commit comments