diff --git a/.github/ISSUE_TEMPLATE/feedback.md b/.github/ISSUE_TEMPLATE/feedback.md deleted file mode 100644 index 837c36632a5..00000000000 --- a/.github/ISSUE_TEMPLATE/feedback.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: "\U0001F4E3 Feedback" -about: Give us general feedback about the GitHub CLI -title: '' -labels: feedback -assignees: '' - ---- - -# CLI Feedback - -You can use this template to give us structured feedback or just wipe it and leave us a note. Thank you! - -## What have you loved? - -_eg "the nice colors"_ - -## What was confusing or gave you pause? - -_eg "it did something unexpected"_ - -## Are there features you'd like to see added? - -_eg "gh cli needs mini-games"_ - -## Anything else? - -_eg "have a nice day"_ diff --git a/.github/workflows/pr-help-wanted.yml b/.github/workflows/pr-help-wanted.yml deleted file mode 100644 index b63d025bc6b..00000000000 --- a/.github/workflows/pr-help-wanted.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: PR Help Wanted Check -on: - pull_request_target: - types: [opened] - workflow_dispatch: - inputs: - pr_number: - description: "Pull Request number to check" - required: true - type: string - -permissions: - contents: none - issues: read - pull-requests: write - -jobs: - check-help-wanted: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set PR variables for workflow_dispatch event - id: pr-vars-dispatch - if: github.event_name == 'workflow_dispatch' - env: - PR_NUMBER: ${{ github.event.inputs.pr_number }} - run: | - # We only need to construct the PR URL from the dispatch event input. - echo "pr_url=https://github.com/cli/cli/pull/${PR_NUMBER}" >> $GITHUB_OUTPUT - - - name: Check for issues without help-wanted label - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # These variables are optionally used in the check-help-wanted.sh - # script for additional checks; but they are not strictly necessary - # for the script to run. This is why we are okay with them being - # empty when the event is workflow_dispatch. - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} - PR_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} - PR_URL: ${{ github.event.pull_request.html_url || steps.pr-vars-dispatch.outputs.pr_url }} - run: | - # Run the script to check for issues without help-wanted label - bash .github/workflows/scripts/check-help-wanted.sh "${PR_URL}" diff --git a/.github/workflows/prauto.yml b/.github/workflows/prauto.yml deleted file mode 100644 index 40dfee8465a..00000000000 --- a/.github/workflows/prauto.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: PR Automation -on: - pull_request_target: - types: [ready_for_review, opened, reopened] - -permissions: - contents: none - issues: write - pull-requests: write - -jobs: - pr-auto: - runs-on: ubuntu-latest - environment: cli-automation - steps: - - name: lint pr - env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }} - PRBODY: ${{ github.event.pull_request.body }} - PRNUM: ${{ github.event.pull_request.number }} - PRHEAD: ${{ github.event.pull_request.head.label }} - PRAUTHOR: ${{ github.event.pull_request.user.login }} - PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} - if: "!github.event.pull_request.draft" - run: | - commentPR () { - gh pr comment $PRNUM -b "${1}" - } - - closePR () { - gh pr close $PRNUM - } - - colID () { - gh api graphql -f query='query($owner:String!, $repo:String!) { - repository(owner:$owner, name:$repo) { - project(number:1) { - columns(first:10) { nodes {id,name} } - } - } - }' -f owner="${GH_REPO%/*}" -f repo="${GH_REPO#*/}" \ - -q ".data.repository.project.columns.nodes[] | select(.name | startswith(\"$1\")) | .id" - } - - if [ "$PR_AUTHOR_TYPE" = "Bot" ] || gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null - then - if [ "$PR_AUTHOR_TYPE" != "Bot" ] - then - gh pr edit $PRNUM --add-assignee $PRAUTHOR - fi - exit 0 - fi - - gh pr edit $PRNUM --add-label "external" - - if [ "$PRHEAD" = "cli:trunk" ] - then - closePR - exit 0 - fi - - if [ $(wc -c <<<"$PRBODY") -lt 10 ] - then - commentPR "Thanks for the pull request! We're a small team and it's helpful to have context around community submissions in order to review them appropriately. Our automation has closed this pull request since it does not have an adequate description. Please edit the body of this pull request to describe what this does, then reopen it." - closePR - exit 0 - fi - - if ! grep -Eq '(#|issues/)[0-9]+' <<<"$PRBODY" - then - commentPR "Hi! Thanks for the pull request. Please ensure that this change is linked to an issue by mentioning an issue number in the description of the pull request. If this pull request would close the issue, please put the word 'Fixes' before the issue number somewhere in the pull request body. If this is a tiny change like fixing a typo, feel free to ignore this message." - fi - - exit 0 diff --git a/.github/workflows/scripts/check-help-wanted.sh b/.github/workflows/scripts/check-help-wanted.sh deleted file mode 100755 index d713be14440..00000000000 --- a/.github/workflows/scripts/check-help-wanted.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/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 - -# Skip if PR is from a bot or org member -if [ "$PR_AUTHOR_TYPE" = "Bot" ] || [ "$PR_AUTHOR_ASSOCIATION" = "MEMBER" ] || [ "$PR_AUTHOR_ASSOCIATION" = "OWNER" ]; then - echo "Skipping check for PR $PR_URL as it is from a bot ($PR_AUTHOR_TYPE) or an org member ($PR_AUTHOR_ASSOCIATION: MEMBER/OWNER)" - exit 0 -fi - -# Skip if PR is a draft -if [ "$(gh pr view "${PR_URL}" --json isDraft --jq '.isDraft')" != "false" ]; then - echo "Skipping check for PR $PR_URL as it is a draft" - exit 0 -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 -qE '^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 - <- + github.event_name == 'pull_request_target' && + (github.event.action == 'opened' || github.event.action == 'reopened') + uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-label-external-pr.yml@main + permissions: + issues: write + pull-requests: write + repository-projects: read + + close-from-default-branch: + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'opened' + uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-from-default-branch.yml@main + with: + default_branch: trunk + permissions: + pull-requests: write + + check-requirements: + if: >- + github.event_name == 'pull_request_target' && + (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'edited') + uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main + permissions: + issues: read + pull-requests: write + + close-unmet-requirements: + if: github.event_name == 'schedule' + uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main + permissions: + issues: read + pull-requests: write + + close-no-help-wanted: + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'labeled' + uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-no-help-wanted.yml@main + permissions: + pull-requests: write + + ready-for-review: + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'labeled' + uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-ready-for-review.yml@main + permissions: + pull-requests: write diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index b0ca81f4063..a2dd8c2f883 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -28,6 +28,10 @@ func (md *DisabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) { return ReleaseFeatures{}, nil } +func (md *DisabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) { + return ActionsFeatures{}, nil +} + type EnabledDetectorMock struct{} func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) { @@ -56,6 +60,12 @@ func (md *EnabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) { }, nil } +func (md *EnabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) { + return ActionsFeatures{ + DispatchRunDetails: true, + }, nil +} + type AdvancedIssueSearchDetectorMock struct { EnabledDetectorMock searchFeatures SearchFeatures diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 2aff20d98fb..b2fb6d65dea 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -18,6 +18,7 @@ type Detector interface { ProjectsV1() gh.ProjectsV1Support SearchFeatures() (SearchFeatures, error) ReleaseFeatures() (ReleaseFeatures, error) + ActionsFeatures() (ActionsFeatures, error) } type IssueFeatures struct { @@ -98,6 +99,16 @@ type ReleaseFeatures struct { ImmutableReleases bool } +type ActionsFeatures struct { + // DispatchRunDetails indicates whether the API supports the `return_run_details` + // field in workflow dispatches that, when set to true, will return the details + // of the created workflow run in the response (with status code 200). + // + // On older API versions (e.g. GHES 3.20 or earlier), this new field is not + // supported and setting it will cause an error. + DispatchRunDetails bool +} + type detector struct { host string httpClient *http.Client @@ -393,6 +404,54 @@ func (d *detector) ReleaseFeatures() (ReleaseFeatures, error) { return ReleaseFeatures{}, nil } +const ( + enterpriseWorkflowDispatchRunDetailsSupport = "3.21.0" +) + +func (d *detector) ActionsFeatures() (ActionsFeatures, error) { + // TODO workflowDispatchRunDetailsCleanup + // Once GHES 3.20 support ends, we don't need feature detection for workflow dispatch (i.e. run details support). + // + // On github.com, workflow dispatch API now supports a new field named `return_run_details` that enabling it will + // result in a 200 OK response with the details of the created workflow run. If not set (or set to false), the API + // will keep the old behavior of returning a 204 No Content response. + // + // On GHES (current latest at 3.20), this new field is not available, and setting it will cause a 400 response. + // + // Once GHES 3.20 support ends, we can remove the feature detection and start using the new field in API calls. + // + // IMPORTANT: In the future REST API versions (i.e. breaking changes), the workflow dispatch endpoint is going to + // always return the details of the created workflow run in the response, and the `return_run_details` field is + // going to be ignored/removed. So, once we are migrating to the new API version we should double check the status + // of the API. + + if !ghauth.IsEnterprise(d.host) { + return ActionsFeatures{ + DispatchRunDetails: true, + }, nil + } + + minSupportedVersion, err := version.NewVersion(enterpriseWorkflowDispatchRunDetailsSupport) + if err != nil { + return ActionsFeatures{}, err + } + + hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host) + if err != nil { + return ActionsFeatures{}, err + } + + if hostVersion.GreaterThanOrEqual(minSupportedVersion) { + return ActionsFeatures{ + DispatchRunDetails: true, + }, nil + } + + return ActionsFeatures{ + DispatchRunDetails: false, + }, nil +} + func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) { var metaResponse struct { InstalledVersion string `json:"installed_version"` diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index d3ee1a7e944..f4eeb5962fa 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -696,3 +696,71 @@ func TestReleaseFeatures(t *testing.T) { }) } } + +func TestActionsFeatures(t *testing.T) { + tests := []struct { + name string + hostname string + httpStubs func(*httpmock.Registry) + wantFeatures ActionsFeatures + }{ + { + name: "github.com, workflow dispatch run details supported", + hostname: "github.com", + wantFeatures: ActionsFeatures{ + DispatchRunDetails: true, + }, + }, + { + name: "ghec data residency (ghe.com), workflow dispatch run details supported", + hostname: "stampname.ghe.com", + wantFeatures: ActionsFeatures{ + DispatchRunDetails: true, + }, + }, + { + name: "GHE 3.20, workflow dispatch run details not supported", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.20.999"}`), + ) + }, + wantFeatures: ActionsFeatures{ + DispatchRunDetails: false, + }, + }, + { + name: "GHE 3.21, workflow dispatch run details supported", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.21.0"}`), + ) + }, + wantFeatures: ActionsFeatures{ + DispatchRunDetails: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + httpClient := &http.Client{} + httpmock.ReplaceTripper(httpClient, reg) + + detector := NewDetector(httpClient, tt.hostname) + + features, err := detector.ActionsFeatures() + require.NoError(t, err) + require.Equal(t, tt.wantFeatures, features) + }) + } +} diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go index 1773a8f8312..6f00c906cbd 100644 --- a/pkg/cmd/gist/edit/edit.go +++ b/pkg/cmd/gist/edit/edit.go @@ -12,6 +12,7 @@ import ( "sort" "strings" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/prompter" @@ -58,6 +59,28 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "edit { | } []", Short: "Edit one of your gists", + Example: heredoc.Doc(` + # Select a gist to edit interactively + $ gh gist edit + + # Edit a gist file in the default editor + $ gh gist edit 1234567890abcdef1234567890abcdef + + # Edit a specific file in the gist + $ gh gist edit 1234567890abcdef1234567890abcdef --filename hello.py + + # Replace a gist file with content from a local file + $ gh gist edit 1234567890abcdef1234567890abcdef --filename hello.py hello.py + + # Add a new file to the gist + $ gh gist edit 1234567890abcdef1234567890abcdef --add newfile.py + + # Change the description of the gist + $ gh gist edit 1234567890abcdef1234567890abcdef --desc "new description" + + # Remove a file from the gist + $ gh gist edit 1234567890abcdef1234567890abcdef --remove hello.py + `), Args: func(cmd *cobra.Command, args []string) error { if len(args) > 2 { return cmdutil.FlagErrorf("too many arguments") diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 2acd1d4ccb8..386227bf16b 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -7,12 +7,15 @@ import ( "fmt" "io" "net/http" + "net/url" "reflect" "sort" "strings" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -25,6 +28,7 @@ type RunOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Detector fd.Detector Prompter iprompter Selector string @@ -64,6 +68,8 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command - Interactively - Via %[1]s-f/--raw-field%[1]s or %[1]s-F/--field%[1]s flags - As JSON, via standard input + + The created workflow run URL will be returned if available. `, "`"), Example: heredoc.Doc(` # Have gh prompt you for what workflow you'd like to run and interactively collect inputs @@ -260,6 +266,11 @@ func runRun(opts *RunOptions) error { return err } + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(c, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost()) + } + ref := opts.Ref if ref == "" { @@ -303,34 +314,77 @@ func runRun(opts *RunOptions) error { } } - path := fmt.Sprintf("repos/%s/actions/workflows/%d/dispatches", - ghrepo.FullName(repo), workflow.ID) + features, err := opts.Detector.ActionsFeatures() + if err != nil { + return err + } + + path := fmt.Sprintf("repos/%s/%s/actions/workflows/%d/dispatches", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), workflow.ID) - requestByte, err := json.Marshal(map[string]interface{}{ + requestBody := map[string]interface{}{ "ref": ref, "inputs": providedInputs, - }) + } + + // TODO workflowDispatchRunDetailsCleanup + // We will have to always set the `return_run_details` field to true, unless + // we opt into the the new REST API version, which will probably return the + // details by default. + if features.DispatchRunDetails { + requestBody["return_run_details"] = true + } + + requestByte, err := json.Marshal(requestBody) if err != nil { return fmt.Errorf("failed to serialize workflow inputs: %w", err) } body := bytes.NewReader(requestByte) - err = client.REST(repo.RepoHost(), "POST", path, body, nil) + var response struct { + WorkflowRunID int64 `json:"workflow_run_id"` + RunURL string `json:"run_url"` + HtmlURL string `json:"html_url"` + } + + // Note that the workflow dispatch endpoint used to return 204 No Content + // (with no body, obviously). Now it's possible for the endpoint to also + // return 200 OK with created run details. So, we have to handle both cases + // because old GHE versions still return 204. Even on github.com, we + // may still get 204 for any reason. + // + // Our REST client library is smart enough to ignore JSON unmarshal when it + // receives 204, so we're safe here anyway. + // + // As a related note, the new REST API version (which will come with breaking + // changes) will probably default to return 200 + run details. + err = client.REST(repo.RepoHost(), "POST", path, body, &response) if err != nil { return fmt.Errorf("could not create workflow dispatch event: %w", err) } if opts.IO.IsStdoutTTY() { - out := opts.IO.Out cs := opts.IO.ColorScheme() - fmt.Fprintf(out, "%s Created workflow_dispatch event for %s at %s\n", + fmt.Fprintf(opts.IO.Out, "%s Created workflow_dispatch event for %s at %s\n", cs.SuccessIcon(), cs.Cyan(workflow.Base()), cs.Bold(ref)) - fmt.Fprintln(out) + if response.HtmlURL != "" { + fmt.Fprintln(opts.IO.Out, response.HtmlURL) + } - fmt.Fprintf(out, "To see runs for this workflow, try: %s\n", + fmt.Fprintln(opts.IO.Out) + + if response.WorkflowRunID != 0 { + fmt.Fprintf(opts.IO.Out, "To see the created workflow run, try: %s\n", + cs.Boldf("gh run view %d", response.WorkflowRunID)) + } + + fmt.Fprintf(opts.IO.Out, "To see runs for this workflow, try: %s\n", cs.Boldf("gh run list --workflow=%q", workflow.Base())) + } else { + if response.HtmlURL != "" { + fmt.Fprintln(opts.IO.Out, response.HtmlURL) + } } return nil diff --git a/pkg/cmd/workflow/run/run_test.go b/pkg/cmd/workflow/run/run_test.go index b121a573d49..a4e44e5dabd 100644 --- a/pkg/cmd/workflow/run/run_test.go +++ b/pkg/cmd/workflow/run/run_test.go @@ -10,7 +10,9 @@ import ( "os" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" @@ -394,6 +396,7 @@ jobs: run: echo "${{ github.event.inputs.message }} ${{ fromJSON('["", "🥳"]')[github.event.inputs.use-emoji == 'true'] }} ${{ github.event.inputs.name }}"`) encodedYAMLContentMissingChoiceIp := base64.StdEncoding.EncodeToString(yamlContentMissingChoiceIp) + // Old GitHub API servers return 204 No Content for successful workflow dispatches. stubs := func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"), @@ -406,6 +409,24 @@ jobs: httpmock.StatusStringResponse(204, "cool")) } + // Current GitHub API servers return 200 OK with run info for successful workflow dispatches, + // if `return_run_details` is enabled in the request body. + stubsWithRunInfo := func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"), + httpmock.JSONResponse(shared.Workflow{ + Path: ".github/workflows/workflow.yml", + ID: 12345, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + } + tests := []struct { name string opts *RunOptions @@ -434,11 +455,14 @@ jobs: errOut: "could not parse provided JSON: unexpected end of JSON input", }, { - name: "good JSON", + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "good JSON without run info (204)", tty: true, opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"name":"scully"}`, + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -447,13 +471,44 @@ jobs: "ref": "trunk", }, httpStubs: stubs, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { - name: "nontty good JSON", + name: "good JSON with run info", + tty: true, opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"name":"scully"}`, + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + }, + "ref": "trunk", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "nontty good JSON without run info (204)", + opts: &RunOptions{ + Selector: "workflow.yml", + JSONInput: `{"name":"scully"}`, + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -464,11 +519,31 @@ jobs: httpStubs: stubs, }, { - name: "nontty good input fields", + name: "nontty good JSON with run info", + opts: &RunOptions{ + Selector: "workflow.yml", + JSONInput: `{"name":"scully"}`, + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + }, + "ref": "trunk", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: "https://github.com/OWNER/REPO/actions/runs/6789\n", + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "nontty good input fields without run info (204)", opts: &RunOptions{ Selector: "workflow.yml", RawFields: []string{`name=scully`}, MagicFields: []string{`greeting=hey`}, + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -480,12 +555,34 @@ jobs: httpStubs: stubs, }, { - name: "respects ref", + name: "nontty good input fields with run info", + opts: &RunOptions{ + Selector: "workflow.yml", + RawFields: []string{`name=scully`}, + MagicFields: []string{`greeting=hey`}, + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + "greeting": "hey", + }, + "ref": "trunk", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: "https://github.com/OWNER/REPO/actions/runs/6789\n", + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "respects ref, without run info (204)", tty: true, opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"name":"scully"}`, Ref: "good-branch", + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -494,7 +591,36 @@ jobs: "ref": "good-branch", }, httpStubs: stubs, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at good-branch\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at good-branch + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + name: "respects ref, with run info", + tty: true, + opts: &RunOptions{ + Selector: "workflow.yml", + JSONInput: `{"name":"scully"}`, + Ref: "good-branch", + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + }, + "ref": "good-branch", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at good-branch + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { // TODO this test is somewhat silly; it's more of a placeholder in case I decide to handle the API error more elegantly @@ -503,6 +629,7 @@ jobs: opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"greeting":"hello there"}`, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -515,6 +642,13 @@ jobs: httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), httpmock.StatusStringResponse(422, "missing something")) }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "greeting": "hello there", + }, + "ref": "trunk", + "return_run_details": true, + }, wantErr: true, errOut: "could not create workflow dispatch event: HTTP 422 (https://api.github.com/repos/OWNER/REPO/actions/workflows/12345/dispatches)", }, @@ -523,6 +657,7 @@ jobs: tty: false, opts: &RunOptions{ Selector: "workflow.yaml", + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -530,13 +665,19 @@ jobs: httpmock.StatusStringResponse(200, `{"id": 12345}`)) reg.Register( httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), - httpmock.StatusStringResponse(204, "")) + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) }, wantBody: map[string]interface{}{ - "inputs": map[string]interface{}{}, - "ref": "trunk", + "inputs": map[string]interface{}{}, + "ref": "trunk", + "return_run_details": true, }, wantErr: false, + wantOut: "https://github.com/OWNER/REPO/actions/runs/6789\n", }, { // TODO this test is somewhat silly; it's more of a placeholder in case I decide to handle the API error more elegantly @@ -544,6 +685,7 @@ jobs: opts: &RunOptions{ Selector: "workflow.yml", RawFields: []string{`greeting="hello there"`}, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -563,7 +705,8 @@ jobs: name: "prompt, no workflows enabled", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -585,7 +728,8 @@ jobs: name: "prompt, no workflows", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -598,10 +742,13 @@ jobs: errOut: "could not fetch workflows for OWNER/REPO: no workflows are enabled", }, { - name: "prompt, minimal yaml", + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "prompt, minimal yaml, without run info (204)", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.DisabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -634,13 +781,71 @@ jobs: "inputs": map[string]interface{}{}, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for minimal.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"minimal.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for minimal.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="minimal.yml" + `), }, { - name: "prompt", + name: "prompt, minimal yaml, with run info", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + { + Name: "minimal workflow", + ID: 1, + State: shared.Active, + Path: ".github/workflows/minimal.yml", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/minimal.yml"), + httpmock.JSONResponse(struct{ Content string }{ + Content: encodedNoInputsYAMLContent, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/1/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"minimal workflow (minimal.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{}, + "ref": "trunk", + "return_run_details": true, + }, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for minimal.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="minimal.yml" + `), + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "prompt without run info (204)", + tty: true, + opts: &RunOptions{ + Prompt: true, + Detector: &fd.DisabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -682,13 +887,80 @@ jobs: }, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { - name: "prompt, workflow choice input", + name: "prompt with run info", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + { + Name: "a workflow", + ID: 12345, + State: shared.Active, + Path: ".github/workflows/workflow.yml", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.JSONResponse(struct{ Content string }{ + Content: encodedYAMLContent, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"a workflow (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterInput("greeting", func(_, _ string) (string, error) { + return "hi", nil + }) + pm.RegisterInput("name (required)", func(_, _ string) (string, error) { + return "scully", nil + }) + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + "greeting": "hi", + }, + "ref": "trunk", + "return_run_details": true, + }, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "prompt, workflow choice input without run info (204)", + tty: true, + opts: &RunOptions{ + Prompt: true, + Detector: &fd.DisabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -731,13 +1003,79 @@ jobs: }, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + name: "prompt, workflow choice input with run info", + tty: true, + opts: &RunOptions{ + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + { + Name: "choice inputs", + ID: 12345, + State: shared.Active, + Path: ".github/workflows/workflow.yml", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.JSONResponse(struct{ Content string }{ + Content: encodedYAMLContentChoiceIp, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"choice inputs (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterSelect("favourite-animal (required)", []string{"dog", "cat"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterSelect("name", []string{"monalisa", "cschleiden"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "monalisa", + "favourite-animal": "dog", + }, + "ref": "trunk", + "return_run_details": true, + }, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { name: "prompt, workflow choice missing input", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -757,9 +1095,6 @@ jobs: httpmock.JSONResponse(struct{ Content string }{ Content: encodedYAMLContentMissingChoiceIp, })) - reg.Register( - httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), - httpmock.StatusStringResponse(204, "cool")) }, promptStubs: func(pm *prompter.MockPrompter) { pm.RegisterSelect("Select a workflow", []string{"choice missing inputs (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { @@ -775,27 +1110,28 @@ jobs: } for _, tt := range tests { - reg := &httpmock.Registry{} - if tt.httpStubs != nil { - tt.httpStubs(reg) - } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - - ios, _, stdout, _ := iostreams.Test() - ios.SetStdinTTY(tt.tty) - ios.SetStdoutTTY(tt.tty) - tt.opts.IO = ios - tt.opts.BaseRepo = func() (ghrepo.Interface, error) { - return api.InitRepoHostname(&api.Repository{ - Name: "REPO", - Owner: api.RepositoryOwner{Login: "OWNER"}, - DefaultBranchRef: api.BranchRef{Name: "trunk"}, - }, "github.com"), nil - } - t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + tt.opts.IO = ios + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return api.InitRepoHostname(&api.Repository{ + Name: "REPO", + Owner: api.RepositoryOwner{Login: "OWNER"}, + DefaultBranchRef: api.BranchRef{Name: "trunk"}, + }, "github.com"), nil + } + pm := prompter.NewMockPrompter(t) tt.opts.Prompter = pm if tt.promptStubs != nil { @@ -810,7 +1146,6 @@ jobs: } assert.NoError(t, err) assert.Equal(t, tt.wantOut, stdout.String()) - reg.Verify(t) if len(reg.Requests) > 0 { lastRequest := reg.Requests[len(reg.Requests)-1]