From aced70141bb5fc75a95a758b54ce8e88713043c4 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Tue, 21 Apr 2026 10:04:32 +0200 Subject: [PATCH 1/2] feat(discord/build): one code block per tag in copy reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "Copy tag"/"Copy tags" now returns an ephemeral reply with each image tag in its own triple-backtick block, so Discord's per-block copy icon copies a single tag — no more duplicated link+block and no risk of grabbing every tag at once. The "Show another image…" dropdown is removed since all tags are visible in the reply. --- pkg/discord/cmd/build/image.go | 9 --- pkg/discord/cmd/build/watcher.go | 119 ++++++++++--------------------- 2 files changed, 36 insertions(+), 92 deletions(-) diff --git a/pkg/discord/cmd/build/image.go b/pkg/discord/cmd/build/image.go index f82181f..ba249de 100644 --- a/pkg/discord/cmd/build/image.go +++ b/pkg/discord/cmd/build/image.go @@ -23,15 +23,6 @@ func (i dockerImage) HubURL() string { return fmt.Sprintf("https://hub.docker.com/r/%s/tags?name=%s", i.Repository, i.Tag) } -// Label returns a short user-facing label for dropdown options. -func (i dockerImage) Label() string { - if i.Variant == "" { - return i.Reference() - } - - return fmt.Sprintf("%s (%s)", i.Reference(), i.Variant) -} - var ( dockerTagInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9._]`) dockerTagLeadingDashes = regexp.MustCompile(`^-+`) diff --git a/pkg/discord/cmd/build/watcher.go b/pkg/discord/cmd/build/watcher.go index adf937e..a3e090d 100644 --- a/pkg/discord/cmd/build/watcher.go +++ b/pkg/discord/cmd/build/watcher.go @@ -515,60 +515,31 @@ func buildCompletionEmbed(b *trackedBuild, conclusion string, images []dockerIma return embed } -// buildCompletionComponents returns the copy button + (optional) select menu -// that accompany the DM. +// 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 } - components := make([]discordgo.MessageComponent, 0, 2) - - components = append(components, discordgo.ActionsRow{ - Components: []discordgo.MessageComponent{ - discordgo.Button{ - Label: "Copy tag", - Style: discordgo.PrimaryButton, - Emoji: &discordgo.ComponentEmoji{Name: "📋"}, - CustomID: fmt.Sprintf("build:copy:%d:0", runID), - }, - }, - }) - + label := "Copy tag" if len(images) > 1 { - options := make([]discordgo.SelectMenuOption, 0, len(images)) - - for idx, img := range images { - description := img.Reference() - if len(description) > 100 { - description = description[:97] + "..." - } - - label := img.Label() - if len(label) > 100 { - label = label[:97] + "..." - } - - options = append(options, discordgo.SelectMenuOption{ - Label: label, - Value: strconv.Itoa(idx), - Description: description, - }) - } + label = "Copy tags" + } - components = append(components, discordgo.ActionsRow{ + return []discordgo.MessageComponent{ + discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ - discordgo.SelectMenu{ - MenuType: discordgo.StringSelectMenu, - CustomID: fmt.Sprintf("build:sel:%d", runID), - Placeholder: "Show another image…", - Options: options, + discordgo.Button{ + Label: label, + Style: discordgo.PrimaryButton, + Emoji: &discordgo.ComponentEmoji{Name: "📋"}, + CustomID: fmt.Sprintf("build:copy:%d", runID), }, }, - }) + }, } - - return components } // HandleComponent responds to component interactions dispatched from the DM. @@ -579,7 +550,7 @@ func (w *BuildWatcher) HandleComponent(s *discordgo.Session, i *discordgo.Intera data := i.MessageComponentData() - runID, idx, ok := parseComponentID(data.CustomID, data.Values) + runID, ok := parseComponentID(data.CustomID) if !ok { w.respondEphemeral(s, i, "Sorry, couldn't decode that interaction.") @@ -593,59 +564,41 @@ func (w *BuildWatcher) HandleComponent(s *discordgo.Session, i *discordgo.Intera return } - if idx < 0 || idx >= len(completed.images) { - w.respondEphemeral(s, i, "Unknown image selection.") + if len(completed.images) == 0 { + w.respondEphemeral(s, i, "No images produced for this build.") return } - img := completed.images[idx] - content := fmt.Sprintf("[`%s`](%s)\n```\n%s\n```", img.Reference(), img.HubURL(), img.Reference()) + var buf strings.Builder + + buf.Grow(len(completed.images) * 64) + + for idx, img := range completed.images { + if idx > 0 { + buf.WriteByte('\n') + } + + fmt.Fprintf(&buf, "```\n%s\n```", img.Reference()) + } - w.respondEphemeral(s, i, content) + w.respondEphemeral(s, i, buf.String()) } -// parseComponentID parses a custom_id produced by buildCompletionComponents. -// -// build:copy:{runID}:{idx} -> idx is encoded in the custom_id -// build:sel:{runID} -> idx is taken from the selected value -func parseComponentID(customID string, values []string) (int64, int, bool) { +// 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" { - return 0, 0, false + 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, 0, false + return 0, false } - switch parts[1] { - case "copy": - if len(parts) < 4 { - return 0, 0, false - } - - idx, err := strconv.Atoi(parts[3]) - if err != nil { - return 0, 0, false - } - - return runID, idx, true - case "sel": - if len(values) == 0 { - return 0, 0, false - } - - idx, err := strconv.Atoi(values[0]) - if err != nil { - return 0, 0, false - } - - return runID, idx, true - default: - return 0, 0, false - } + return runID, true } func (w *BuildWatcher) respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, content string) { From a4f31aca7b99ac44de50ba216ea802b9021173ef Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Tue, 21 Apr 2026 10:16:50 +0200 Subject: [PATCH 2/2] feat(discord/build): validate ref before dispatching workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probe GitHub's repos/{owner}/{repo}/commits/{ref} endpoint right after defaults are resolved and before the workflow is dispatched. On 404/422 the ephemeral reply is edited to "Build not dispatched — ref X not found in owner/repo" and no workflow run is created. Transient probe failures (network, rate limit, unexpected status) are logged and fall through to dispatch so we don't regress reliability on the happy path. --- pkg/discord/cmd/build/trigger.go | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/pkg/discord/cmd/build/trigger.go b/pkg/discord/cmd/build/trigger.go index 56411be..8ea74ca 100644 --- a/pkg/discord/cmd/build/trigger.go +++ b/pkg/discord/cmd/build/trigger.go @@ -3,6 +3,7 @@ package build import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -20,8 +21,16 @@ const ( // before responding without a Run link. The background watcher keeps // trying for the full claimTimeout and relinks the embed when it lands. inlineClaimTimeout = 12 * time.Second + // refValidationTimeout caps the pre-dispatch GitHub call that verifies the + // user's repository + ref resolve to a commit before we spin up a workflow. + refValidationTimeout = 5 * time.Second ) +// errRefNotFound is returned by validateRef when GitHub explicitly rejects the +// repository + ref pair (404/422). Other errors indicate a transient probe +// failure and the caller should proceed with dispatch rather than block on us. +var errRefNotFound = errors.New("ref not found in repository") + // handleBuild handles the build subcommands (client-cl, client-el, tool). // //nolint:gocyclo // Not that bad, switch statement throwing it. @@ -165,6 +174,33 @@ func (c *BuildCommand) handleBuild(s *discordgo.Session, i *discordgo.Interactio buildArgs = c.GetDefaultBuildArgs(targetName) } + // Probe GitHub to confirm the repository + ref resolve to a commit before + // we dispatch. Catches typo'd branches, deleted tags, and bad SHAs in the + // same ephemeral reply instead of letting the user wait on a queued run + // that will fail at checkout. + validateCtx, validateCancel := context.WithTimeout(context.Background(), refValidationTimeout) + + validateErr := c.validateRef(validateCtx, repository, ref) + + validateCancel() + + if errors.Is(validateErr, errRefNotFound) { + if _, interactionErr := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: new(fmt.Sprintf("❌ Build not dispatched — ref `%s` not found in `%s`.", ref, repository)), + }); interactionErr != nil { + return fmt.Errorf("failed to edit response: %w", interactionErr) + } + + return nil + } + + if validateErr != nil { + c.log.WithError(validateErr).WithFields(logrus.Fields{ + "repository": repository, + "ref": ref, + }).Warn("Pre-dispatch ref validation failed; proceeding with dispatch") + } + // Generate a correlation ID so we can locate the resulting workflow run and // DM the invoker when it finishes. correlationID := uuid.NewString() @@ -369,6 +405,39 @@ func buildTriggeredEmbed(in triggeredEmbedInput) *discordgo.MessageEmbed { return embed } +// validateRef probes GitHub's commits endpoint to confirm that `ref` resolves +// to a commit in `repository`. The endpoint accepts branches, tags, and SHAs, +// so a single call covers every shape the /build command accepts. Returns +// errRefNotFound for 404/422 (definitively bad ref) and a wrapped error for +// any other failure (transient — caller should proceed with dispatch). +func (c *BuildCommand) validateRef(ctx context.Context, repository, ref string) error { + url := fmt.Sprintf("https://api.github.com/repos/%s/commits/%s", repository, ref) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create ref validation request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Authorization", "Bearer "+c.githubToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send ref validation request: %w", err) + } + + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return nil + case http.StatusNotFound, http.StatusUnprocessableEntity: + return errRefNotFound + default: + return fmt.Errorf("unexpected status %d from github commits lookup", resp.StatusCode) + } +} + // triggerWorkflow triggers the GitHub workflow for the given build target. func (c *BuildCommand) triggerWorkflow(buildTarget, repository, ref, dockerTag, buildArgs, correlationID string) (string, error) { // Prepare the workflow inputs.