From 61d1e67a4c2dac789bb8edb9f48687651e5c0560 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Wed, 22 Apr 2026 11:36:56 +0200 Subject: [PATCH 1/2] feat(discord/build): group image tags into main/minimal sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort manifest variants deterministically (beacon → validator → beacon-minimal → validator-minimal) so the "primary" image on the completion DM is no longer whichever variant Go's map iterator yielded first. Inline every tag into the embed as its own fenced code block, grouped into "Images" and "Minimal images" fields. This removes the Copy tags button and the completed-build cache that only existed to back it; each tag keeps a per-block copy icon on hover. Single-manifest clients (Geth, Reth, etc.) render as one code block under "Images" with no minimal section. --- pkg/discord/cmd/build/command.go | 7 - pkg/discord/cmd/build/watcher.go | 186 +++++----------------- pkg/discord/cmd/build/workflow_fetcher.go | 29 ++++ 3 files changed, 69 insertions(+), 153 deletions(-) diff --git a/pkg/discord/cmd/build/command.go b/pkg/discord/cmd/build/command.go index a1a635e..47172a8 100644 --- a/pkg/discord/cmd/build/command.go +++ b/pkg/discord/cmd/build/command.go @@ -211,13 +211,6 @@ func (c *BuildCommand) UpdateChoices(session *discordgo.Session) error { return nil } -// HandleComponent handles button and select-menu interactions that originate -// from the build completion DM. It's wired from bot.handleInteraction for any -// component custom_id that starts with "build:". -func (c *BuildCommand) HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { - c.watcher.HandleComponent(s, i) -} - // Handle handles the /build command. func (c *BuildCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { if i.Type == discordgo.InteractionApplicationCommandAutocomplete { diff --git a/pkg/discord/cmd/build/watcher.go b/pkg/discord/cmd/build/watcher.go index a3e090d..66de7e9 100644 --- a/pkg/discord/cmd/build/watcher.go +++ b/pkg/discord/cmd/build/watcher.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" "strings" "sync" "time" @@ -23,10 +22,6 @@ const ( claimPollInterval = 2 * time.Second // runTimeout is an upper bound after which we stop watching a build. runTimeout = 3 * time.Hour - // completedRetention is how long we keep a build's resolved images around so - // that Copy-button and dropdown interactions from the DM still work after the - // notification has been sent. - completedRetention = 2 * time.Hour ) // ClaimRequest captures everything the watcher needs to resolve the set of @@ -60,15 +55,6 @@ type trackedBuild struct { manifests []ManifestInfo } -// completedBuild retains the resolved image list for a completed run so the -// DM's Copy button and dropdown can respond with the exact tags even after -// the poller has untracked the run. -type completedBuild struct { - targetDisplay string - images []dockerImage - expiresAt time.Time -} - // workflowRun is the subset of the GitHub workflow run payload we use. // //nolint:tagliatelle // Github defined structure. @@ -107,9 +93,8 @@ type BuildWatcher struct { httpClient *http.Client githubToken string - mu sync.Mutex - tracks map[int64]*trackedBuild - completed map[int64]*completedBuild + mu sync.Mutex + tracks map[int64]*trackedBuild wg sync.WaitGroup } @@ -122,7 +107,6 @@ func NewBuildWatcher(log logrus.FieldLogger, session *discordgo.Session, client httpClient: client, githubToken: githubToken, tracks: make(map[int64]*trackedBuild, 8), - completed: make(map[int64]*completedBuild, 8), } } @@ -310,7 +294,6 @@ func (w *BuildWatcher) tickOnce(ctx context.Context) { w.finalize(ctx, b, run.Conclusion) } - w.sweepCompleted() } func (w *BuildWatcher) finalize(ctx context.Context, b *trackedBuild, conclusion string) { @@ -320,14 +303,6 @@ func (w *BuildWatcher) finalize(ctx context.Context, b *trackedBuild, conclusion w.mu.Lock() delete(w.tracks, b.runID) - - if len(images) > 0 { - w.completed[b.runID] = &completedBuild{ - targetDisplay: b.targetDisplay, - images: images, - expiresAt: time.Now().Add(completedRetention), - } - } w.mu.Unlock() } @@ -431,37 +406,6 @@ func (w *BuildWatcher) fetchJobs(ctx context.Context, runID int64) ([]workflowJo return body.Jobs, nil } -func (w *BuildWatcher) sweepCompleted() { - w.mu.Lock() - defer w.mu.Unlock() - - now := time.Now() - for id, c := range w.completed { - if now.After(c.expiresAt) { - delete(w.completed, id) - } - } -} - -// lookupCompleted returns the retained images for a completed run, if any. -func (w *BuildWatcher) lookupCompleted(runID int64) (*completedBuild, bool) { - w.mu.Lock() - defer w.mu.Unlock() - - c, ok := w.completed[runID] - if !ok { - return nil, false - } - - if time.Now().After(c.expiresAt) { - delete(w.completed, runID) - - return nil, false - } - - return c, true -} - func (w *BuildWatcher) notify(b *trackedBuild, conclusion string, images []dockerImage) { channel, err := w.session.UserChannelCreate(b.userID) if err != nil { @@ -471,11 +415,9 @@ func (w *BuildWatcher) notify(b *trackedBuild, conclusion string, images []docke } embed := buildCompletionEmbed(b, conclusion, images) - components := buildCompletionComponents(b.runID, images) send := &discordgo.MessageSend{ - Embeds: []*discordgo.MessageEmbed{embed}, - Components: components, + Embeds: []*discordgo.MessageEmbed{embed}, } if _, err := w.session.ChannelMessageSendComplex(channel.ID, send); err != nil { @@ -483,7 +425,9 @@ func (w *BuildWatcher) notify(b *trackedBuild, conclusion string, images []docke } } -// buildCompletionEmbed builds the DM embed shown to the invoker. +// buildCompletionEmbed builds the DM embed shown to the invoker. Image tags +// are inlined as fenced code blocks so each can be copied via Discord's +// per-block copy icon, and are grouped into main and minimal sections. func buildCompletionEmbed(b *trackedBuild, conclusion string, images []dockerImage) *discordgo.MessageEmbed { embed := &discordgo.MessageEmbed{ Title: fmt.Sprintf("%s Build %s: %s", conclusionEmoji(conclusion), conclusionLabel(conclusion), b.targetDisplay), @@ -503,78 +447,56 @@ func buildCompletionEmbed(b *trackedBuild, conclusion string, images []dockerIma Timestamp: time.Now().Format(time.RFC3339), } - if len(images) > 0 { - primary := images[0] + main, minimal := partitionImages(images) + + if value := formatImageBlocks(main); value != "" { embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ - Name: "Image", - Value: fmt.Sprintf("[`%s`](%s)", primary.Reference(), primary.HubURL()), + Name: "Images", + Value: value, Inline: false, }) } - return embed -} - -// buildCompletionComponents returns the copy button that accompanies the DM. -// Clicking it produces an ephemeral reply with one code block per image tag, -// so each tag can be copied independently via Discord's per-block copy icon. -func buildCompletionComponents(runID int64, images []dockerImage) []discordgo.MessageComponent { - if len(images) == 0 { - return nil - } - - label := "Copy tag" - if len(images) > 1 { - label = "Copy tags" + if value := formatImageBlocks(minimal); value != "" { + embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ + Name: "Minimal images", + Value: value, + Inline: false, + }) } - return []discordgo.MessageComponent{ - discordgo.ActionsRow{ - Components: []discordgo.MessageComponent{ - discordgo.Button{ - Label: label, - Style: discordgo.PrimaryButton, - Emoji: &discordgo.ComponentEmoji{Name: "📋"}, - CustomID: fmt.Sprintf("build:copy:%d", runID), - }, - }, - }, - } + return embed } -// HandleComponent responds to component interactions dispatched from the DM. -func (w *BuildWatcher) HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { - if i.Type != discordgo.InteractionMessageComponent { - return - } - - data := i.MessageComponentData() - - runID, ok := parseComponentID(data.CustomID) - if !ok { - w.respondEphemeral(s, i, "Sorry, couldn't decode that interaction.") - - return - } - - completed, ok := w.lookupCompleted(runID) - if !ok { - w.respondEphemeral(s, i, "This build is no longer in cache — open the image on Docker Hub from the link above.") - - return +// partitionImages splits images into main and minimal groups based on whether +// the manifest variant carries the "minimal" marker. +func partitionImages(images []dockerImage) (main, minimal []dockerImage) { + main = make([]dockerImage, 0, len(images)) + minimal = make([]dockerImage, 0, len(images)) + + for _, img := range images { + if strings.Contains(img.Variant, "minimal") { + minimal = append(minimal, img) + } else { + main = append(main, img) + } } - if len(completed.images) == 0 { - w.respondEphemeral(s, i, "No images produced for this build.") + return main, minimal +} - return +// formatImageBlocks renders each image as its own fenced code block so +// Discord shows a per-block copy icon on hover. +func formatImageBlocks(images []dockerImage) string { + if len(images) == 0 { + return "" } var buf strings.Builder - buf.Grow(len(completed.images) * 64) + buf.Grow(len(images) * 64) - for idx, img := range completed.images { + for idx, img := range images { if idx > 0 { buf.WriteByte('\n') } @@ -582,35 +504,7 @@ func (w *BuildWatcher) HandleComponent(s *discordgo.Session, i *discordgo.Intera fmt.Fprintf(&buf, "```\n%s\n```", img.Reference()) } - w.respondEphemeral(s, i, buf.String()) -} - -// parseComponentID parses a custom_id of the form "build:copy:{runID}" -// produced by buildCompletionComponents. -func parseComponentID(customID string) (int64, bool) { - parts := strings.Split(customID, ":") - if len(parts) != 3 || parts[0] != "build" || parts[1] != "copy" { - return 0, false - } - - runID, err := strconv.ParseInt(parts[2], 10, 64) - if err != nil { - return 0, false - } - - return runID, true -} - -func (w *BuildWatcher) respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, content string) { - if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: content, - Flags: discordgo.MessageFlagsEphemeral, - }, - }); err != nil { - w.log.WithError(err).Warn("Failed to respond to component interaction") - } + return buf.String() } func conclusionEmoji(conclusion string) string { diff --git a/pkg/discord/cmd/build/workflow_fetcher.go b/pkg/discord/cmd/build/workflow_fetcher.go index f79d51e..a837b20 100644 --- a/pkg/discord/cmd/build/workflow_fetcher.go +++ b/pkg/discord/cmd/build/workflow_fetcher.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "net/http" + "sort" "strings" "sync" "time" @@ -408,9 +409,37 @@ func extractManifests(jobs map[string]WorkflowJob) []ManifestInfo { } } + sort.SliceStable(manifests, func(i, j int) bool { + ri, rj := manifestRank(manifests[i].Variant), manifestRank(manifests[j].Variant) + if ri != rj { + return ri < rj + } + + return manifests[i].Variant < manifests[j].Variant + }) + return manifests } +// manifestRank returns a sort priority for a manifest variant so that related +// images are printed in a predictable order: main beacon first, then validator, +// then their minimal counterparts, with unknown variants falling through to +// alphabetical ordering at the end. +func manifestRank(variant string) int { + switch variant { + case "", "beacon": + return 0 + case "validator": + return 1 + case "beacon-minimal": + return 2 + case "validator-minimal": + return 3 + default: + return 4 + } +} + // findTagSuffix returns the literal text that follows // ${{ needs.prepare.outputs.target_tag }} inside a target_tag expression. It // rejects expressions that still contain dynamic interpolations (such as From 3d44389dce0d581d4f6de9dd35c0d065749a1ccd Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Wed, 22 Apr 2026 11:38:20 +0200 Subject: [PATCH 2/2] fix(discord/build): drop stray blank line left from tickOnce cleanup --- pkg/discord/cmd/build/watcher.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/discord/cmd/build/watcher.go b/pkg/discord/cmd/build/watcher.go index 66de7e9..f7c70cb 100644 --- a/pkg/discord/cmd/build/watcher.go +++ b/pkg/discord/cmd/build/watcher.go @@ -293,7 +293,6 @@ func (w *BuildWatcher) tickOnce(ctx context.Context) { w.finalize(ctx, b, run.Conclusion) } - } func (w *BuildWatcher) finalize(ctx context.Context, b *trackedBuild, conclusion string) {