Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions pkg/discord/cmd/build/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`^-+`)
Expand Down
69 changes: 69 additions & 0 deletions pkg/discord/cmd/build/trigger.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package build
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
119 changes: 36 additions & 83 deletions pkg/discord/cmd/build/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.")

Expand All @@ -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) {
Expand Down
Loading