From 890ab3a40f07da6651ab836144f0a1145480cdaa Mon Sep 17 00:00:00 2001 From: Antoine Eddi <5222525+aeddi@users.noreply.github.com> Date: Mon, 2 Dec 2024 18:57:35 +0100 Subject: [PATCH] ci: add debug on github-bot matrix subcommand + fixes (#3244) This PR will allow debugging errors of [this type](https://github.com/gnolang/gno/actions/runs/12072757244) that unfortunately cannot be tested locally since they rely on the context of GitHub Actions. Since I also had to add flags to the matrix subcommand, I moved the two matrix and check subcommands into subfolders. This PR also modify the comment to stick to moul's request and fixes several Github Actions errors. Related to #3238 Changes: - https://github.com/gnolang/gno/pull/3244/commits/d11ad5a08e457921907e3db32b8576921dde8563 moves matrix and check subcommands to their own packages in internal - https://github.com/gnolang/gno/pull/3244/commits/462ac01321ff15e34cbe956a7ecc07096e665e28 https://github.com/gnolang/gno/pull/3244/commits/5c1edda51950c74c8bccb7eb8c16c036df3bd1f7 https://github.com/gnolang/gno/pull/3244/commits/ffdce936c39c1ad587f0ed17158f579b4ded067e adds a debug to matrix subcommand (print event input / matrix output) + direct output of matrix to GitHub Actions using a matrix-key flag - https://github.com/gnolang/gno/pull/3244/commits/6af501d4cd923c122e8ea6791ab58f394e2bbf1f embed comment template file as a string at compile time instead of opening it at runtime - https://github.com/gnolang/gno/pull/3244/commits/59c3ad6835191cae92dc811de4484b6a6793ea74 modifies bot comment to meet [this requirements](https://github.com/gnolang/gno/issues/3238#issuecomment-2506520120) - https://github.com/gnolang/gno/pull/3244/commits/241a75532ce5e035ac745b4cd66f3bea2d9a420f filter out from the matrix generation and the PR processing all issues or closed PRs (process / list only opened PRs)
Contributors' checklist... - [x] Added new tests, or not needed, or not feasible - [x] Provided an example (e.g. screenshot) to aid review or the PR is self-explanatory - [x] Updated the official documentation or not needed - [x] No breaking changes were made, or a `BREAKING CHANGE: xxx` message was included in the description - [x] Added references to related issues and PRs - [x] Provided any useful hints for running manual tests
--------- Co-authored-by: Morgan --- .github/workflows/bot.yml | 4 +- contribs/github-bot/README.md | 4 +- contribs/github-bot/comment.tmpl | 51 ------- .../github-bot/{ => internal/check}/check.go | 62 +++----- .../{params/params.go => check/cmd.go} | 75 ++++++---- .../{ => internal/check}/comment.go | 43 +++--- .../github-bot/internal/check/comment.tmpl | 54 +++++++ .../{ => internal/check}/comment_test.go | 41 ++++-- contribs/github-bot/internal/client/client.go | 52 +++++-- .../{ => internal/config}/config.go | 60 ++++---- contribs/github-bot/internal/matrix/cmd.go | 53 +++++++ contribs/github-bot/internal/matrix/matrix.go | 139 ++++++++++++++++++ .../{ => internal/matrix}/matrix_test.go | 45 +++--- .../internal/requirements/assignee_test.go | 2 +- .../internal/requirements/branch.go | 2 +- .../internal/requirements/label_test.go | 2 +- contribs/github-bot/internal/utils/actions.go | 2 +- .../github-bot/internal/utils/github_const.go | 6 +- .../internal/{params => utils}/prlist.go | 3 +- contribs/github-bot/main.go | 24 ++- contribs/github-bot/matrix.go | 117 --------------- 21 files changed, 490 insertions(+), 351 deletions(-) delete mode 100644 contribs/github-bot/comment.tmpl rename contribs/github-bot/{ => internal/check}/check.go (78%) rename contribs/github-bot/internal/{params/params.go => check/cmd.go} (56%) rename contribs/github-bot/{ => internal/check}/comment.go (90%) create mode 100644 contribs/github-bot/internal/check/comment.tmpl rename contribs/github-bot/{ => internal/check}/comment_test.go (86%) rename contribs/github-bot/{ => internal/config}/config.go (54%) create mode 100644 contribs/github-bot/internal/matrix/cmd.go create mode 100644 contribs/github-bot/internal/matrix/matrix.go rename contribs/github-bot/{ => internal/matrix}/matrix_test.go (91%) rename contribs/github-bot/internal/{params => utils}/prlist.go (91%) delete mode 100644 contribs/github-bot/matrix.go diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml index 21950459ae8..cbfec5730fc 100644 --- a/.github/workflows/bot.yml +++ b/.github/workflows/bot.yml @@ -55,13 +55,15 @@ jobs: working-directory: contribs/github-bot env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: echo "pr-numbers=$(go run . matrix)" >> "$GITHUB_OUTPUT" + run: go run . matrix -matrix-key 'pr-numbers' -verbose # This job processes each pull request in the matrix individually while ensuring # that a same PR cannot be processed concurrently by mutliple runners process-pr: name: Process PR needs: define-prs-matrix + # Just skip this job if PR numbers matrix is empty (prevent failed state) + if: ${{ needs.define-prs-matrix.outputs.pr-numbers != '[]' && needs.define-prs-matrix.outputs.pr-numbers != '' }} runs-on: ubuntu-latest strategy: matrix: diff --git a/contribs/github-bot/README.md b/contribs/github-bot/README.md index 78c9c3c01b8..7932300cb9d 100644 --- a/contribs/github-bot/README.md +++ b/contribs/github-bot/README.md @@ -13,7 +13,7 @@ The bot operates by defining a set of rules that are evaluated against each pull - **Automatic Checks**: These are rules that the bot evaluates automatically. If a pull request meets the conditions specified in the rule, then the corresponding requirements are executed. For example, ensuring that changes to specific directories are reviewed by specific team members. - **Manual Checks**: These require human intervention. If a pull request meets the conditions specified in the rule, then a checkbox that can be checked only by specified teams is displayed on the bot comment. For example, determining if infrastructure needs to be updated based on changes to specific files. -The bot configuration is defined in Go and is located in the file [config.go](./config.go). +The bot configuration is defined in Go and is located in the file [config.go](./internal/config/config.go). ### GitHub Token @@ -31,7 +31,7 @@ For the bot to make requests to the GitHub API, it needs a Personal Access Token USAGE github-bot check [flags] -This tool checks if the requirements for a pull request to be merged are satisfied (defined in config.go) and displays PR status checks accordingly. +This tool checks if the requirements for a pull request to be merged are satisfied (defined in ./internal/config/config.go) and displays PR status checks accordingly. A valid GitHub Token must be provided by setting the GITHUB_TOKEN environment variable. FLAGS diff --git a/contribs/github-bot/comment.tmpl b/contribs/github-bot/comment.tmpl deleted file mode 100644 index ebd07fdd4b9..00000000000 --- a/contribs/github-bot/comment.tmpl +++ /dev/null @@ -1,51 +0,0 @@ -# Merge Requirements - -The following requirements must be fulfilled before a pull request can be merged. -Some requirement checks are automated and can be verified by the CI, while others need manual verification by a staff member. - -These requirements are defined in this [configuration file](https://github.com/GnoCheckBot/demo/blob/main/config.go). - -## Automated Checks - -{{ range .AutoRules }} {{ if .Satisfied }}🟢{{ else }}🔴{{ end }} {{ .Description }} -{{ end }} - -{{ if .AutoRules }}
Details
-{{ range .AutoRules }} -
{{ .Description | stripLinks }}
- -### If -``` -{{ .ConditionDetails | stripLinks }} -``` -### Then -``` -{{ .RequirementDetails | stripLinks }} -``` -
-{{ end }} -
-{{ else }}*No automated checks match this pull request.*{{ end }} - -## Manual Checks - -{{ range .ManualRules }}- [{{ if .CheckedBy }}x{{ else }} {{ end }}] {{ .Description }}{{ if .CheckedBy }} (checked by @{{ .CheckedBy }}){{ end }} -{{ end }} - -{{ if .ManualRules }}
Details
-{{ range .ManualRules }} -
{{ .Description | stripLinks }}
- -### If -``` -{{ .ConditionDetails }} -``` -### Can be checked by -{{range $item := .Teams }} - team {{ $item | stripLinks }} -{{ else }} -- Any user with comment edit permission -{{end}} -
-{{ end }} -
-{{ else }}*No manual checks match this pull request.*{{ end }} diff --git a/contribs/github-bot/check.go b/contribs/github-bot/internal/check/check.go similarity index 78% rename from contribs/github-bot/check.go rename to contribs/github-bot/internal/check/check.go index 8019246d27c..5ca2235e823 100644 --- a/contribs/github-bot/check.go +++ b/contribs/github-bot/internal/check/check.go @@ -1,4 +1,4 @@ -package main +package check import ( "context" @@ -9,44 +9,30 @@ import ( "sync/atomic" "github.com/gnolang/gno/contribs/github-bot/internal/client" + "github.com/gnolang/gno/contribs/github-bot/internal/config" "github.com/gnolang/gno/contribs/github-bot/internal/logger" - p "github.com/gnolang/gno/contribs/github-bot/internal/params" "github.com/gnolang/gno/contribs/github-bot/internal/utils" - "github.com/gnolang/gno/tm2/pkg/commands" "github.com/google/go-github/v64/github" "github.com/sethvargo/go-githubactions" "github.com/xlab/treeprint" ) -func newCheckCmd() *commands.Command { - params := &p.Params{} - - return commands.NewCommand( - commands.Metadata{ - Name: "check", - ShortUsage: "github-bot check [flags]", - ShortHelp: "checks requirements for a pull request to be merged", - LongHelp: "This tool checks if the requirements for a pull request to be merged are satisfied (defined in config.go) and displays PR status checks accordingly.\nA valid GitHub Token must be provided by setting the GITHUB_TOKEN environment variable.", - }, - params, - func(_ context.Context, _ []string) error { - params.ValidateFlags() - return execCheck(params) - }, - ) -} - -func execCheck(params *p.Params) error { +func execCheck(flags *checkFlags) error { // Create context with timeout if specified in the parameters. ctx := context.Background() - if params.Timeout > 0 { + if flags.Timeout > 0 { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(context.Background(), params.Timeout) + ctx, cancel = context.WithTimeout(context.Background(), flags.Timeout) defer cancel() } // Init GitHub API client. - gh, err := client.New(ctx, params) + gh, err := client.New(ctx, &client.Config{ + Owner: flags.Owner, + Repo: flags.Repo, + Verbose: *flags.Verbose, + DryRun: flags.DryRun, + }) if err != nil { return fmt.Errorf("comment update handling failed: %w", err) } @@ -69,7 +55,7 @@ func execCheck(params *p.Params) error { var prs []*github.PullRequest // If requested, retrieve all open pull requests. - if params.PRAll { + if flags.PRAll { prs, err = gh.ListPR(utils.PRStateOpen) if err != nil { return fmt.Errorf("unable to list all PR: %w", err) @@ -77,11 +63,11 @@ func execCheck(params *p.Params) error { } else { // Otherwise, retrieve only specified pull request(s) // (flag or GitHub Action context). - prs = make([]*github.PullRequest, len(params.PRNums)) - for i, prNum := range params.PRNums { - pr, _, err := gh.Client.PullRequests.Get(gh.Ctx, gh.Owner, gh.Repo, prNum) + prs = make([]*github.PullRequest, len(flags.PRNums)) + for i, prNum := range flags.PRNums { + pr, err := gh.GetOpenedPullRequest(prNum) if err != nil { - return fmt.Errorf("unable to retrieve specified pull request (%d): %w", prNum, err) + return fmt.Errorf("unable to process PR list: %w", err) } prs[i] = pr } @@ -101,7 +87,7 @@ func processPRList(gh *client.GitHub, prs []*github.PullRequest) error { } // Process all pull requests in parallel. - autoRules, manualRules := config(gh) + autoRules, manualRules := config.Config(gh) var wg sync.WaitGroup // Used in dry-run mode to log cleanly from different goroutines. @@ -122,15 +108,15 @@ func processPRList(gh *client.GitHub, prs []*github.PullRequest) error { ifDetails := treeprint.NewWithRoot(fmt.Sprintf("%s Condition met", utils.Success)) // Check if conditions of this rule are met by this PR. - if !autoRule.ifC.IsMet(pr, ifDetails) { + if !autoRule.If.IsMet(pr, ifDetails) { continue } - c := AutoContent{Description: autoRule.description, Satisfied: false} + c := AutoContent{Description: autoRule.Description, Satisfied: false} thenDetails := treeprint.NewWithRoot(fmt.Sprintf("%s Requirement not satisfied", utils.Fail)) // Check if requirements of this rule are satisfied by this PR. - if autoRule.thenR.IsSatisfied(pr, thenDetails) { + if autoRule.Then.IsSatisfied(pr, thenDetails) { thenDetails.SetValue(fmt.Sprintf("%s Requirement satisfied", utils.Success)) c.Satisfied = true } else { @@ -153,13 +139,13 @@ func processPRList(gh *client.GitHub, prs []*github.PullRequest) error { ifDetails := treeprint.NewWithRoot(fmt.Sprintf("%s Condition met", utils.Success)) // Check if conditions of this rule are met by this PR. - if !manualRule.ifC.IsMet(pr, ifDetails) { + if !manualRule.If.IsMet(pr, ifDetails) { continue } // Get check status from current comment, if any. checkedBy := "" - check, ok := checks[manualRule.description] + check, ok := checks[manualRule.Description] if ok { checkedBy = check.checkedBy } @@ -167,10 +153,10 @@ func processPRList(gh *client.GitHub, prs []*github.PullRequest) error { commentContent.ManualRules = append( commentContent.ManualRules, ManualContent{ - Description: manualRule.description, + Description: manualRule.Description, ConditionDetails: ifDetails.String(), CheckedBy: checkedBy, - Teams: manualRule.teams, + Teams: manualRule.Teams, }, ) diff --git a/contribs/github-bot/internal/params/params.go b/contribs/github-bot/internal/check/cmd.go similarity index 56% rename from contribs/github-bot/internal/params/params.go rename to contribs/github-bot/internal/check/cmd.go index c11d1b62419..7ea6c02795b 100644 --- a/contribs/github-bot/internal/params/params.go +++ b/contribs/github-bot/internal/check/cmd.go @@ -1,118 +1,131 @@ -package params +package check import ( + "context" "flag" "fmt" "os" "time" "github.com/gnolang/gno/contribs/github-bot/internal/utils" + "github.com/gnolang/gno/tm2/pkg/commands" "github.com/sethvargo/go-githubactions" ) -type Params struct { +type checkFlags struct { Owner string Repo string PRAll bool - PRNums PRList - Verbose bool + PRNums utils.PRList + Verbose *bool DryRun bool Timeout time.Duration flagSet *flag.FlagSet } -func (p *Params) RegisterFlags(fs *flag.FlagSet) { +func NewCheckCmd(verbose *bool) *commands.Command { + flags := &checkFlags{Verbose: verbose} + + return commands.NewCommand( + commands.Metadata{ + Name: "check", + ShortUsage: "github-bot check [flags]", + ShortHelp: "checks requirements for a pull request to be merged", + LongHelp: "This tool checks if the requirements for a pull request to be merged are satisfied (defined in ./internal/config/config.go) and displays PR status checks accordingly.\nA valid GitHub Token must be provided by setting the GITHUB_TOKEN environment variable.", + }, + flags, + func(_ context.Context, _ []string) error { + flags.validateFlags() + return execCheck(flags) + }, + ) +} + +func (flags *checkFlags) RegisterFlags(fs *flag.FlagSet) { fs.StringVar( - &p.Owner, + &flags.Owner, "owner", "", "owner of the repo to process, if empty, will be retrieved from GitHub Actions context", ) fs.StringVar( - &p.Repo, + &flags.Repo, "repo", "", "repo to process, if empty, will be retrieved from GitHub Actions context", ) fs.BoolVar( - &p.PRAll, + &flags.PRAll, "pr-all", false, "process all opened pull requests", ) fs.TextVar( - &p.PRNums, + &flags.PRNums, "pr-numbers", - PRList(nil), + utils.PRList(nil), "pull request(s) to process, must be a comma separated list of PR numbers, e.g '42,1337,7890'. If empty, will be retrieved from GitHub Actions context", ) fs.BoolVar( - &p.Verbose, - "verbose", - false, - "set logging level to debug", - ) - - fs.BoolVar( - &p.DryRun, + &flags.DryRun, "dry-run", false, "print if pull request requirements are satisfied without updating anything on GitHub", ) fs.DurationVar( - &p.Timeout, + &flags.Timeout, "timeout", 0, "timeout after which the bot execution is interrupted", ) - p.flagSet = fs + flags.flagSet = fs } -func (p *Params) ValidateFlags() { +func (flags *checkFlags) validateFlags() { // Helper to display an error + usage message before exiting. errorUsage := func(err string) { - fmt.Fprintf(p.flagSet.Output(), "Error: %s\n\n", err) - p.flagSet.Usage() + fmt.Fprintf(flags.flagSet.Output(), "Error: %s\n\n", err) + flags.flagSet.Usage() os.Exit(1) } // Check if flags are coherent. - if p.PRAll && len(p.PRNums) != 0 { + if flags.PRAll && len(flags.PRNums) != 0 { errorUsage("You can specify only one of the '-pr-all' and '-pr-numbers' flags.") } // If one of these values is empty, it must be retrieved // from GitHub Actions context. - if p.Owner == "" || p.Repo == "" || (len(p.PRNums) == 0 && !p.PRAll) { + if flags.Owner == "" || flags.Repo == "" || (len(flags.PRNums) == 0 && !flags.PRAll) { actionCtx, err := githubactions.Context() if err != nil { errorUsage(fmt.Sprintf("Unable to get GitHub Actions context: %v.", err)) } - if p.Owner == "" { - if p.Owner, _ = actionCtx.Repo(); p.Owner == "" { + if flags.Owner == "" { + if flags.Owner, _ = actionCtx.Repo(); flags.Owner == "" { errorUsage("Unable to retrieve owner from GitHub Actions context, you may want to set it using -onwer flag.") } } - if p.Repo == "" { - if _, p.Repo = actionCtx.Repo(); p.Repo == "" { + if flags.Repo == "" { + if _, flags.Repo = actionCtx.Repo(); flags.Repo == "" { errorUsage("Unable to retrieve repo from GitHub Actions context, you may want to set it using -repo flag.") } } - if len(p.PRNums) == 0 && !p.PRAll { + if len(flags.PRNums) == 0 && !flags.PRAll { prNum, err := utils.GetPRNumFromActionsCtx(actionCtx) if err != nil { errorUsage(fmt.Sprintf("Unable to retrieve pull request number from GitHub Actions context: %s\nYou may want to set it using -pr-numbers flag.", err.Error())) } - p.PRNums = PRList{prNum} + flags.PRNums = utils.PRList{prNum} } } } diff --git a/contribs/github-bot/comment.go b/contribs/github-bot/internal/check/comment.go similarity index 90% rename from contribs/github-bot/comment.go rename to contribs/github-bot/internal/check/comment.go index f6605ea8554..434df8f9e76 100644 --- a/contribs/github-bot/comment.go +++ b/contribs/github-bot/internal/check/comment.go @@ -1,7 +1,8 @@ -package main +package check import ( "bytes" + _ "embed" "errors" "fmt" "regexp" @@ -9,12 +10,15 @@ import ( "text/template" "github.com/gnolang/gno/contribs/github-bot/internal/client" + "github.com/gnolang/gno/contribs/github-bot/internal/config" "github.com/gnolang/gno/contribs/github-bot/internal/utils" - "github.com/google/go-github/v64/github" "github.com/sethvargo/go-githubactions" ) +//go:embed comment.tmpl +var tmplString string // Embed template used for comment generation. + var errTriggeredByBot = errors.New("event triggered by bot") // Compile regex only once. @@ -95,6 +99,18 @@ func handleCommentUpdate(gh *client.GitHub, actionCtx *githubactions.GitHubConte return nil } + // Get PR number from GitHub Actions context. + prNumFloat, ok := utils.IndexMap(actionCtx.Event, "issue", "number").(float64) + if !ok || prNumFloat <= 0 { + return errors.New("unable to get issue number on issue comment event") + } + prNum := int(prNumFloat) + + // Ignore if this comment update is not related to an opened PR. + if _, err := gh.GetOpenedPullRequest(prNum); err != nil { + return nil // May come from an issue or a closed PR + } + // Return if comment was edited by bot (current authenticated user). authUser, _, err := gh.Client.Users.Get(gh.Ctx, "") if err != nil { @@ -129,17 +145,11 @@ func handleCommentUpdate(gh *client.GitHub, actionCtx *githubactions.GitHubConte return errors.New("unable to get changes body content on issue comment event") } - // Get PR number from GitHub Actions context. - prNum, ok := utils.IndexMap(actionCtx.Event, "issue", "number").(float64) - if !ok || prNum <= 0 { - return errors.New("unable to get issue number on issue comment event") - } - // Check if change is only a checkbox being checked or unckecked. if checkboxes.ReplaceAllString(current, "") != checkboxes.ReplaceAllString(previous, "") { // If not, restore previous comment body. if !gh.DryRun { - gh.SetBotComment(previous, int(prNum)) + gh.SetBotComment(previous, prNum) } return errors.New("bot comment edited outside of checkboxes") } @@ -157,12 +167,12 @@ func handleCommentUpdate(gh *client.GitHub, actionCtx *githubactions.GitHubConte // Get teams allowed to edit this box from config. var teams []string found := false - _, manualRules := config(gh) + _, manualRules := config.Config(gh) for _, manualRule := range manualRules { - if manualRule.description == key { + if manualRule.Description == key { found = true - teams = manualRule.teams + teams = manualRule.Teams } } @@ -175,9 +185,9 @@ func handleCommentUpdate(gh *client.GitHub, actionCtx *githubactions.GitHubConte // If teams specified in rule, check if actor is a member of one of them. if len(teams) > 0 { - if !gh.IsUserInTeams(actionCtx.Actor, teams) { // If user not allowed + if !gh.IsUserInTeams(actionCtx.Actor, teams) { // If user not allowed to check the boxes. if !gh.DryRun { - gh.SetBotComment(previous, int(prNum)) // Restore previous state + gh.SetBotComment(previous, prNum) // Then restore previous state. } return errors.New("checkbox edited by a user not allowed to") } @@ -199,7 +209,7 @@ func handleCommentUpdate(gh *client.GitHub, actionCtx *githubactions.GitHubConte // Update comment with username. if edited != "" && !gh.DryRun { - gh.SetBotComment(edited, int(prNum)) + gh.SetBotComment(edited, prNum) gh.Logger.Debugf("Comment manual checks updated successfully") } @@ -217,8 +227,7 @@ func generateComment(content CommentContent) (string, error) { } // Bind markdown stripping function to template generator. - const tmplFile = "comment.tmpl" - tmpl, err := template.New(tmplFile).Funcs(funcMap).ParseFiles(tmplFile) + tmpl, err := template.New("comment").Funcs(funcMap).Parse(tmplString) if err != nil { return "", fmt.Errorf("unable to init template: %w", err) } diff --git a/contribs/github-bot/internal/check/comment.tmpl b/contribs/github-bot/internal/check/comment.tmpl new file mode 100644 index 00000000000..4312019dd2e --- /dev/null +++ b/contribs/github-bot/internal/check/comment.tmpl @@ -0,0 +1,54 @@ +I'm a bot that assists the Gno Core team in maintaining this repository. My role is to ensure that contributors understand and follow our guidelines, helping to streamline the development process. + +The following requirements must be fulfilled before a pull request can be merged. +Some requirement checks are automated and can be verified by the CI, while others need manual verification by a staff member. + +These requirements are defined in this [configuration file](https://github.com/gnolang/gno/tree/master/contribs/github-bot/internal/config/config.go). + +## Automated Checks + +{{ if .AutoRules }}{{ range .AutoRules }} {{ if .Satisfied }}🟢{{ else }}🔴{{ end }} {{ .Description }} +{{ end }}{{ else }}*No automated checks match this pull request.*{{ end }} + +## Manual Checks + +{{ if .ManualRules }}{{ range .ManualRules }}- [{{ if .CheckedBy }}x{{ else }} {{ end }}] {{ .Description }}{{ if .CheckedBy }} (checked by @{{ .CheckedBy }}){{ end }} +{{ end }}{{ else }}*No manual checks match this pull request.*{{ end }} + +{{ if or .AutoRules .ManualRules }}
Debug
+{{ if .AutoRules }}
Automated Checks
+{{ range .AutoRules }} +
{{ .Description | stripLinks }}
+ +### If +``` +{{ .ConditionDetails | stripLinks }} +``` +### Then +``` +{{ .RequirementDetails | stripLinks }} +``` +
+{{ end }} +
+{{ end }} + +{{ if .ManualRules }}
Manual Checks
+{{ range .ManualRules }} +
{{ .Description | stripLinks }}
+ +### If +``` +{{ .ConditionDetails }} +``` +### Can be checked by +{{range $item := .Teams }} - team {{ $item | stripLinks }} +{{ else }} +- Any user with comment edit permission +{{end}} +
+{{ end }} +
+{{ end }} +
+{{ end }} diff --git a/contribs/github-bot/comment_test.go b/contribs/github-bot/internal/check/comment_test.go similarity index 86% rename from contribs/github-bot/comment_test.go rename to contribs/github-bot/internal/check/comment_test.go index fd8790dd9e1..0334b76f95c 100644 --- a/contribs/github-bot/comment_test.go +++ b/contribs/github-bot/internal/check/comment_test.go @@ -1,4 +1,4 @@ -package main +package check import ( "context" @@ -108,19 +108,34 @@ func TestCommentUpdateHandler(t *testing.T) { } gh := newGHClient() - // Exit without error because EventName is empty + // Exit without error because EventName is empty. assert.NoError(t, handleCommentUpdate(gh, actionCtx)) actionCtx.EventName = utils.EventIssueComment - // Exit with error because Event.action is not set + // Exit with error because Event.action is not set. assert.Error(t, handleCommentUpdate(gh, actionCtx)) actionCtx.Event["action"] = "" - // Exit without error because Event.action is set but not 'deleted' + // Exit without error because Event.action is set but not 'deleted'. assert.NoError(t, handleCommentUpdate(gh, actionCtx)) actionCtx.Event["action"] = "deleted" - // Exit with error because mock not setup to return authUser + // Exit with error because Event.issue.number is not set. + assert.Error(t, handleCommentUpdate(gh, actionCtx)) + actionCtx.Event = setValue(t, actionCtx.Event, float64(42), "issue", "number") + + // Exit without error can't get open pull request associated with PR num. + assert.NoError(t, handleCommentUpdate(gh, actionCtx)) + mockOptions = append(mockOptions, mock.WithRequestMatchPages( + mock.EndpointPattern{ + Pattern: "/repos/pulls/42", + Method: "GET", + }, + github.PullRequest{Number: github.Int(42), State: github.String(utils.PRStateOpen)}, + )) + gh = newGHClient() + + // Exit with error because mock not setup to return authUser. assert.Error(t, handleCommentUpdate(gh, actionCtx)) mockOptions = append(mockOptions, mock.WithRequestMatchPages( mock.EndpointPattern{ @@ -132,31 +147,27 @@ func TestCommentUpdateHandler(t *testing.T) { gh = newGHClient() actionCtx.Actor = bot - // Exit with error because authUser and action actor is the same user + // Exit with error because authUser and action actor is the same user. assert.ErrorIs(t, handleCommentUpdate(gh, actionCtx), errTriggeredByBot) actionCtx.Actor = user - // Exit with error because Event.comment.user.login is not set + // Exit with error because Event.comment.user.login is not set. assert.Error(t, handleCommentUpdate(gh, actionCtx)) actionCtx.Event = setValue(t, actionCtx.Event, user, "comment", "user", "login") - // Exit without error because comment author is not the bot + // Exit without error because comment author is not the bot. assert.NoError(t, handleCommentUpdate(gh, actionCtx)) actionCtx.Event = setValue(t, actionCtx.Event, bot, "comment", "user", "login") - // Exit with error because Event.comment.body is not set + // Exit with error because Event.comment.body is not set. assert.Error(t, handleCommentUpdate(gh, actionCtx)) actionCtx.Event = setValue(t, actionCtx.Event, "current_body", "comment", "body") - // Exit with error because Event.changes.body.from is not set + // Exit with error because Event.changes.body.from is not set. assert.Error(t, handleCommentUpdate(gh, actionCtx)) actionCtx.Event = setValue(t, actionCtx.Event, "updated_body", "changes", "body", "from") - // Exit with error because Event.issue.number is not set - assert.Error(t, handleCommentUpdate(gh, actionCtx)) - actionCtx.Event = setValue(t, actionCtx.Event, float64(42), "issue", "number") - - // Exit with error because checkboxes are differents + // Exit with error because checkboxes are differents. assert.Error(t, handleCommentUpdate(gh, actionCtx)) actionCtx.Event = setValue(t, actionCtx.Event, "current_body", "changes", "body", "from") diff --git a/contribs/github-bot/internal/client/client.go b/contribs/github-bot/internal/client/client.go index 474146ad3da..a5c875e0d22 100644 --- a/contribs/github-bot/internal/client/client.go +++ b/contribs/github-bot/internal/client/client.go @@ -7,8 +7,7 @@ import ( "os" "github.com/gnolang/gno/contribs/github-bot/internal/logger" - p "github.com/gnolang/gno/contribs/github-bot/internal/params" - + "github.com/gnolang/gno/contribs/github-bot/internal/utils" "github.com/google/go-github/v64/github" ) @@ -32,21 +31,28 @@ type GitHub struct { Repo string } +type Config struct { + Owner string + Repo string + Verbose bool + DryRun bool +} + // GetBotComment retrieves the bot's (current user) comment on provided PR number. func (gh *GitHub) GetBotComment(prNum int) (*github.IssueComment, error) { - // List existing comments + // List existing comments. const ( sort = "created" direction = "desc" ) - // Get current user (bot) + // Get current user (bot). currentUser, _, err := gh.Client.Users.Get(gh.Ctx, "") if err != nil { return nil, fmt.Errorf("unable to get current user: %w", err) } - // Pagination option + // Pagination option. opts := &github.IssueListCommentsOptions{ Sort: github.String(sort), Direction: github.String(direction), @@ -67,7 +73,7 @@ func (gh *GitHub) GetBotComment(prNum int) (*github.IssueComment, error) { return nil, fmt.Errorf("unable to list comments for PR %d: %w", prNum, err) } - // Get the comment created by current user + // Get the comment created by current user. for _, comment := range comments { if comment.GetUser().GetLogin() == currentUser.GetLogin() { return comment, nil @@ -86,7 +92,12 @@ func (gh *GitHub) GetBotComment(prNum int) (*github.IssueComment, error) { // SetBotComment creates a bot's comment on the provided PR number // or updates it if it already exists. func (gh *GitHub) SetBotComment(body string, prNum int) (*github.IssueComment, error) { - // Create bot comment if it does not already exist + // Prevent updating anything in dry run mode. + if gh.DryRun { + return nil, errors.New("should not write bot comment in dry run mode") + } + + // Create bot comment if it does not already exist. comment, err := gh.GetBotComment(prNum) if errors.Is(err, ErrBotCommentNotFound) { newComment, _, err := gh.Client.Issues.CreateComment( @@ -119,6 +130,17 @@ func (gh *GitHub) SetBotComment(body string, prNum int) (*github.IssueComment, e return editComment, nil } +func (gh *GitHub) GetOpenedPullRequest(prNum int) (*github.PullRequest, error) { + pr, _, err := gh.Client.PullRequests.Get(gh.Ctx, gh.Owner, gh.Repo, prNum) + if err != nil { + return nil, fmt.Errorf("unable to retrieve specified pull request (%d): %w", prNum, err) + } else if pr.GetState() != utils.PRStateOpen { + return nil, fmt.Errorf("pull request %d is not opened, actual state: %s", prNum, pr.GetState()) + } + + return pr, nil +} + // ListTeamMembers lists the members of the specified team. func (gh *GitHub) ListTeamMembers(team string) ([]*github.User, error) { var ( @@ -268,25 +290,25 @@ func (gh *GitHub) ListPR(state string) ([]*github.PullRequest, error) { } // New initializes the API client, the logger, and creates an instance of GitHub. -func New(ctx context.Context, params *p.Params) (*GitHub, error) { +func New(ctx context.Context, cfg *Config) (*GitHub, error) { gh := &GitHub{ Ctx: ctx, - Owner: params.Owner, - Repo: params.Repo, - DryRun: params.DryRun, + Owner: cfg.Owner, + Repo: cfg.Repo, + DryRun: cfg.DryRun, } // Detect if the current process was launched by a GitHub Action and return - // a logger suitable for terminal output or the GitHub Actions web interface - gh.Logger = logger.NewLogger(params.Verbose) + // a logger suitable for terminal output or the GitHub Actions web interface. + gh.Logger = logger.NewLogger(cfg.Verbose) - // Retrieve GitHub API token from env + // Retrieve GitHub API token from env. token, set := os.LookupEnv("GITHUB_TOKEN") if !set { return nil, errors.New("GITHUB_TOKEN is not set in env") } - // Init GitHub API client using token + // Init GitHub API client using token. gh.Client = github.NewClient(nil).WithAuthToken(token) return gh, nil diff --git a/contribs/github-bot/config.go b/contribs/github-bot/internal/config/config.go similarity index 54% rename from contribs/github-bot/config.go rename to contribs/github-bot/internal/config/config.go index 4a28565ef7f..ac1d185f759 100644 --- a/contribs/github-bot/config.go +++ b/contribs/github-bot/internal/config/config.go @@ -1,4 +1,4 @@ -package main +package config import ( "github.com/gnolang/gno/contribs/github-bot/internal/client" @@ -9,37 +9,37 @@ import ( type Teams []string // Automatic check that will be performed by the bot. -type automaticCheck struct { - description string - ifC c.Condition // If the condition is met, the rule is displayed and the requirement is executed. - thenR r.Requirement // If the requirement is satisfied, the check passes. +type AutomaticCheck struct { + Description string + If c.Condition // If the condition is met, the rule is displayed and the requirement is executed. + Then r.Requirement // If the requirement is satisfied, the check passes. } // Manual check that will be performed by users. -type manualCheck struct { - description string - ifC c.Condition // If the condition is met, a checkbox will be displayed on bot comment. - teams Teams // Members of these teams can check the checkbox to make the check pass. +type ManualCheck struct { + Description string + If c.Condition // If the condition is met, a checkbox will be displayed on bot comment. + Teams Teams // Members of these teams can check the checkbox to make the check pass. } // This function returns the configuration of the bot consisting of automatic and manual checks // in which the GitHub client is injected. -func config(gh *client.GitHub) ([]automaticCheck, []manualCheck) { - auto := []automaticCheck{ +func Config(gh *client.GitHub) ([]AutomaticCheck, []ManualCheck) { + auto := []AutomaticCheck{ { - description: "Maintainers must be able to edit this pull request", - ifC: c.Always(), - thenR: r.MaintainerCanModify(), + Description: "Maintainers must be able to edit this pull request", + If: c.Always(), + Then: r.MaintainerCanModify(), }, { - description: "The pull request head branch must be up-to-date with its base", - ifC: c.Always(), - thenR: r.UpToDateWith(gh, r.PR_BASE), + Description: "The pull request head branch must be up-to-date with its base", + If: c.Always(), + Then: r.UpToDateWith(gh, r.PR_BASE), }, { - description: "Changes to 'docs' folder must be reviewed/authored by at least one devrel and one tech-staff", - ifC: c.FileChanged(gh, "^docs/"), - thenR: r.Or( + Description: "Changes to 'docs' folder must be reviewed/authored by at least one devrel and one tech-staff", + If: c.FileChanged(gh, "^docs/"), + Then: r.Or( r.And( r.AuthorInTeam(gh, "devrels"), r.ReviewByTeamMembers(gh, "tech-staff", 1), @@ -52,15 +52,15 @@ func config(gh *client.GitHub) ([]automaticCheck, []manualCheck) { }, } - manual := []manualCheck{ + manual := []ManualCheck{ { - description: "The pull request description provides enough details", - ifC: c.Not(c.AuthorInTeam(gh, "core-contributors")), - teams: Teams{"core-contributors"}, + Description: "The pull request description provides enough details", + If: c.Not(c.AuthorInTeam(gh, "core-contributors")), + Teams: Teams{"core-contributors"}, }, { - description: "Determine if infra needs to be updated before merging", - ifC: c.And( + Description: "Determine if infra needs to be updated before merging", + If: c.And( c.BaseBranch("master"), c.Or( c.FileChanged(gh, `Dockerfile`), @@ -70,17 +70,17 @@ func config(gh *client.GitHub) ([]automaticCheck, []manualCheck) { c.FileChanged(gh, `^.github/workflows/portal-loop\.yml$`), ), ), - teams: Teams{"devops"}, + Teams: Teams{"devops"}, }, } // Check for duplicates in manual rule descriptions (needs to be unique for the bot operations). unique := make(map[string]struct{}) for _, rule := range manual { - if _, exists := unique[rule.description]; exists { - gh.Logger.Fatalf("Manual rule descriptions must be unique (duplicate: %s)", rule.description) + if _, exists := unique[rule.Description]; exists { + gh.Logger.Fatalf("Manual rule descriptions must be unique (duplicate: %s)", rule.Description) } - unique[rule.description] = struct{}{} + unique[rule.Description] = struct{}{} } return auto, manual diff --git a/contribs/github-bot/internal/matrix/cmd.go b/contribs/github-bot/internal/matrix/cmd.go new file mode 100644 index 00000000000..8bcc3a34424 --- /dev/null +++ b/contribs/github-bot/internal/matrix/cmd.go @@ -0,0 +1,53 @@ +package matrix + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/gnolang/gno/tm2/pkg/commands" +) + +type matrixFlags struct { + verbose *bool + matrixKey string + flagSet *flag.FlagSet +} + +func NewMatrixCmd(verbose *bool) *commands.Command { + flags := &matrixFlags{verbose: verbose} + + return commands.NewCommand( + commands.Metadata{ + Name: "matrix", + ShortUsage: "github-bot matrix [flags]", + ShortHelp: "parses GitHub Actions event and defines matrix accordingly", + LongHelp: "This tool retrieves the GitHub Actions context, parses the attached event, and defines the matrix with the pull request numbers to be processed accordingly", + }, + flags, + func(_ context.Context, _ []string) error { + flags.validateFlags() + return execMatrix(flags) + }, + ) +} + +func (flags *matrixFlags) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &flags.matrixKey, + "matrix-key", + "", + "key of the matrix to set in Github Actions output (required)", + ) + + flags.flagSet = fs +} + +func (flags *matrixFlags) validateFlags() { + if flags.matrixKey == "" { + fmt.Fprintf(flags.flagSet.Output(), "Error: no matrix-key provided\n\n") + flags.flagSet.Usage() + os.Exit(1) + } +} diff --git a/contribs/github-bot/internal/matrix/matrix.go b/contribs/github-bot/internal/matrix/matrix.go new file mode 100644 index 00000000000..9c8f12e4214 --- /dev/null +++ b/contribs/github-bot/internal/matrix/matrix.go @@ -0,0 +1,139 @@ +package matrix + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/gnolang/gno/contribs/github-bot/internal/client" + "github.com/gnolang/gno/contribs/github-bot/internal/utils" + "github.com/sethvargo/go-githubactions" +) + +func execMatrix(flags *matrixFlags) error { + // Get GitHub Actions context to retrieve event. + actionCtx, err := githubactions.Context() + if err != nil { + return fmt.Errorf("unable to get GitHub Actions context: %w", err) + } + + // If verbose is set, print the Github Actions event for debugging purpose. + if *flags.verbose { + jsonBytes, err := json.MarshalIndent(actionCtx.Event, "", " ") + if err != nil { + return fmt.Errorf("unable to marshal event to json: %w", err) + } + fmt.Println("Event:", string(jsonBytes)) + } + + // Init Github client using only GitHub Actions context. + owner, repo := actionCtx.Repo() + gh, err := client.New(context.Background(), &client.Config{ + Owner: owner, + Repo: repo, + Verbose: *flags.verbose, + DryRun: true, + }) + if err != nil { + return fmt.Errorf("unable to init GitHub client: %w", err) + } + + // Retrieve PR list from GitHub Actions event. + prList, err := getPRListFromEvent(gh, actionCtx) + if err != nil { + return err + } + + // Format PR list for GitHub Actions matrix definition. + bytes, err := prList.MarshalText() + if err != nil { + return fmt.Errorf("unable to marshal PR list: %w", err) + } + matrix := fmt.Sprintf("%s=[%s]", flags.matrixKey, string(bytes)) + + // If verbose is set, print the matrix for debugging purpose. + if *flags.verbose { + fmt.Printf("Matrix: %s\n", matrix) + } + + // Get the path of the GitHub Actions environment file used for output. + output, ok := os.LookupEnv("GITHUB_OUTPUT") + if !ok { + return errors.New("unable to get GITHUB_OUTPUT var") + } + + // Open GitHub Actions output file + file, err := os.OpenFile(output, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("unable to open GitHub Actions output file: %w", err) + } + defer file.Close() + + // Append matrix to GitHub Actions output file + if _, err := fmt.Fprintf(file, "%s\n", matrix); err != nil { + return fmt.Errorf("unable to write matrix in GitHub Actions output file: %w", err) + } + + return nil +} + +func getPRListFromEvent(gh *client.GitHub, actionCtx *githubactions.GitHubContext) (utils.PRList, error) { + var prList utils.PRList + + switch actionCtx.EventName { + // Event triggered from GitHub Actions user interface. + case utils.EventWorkflowDispatch: + // Get input entered by the user. + rawInput, ok := utils.IndexMap(actionCtx.Event, "inputs", "pull-request-list").(string) + if !ok { + return nil, errors.New("unable to get workflow dispatch input") + } + input := strings.TrimSpace(rawInput) + + // If all PR are requested, list them from GitHub API. + if input == "all" { + prs, err := gh.ListPR(utils.PRStateOpen) + if err != nil { + return nil, fmt.Errorf("unable to list all PR: %w", err) + } + + prList = make(utils.PRList, len(prs)) + for i := range prs { + prList[i] = prs[i].GetNumber() + } + } else { + // If a PR list is provided, parse it. + if err := prList.UnmarshalText([]byte(input)); err != nil { + return nil, fmt.Errorf("invalid PR list provided as input: %w", err) + } + } + + // Event triggered by an issue / PR comment being created / edited / deleted + // or any update on a PR. + case utils.EventIssueComment, utils.EventPullRequest, utils.EventPullRequestTarget: + // For these events, retrieve the number of the associated PR from the context. + prNum, err := utils.GetPRNumFromActionsCtx(actionCtx) + if err != nil { + return nil, fmt.Errorf("unable to retrieve PR number from GitHub Actions context: %w", err) + } + prList = utils.PRList{prNum} + + default: + return nil, fmt.Errorf("unsupported event type: %s", actionCtx.EventName) + } + + // Then only keep provided PR that are opened. + var openedPRList utils.PRList = nil + for _, prNum := range prList { + if _, err := gh.GetOpenedPullRequest(prNum); err != nil { + gh.Logger.Warningf("Can't get PR from event: %v", err) + } else { + openedPRList = append(openedPRList, prNum) + } + } + + return openedPRList, nil +} diff --git a/contribs/github-bot/matrix_test.go b/contribs/github-bot/internal/matrix/matrix_test.go similarity index 91% rename from contribs/github-bot/matrix_test.go rename to contribs/github-bot/internal/matrix/matrix_test.go index bce4ec1bd8f..fe5b7452a49 100644 --- a/contribs/github-bot/matrix_test.go +++ b/contribs/github-bot/internal/matrix/matrix_test.go @@ -1,4 +1,4 @@ -package main +package matrix import ( "context" @@ -9,7 +9,6 @@ import ( "github.com/gnolang/gno/contribs/github-bot/internal/client" "github.com/gnolang/gno/contribs/github-bot/internal/logger" - "github.com/gnolang/gno/contribs/github-bot/internal/params" "github.com/gnolang/gno/contribs/github-bot/internal/utils" "github.com/google/go-github/v64/github" "github.com/migueleliasweb/go-github-mock/src/mock" @@ -34,7 +33,7 @@ func TestProcessEvent(t *testing.T) { name string gaCtx *githubactions.GitHubContext prs []*github.PullRequest - expectedPRList params.PRList + expectedPRList utils.PRList expectedError bool }{ { @@ -44,7 +43,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"issue": map[string]any{"number": 1.}}, }, prs, - params.PRList{1}, + utils.PRList{1}, false, }, { "valid pull_request event", @@ -53,7 +52,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"pull_request": map[string]any{"number": 1.}}, }, prs, - params.PRList{1}, + utils.PRList{1}, false, }, { "valid pull_request_target event", @@ -62,7 +61,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"pull_request": map[string]any{"number": 1.}}, }, prs, - params.PRList{1}, + utils.PRList{1}, false, }, { "invalid event (PR number not set)", @@ -71,7 +70,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"issue": nil}, }, prs, - params.PRList(nil), + utils.PRList(nil), true, }, { "invalid event name", @@ -80,7 +79,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"issue": map[string]any{"number": 1.}}, }, prs, - params.PRList(nil), + utils.PRList(nil), true, }, { "valid workflow_dispatch all", @@ -89,7 +88,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": "all"}}, }, openPRs, - params.PRList{1, 2, 3}, + utils.PRList{1, 2, 3}, false, }, { "valid workflow_dispatch all (no prs)", @@ -98,7 +97,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": "all"}}, }, nil, - params.PRList{}, + utils.PRList(nil), false, }, { "valid workflow_dispatch list", @@ -107,7 +106,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": "1,2,3"}}, }, prs, - params.PRList{1, 2, 3}, + utils.PRList{1, 2, 3}, false, }, { "valid workflow_dispatch list with spaces", @@ -116,7 +115,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": " 1, 2 ,3 "}}, }, prs, - params.PRList{1, 2, 3}, + utils.PRList{1, 2, 3}, false, }, { "invalid workflow_dispatch list (1 closed)", @@ -125,8 +124,8 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": "1,2,3,4"}}, }, prs, - params.PRList(nil), - true, + utils.PRList{1, 2, 3}, + false, }, { "invalid workflow_dispatch list (1 doesn't exist)", &githubactions.GitHubContext{ @@ -134,8 +133,8 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": "42"}}, }, prs, - params.PRList(nil), - true, + utils.PRList(nil), + false, }, { "invalid workflow_dispatch list (all closed)", &githubactions.GitHubContext{ @@ -143,8 +142,8 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": "4,5,6"}}, }, prs, - params.PRList(nil), - true, + utils.PRList(nil), + false, }, { "invalid workflow_dispatch list (empty)", &githubactions.GitHubContext{ @@ -152,7 +151,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": ""}}, }, prs, - params.PRList(nil), + utils.PRList(nil), true, }, { "invalid workflow_dispatch list (unset)", @@ -161,7 +160,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": ""}, }, prs, - params.PRList(nil), + utils.PRList(nil), true, }, { "invalid workflow_dispatch list (not a number list)", @@ -170,7 +169,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": "foo"}}, }, prs, - params.PRList(nil), + utils.PRList(nil), true, }, { "invalid workflow_dispatch list (number list with invalid elem)", @@ -179,7 +178,7 @@ func TestProcessEvent(t *testing.T) { Event: map[string]any{"inputs": map[string]any{"pull-request-list": "1,2,foo"}}, }, prs, - params.PRList(nil), + utils.PRList(nil), true, }, } { @@ -214,7 +213,7 @@ func TestProcessEvent(t *testing.T) { prNumStr := parts[len(parts)-1] prNum, err = strconv.Atoi(prNumStr) if err != nil { - panic(err) // Should never happen + panic(err) // Should never happen. } } diff --git a/contribs/github-bot/internal/requirements/assignee_test.go b/contribs/github-bot/internal/requirements/assignee_test.go index d72e8ad2a19..aa86fb0054d 100644 --- a/contribs/github-bot/internal/requirements/assignee_test.go +++ b/contribs/github-bot/internal/requirements/assignee_test.go @@ -45,7 +45,7 @@ func TestAssignee(t *testing.T) { mock.WithRequestMatchHandler( mock.EndpointPattern{ Pattern: "/repos/issues/0/assignees", - Method: "GET", // It looks like this mock package doesn't support mocking POST requests + Method: "GET", // It looks like this mock package doesn't support mocking POST requests. }, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { requested = true diff --git a/contribs/github-bot/internal/requirements/branch.go b/contribs/github-bot/internal/requirements/branch.go index b686a093015..6481285ae82 100644 --- a/contribs/github-bot/internal/requirements/branch.go +++ b/contribs/github-bot/internal/requirements/branch.go @@ -29,7 +29,7 @@ func (u *upToDateWith) IsSatisfied(pr *github.PullRequest, details treeprint.Tre } head := pr.GetHead().GetRef() - // If pull request is open from a fork, prepend head ref with fork owner login + // If pull request is open from a fork, prepend head ref with fork owner login. if pr.GetHead().GetRepo().GetFullName() != pr.GetBase().GetRepo().GetFullName() { head = fmt.Sprintf("%s:%s", pr.GetHead().GetRepo().GetOwner().GetLogin(), pr.GetHead().GetRef()) } diff --git a/contribs/github-bot/internal/requirements/label_test.go b/contribs/github-bot/internal/requirements/label_test.go index 7e991b55756..631bff9e64b 100644 --- a/contribs/github-bot/internal/requirements/label_test.go +++ b/contribs/github-bot/internal/requirements/label_test.go @@ -45,7 +45,7 @@ func TestLabel(t *testing.T) { mock.WithRequestMatchHandler( mock.EndpointPattern{ Pattern: "/repos/issues/0/labels", - Method: "GET", // It looks like this mock package doesn't support mocking POST requests + Method: "GET", // It looks like this mock package doesn't support mocking POST requests. }, http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { requested = true diff --git a/contribs/github-bot/internal/utils/actions.go b/contribs/github-bot/internal/utils/actions.go index 91b8ac7e6b4..3e08a8e1548 100644 --- a/contribs/github-bot/internal/utils/actions.go +++ b/contribs/github-bot/internal/utils/actions.go @@ -23,7 +23,7 @@ func IndexMap(m map[string]any, keys ...string) any { return nil } -// Retrieve PR number from GitHub Actions context +// Retrieve PR number from GitHub Actions context. func GetPRNumFromActionsCtx(actionCtx *githubactions.GitHubContext) (int, error) { firstKey := "" diff --git a/contribs/github-bot/internal/utils/github_const.go b/contribs/github-bot/internal/utils/github_const.go index 564b7d3fb38..26d7d54d477 100644 --- a/contribs/github-bot/internal/utils/github_const.go +++ b/contribs/github-bot/internal/utils/github_const.go @@ -1,14 +1,14 @@ package utils -// GitHub const +// GitHub API const. const ( - // GitHub Actions Event Names + // GitHub Actions Event Names. EventIssueComment = "issue_comment" EventPullRequest = "pull_request" EventPullRequestTarget = "pull_request_target" EventWorkflowDispatch = "workflow_dispatch" - // Pull Request States + // Pull Request States. PRStateOpen = "open" PRStateClosed = "closed" ) diff --git a/contribs/github-bot/internal/params/prlist.go b/contribs/github-bot/internal/utils/prlist.go similarity index 91% rename from contribs/github-bot/internal/params/prlist.go rename to contribs/github-bot/internal/utils/prlist.go index ace7bcbe3b6..2893bf802b5 100644 --- a/contribs/github-bot/internal/params/prlist.go +++ b/contribs/github-bot/internal/utils/prlist.go @@ -1,4 +1,4 @@ -package params +package utils import ( "encoding" @@ -7,6 +7,7 @@ import ( "strings" ) +// Type used to (un)marshal input/output for check and matrix subcommands. type PRList []int // PRList is both a TextMarshaler and a TextUnmarshaler. diff --git a/contribs/github-bot/main.go b/contribs/github-bot/main.go index 9895f44dc70..e11fe6ffd78 100644 --- a/contribs/github-bot/main.go +++ b/contribs/github-bot/main.go @@ -2,25 +2,43 @@ package main import ( "context" + "flag" "os" + "github.com/gnolang/gno/contribs/github-bot/internal/check" + "github.com/gnolang/gno/contribs/github-bot/internal/matrix" "github.com/gnolang/gno/tm2/pkg/commands" ) +type rootFlags struct { + verbose bool +} + func main() { + flags := &rootFlags{} + cmd := commands.NewCommand( commands.Metadata{ ShortUsage: "github-bot [flags]", LongHelp: "Bot that allows for advanced management of GitHub pull requests.", }, - commands.NewEmptyConfig(), + flags, commands.HelpExec, ) cmd.AddSubCommands( - newCheckCmd(), - newMatrixCmd(), + check.NewCheckCmd(&flags.verbose), + matrix.NewMatrixCmd(&flags.verbose), ) cmd.Execute(context.Background(), os.Args[1:]) } + +func (flags *rootFlags) RegisterFlags(fs *flag.FlagSet) { + fs.BoolVar( + &flags.verbose, + "verbose", + false, + "set logging level to debug", + ) +} diff --git a/contribs/github-bot/matrix.go b/contribs/github-bot/matrix.go deleted file mode 100644 index 56d6667589a..00000000000 --- a/contribs/github-bot/matrix.go +++ /dev/null @@ -1,117 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/gnolang/gno/contribs/github-bot/internal/client" - "github.com/gnolang/gno/contribs/github-bot/internal/params" - "github.com/gnolang/gno/contribs/github-bot/internal/utils" - "github.com/gnolang/gno/tm2/pkg/commands" - "github.com/sethvargo/go-githubactions" -) - -func newMatrixCmd() *commands.Command { - return commands.NewCommand( - commands.Metadata{ - Name: "matrix", - ShortUsage: "github-bot matrix", - ShortHelp: "parses GitHub Actions event and defines matrix accordingly", - LongHelp: "This tool checks if the requirements for a PR to be merged are satisfied (defined in config.go) and displays PR status checks accordingly.\nA valid GitHub Token must be provided by setting the GITHUB_TOKEN environment variable.", - }, - commands.NewEmptyConfig(), - func(_ context.Context, _ []string) error { - return execMatrix() - }, - ) -} - -func execMatrix() error { - // Get GitHub Actions context to retrieve event. - actionCtx, err := githubactions.Context() - if err != nil { - return fmt.Errorf("unable to get GitHub Actions context: %w", err) - } - - // Init Github client using only GitHub Actions context - owner, repo := actionCtx.Repo() - gh, err := client.New(context.Background(), ¶ms.Params{Owner: owner, Repo: repo}) - if err != nil { - return fmt.Errorf("unable to init GitHub client: %w", err) - } - - // Retrieve PR list from GitHub Actions event - prList, err := getPRListFromEvent(gh, actionCtx) - if err != nil { - return err - } - - // Print PR list for GitHub Actions matrix definition - bytes, err := prList.MarshalText() - if err != nil { - return fmt.Errorf("unable to marshal PR list: %w", err) - } - fmt.Printf("[%s]", string(bytes)) - - return nil -} - -func getPRListFromEvent(gh *client.GitHub, actionCtx *githubactions.GitHubContext) (params.PRList, error) { - var prList params.PRList - - switch actionCtx.EventName { - // Event triggered from GitHub Actions user interface - case utils.EventWorkflowDispatch: - // Get input entered by the user - rawInput, ok := utils.IndexMap(actionCtx.Event, "inputs", "pull-request-list").(string) - if !ok { - return nil, errors.New("unable to get workflow dispatch input") - } - input := strings.TrimSpace(rawInput) - - // If all PR are requested, list them from GitHub API - if input == "all" { - prs, err := gh.ListPR(utils.PRStateOpen) - if err != nil { - return nil, fmt.Errorf("unable to list all PR: %w", err) - } - - prList = make(params.PRList, len(prs)) - for i := range prs { - prList[i] = prs[i].GetNumber() - } - } else { - // If a PR list is provided, parse it - if err := prList.UnmarshalText([]byte(input)); err != nil { - return nil, fmt.Errorf("invalid PR list provided as input: %w", err) - } - - // Then check if all provided PR are opened - for _, prNum := range prList { - pr, _, err := gh.Client.PullRequests.Get(gh.Ctx, gh.Owner, gh.Repo, prNum) - if err != nil { - return nil, fmt.Errorf("unable to retrieve specified pull request (%d): %w", prNum, err) - } else if pr.GetState() != utils.PRStateOpen { - return nil, fmt.Errorf("pull request %d is not opened, actual state: %s", prNum, pr.GetState()) - } - } - } - - // Event triggered by an issue / PR comment being created / edited / deleted - // or any update on a PR - case utils.EventIssueComment, utils.EventPullRequest, utils.EventPullRequestTarget: - // For these events, retrieve the number of the associated PR from the context - prNum, err := utils.GetPRNumFromActionsCtx(actionCtx) - if err != nil { - return nil, fmt.Errorf("unable to retrieve PR number from GitHub Actions context: %w", err) - } - prList = params.PRList{prNum} - - default: - return nil, fmt.Errorf("unsupported event type: %s", actionCtx.EventName) - } - - return prList, nil -}