diff --git a/.github/workflows/components.yaml b/.github/workflows/components.yaml new file mode 100644 index 0000000000..6e6ff42cf5 --- /dev/null +++ b/.github/workflows/components.yaml @@ -0,0 +1,23 @@ +name: Update kn components + +permissions: + contents: write + pull-requests: write + +on: + schedule: + # every 4 hours + - cron: '0 */4 * * *' + +jobs: + update: + name: Update components + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v6 + - name: Run script + env: + GITHUB_TOKEN: ${{ GITHUB.TOKEN }} + run: make hack-generate-components + diff --git a/hack/cmd/components/main.go b/hack/cmd/components/main.go index 9d51128646..99e71d7637 100644 --- a/hack/cmd/components/main.go +++ b/hack/cmd/components/main.go @@ -25,10 +25,13 @@ package main import ( "context" "encoding/json" + "flag" "fmt" "html/template" "os" + "os/exec" "os/signal" + "strings" "syscall" github "github.com/google/go-github/v68/github" @@ -72,6 +75,10 @@ type Component struct { type ComponentList map[string]*Component func main() { + // Parse flags + localMode := flag.Bool("local", false, "run in local mode (skip PR creation)") + flag.Parse() + // Set up context for possible signal inputs to not disrupt cleanup process. // This is not gonna do much for workflows since they finish and shutdown // but in case of local testing - dont leave left over resources on disk/RAM. @@ -91,6 +98,7 @@ func main() { fmt.Println("client with token") return github.NewClient(nil).WithAuthToken(token) } + fmt.Println("client without token") return github.NewClient(nil) } client := getClient(os.Getenv("GITHUB_TOKEN")) @@ -102,34 +110,67 @@ func main() { os.Exit(1) } - // update componentList in-situ + // update components in struct updated, err := update(ctx, client, &componentList) if err != nil { fmt.Fprintf(os.Stderr, "failed to update %v\n", err) os.Exit(1) } - if !updated { - // nothing was updated, nothing to do - fmt.Println("no newer versions found, re-generating .sh just in case") - // regenerate .sh to keep up to date if changed - err = writeScript(componentList, fileScript) - if err != nil { - err = fmt.Errorf("failed to re-generate script: %v", err) - fmt.Fprintln(os.Stderr, err) + const ( + branchName = "bot-auto-update-components" + owner = "knative" + repo = "func" + baseBranch = "main" + ) + + if !*localMode { + // pull main and checkout/rebase branch + if err := setupBranch(branchName, baseBranch); err != nil { + fmt.Fprintf(os.Stderr, "failed to setup and rebase branch: %v\n", err) os.Exit(1) } - fmt.Println("all good") - os.Exit(0) } - + // write files to disk if err := writeFiles(componentList, fileScript, fileJson); err != nil { err = fmt.Errorf("failed to write files: %v", err) fmt.Fprintln(os.Stderr, err) os.Exit(1) } - fmt.Println("files updated!") + if updated { + fmt.Println("files updated!") + } else { + fmt.Println("no component updates; regenerated .sh just in case") + } + + if *localMode { + fmt.Println("local update done") + os.Exit(0) + } + + hasChanges, err := checkForChanges() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to check for changes: %v\n", err) + os.Exit(1) + } + + if hasChanges { + if err := commitChanges(); err != nil { + fmt.Fprintf(os.Stderr, "failed to commit: %v\n", err) + os.Exit(1) + } + } + + if err := pushChanges(branchName); err != nil { + fmt.Fprintf(os.Stderr, "failed to push: %v\n", err) + os.Exit(1) + } + + if err := ensurePR(ctx, client, owner, repo, branchName, baseBranch); err != nil { + fmt.Fprintf(os.Stderr, "failed to create or update PR: %v\n", err) + os.Exit(1) + } } // do the update for each repo defined @@ -176,7 +217,7 @@ func readVersions(file string) (c ComponentList, err error) { // Arguments 'script' & 'json' are paths to files for autogenerated script and // source (json) file respectively. func writeFiles(cl ComponentList, script, json string) error { - fmt.Print("Writing files") + fmt.Println("writing files") // write to json err := writeSource(cl, json) if err != nil { @@ -225,6 +266,137 @@ func writeScript(cl ComponentList, file string) error { return nil } +// setupBranch configures git, checks out branch, and pulls with rebase +func setupBranch(branchName, baseBranch string) error { + fmt.Println("setting up git and rebasing branch on main...") + setupScript := fmt.Sprintf(` + git config user.email "automation@knative.team" && \ + git config user.name "Knative Automation" && \ + git fetch origin && \ + (git switch %s || git switch -c %s origin/%s) && \ + git pull --rebase -X theirs origin %s + `, branchName, branchName, baseBranch, baseBranch) + + if err := runCommand("sh", "-c", setupScript); err != nil { + return fmt.Errorf("failed to setup and rebase branch: %w", err) + } + fmt.Printf("branch %s rebased on %s\n", branchName, baseBranch) + return nil +} + +// checkForChanges checks if there are any uncommitted changes in the working directory +func checkForChanges() (bool, error) { + fmt.Println("checking for changes...") + cmd := exec.Command("git", "status", "--porcelain") + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("failed to check git status: %w", err) + } + + hasChanges := len(strings.TrimSpace(string(output))) > 0 + if hasChanges { + fmt.Println("changes detected") + } else { + fmt.Println("no changes detected") + } + return hasChanges, nil +} + +// commitChanges adds and commits changes +func commitChanges() error { + fmt.Println("committing changes...") + + commitScript := fmt.Sprintf(` + git add %s %s && \ + git commit -m "update components" + `, fileJson, fileScript) + + if err := runCommand("sh", "-c", commitScript); err != nil { + return fmt.Errorf("failed to commit: %w", err) + } + + fmt.Println("changes committed") + return nil +} + +// pushChanges pushes changes to the remote branch (force push since we rebased) +func pushChanges(branchName string) error { + fmt.Println("pushing changes...") + + pushScript := fmt.Sprintf(`git push -f --set-upstream origin %s`, branchName) + + if err := runCommand("sh", "-c", pushScript); err != nil { + return fmt.Errorf("failed to push: %w", err) + } + + fmt.Println("changes pushed") + return nil +} + +// ensurePR checks for an existing PR or creates a new one +func ensurePR(ctx context.Context, client *github.Client, owner, repo, branchName, baseBranch string) error { + const prTitle = "chore: update kn components" + + fmt.Println("checking for existing PR...") + pr, err := findPRByBranch(ctx, client, owner, repo, branchName) + if err != nil { + return fmt.Errorf("failed to check for existing PR: %w", err) + } + + if pr != nil { + fmt.Printf("PR already exists: %s\n", pr.GetHTMLURL()) + return nil + } + + // Create new PR + fmt.Println("creating new PR...") + prBody := "you most likely need to close&re-open the PR for tests to run properly\n/assign gauron99" + newPR := &github.NewPullRequest{ + Title: github.Ptr(prTitle), + Head: github.Ptr(branchName), + Base: github.Ptr(baseBranch), + Body: github.Ptr(prBody), + } + + createdPR, _, err := client.PullRequests.Create(ctx, owner, repo, newPR) + if err != nil { + return fmt.Errorf("failed to create PR: %w", err) + } + + fmt.Printf("PR created successfully: %s\n", createdPR.GetHTMLURL()) + return nil +} + +// findPRByBranch searches for an existing PR with the given head branch +func findPRByBranch(ctx context.Context, client *github.Client, owner, repo, branch string) (*github.PullRequest, error) { + opts := &github.PullRequestListOptions{ + State: "open", + Head: fmt.Sprintf("%s:%s", owner, branch), // GitHub API requires owner:branch format + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + + prs, _, err := client.PullRequests.List(ctx, owner, repo, opts) + if err != nil { + return nil, err + } + + // Should only return 0 or 1 PR since head branch is unique + if len(prs) > 0 { + return prs[0], nil + } + + return nil, nil +} + +func runCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + // get latest version of owner/repo via GH API func getLatestVersion(ctx context.Context, client *github.Client, owner string, repo string) (v string, err error) { rr, res, err := client.Repositories.GetLatestRelease(ctx, owner, repo) diff --git a/hack/update-deps.sh b/hack/update-deps.sh index 0eb2a4c496..8111cec4d5 100755 --- a/hack/update-deps.sh +++ b/hack/update-deps.sh @@ -21,6 +21,3 @@ set -o pipefail source "$(go run knative.dev/hack/cmd/script library.sh)" go_update_deps "$@" - -# Update hack components -make hack-generate-components