diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8cd5ecbee..d74e1c142 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,6 +18,10 @@ permissions: jobs: CodeQL-Build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: ['go', 'actions'] steps: - name: Check out code @@ -26,13 +30,16 @@ jobs: - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: - languages: go + languages: ${{ matrix.language }} queries: security-and-quality - name: Setup Go + if: matrix.language == 'go' uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 850cc19b7..17758a3e6 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -309,7 +309,7 @@ jobs: rpmsign --addsign dist/*.rpm - name: Attest release artifacts if: inputs.environment == 'production' - uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-path: "dist/gh_*" - name: Run createrepo @@ -384,7 +384,7 @@ jobs: git diff --name-status @{upstream}.. fi - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@942e550c6344cfdb9e1ab29b9bb9bf0c43efa19b + uses: mislav/bump-homebrew-formula-action@8e2baa47daaa8db10fcdeb04105dfa6850eb0d68 if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') with: formula-name: gh diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index 228f1a345..0b42803aa 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@942e550c6344cfdb9e1ab29b9bb9bf0c43efa19b + uses: mislav/bump-homebrew-formula-action@8e2baa47daaa8db10fcdeb04105dfa6850eb0d68 if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') with: formula-name: gh diff --git a/.github/workflows/pr-help-wanted.yml b/.github/workflows/pr-help-wanted.yml new file mode 100644 index 000000000..5475d2eff --- /dev/null +++ b/.github/workflows/pr-help-wanted.yml @@ -0,0 +1,29 @@ +name: PR Help Wanted Check +on: + pull_request_target: + types: [opened] + +permissions: + contents: none + issues: read + pull-requests: write + +jobs: + check-help-wanted: + runs-on: ubuntu-latest + steps: + - name: Check for issues without help-wanted label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + if: !github.event.pull_request.draft + run: | + # Skip if PR is from a bot or org member + if [ "$PR_AUTHOR_TYPE" = "Bot" ] || "gh api orgs/cli/public_members/${PR_AUTHOR}" --silent 2>/dev/null + then + exit 0 + fi + + # Run the script to check for issues without help-wanted label + bash .github/scripts/check-help-wanted.sh ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/scripts/check-help-wanted.sh b/.github/workflows/scripts/check-help-wanted.sh new file mode 100755 index 000000000..59c12fecd --- /dev/null +++ b/.github/workflows/scripts/check-help-wanted.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -e + +PR_URL="$1" + +if [ -z "$PR_URL" ]; then + echo "Usage: $0 " + echo "" + echo "Check if the PR references any non-help-wanted issues and, if so, comment" + echo "on it explaining why the team might close/dismiss it." + exit 1 +fi + +# Extract PR number from URL for logging +PR_NUM="$(basename "$PR_URL")" + +# Extract cli/cli closing issues references from PR +CLOSING_ISSUES="$(gh pr view "$PR_URL" --json closingIssuesReferences --jq '.closingIssuesReferences[] | select(.repository.name == "cli" and .repository.owner.login == "cli") | .number')" + +if [ -z "$CLOSING_ISSUES" ]; then + echo "No closing issues found for PR #$PR_NUM" + exit 0 +fi + +# Check each closing issue for 'help-wanted' label +ISSUES_WITHOUT_HELP_WANTED=() + +for issue_num in $CLOSING_ISSUES; do + echo "Checking issue #$issue_num for 'help wanted' label..." + + # Get issue labels + LABELS=$(gh issue view "$issue_num" --json labels --jq '.labels[].name') + + # Skip if the issue has the gh-attestion or gh-codespace label + # This is because the codeowners for these commands may not be public + # cli org members, and so unless we authenticate with a PAT, we can't + # know who is an external contributor or not. + # So we skip these issues to avoid falsely writing a comment + # on each PR opened by these codeowners. + if echo "$LABELS" | grep -q -e "gh-attestation" -e "gh-codespace"; then + echo "Issue #$issue_num is skipped due to labels" + continue + fi + + # Check if 'help wanted' label exists + if ! echo "$LABELS" | grep -q "help wanted"; then + ISSUES_WITHOUT_HELP_WANTED+=("$issue_num") + echo "Issue #$issue_num does not have 'help wanted' label" + else + echo "Issue #$issue_num has 'help wanted' label" + fi +done + +# If we found issues without 'help wanted' label, post a comment +if [ ${#ISSUES_WITHOUT_HELP_WANTED[@]} -gt 0 ]; then + echo "Found ${#ISSUES_WITHOUT_HELP_WANTED[@]} issues without 'help wanted' label" + + # Build issue list for comment + ISSUE_LIST="" + for issue_num in "${ISSUES_WITHOUT_HELP_WANTED[@]}"; do + ISSUE_LIST="$ISSUE_LIST- #$issue_num"$'\n' + done + + # Create comment message + gh pr comment "$PR_URL" --body-file - < 0 { // If we have a PR number, let's look it up @@ -297,6 +324,10 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err }) } + if actorAssigneesUsed { + pr.AssignedActorsUsed = true + } + return pr, f.baseRefRepo, g.Wait() } diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index abc754d1a..0fd96e09b 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -9,6 +9,7 @@ import ( ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/require" @@ -705,6 +706,81 @@ func TestFind(t *testing.T) { } } +func TestFindAssignableActors(t *testing.T) { + t.Run("given actors are not assignable, do nothing special", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Ensure we never request assignedActors + reg.Exclude(t, httpmock.GraphQL(`assignedActors`)) + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{"number":13} + }}}`)) + + f := finder{ + httpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + pr, _, err := f.Find(FindOptions{ + Detector: &fd.DisabledDetectorMock{}, + Fields: []string{"assignees"}, + Selector: "https://github.com/cli/cli/pull/13", + }) + require.NoError(t, err) + + require.False(t, pr.AssignedActorsUsed, "expected PR not to have assigned actors used") + }) + + t.Run("given actors are assignable, request assignedActors and indicate that on the returned PR", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Ensure that we only respond if assignedActors is requested + reg.Register( + httpmock.GraphQL(`assignedActors`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{ + "number":13, + "assignedActors": { + "nodes": [ + { + "id": "HUBOTID", + "login": "hubot", + "__typename": "Bot" + }, + { + "id": "MONAID", + "login": "MonaLisa", + "name": "Mona Display Name", + "__typename": "User" + } + ], + "totalCount": 2 + }} + }}}`)) + + f := finder{ + httpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + pr, _, err := f.Find(FindOptions{ + Detector: &fd.EnabledDetectorMock{}, + Fields: []string{"assignees"}, + Selector: "https://github.com/cli/cli/pull/13", + }) + require.NoError(t, err) + + require.Equal(t, []string{"hubot", "MonaLisa"}, pr.AssignedActors.Logins()) + require.True(t, pr.AssignedActorsUsed, "expected PR to have assigned actors used") + }) +} + func stubBranchConfig(branchConfig git.BranchConfig, err error) func(context.Context, string) (git.BranchConfig, error) { return func(_ context.Context, branch string) (git.BranchConfig, error) { return branchConfig, err diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go index 66d5d6f64..8777e0a8a 100644 --- a/pkg/cmd/run/rerun/rerun.go +++ b/pkg/cmd/run/rerun/rerun.go @@ -202,10 +202,7 @@ func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFa if err != nil { var httpError api.HTTPError if errors.As(err, &httpError) && httpError.StatusCode == 403 { - if httpError.Message == "Unable to retry this workflow run because it was created over a month ago" { - return fmt.Errorf("run %d cannot be rerun; %s", run.ID, httpError.Message) - } - return fmt.Errorf("run %d cannot be rerun; its workflow file may be broken", run.ID) + return fmt.Errorf("run %d cannot be rerun; %s", run.ID, httpError.Message) } return fmt.Errorf("failed to rerun: %w", err) } diff --git a/pkg/cmd/run/rerun/rerun_test.go b/pkg/cmd/run/rerun/rerun_test.go index 0dc74129e..77f0a922f 100644 --- a/pkg/cmd/run/rerun/rerun_test.go +++ b/pkg/cmd/run/rerun/rerun_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/api" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -374,7 +375,7 @@ func TestRerun(t *testing.T) { errOut: "no recent runs have failed; please specify a specific ``", }, { - name: "unrerunnable", + name: "API error (403)", tty: true, opts: &RerunOptions{ RunID: "3", @@ -392,10 +393,42 @@ func TestRerun(t *testing.T) { })) reg.Register( httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/3/rerun"), - httpmock.StatusStringResponse(403, "no")) + httpmock.JSONErrorResponse(403, api.HTTPError{ + StatusCode: 403, + Message: "blah blah", + }), + ) }, wantErr: true, - errOut: "run 3 cannot be rerun; its workflow file may be broken", + errOut: "run 3 cannot be rerun; blah blah", + }, + { + name: "API error (non-403)", + tty: true, + opts: &RerunOptions{ + RunID: "3", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/3/rerun"), + httpmock.JSONErrorResponse(500, api.HTTPError{ + StatusCode: 500, + Message: "blah blah", + }), + ) + }, + wantErr: true, + errOut: "failed to rerun: HTTP 500: blah blah (https://api.github.com/repos/OWNER/REPO/actions/runs/3/rerun)", }, }