├── .devcontainer └── devcontainer.json ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── pullpo.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── api ├── client.go ├── client_test.go ├── export_pr.go ├── export_pr_test.go ├── export_repo.go ├── http_client.go ├── http_client_test.go ├── pull_request_test.go ├── queries_branch_issue_reference.go ├── queries_comments.go ├── queries_issue.go ├── queries_org.go ├── queries_pr.go ├── queries_pr_review.go ├── queries_pr_test.go ├── queries_projects_v2.go ├── queries_projects_v2_test.go ├── queries_repo.go ├── queries_repo_test.go ├── queries_user.go ├── query_builder.go ├── query_builder_test.go ├── reaction_groups.go └── reaction_groups_test.go ├── build └── windows │ ├── gh.wixproj │ ├── gh.wxs │ └── ui.wxs ├── cmd ├── gen-docs │ ├── main.go │ └── main_test.go └── pullpo │ ├── main.go │ └── main_test.go ├── context ├── context.go ├── remote.go └── remote_test.go ├── git ├── client.go ├── client_test.go ├── command.go ├── errors.go ├── fixtures │ ├── .gitignore │ └── simple.git │ │ ├── HEAD │ │ ├── config │ │ ├── index │ │ ├── logs │ │ ├── HEAD │ │ └── refs │ │ │ └── heads │ │ │ └── main │ │ ├── objects │ │ ├── 4b │ │ │ └── 825dc642cb6eb9a060e54bf8d69288fbee4904 │ │ ├── 6f │ │ │ └── 1a2405cace1633d89a79c74c65f22fe78f9659 │ │ └── d1 │ │ │ └── e0abfb7d158ed544a202a6958c62d4fc22e12f │ │ └── refs │ │ └── heads │ │ └── main ├── objects.go ├── url.go └── url_test.go ├── go.mod ├── go.sum ├── internal ├── authflow │ ├── flow.go │ └── success.go ├── browser │ ├── browser.go │ └── stub.go ├── build │ └── build.go ├── codespaces │ ├── api │ │ ├── api.go │ │ └── api_test.go │ ├── codespaces.go │ ├── connection │ │ ├── connection.go │ │ ├── connection_test.go │ │ └── tunnels_api_server_mock.go │ ├── portforwarder │ │ ├── port_forwarder.go │ │ └── port_forwarder_test.go │ ├── rpc │ │ ├── codespace │ │ │ ├── codespace_host_service.v1.pb.go │ │ │ ├── codespace_host_service.v1.proto │ │ │ ├── codespace_host_service.v1.proto.mock.go │ │ │ └── codespace_host_service.v1_grpc.pb.go │ │ ├── generate.md │ │ ├── generate.sh │ │ ├── invoker.go │ │ ├── invoker_test.go │ │ ├── jupyter │ │ │ ├── jupyter_server_host_service.v1.pb.go │ │ │ ├── jupyter_server_host_service.v1.proto │ │ │ ├── jupyter_server_host_service.v1.proto.mock.go │ │ │ └── jupyter_server_host_service.v1_grpc.pb.go │ │ ├── ssh │ │ │ ├── ssh_server_host_service.v1.pb.go │ │ │ ├── ssh_server_host_service.v1.proto │ │ │ ├── ssh_server_host_service.v1.proto.mock.go │ │ │ └── ssh_server_host_service.v1_grpc.pb.go │ │ └── test │ │ │ └── port_forwarder.go │ ├── ssh.go │ ├── ssh_test.go │ └── states.go ├── config │ ├── auth_config_test.go │ ├── config.go │ ├── config_mock.go │ ├── config_test.go │ └── stub.go ├── docs │ ├── docs_test.go │ ├── man.go │ ├── man_test.go │ ├── markdown.go │ └── markdown_test.go ├── featuredetection │ ├── detector_mock.go │ ├── feature_detection.go │ └── feature_detection_test.go ├── ghinstance │ ├── host.go │ └── host_test.go ├── ghrepo │ ├── repo.go │ └── repo_test.go ├── keyring │ └── keyring.go ├── prompter │ ├── prompter.go │ ├── prompter_mock.go │ └── test.go ├── run │ ├── run.go │ └── stub.go ├── tableprinter │ └── table_printer.go ├── text │ ├── text.go │ └── text_test.go └── update │ ├── update.go │ └── update_test.go ├── pkg ├── cmd │ ├── actions │ │ └── actions.go │ ├── alias │ │ ├── alias.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── imports │ │ │ ├── import.go │ │ │ └── import_test.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── set │ │ │ ├── set.go │ │ │ └── set_test.go │ │ └── shared │ │ │ ├── validations.go │ │ │ └── validations_test.go │ ├── api │ │ ├── api.go │ │ ├── api_test.go │ │ ├── fields.go │ │ ├── fields_test.go │ │ ├── http.go │ │ ├── http_test.go │ │ ├── pagination.go │ │ └── pagination_test.go │ ├── auth │ │ ├── auth.go │ │ ├── gitcredential │ │ │ ├── helper.go │ │ │ └── helper_test.go │ │ ├── login │ │ │ ├── login.go │ │ │ └── login_test.go │ │ ├── logout │ │ │ ├── logout.go │ │ │ └── logout_test.go │ │ ├── refresh │ │ │ ├── refresh.go │ │ │ └── refresh_test.go │ │ ├── setupgit │ │ │ ├── setupgit.go │ │ │ └── setupgit_test.go │ │ ├── shared │ │ │ ├── git_credential.go │ │ │ ├── git_credential_test.go │ │ │ ├── login_flow.go │ │ │ ├── login_flow_test.go │ │ │ ├── oauth_scopes.go │ │ │ ├── oauth_scopes_test.go │ │ │ ├── prompt.go │ │ │ └── writeable.go │ │ ├── status │ │ │ ├── status.go │ │ │ └── status_test.go │ │ └── token │ │ │ ├── token.go │ │ │ └── token_test.go │ ├── browse │ │ ├── browse.go │ │ └── browse_test.go │ ├── cache │ │ ├── cache.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ └── shared │ │ │ ├── shared.go │ │ │ └── shared_test.go │ ├── codespace │ │ ├── code.go │ │ ├── code_test.go │ │ ├── codespace_selector.go │ │ ├── codespace_selector_test.go │ │ ├── common.go │ │ ├── common_test.go │ │ ├── create.go │ │ ├── create_test.go │ │ ├── delete.go │ │ ├── delete_test.go │ │ ├── edit.go │ │ ├── edit_test.go │ │ ├── jupyter.go │ │ ├── list.go │ │ ├── list_test.go │ │ ├── logs.go │ │ ├── logs_test.go │ │ ├── mock_api.go │ │ ├── mock_prompter.go │ │ ├── ports.go │ │ ├── ports_test.go │ │ ├── rebuild.go │ │ ├── rebuild_test.go │ │ ├── root.go │ │ ├── select.go │ │ ├── select_test.go │ │ ├── ssh.go │ │ ├── ssh_test.go │ │ ├── stop.go │ │ ├── stop_test.go │ │ ├── view.go │ │ └── view_test.go │ ├── completion │ │ ├── completion.go │ │ └── completion_test.go │ ├── config │ │ ├── clear-cache │ │ │ ├── clear_cache.go │ │ │ └── clear_cache_test.go │ │ ├── config.go │ │ ├── get │ │ │ ├── get.go │ │ │ └── get_test.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ └── set │ │ │ ├── set.go │ │ │ └── set_test.go │ ├── extension │ │ ├── browse │ │ │ ├── browse.go │ │ │ ├── browse_test.go │ │ │ └── rg.go │ │ ├── command.go │ │ ├── command_test.go │ │ ├── ext_tmpls │ │ │ ├── buildScript.sh │ │ │ ├── goBinMain.go.txt │ │ │ ├── goBinWorkflow.yml │ │ │ ├── otherBinWorkflow.yml │ │ │ └── script.sh │ │ ├── extension.go │ │ ├── extension_test.go │ │ ├── git.go │ │ ├── http.go │ │ ├── manager.go │ │ ├── manager_test.go │ │ ├── mocks.go │ │ ├── symlink_other.go │ │ └── symlink_windows.go │ ├── factory │ │ ├── default.go │ │ ├── default_test.go │ │ ├── remote_resolver.go │ │ └── remote_resolver_test.go │ ├── gist │ │ ├── clone │ │ │ ├── clone.go │ │ │ └── clone_test.go │ │ ├── create │ │ │ ├── create.go │ │ │ └── create_test.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── edit │ │ │ ├── edit.go │ │ │ └── edit_test.go │ │ ├── gist.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── rename │ │ │ ├── rename.go │ │ │ └── rename_test.go │ │ ├── shared │ │ │ ├── shared.go │ │ │ └── shared_test.go │ │ └── view │ │ │ ├── view.go │ │ │ └── view_test.go │ ├── gpg-key │ │ ├── add │ │ │ ├── add.go │ │ │ ├── add_test.go │ │ │ └── http.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ ├── delete_test.go │ │ │ └── http.go │ │ ├── gpg_key.go │ │ └── list │ │ │ ├── http.go │ │ │ ├── list.go │ │ │ └── list_test.go │ ├── issue │ │ ├── close │ │ │ ├── close.go │ │ │ └── close_test.go │ │ ├── comment │ │ │ ├── comment.go │ │ │ └── comment_test.go │ │ ├── create │ │ │ ├── create.go │ │ │ ├── create_test.go │ │ │ └── fixtures │ │ │ │ └── repoWithNonLegacyIssueTemplates │ │ │ │ └── .github │ │ │ │ └── ISSUE_TEMPLATE │ │ │ │ ├── bug_report.md │ │ │ │ └── enhancement.md │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── develop │ │ │ ├── develop.go │ │ │ └── develop_test.go │ │ ├── edit │ │ │ ├── edit.go │ │ │ └── edit_test.go │ │ ├── issue.go │ │ ├── list │ │ │ ├── fixtures │ │ │ │ ├── issueList.json │ │ │ │ └── issueSearch.json │ │ │ ├── http.go │ │ │ ├── http_test.go │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── lock │ │ │ ├── lock.go │ │ │ └── lock_test.go │ │ ├── pin │ │ │ ├── pin.go │ │ │ └── pin_test.go │ │ ├── reopen │ │ │ ├── reopen.go │ │ │ └── reopen_test.go │ │ ├── shared │ │ │ ├── display.go │ │ │ ├── lookup.go │ │ │ └── lookup_test.go │ │ ├── status │ │ │ ├── fixtures │ │ │ │ └── issueStatus.json │ │ │ ├── status.go │ │ │ └── status_test.go │ │ ├── transfer │ │ │ ├── transfer.go │ │ │ └── transfer_test.go │ │ ├── unpin │ │ │ ├── unpin.go │ │ │ └── unpin_test.go │ │ └── view │ │ │ ├── fixtures │ │ │ ├── issueView_preview.json │ │ │ ├── issueView_previewClosedState.json │ │ │ ├── issueView_previewFullComments.json │ │ │ ├── issueView_previewSingleComment.json │ │ │ ├── issueView_previewWithEmptyBody.json │ │ │ └── issueView_previewWithMetadata.json │ │ │ ├── http.go │ │ │ ├── view.go │ │ │ └── view_test.go │ ├── label │ │ ├── clone.go │ │ ├── clone_test.go │ │ ├── create.go │ │ ├── create_test.go │ │ ├── delete.go │ │ ├── delete_test.go │ │ ├── edit.go │ │ ├── edit_test.go │ │ ├── http.go │ │ ├── http_test.go │ │ ├── label.go │ │ ├── list.go │ │ ├── list_test.go │ │ └── shared.go │ ├── org │ │ ├── list │ │ │ ├── http.go │ │ │ ├── http_test.go │ │ │ ├── list.go │ │ │ └── list_test.go │ │ └── org.go │ ├── pr │ │ ├── checkout │ │ │ ├── checkout.go │ │ │ └── checkout_test.go │ │ ├── checks │ │ │ ├── aggregate.go │ │ │ ├── checks.go │ │ │ ├── checks_test.go │ │ │ ├── fixtures │ │ │ │ ├── allPassing.json │ │ │ │ ├── onlyRequired.json │ │ │ │ ├── someCancelled.json │ │ │ │ ├── someFailing.json │ │ │ │ ├── somePending.json │ │ │ │ ├── someSkipping.json │ │ │ │ ├── withDescriptions.json │ │ │ │ ├── withEvents.json │ │ │ │ ├── withStatuses.json │ │ │ │ └── withoutEvents.json │ │ │ └── output.go │ │ ├── close │ │ │ ├── close.go │ │ │ └── close_test.go │ │ ├── comment │ │ │ ├── comment.go │ │ │ └── comment_test.go │ │ ├── create │ │ │ ├── create.go │ │ │ ├── create_test.go │ │ │ ├── fixtures │ │ │ │ └── repoWithNonLegacyPRTemplates │ │ │ │ │ └── .github │ │ │ │ │ └── PULL_REQUEST_TEMPLATE │ │ │ │ │ └── bug_fix.md │ │ │ ├── regexp_writer.go │ │ │ └── regexp_writer_test.go │ │ ├── diff │ │ │ ├── diff.go │ │ │ └── diff_test.go │ │ ├── edit │ │ │ ├── edit.go │ │ │ └── edit_test.go │ │ ├── list │ │ │ ├── fixtures │ │ │ │ ├── prList.json │ │ │ │ └── prListWithDuplicates.json │ │ │ ├── http.go │ │ │ ├── http_test.go │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── merge │ │ │ ├── http.go │ │ │ ├── merge.go │ │ │ └── merge_test.go │ │ ├── pr.go │ │ ├── ready │ │ │ ├── ready.go │ │ │ └── ready_test.go │ │ ├── reopen │ │ │ ├── reopen.go │ │ │ └── reopen_test.go │ │ ├── review │ │ │ ├── review.go │ │ │ └── review_test.go │ │ ├── shared │ │ │ ├── commentable.go │ │ │ ├── comments.go │ │ │ ├── completion.go │ │ │ ├── display.go │ │ │ ├── display_test.go │ │ │ ├── editable.go │ │ │ ├── editable_http.go │ │ │ ├── finder.go │ │ │ ├── finder_test.go │ │ │ ├── params.go │ │ │ ├── params_test.go │ │ │ ├── preserve.go │ │ │ ├── preserve_test.go │ │ │ ├── reaction_groups.go │ │ │ ├── state.go │ │ │ ├── survey.go │ │ │ ├── survey_test.go │ │ │ ├── templates.go │ │ │ └── templates_test.go │ │ ├── status │ │ │ ├── fixtures │ │ │ │ ├── prStatus.json │ │ │ │ ├── prStatusChecks.json │ │ │ │ ├── prStatusChecksWithStatesByCount.json │ │ │ │ ├── prStatusCurrentBranch.json │ │ │ │ ├── prStatusCurrentBranchClosed.json │ │ │ │ ├── prStatusCurrentBranchClosedOnDefaultBranch.json │ │ │ │ ├── prStatusCurrentBranchMerged.json │ │ │ │ └── prStatusCurrentBranchMergedOnDefaultBranch.json │ │ │ ├── http.go │ │ │ ├── status.go │ │ │ └── status_test.go │ │ └── view │ │ │ ├── fixtures │ │ │ ├── prViewPreview.json │ │ │ ├── prViewPreviewClosedState.json │ │ │ ├── prViewPreviewDraftState.json │ │ │ ├── prViewPreviewFullComments.json │ │ │ ├── prViewPreviewManyReviews.json │ │ │ ├── prViewPreviewMergedState.json │ │ │ ├── prViewPreviewReviews.json │ │ │ ├── prViewPreviewSingleComment.json │ │ │ ├── prViewPreviewWithAllChecksFailing.json │ │ │ ├── prViewPreviewWithAllChecksPassing.json │ │ │ ├── prViewPreviewWithAutoMergeEnabled.json │ │ │ ├── prViewPreviewWithMetadataByNumber.json │ │ │ ├── prViewPreviewWithNoChecks.json │ │ │ ├── prViewPreviewWithReviewersByNumber.json │ │ │ ├── prViewPreviewWithSomeChecksFailing.json │ │ │ └── prViewPreviewWithSomeChecksPending.json │ │ │ ├── view.go │ │ │ └── view_test.go │ ├── project │ │ ├── close │ │ │ ├── close.go │ │ │ └── close_test.go │ │ ├── copy │ │ │ ├── copy.go │ │ │ └── copy_test.go │ │ ├── create │ │ │ ├── create.go │ │ │ └── create_test.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── edit │ │ │ ├── edit.go │ │ │ └── edit_test.go │ │ ├── field-create │ │ │ ├── field_create.go │ │ │ └── field_create_test.go │ │ ├── field-delete │ │ │ ├── field_delete.go │ │ │ └── field_delete_test.go │ │ ├── field-list │ │ │ ├── field_list.go │ │ │ └── field_list_test.go │ │ ├── item-add │ │ │ ├── item_add.go │ │ │ └── item_add_test.go │ │ ├── item-archive │ │ │ ├── item_archive.go │ │ │ └── item_archive_test.go │ │ ├── item-create │ │ │ ├── item_create.go │ │ │ └── item_create_test.go │ │ ├── item-delete │ │ │ ├── item_delete.go │ │ │ └── item_delete_test.go │ │ ├── item-edit │ │ │ ├── item_edit.go │ │ │ └── item_edit_test.go │ │ ├── item-list │ │ │ ├── item_list.go │ │ │ └── item_list_test.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── mark-template │ │ │ ├── mark_template.go │ │ │ └── mark_template_test.go │ │ ├── project.go │ │ ├── shared │ │ │ ├── client │ │ │ │ └── client.go │ │ │ ├── format │ │ │ │ ├── json.go │ │ │ │ └── json_test.go │ │ │ └── queries │ │ │ │ ├── queries.go │ │ │ │ └── queries_test.go │ │ └── view │ │ │ ├── view.go │ │ │ └── view_test.go │ ├── release │ │ ├── create │ │ │ ├── create.go │ │ │ ├── create_test.go │ │ │ └── http.go │ │ ├── delete-asset │ │ │ ├── delete_asset.go │ │ │ └── delete_asset_test.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── download │ │ │ ├── download.go │ │ │ └── download_test.go │ │ ├── edit │ │ │ ├── edit.go │ │ │ ├── edit_test.go │ │ │ └── http.go │ │ ├── list │ │ │ ├── http.go │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── release.go │ │ ├── shared │ │ │ ├── fetch.go │ │ │ ├── upload.go │ │ │ └── upload_test.go │ │ ├── upload │ │ │ ├── upload.go │ │ │ └── upload_test.go │ │ └── view │ │ │ ├── view.go │ │ │ └── view_test.go │ ├── repo │ │ ├── archive │ │ │ ├── archive.go │ │ │ ├── archive_test.go │ │ │ └── http.go │ │ ├── clone │ │ │ ├── clone.go │ │ │ └── clone_test.go │ │ ├── create │ │ │ ├── create.go │ │ │ ├── create_test.go │ │ │ ├── fixtures │ │ │ │ └── repoTempList.json │ │ │ ├── http.go │ │ │ └── http_test.go │ │ ├── credits │ │ │ └── credits.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ ├── delete_test.go │ │ │ └── http.go │ │ ├── deploy-key │ │ │ ├── add │ │ │ │ ├── add.go │ │ │ │ ├── add_test.go │ │ │ │ └── http.go │ │ │ ├── delete │ │ │ │ ├── delete.go │ │ │ │ ├── delete_test.go │ │ │ │ └── http.go │ │ │ ├── deploy-key.go │ │ │ └── list │ │ │ │ ├── http.go │ │ │ │ ├── list.go │ │ │ │ └── list_test.go │ │ ├── edit │ │ │ ├── edit.go │ │ │ └── edit_test.go │ │ ├── fork │ │ │ ├── fork.go │ │ │ ├── forkResult.json │ │ │ └── fork_test.go │ │ ├── garden │ │ │ ├── garden.go │ │ │ └── http.go │ │ ├── list │ │ │ ├── fixtures │ │ │ │ ├── repoList.json │ │ │ │ └── repoSearch.json │ │ │ ├── http.go │ │ │ ├── http_test.go │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── rename │ │ │ ├── rename.go │ │ │ └── rename_test.go │ │ ├── repo.go │ │ ├── setdefault │ │ │ ├── setdefault.go │ │ │ └── setdefault_test.go │ │ ├── shared │ │ │ ├── repo.go │ │ │ └── repo_test.go │ │ ├── sync │ │ │ ├── git.go │ │ │ ├── http.go │ │ │ ├── mocks.go │ │ │ ├── sync.go │ │ │ └── sync_test.go │ │ ├── unarchive │ │ │ ├── http.go │ │ │ ├── unarchive.go │ │ │ └── unarchive_test.go │ │ └── view │ │ │ ├── http.go │ │ │ ├── view.go │ │ │ └── view_test.go │ ├── root │ │ ├── alias.go │ │ ├── alias_test.go │ │ ├── extension.go │ │ ├── help.go │ │ ├── help_reference.go │ │ ├── help_test.go │ │ ├── help_topic.go │ │ ├── help_topic_test.go │ │ └── root.go │ ├── ruleset │ │ ├── check │ │ │ ├── check.go │ │ │ ├── check_test.go │ │ │ └── fixtures │ │ │ │ └── rulesetCheck.json │ │ ├── list │ │ │ ├── fixtures │ │ │ │ └── rulesetList.json │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── ruleset.go │ │ ├── shared │ │ │ ├── http.go │ │ │ └── shared.go │ │ └── view │ │ │ ├── fixtures │ │ │ ├── rulesetViewMultiple.json │ │ │ ├── rulesetViewOrg.json │ │ │ └── rulesetViewRepo.json │ │ │ ├── http.go │ │ │ ├── view.go │ │ │ └── view_test.go │ ├── run │ │ ├── cancel │ │ │ ├── cancel.go │ │ │ └── cancel_test.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── download │ │ │ ├── download.go │ │ │ ├── download_test.go │ │ │ ├── fixtures │ │ │ │ └── myproject.zip │ │ │ ├── http.go │ │ │ ├── http_test.go │ │ │ ├── zip.go │ │ │ └── zip_test.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── rerun │ │ │ ├── rerun.go │ │ │ └── rerun_test.go │ │ ├── run.go │ │ ├── shared │ │ │ ├── artifacts.go │ │ │ ├── artifacts_test.go │ │ │ ├── presentation.go │ │ │ ├── shared.go │ │ │ ├── shared_test.go │ │ │ └── test.go │ │ ├── view │ │ │ ├── fixtures │ │ │ │ └── run_log.zip │ │ │ ├── view.go │ │ │ └── view_test.go │ │ └── watch │ │ │ ├── watch.go │ │ │ └── watch_test.go │ ├── search │ │ ├── code │ │ │ ├── code.go │ │ │ └── code_test.go │ │ ├── commits │ │ │ ├── commits.go │ │ │ └── commits_test.go │ │ ├── issues │ │ │ ├── issues.go │ │ │ └── issues_test.go │ │ ├── prs │ │ │ ├── prs.go │ │ │ └── prs_test.go │ │ ├── repos │ │ │ ├── repos.go │ │ │ └── repos_test.go │ │ ├── search.go │ │ └── shared │ │ │ ├── shared.go │ │ │ └── shared_test.go │ ├── secret │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── secret.go │ │ ├── set │ │ │ ├── http.go │ │ │ ├── set.go │ │ │ └── set_test.go │ │ └── shared │ │ │ ├── shared.go │ │ │ └── shared_test.go │ ├── ssh-key │ │ ├── add │ │ │ ├── add.go │ │ │ ├── add_test.go │ │ │ └── http.go │ │ ├── delete │ │ │ ├── delete.go │ │ │ ├── delete_test.go │ │ │ └── http.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── shared │ │ │ └── user_keys.go │ │ └── ssh_key.go │ ├── status │ │ ├── fixtures │ │ │ ├── events.json │ │ │ ├── notifications.json │ │ │ ├── search.json │ │ │ └── search_forbidden.json │ │ ├── status.go │ │ └── status_test.go │ ├── variable │ │ ├── delete │ │ │ ├── delete.go │ │ │ └── delete_test.go │ │ ├── list │ │ │ ├── list.go │ │ │ └── list_test.go │ │ ├── set │ │ │ ├── http.go │ │ │ ├── set.go │ │ │ └── set_test.go │ │ ├── shared │ │ │ ├── shared.go │ │ │ └── shared_test.go │ │ └── variable.go │ ├── version │ │ ├── version.go │ │ └── version_test.go │ └── workflow │ │ ├── disable │ │ ├── disable.go │ │ └── disable_test.go │ │ ├── enable │ │ ├── enable.go │ │ └── enable_test.go │ │ ├── list │ │ ├── list.go │ │ └── list_test.go │ │ ├── run │ │ ├── run.go │ │ └── run_test.go │ │ ├── shared │ │ ├── shared.go │ │ └── test.go │ │ ├── view │ │ ├── view.go │ │ └── view_test.go │ │ └── workflow.go ├── cmdutil │ ├── args.go │ ├── args_test.go │ ├── auth_check.go │ ├── auth_check_test.go │ ├── cmdgroup.go │ ├── errors.go │ ├── factory.go │ ├── factory_test.go │ ├── file_input.go │ ├── flags.go │ ├── json_flags.go │ ├── json_flags_test.go │ ├── legacy.go │ └── repo_override.go ├── extensions │ ├── extension.go │ ├── extension_mock.go │ └── manager_mock.go ├── findsh │ ├── find.go │ └── find_windows.go ├── githubtemplate │ ├── github_template.go │ └── github_template_test.go ├── httpmock │ ├── legacy.go │ ├── registry.go │ └── stub.go ├── iostreams │ ├── color.go │ ├── color_test.go │ ├── console.go │ ├── console_windows.go │ ├── epipe_other.go │ ├── epipe_windows.go │ ├── iostreams.go │ └── iostreams_test.go ├── jsoncolor │ ├── jsoncolor.go │ └── jsoncolor_test.go ├── markdown │ └── markdown.go ├── search │ ├── query.go │ ├── query_test.go │ ├── result.go │ ├── result_test.go │ ├── searcher.go │ ├── searcher_mock.go │ └── searcher_test.go ├── set │ ├── string_set.go │ └── string_set_test.go ├── ssh │ └── ssh_keys.go └── surveyext │ ├── editor.go │ ├── editor_manual.go │ └── editor_test.go ├── readme ├── banner.png ├── demo.gif └── install-pullpo.gif ├── script ├── build.bat ├── build.go ├── createrepo.sh ├── distributions ├── label-assets ├── nolint-insert ├── override.ubuntu ├── release ├── rpmmacros ├── sign ├── sign.bat └── signtool.exe ├── test └── helpers.go └── utils └── utils.go /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/go:1.19", 3 | "features": { 4 | "ghcr.io/devcontainers/features/sshd:1": {} 5 | }, 6 | "remoteUser": "vscode", 7 | "customizations": { 8 | "vscode": { 9 | "extensions": [ 10 | "golang.go" 11 | ], 12 | "settings": { 13 | "go.toolsManagement.checkForUpdates": "local", 14 | "go.useLanguageServer": true, 15 | "go.gopath": "/go" 16 | } 17 | } 18 | }, 19 | "runArgs": [ 20 | "--cap-add=SYS_PTRACE", 21 | "--security-opt", 22 | "seccomp=unconfined" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .github/actions/*/lib/* linguist-generated 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | ignore: 8 | - dependency-name: "*" 9 | update-types: 10 | - version-update:semver-minor 11 | - version-update:semver-major 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /share/bash-completion/completions 3 | /share/fish/vendor_completions.d 4 | /share/man/man1 5 | /share/zsh/site-functions 6 | /gh-cli 7 | .envrc 8 | /dist 9 | /site 10 | .github/**/node_modules 11 | /CHANGELOG.md 12 | /.goreleaser.generated.yml 13 | /script/build 14 | /script/build.exe 15 | 16 | # VS Code 17 | .vscode 18 | 19 | # IntelliJ 20 | .idea 21 | 22 | # macOS 23 | .DS_Store 24 | 25 | # vim 26 | *.swp 27 | 28 | vendor/ 29 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gofmt 4 | - nolintlint 5 | 6 | issues: 7 | max-issues-per-linter: 0 8 | max-same-issues: 0 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 GitHub Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/export_repo.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | func (repo *Repository) ExportData(fields []string) map[string]interface{} { 8 | v := reflect.ValueOf(repo).Elem() 9 | data := map[string]interface{}{} 10 | 11 | for _, f := range fields { 12 | switch f { 13 | case "parent": 14 | data[f] = miniRepoExport(repo.Parent) 15 | case "templateRepository": 16 | data[f] = miniRepoExport(repo.TemplateRepository) 17 | case "languages": 18 | data[f] = repo.Languages.Edges 19 | case "labels": 20 | data[f] = repo.Labels.Nodes 21 | case "assignableUsers": 22 | data[f] = repo.AssignableUsers.Nodes 23 | case "mentionableUsers": 24 | data[f] = repo.MentionableUsers.Nodes 25 | case "milestones": 26 | data[f] = repo.Milestones.Nodes 27 | case "projects": 28 | data[f] = repo.Projects.Nodes 29 | case "repositoryTopics": 30 | var topics []RepositoryTopic 31 | for _, n := range repo.RepositoryTopics.Nodes { 32 | topics = append(topics, n.Topic) 33 | } 34 | data[f] = topics 35 | default: 36 | sf := fieldByName(v, f) 37 | data[f] = sf.Interface() 38 | } 39 | } 40 | 41 | return data 42 | } 43 | 44 | func miniRepoExport(r *Repository) map[string]interface{} { 45 | if r == nil { 46 | return nil 47 | } 48 | return map[string]interface{}{ 49 | "id": r.ID, 50 | "name": r.Name, 51 | "owner": r.Owner, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /api/queries_user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type Organization struct { 4 | Login string 5 | } 6 | 7 | func CurrentLoginName(client *Client, hostname string) (string, error) { 8 | var query struct { 9 | Viewer struct { 10 | Login string 11 | } 12 | } 13 | err := client.Query(hostname, "UserCurrent", &query, nil) 14 | return query.Viewer.Login, err 15 | } 16 | 17 | func CurrentLoginNameAndOrgs(client *Client, hostname string) (string, []string, error) { 18 | var query struct { 19 | Viewer struct { 20 | Login string 21 | Organizations struct { 22 | Nodes []Organization 23 | } `graphql:"organizations(first: 100)"` 24 | } 25 | } 26 | err := client.Query(hostname, "UserCurrent", &query, nil) 27 | if err != nil { 28 | return "", nil, err 29 | } 30 | orgNames := []string{} 31 | for _, org := range query.Viewer.Organizations.Nodes { 32 | orgNames = append(orgNames, org.Login) 33 | } 34 | return query.Viewer.Login, orgNames, err 35 | } 36 | 37 | func CurrentUserID(client *Client, hostname string) (string, error) { 38 | var query struct { 39 | Viewer struct { 40 | ID string 41 | } 42 | } 43 | err := client.Query(hostname, "UserCurrent", &query, nil) 44 | return query.Viewer.ID, err 45 | } 46 | -------------------------------------------------------------------------------- /api/reaction_groups.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | ) 7 | 8 | type ReactionGroups []ReactionGroup 9 | 10 | func (rg ReactionGroups) MarshalJSON() ([]byte, error) { 11 | buf := bytes.Buffer{} 12 | buf.WriteRune('[') 13 | encoder := json.NewEncoder(&buf) 14 | encoder.SetEscapeHTML(false) 15 | 16 | hasPrev := false 17 | for _, g := range rg { 18 | if g.Users.TotalCount == 0 { 19 | continue 20 | } 21 | if hasPrev { 22 | buf.WriteRune(',') 23 | } 24 | if err := encoder.Encode(&g); err != nil { 25 | return nil, err 26 | } 27 | hasPrev = true 28 | } 29 | buf.WriteRune(']') 30 | return buf.Bytes(), nil 31 | } 32 | 33 | type ReactionGroup struct { 34 | Content string `json:"content"` 35 | Users ReactionGroupUsers `json:"users"` 36 | } 37 | 38 | type ReactionGroupUsers struct { 39 | TotalCount int `json:"totalCount"` 40 | } 41 | 42 | func (rg ReactionGroup) Count() int { 43 | return rg.Users.TotalCount 44 | } 45 | 46 | func (rg ReactionGroup) Emoji() string { 47 | return reactionEmoji[rg.Content] 48 | } 49 | 50 | var reactionEmoji = map[string]string{ 51 | "THUMBS_UP": "\U0001f44d", 52 | "THUMBS_DOWN": "\U0001f44e", 53 | "LAUGH": "\U0001f604", 54 | "HOORAY": "\U0001f389", 55 | "CONFUSED": "\U0001f615", 56 | "HEART": "\u2764\ufe0f", 57 | "ROCKET": "\U0001f680", 58 | "EYES": "\U0001f440", 59 | } 60 | -------------------------------------------------------------------------------- /build/windows/gh.wixproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Release 5 | x64 6 | 0.1.0 7 | $(MSBuildProjectName) 8 | package 9 | $([MSBuild]::NormalizeDirectory($(MSBuildProjectDirectory)\..\..)) 10 | $(RepoPath)bin\$(Platform)\ 11 | $(RepoPath)bin\obj\$(Platform)\ 12 | 13 | $(DefineConstants); 14 | ProductVersion=$(ProductVersion); 15 | 16 | ICE39 17 | false 18 | $(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.targets 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /cmd/gen-docs/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func Test_run(t *testing.T) { 10 | dir := t.TempDir() 11 | args := []string{"--man-page", "--website", "--doc-path", dir} 12 | err := run(args) 13 | if err != nil { 14 | t.Fatalf("got error: %v", err) 15 | } 16 | 17 | manPage, err := os.ReadFile(dir + "/gh-issue-create.1") 18 | if err != nil { 19 | t.Fatalf("error reading `gh-issue-create.1`: %v", err) 20 | } 21 | if !strings.Contains(string(manPage), `\fBpullpo issue create`) { 22 | t.Fatal("man page corrupted") 23 | } 24 | 25 | markdownPage, err := os.ReadFile(dir + "/gh_issue_create.md") 26 | if err != nil { 27 | t.Fatalf("error reading `gh_issue_create.md`: %v", err) 28 | } 29 | if !strings.Contains(string(markdownPage), `## pullpo issue create`) { 30 | t.Fatal("markdown page corrupted") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /cmd/pullpo/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "testing" 9 | 10 | "github.com/cli/cli/v2/pkg/cmdutil" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func Test_printError(t *testing.T) { 15 | cmd := &cobra.Command{} 16 | 17 | type args struct { 18 | err error 19 | cmd *cobra.Command 20 | debug bool 21 | } 22 | tests := []struct { 23 | name string 24 | args args 25 | wantOut string 26 | }{ 27 | { 28 | name: "generic error", 29 | args: args{ 30 | err: errors.New("the app exploded"), 31 | cmd: nil, 32 | debug: false, 33 | }, 34 | wantOut: "the app exploded\n", 35 | }, 36 | { 37 | name: "DNS error", 38 | args: args{ 39 | err: fmt.Errorf("DNS oopsie: %w", &net.DNSError{ 40 | Name: "api.github.com", 41 | }), 42 | cmd: nil, 43 | debug: false, 44 | }, 45 | wantOut: `error connecting to api.github.com 46 | check your internet connection or https://githubstatus.com 47 | `, 48 | }, 49 | { 50 | name: "Cobra flag error", 51 | args: args{ 52 | err: cmdutil.FlagErrorf("unknown flag --foo"), 53 | cmd: cmd, 54 | debug: false, 55 | }, 56 | wantOut: "unknown flag --foo\n\nUsage:\n\n", 57 | }, 58 | { 59 | name: "unknown Cobra command error", 60 | args: args{ 61 | err: errors.New("unknown command foo"), 62 | cmd: cmd, 63 | debug: false, 64 | }, 65 | wantOut: "unknown command foo\n\nUsage:\n\n", 66 | }, 67 | } 68 | 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | out := &bytes.Buffer{} 72 | printError(out, tt.args.err, tt.args.cmd, tt.args.debug) 73 | if gotOut := out.String(); gotOut != tt.wantOut { 74 | t.Errorf("printError() = %q, want %q", gotOut, tt.wantOut) 75 | } 76 | }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /git/errors.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // ErrNotOnAnyBranch indicates that the user is in detached HEAD state. 9 | var ErrNotOnAnyBranch = errors.New("git: not on any branch") 10 | 11 | type NotInstalled struct { 12 | message string 13 | err error 14 | } 15 | 16 | func (e *NotInstalled) Error() string { 17 | return e.message 18 | } 19 | 20 | func (e *NotInstalled) Unwrap() error { 21 | return e.err 22 | } 23 | 24 | type GitError struct { 25 | ExitCode int 26 | Stderr string 27 | err error 28 | } 29 | 30 | func (ge *GitError) Error() string { 31 | if ge.Stderr == "" { 32 | return fmt.Sprintf("failed to run git: %v", ge.err) 33 | } 34 | return fmt.Sprintf("failed to run git: %s", ge.Stderr) 35 | } 36 | 37 | func (ge *GitError) Unwrap() error { 38 | return ge.err 39 | } 40 | -------------------------------------------------------------------------------- /git/fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | *.git/COMMIT_EDITMSG 2 | -------------------------------------------------------------------------------- /git/fixtures/simple.git/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/main 2 | -------------------------------------------------------------------------------- /git/fixtures/simple.git/config: -------------------------------------------------------------------------------- 1 | [core] 2 | repositoryformatversion = 0 3 | filemode = true 4 | ;bare = true 5 | ignorecase = true 6 | precomposeunicode = true 7 | [user] 8 | name = Mona the Cat 9 | email = monalisa@github.com 10 | -------------------------------------------------------------------------------- /git/fixtures/simple.git/index: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pullpo-io/cli/0567c8540f11d780406fb2e5eebf923b5e8d9e47/git/fixtures/simple.git/index -------------------------------------------------------------------------------- /git/fixtures/simple.git/logs/HEAD: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000 d1e0abfb7d158ed544a202a6958c62d4fc22e12f Mona the Cat 1614174263 +0100 commit (initial): Initial commit 2 | d1e0abfb7d158ed544a202a6958c62d4fc22e12f 6f1a2405cace1633d89a79c74c65f22fe78f9659 Mona the Cat 1614174275 +0100 commit: Second commit 3 | -------------------------------------------------------------------------------- /git/fixtures/simple.git/logs/refs/heads/main: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000 d1e0abfb7d158ed544a202a6958c62d4fc22e12f Mona the Cat 1614174263 +0100 commit (initial): Initial commit 2 | d1e0abfb7d158ed544a202a6958c62d4fc22e12f 6f1a2405cace1633d89a79c74c65f22fe78f9659 Mona the Cat 1614174275 +0100 commit: Second commit 3 | -------------------------------------------------------------------------------- /git/fixtures/simple.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904: -------------------------------------------------------------------------------- 1 | x+)JMU0` 2 | , -------------------------------------------------------------------------------- /git/fixtures/simple.git/objects/6f/1a2405cace1633d89a79c74c65f22fe78f9659: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pullpo-io/cli/0567c8540f11d780406fb2e5eebf923b5e8d9e47/git/fixtures/simple.git/objects/6f/1a2405cace1633d89a79c74c65f22fe78f9659 -------------------------------------------------------------------------------- /git/fixtures/simple.git/objects/d1/e0abfb7d158ed544a202a6958c62d4fc22e12f: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pullpo-io/cli/0567c8540f11d780406fb2e5eebf923b5e8d9e47/git/fixtures/simple.git/objects/d1/e0abfb7d158ed544a202a6958c62d4fc22e12f -------------------------------------------------------------------------------- /git/fixtures/simple.git/refs/heads/main: -------------------------------------------------------------------------------- 1 | 6f1a2405cace1633d89a79c74c65f22fe78f9659 2 | -------------------------------------------------------------------------------- /git/objects.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | // RemoteSet is a slice of git remotes. 9 | type RemoteSet []*Remote 10 | 11 | func (r RemoteSet) Len() int { return len(r) } 12 | func (r RemoteSet) Swap(i, j int) { r[i], r[j] = r[j], r[i] } 13 | func (r RemoteSet) Less(i, j int) bool { 14 | return remoteNameSortScore(r[i].Name) > remoteNameSortScore(r[j].Name) 15 | } 16 | 17 | func remoteNameSortScore(name string) int { 18 | switch strings.ToLower(name) { 19 | case "upstream": 20 | return 3 21 | case "github": 22 | return 2 23 | case "origin": 24 | return 1 25 | default: 26 | return 0 27 | } 28 | } 29 | 30 | // Remote is a parsed git remote. 31 | type Remote struct { 32 | Name string 33 | Resolved string 34 | FetchURL *url.URL 35 | PushURL *url.URL 36 | } 37 | 38 | func (r *Remote) String() string { 39 | return r.Name 40 | } 41 | 42 | func NewRemote(name string, u string) *Remote { 43 | pu, _ := url.Parse(u) 44 | return &Remote{ 45 | Name: name, 46 | FetchURL: pu, 47 | PushURL: pu, 48 | } 49 | } 50 | 51 | // Ref represents a git commit reference. 52 | type Ref struct { 53 | Hash string 54 | Name string 55 | } 56 | 57 | // TrackingRef represents a ref for a remote tracking branch. 58 | type TrackingRef struct { 59 | RemoteName string 60 | BranchName string 61 | } 62 | 63 | func (r TrackingRef) String() string { 64 | return "refs/remotes/" + r.RemoteName + "/" + r.BranchName 65 | } 66 | 67 | type Commit struct { 68 | Sha string 69 | Title string 70 | } 71 | 72 | type BranchConfig struct { 73 | RemoteName string 74 | RemoteURL *url.URL 75 | MergeRef string 76 | } 77 | -------------------------------------------------------------------------------- /git/url.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | func IsURL(u string) bool { 9 | return strings.HasPrefix(u, "git@") || isSupportedProtocol(u) 10 | } 11 | 12 | func isSupportedProtocol(u string) bool { 13 | return strings.HasPrefix(u, "ssh:") || 14 | strings.HasPrefix(u, "git+ssh:") || 15 | strings.HasPrefix(u, "git:") || 16 | strings.HasPrefix(u, "http:") || 17 | strings.HasPrefix(u, "git+https:") || 18 | strings.HasPrefix(u, "https:") 19 | } 20 | 21 | func isPossibleProtocol(u string) bool { 22 | return isSupportedProtocol(u) || 23 | strings.HasPrefix(u, "ftp:") || 24 | strings.HasPrefix(u, "ftps:") || 25 | strings.HasPrefix(u, "file:") 26 | } 27 | 28 | // ParseURL normalizes git remote urls 29 | func ParseURL(rawURL string) (u *url.URL, err error) { 30 | if !isPossibleProtocol(rawURL) && 31 | strings.ContainsRune(rawURL, ':') && 32 | // not a Windows path 33 | !strings.ContainsRune(rawURL, '\\') { 34 | // support scp-like syntax for ssh protocol 35 | rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1) 36 | } 37 | 38 | u, err = url.Parse(rawURL) 39 | if err != nil { 40 | return 41 | } 42 | 43 | if u.Scheme == "git+ssh" { 44 | u.Scheme = "ssh" 45 | } 46 | 47 | if u.Scheme == "git+https" { 48 | u.Scheme = "https" 49 | } 50 | 51 | if u.Scheme != "ssh" { 52 | return 53 | } 54 | 55 | if strings.HasPrefix(u.Path, "//") { 56 | u.Path = strings.TrimPrefix(u.Path, "/") 57 | } 58 | 59 | if idx := strings.Index(u.Host, ":"); idx >= 0 { 60 | u.Host = u.Host[0:idx] 61 | } 62 | 63 | return 64 | } 65 | -------------------------------------------------------------------------------- /internal/authflow/success.go: -------------------------------------------------------------------------------- 1 | package authflow 2 | 3 | const oauthSuccessPage = ` 4 | 5 | 6 | Success: GitHub CLI 7 | 35 | 36 | 37 |
38 |

Successfully authenticated GitHub CLI

39 |

You may now close this tab and return to the terminal.

40 |
41 | 42 | ` 43 | -------------------------------------------------------------------------------- /internal/browser/browser.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "io" 5 | 6 | ghBrowser "github.com/cli/go-gh/v2/pkg/browser" 7 | ) 8 | 9 | type Browser interface { 10 | Browse(string) error 11 | } 12 | 13 | func New(launcher string, stdout, stderr io.Writer) Browser { 14 | b := ghBrowser.New(launcher, stdout, stderr) 15 | return b 16 | } 17 | -------------------------------------------------------------------------------- /internal/browser/stub.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | type Stub struct { 4 | urls []string 5 | } 6 | 7 | func (b *Stub) Browse(url string) error { 8 | b.urls = append(b.urls, url) 9 | return nil 10 | } 11 | 12 | func (b *Stub) BrowsedURL() string { 13 | if len(b.urls) > 0 { 14 | return b.urls[0] 15 | } 16 | return "" 17 | } 18 | 19 | type _testing interface { 20 | Errorf(string, ...interface{}) 21 | Helper() 22 | } 23 | 24 | func (b *Stub) Verify(t _testing, url string) { 25 | t.Helper() 26 | if url != "" { 27 | switch len(b.urls) { 28 | case 0: 29 | t.Errorf("expected browser to open URL %q, but it was never invoked", url) 30 | case 1: 31 | if url != b.urls[0] { 32 | t.Errorf("expected browser to open URL %q, got %q", url, b.urls[0]) 33 | } 34 | default: 35 | t.Errorf("expected browser to open one URL, but was invoked %d times", len(b.urls)) 36 | } 37 | } else if len(b.urls) > 0 { 38 | t.Errorf("expected no browser to open, but was invoked %d times: %v", len(b.urls), b.urls) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/build/build.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "os" 5 | "runtime/debug" 6 | ) 7 | 8 | // Version is dynamically set by the toolchain or overridden by the Makefile. 9 | var Version = "DEV" 10 | 11 | // Date is dynamically set at build time in the Makefile. 12 | var Date = "" // YYYY-MM-DD 13 | 14 | func init() { 15 | if Version == "DEV" { 16 | if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" { 17 | Version = info.Main.Version 18 | } 19 | } 20 | 21 | // Signal the tcell library to skip its expensive `init` block. This saves 30-40ms in startup 22 | // time for the pullpo process. The downside is that some Unicode glyphs from user-generated 23 | // content might cause mis-alignment in tcell-enabled views. 24 | // 25 | // https://github.com/gdamore/tcell/commit/2f889d79bd61b1fd2f43372529975a65b792a7ae 26 | _ = os.Setenv("TCELL_MINIMIZE", "1") 27 | } 28 | -------------------------------------------------------------------------------- /internal/codespaces/rpc/codespace/codespace_host_service.v1.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option go_package = "./codespace"; 4 | 5 | package Codespaces.Grpc.CodespaceHostService.v1; 6 | 7 | service CodespaceHost { 8 | rpc NotifyCodespaceOfClientActivity (NotifyCodespaceOfClientActivityRequest) returns (NotifyCodespaceOfClientActivityResponse); 9 | rpc RebuildContainerAsync (RebuildContainerRequest) returns (RebuildContainerResponse); 10 | } 11 | 12 | message NotifyCodespaceOfClientActivityRequest { 13 | string ClientId = 1; 14 | repeated string ClientActivities = 2; 15 | } 16 | message NotifyCodespaceOfClientActivityResponse { 17 | bool Result = 1; 18 | string Message = 2; 19 | } 20 | 21 | message RebuildContainerRequest { 22 | optional bool Incremental = 1; 23 | } 24 | 25 | message RebuildContainerResponse { 26 | bool RebuildContainer = 1; 27 | } 28 | -------------------------------------------------------------------------------- /internal/codespaces/rpc/generate.md: -------------------------------------------------------------------------------- 1 | # Protocol Buffers for Codespaces 2 | 3 | Instructions for generating and adding gRPC protocol buffers. 4 | 5 | ## Generate Protocol Buffers 6 | 7 | 1. [Download `protoc`](https://grpc.io/docs/protoc-installation/) 8 | 2. [Download protocol compiler plugins for Go](https://grpc.io/docs/languages/go/quickstart/) 9 | 3. Install moq: `go install github.com/matryer/moq@latest` 10 | 4. Run `./generate.sh` from the `internal/codespaces/rpc` directory 11 | 12 | ## Add New Protocol Buffers 13 | 14 | 1. Download a `.proto` contract from the service repo 15 | 2. Create a new directory and copy the `.proto` to it 16 | 3. Update `generate.sh` to include the include the new `.proto` 17 | 4. Follow the instructions to [Generate Protocol Buffers](#generate-protocol-buffers) 18 | -------------------------------------------------------------------------------- /internal/codespaces/rpc/generate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if ! protoc --version; then 6 | echo 'ERROR: protoc is not on your PATH' 7 | exit 1 8 | fi 9 | if ! protoc-gen-go --version; then 10 | echo 'ERROR: protoc-gen-go is not on your PATH' 11 | exit 1 12 | fi 13 | if ! protoc-gen-go-grpc --version; then 14 | echo 'ERROR: protoc-gen-go-grpc is not on your PATH' 15 | fi 16 | 17 | function generate { 18 | local dir="$1" 19 | local proto="$2" 20 | 21 | local contract="$dir/$proto" 22 | 23 | protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract" --experimental_allow_proto3_optional 24 | echo "Generated protocol buffers for $contract" 25 | 26 | services=$(grep -Eo "service .+ {" <$contract | awk '{print $2 "Server"}') 27 | moq -out "$contract.mock.go" "$dir" "$services" 28 | echo "Generated mock protocols for $contract" 29 | } 30 | 31 | generate jupyter jupyter_server_host_service.v1.proto 32 | generate codespace codespace_host_service.v1.proto 33 | generate ssh ssh_server_host_service.v1.proto 34 | 35 | echo 'Done!' 36 | -------------------------------------------------------------------------------- /internal/codespaces/rpc/jupyter/jupyter_server_host_service.v1.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option go_package = "./jupyter"; 4 | 5 | package Codespaces.Grpc.JupyterServerHostService.v1; 6 | 7 | service JupyterServerHost { 8 | rpc GetRunningServer (GetRunningServerRequest) returns (GetRunningServerResponse); 9 | } 10 | 11 | message GetRunningServerRequest { 12 | } 13 | 14 | message GetRunningServerResponse { 15 | bool Result = 1; 16 | string Message = 2; 17 | string Port = 3; 18 | string ServerUrl = 4; 19 | } 20 | -------------------------------------------------------------------------------- /internal/codespaces/rpc/ssh/ssh_server_host_service.v1.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option go_package = "./ssh"; 4 | 5 | package Codespaces.Grpc.SshServerHostService.v1; 6 | 7 | service SshServerHost { 8 | rpc StartRemoteServerAsync (StartRemoteServerRequest) returns (StartRemoteServerResponse); 9 | } 10 | 11 | message StartRemoteServerRequest { 12 | string UserPublicKey = 1; 13 | } 14 | 15 | message StartRemoteServerResponse { 16 | bool Result = 1; 17 | string ServerPort = 2; 18 | string User = 3; 19 | string Message = 4; 20 | } 21 | -------------------------------------------------------------------------------- /internal/featuredetection/detector_mock.go: -------------------------------------------------------------------------------- 1 | package featuredetection 2 | 3 | type DisabledDetectorMock struct{} 4 | 5 | func (md *DisabledDetectorMock) IssueFeatures() (IssueFeatures, error) { 6 | return IssueFeatures{}, nil 7 | } 8 | 9 | func (md *DisabledDetectorMock) PullRequestFeatures() (PullRequestFeatures, error) { 10 | return PullRequestFeatures{}, nil 11 | } 12 | 13 | func (md *DisabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) { 14 | return RepositoryFeatures{}, nil 15 | } 16 | 17 | type EnabledDetectorMock struct{} 18 | 19 | func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) { 20 | return allIssueFeatures, nil 21 | } 22 | 23 | func (md *EnabledDetectorMock) PullRequestFeatures() (PullRequestFeatures, error) { 24 | return allPullRequestFeatures, nil 25 | } 26 | 27 | func (md *EnabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) { 28 | return allRepositoryFeatures, nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/keyring/keyring.go: -------------------------------------------------------------------------------- 1 | // Package keyring is a simple wrapper that adds timeouts to the zalando/go-keyring package. 2 | package keyring 3 | 4 | import ( 5 | "time" 6 | 7 | "github.com/zalando/go-keyring" 8 | ) 9 | 10 | type TimeoutError struct { 11 | message string 12 | } 13 | 14 | func (e *TimeoutError) Error() string { 15 | return e.message 16 | } 17 | 18 | // Set secret in keyring for user. 19 | func Set(service, user, secret string) error { 20 | ch := make(chan error, 1) 21 | go func() { 22 | defer close(ch) 23 | ch <- keyring.Set(service, user, secret) 24 | }() 25 | select { 26 | case err := <-ch: 27 | return err 28 | case <-time.After(3 * time.Second): 29 | return &TimeoutError{"timeout while trying to set secret in keyring"} 30 | } 31 | } 32 | 33 | // Get secret from keyring given service and user name. 34 | func Get(service, user string) (string, error) { 35 | ch := make(chan struct { 36 | val string 37 | err error 38 | }, 1) 39 | go func() { 40 | defer close(ch) 41 | val, err := keyring.Get(service, user) 42 | ch <- struct { 43 | val string 44 | err error 45 | }{val, err} 46 | }() 47 | select { 48 | case res := <-ch: 49 | return res.val, res.err 50 | case <-time.After(3 * time.Second): 51 | return "", &TimeoutError{"timeout while trying to get secret from keyring"} 52 | } 53 | } 54 | 55 | // Delete secret from keyring. 56 | func Delete(service, user string) error { 57 | ch := make(chan error, 1) 58 | go func() { 59 | defer close(ch) 60 | ch <- keyring.Delete(service, user) 61 | }() 62 | select { 63 | case err := <-ch: 64 | return err 65 | case <-time.After(3 * time.Second): 66 | return &TimeoutError{"timeout while trying to delete secret from keyring"} 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /internal/text/text_test.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestRemoveExcessiveWhitespace(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | input string 14 | want string 15 | }{ 16 | { 17 | name: "nothing to remove", 18 | input: "one two three", 19 | want: "one two three", 20 | }, 21 | { 22 | name: "whitespace b-gone", 23 | input: "\n one\n\t two three\r\n ", 24 | want: "one two three", 25 | }, 26 | } 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | got := RemoveExcessiveWhitespace(tt.input) 30 | assert.Equal(t, tt.want, got) 31 | }) 32 | } 33 | } 34 | 35 | func TestFuzzyAgoAbbr(t *testing.T) { 36 | const form = "2006-Jan-02 15:04:05" 37 | now, _ := time.Parse(form, "2020-Nov-22 14:00:00") 38 | cases := map[string]string{ 39 | "2020-Nov-22 14:00:00": "0m", 40 | "2020-Nov-22 13:59:00": "1m", 41 | "2020-Nov-22 13:30:00": "30m", 42 | "2020-Nov-22 13:00:00": "1h", 43 | "2020-Nov-22 02:00:00": "12h", 44 | "2020-Nov-21 14:00:00": "1d", 45 | "2020-Nov-07 14:00:00": "15d", 46 | "2020-Oct-24 14:00:00": "29d", 47 | "2020-Oct-23 14:00:00": "Oct 23, 2020", 48 | "2019-Nov-22 14:00:00": "Nov 22, 2019", 49 | } 50 | for createdAt, expected := range cases { 51 | d, err := time.Parse(form, createdAt) 52 | assert.NoError(t, err) 53 | fuzzy := FuzzyAgoAbbr(now, d) 54 | assert.Equal(t, expected, fuzzy) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/cmd/alias/alias.go: -------------------------------------------------------------------------------- 1 | package alias 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | deleteCmd "github.com/cli/cli/v2/pkg/cmd/alias/delete" 6 | importCmd "github.com/cli/cli/v2/pkg/cmd/alias/imports" 7 | listCmd "github.com/cli/cli/v2/pkg/cmd/alias/list" 8 | setCmd "github.com/cli/cli/v2/pkg/cmd/alias/set" 9 | "github.com/cli/cli/v2/pkg/cmdutil" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func NewCmdAlias(f *cmdutil.Factory) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "alias ", 16 | Short: "Create command shortcuts", 17 | Long: heredoc.Docf(` 18 | Aliases can be used to make shortcuts for pullpo commands or to compose multiple commands. 19 | 20 | Run %[1]spullpo help alias set%[1]s to learn more. 21 | `, "`"), 22 | } 23 | 24 | cmdutil.DisableAuthCheck(cmd) 25 | 26 | cmd.AddCommand(deleteCmd.NewCmdDelete(f, nil)) 27 | cmd.AddCommand(importCmd.NewCmdImport(f, nil)) 28 | cmd.AddCommand(listCmd.NewCmdList(f, nil)) 29 | cmd.AddCommand(setCmd.NewCmdSet(f, nil)) 30 | 31 | return cmd 32 | } 33 | -------------------------------------------------------------------------------- /pkg/cmd/alias/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | "github.com/cli/cli/v2/internal/config" 6 | "github.com/cli/cli/v2/pkg/cmdutil" 7 | "github.com/cli/cli/v2/pkg/iostreams" 8 | "github.com/spf13/cobra" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | type ListOptions struct { 13 | Config func() (config.Config, error) 14 | IO *iostreams.IOStreams 15 | } 16 | 17 | func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { 18 | opts := &ListOptions{ 19 | IO: f.IOStreams, 20 | Config: f.Config, 21 | } 22 | 23 | cmd := &cobra.Command{ 24 | Use: "list", 25 | Short: "List your aliases", 26 | Aliases: []string{"ls"}, 27 | Long: heredoc.Doc(` 28 | This command prints out all of the aliases pullpo is configured to use. 29 | `), 30 | Args: cobra.NoArgs, 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | if runF != nil { 33 | return runF(opts) 34 | } 35 | return listRun(opts) 36 | }, 37 | } 38 | 39 | return cmd 40 | } 41 | 42 | func listRun(opts *ListOptions) error { 43 | cfg, err := opts.Config() 44 | if err != nil { 45 | return err 46 | } 47 | 48 | aliasCfg := cfg.Aliases() 49 | 50 | aliasMap := aliasCfg.All() 51 | 52 | if len(aliasMap) == 0 { 53 | return cmdutil.NewNoResultsError("no aliases configured") 54 | } 55 | 56 | enc := yaml.NewEncoder(opts.IO.Out) 57 | return enc.Encode(aliasMap) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/cmd/alias/shared/validations.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/google/shlex" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // ValidAliasNameFunc returns a function that will check if the given string 11 | // is a valid alias name. A name is valid if: 12 | // - it does not shadow an existing command, 13 | // - it is not nested under a command that is runnable, 14 | // - it is not nested under a command that does not exist. 15 | func ValidAliasNameFunc(cmd *cobra.Command) func(string) bool { 16 | return func(args string) bool { 17 | split, err := shlex.Split(args) 18 | if err != nil || len(split) == 0 { 19 | return false 20 | } 21 | 22 | rootCmd := cmd.Root() 23 | foundCmd, foundArgs, _ := rootCmd.Find(split) 24 | if foundCmd != nil && !foundCmd.Runnable() && len(foundArgs) == 1 { 25 | return true 26 | } 27 | 28 | return false 29 | } 30 | } 31 | 32 | // ValidAliasExpansionFunc returns a function that will check if the given string 33 | // is a valid alias expansion. An expansion is valid if: 34 | // - it is a shell expansion, 35 | // - it is a non-shell expansion that corresponds to an existing command, extension, or alias. 36 | func ValidAliasExpansionFunc(cmd *cobra.Command) func(string) bool { 37 | return func(expansion string) bool { 38 | if strings.HasPrefix(expansion, "!") { 39 | return true 40 | } 41 | 42 | split, err := shlex.Split(expansion) 43 | if err != nil || len(split) == 0 { 44 | return false 45 | } 46 | 47 | rootCmd := cmd.Root() 48 | cmd, _, _ = rootCmd.Find(split) 49 | return cmd != rootCmd 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/cmd/alias/shared/validations_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestValidAliasNameFunc(t *testing.T) { 11 | // Create fake command structure for testing. 12 | issueCmd := &cobra.Command{Use: "issue"} 13 | prCmd := &cobra.Command{Use: "pr"} 14 | prCmd.AddCommand(&cobra.Command{Use: "checkout"}) 15 | 16 | cmd := &cobra.Command{} 17 | cmd.AddCommand(prCmd) 18 | cmd.AddCommand(issueCmd) 19 | 20 | f := ValidAliasNameFunc(cmd) 21 | 22 | assert.False(t, f("pr")) 23 | assert.False(t, f("pr checkout")) 24 | assert.False(t, f("issue")) 25 | assert.False(t, f("repo list")) 26 | 27 | assert.True(t, f("ps")) 28 | assert.True(t, f("checkout")) 29 | assert.True(t, f("issue erase")) 30 | assert.True(t, f("pr erase")) 31 | assert.True(t, f("pr checkout branch")) 32 | } 33 | 34 | func TestValidAliasExpansionFunc(t *testing.T) { 35 | // Create fake command structure for testing. 36 | issueCmd := &cobra.Command{Use: "issue"} 37 | prCmd := &cobra.Command{Use: "pr"} 38 | prCmd.AddCommand(&cobra.Command{Use: "checkout"}) 39 | 40 | cmd := &cobra.Command{} 41 | cmd.AddCommand(prCmd) 42 | cmd.AddCommand(issueCmd) 43 | 44 | f := ValidAliasExpansionFunc(cmd) 45 | 46 | assert.False(t, f("ps")) 47 | assert.False(t, f("checkout")) 48 | assert.False(t, f("repo list")) 49 | 50 | assert.True(t, f("!git branch --show-current")) 51 | assert.True(t, f("pr")) 52 | assert.True(t, f("pr checkout")) 53 | assert.True(t, f("issue")) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/cmd/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | gitCredentialCmd "github.com/cli/cli/v2/pkg/cmd/auth/gitcredential" 5 | authLoginCmd "github.com/cli/cli/v2/pkg/cmd/auth/login" 6 | authLogoutCmd "github.com/cli/cli/v2/pkg/cmd/auth/logout" 7 | authRefreshCmd "github.com/cli/cli/v2/pkg/cmd/auth/refresh" 8 | authSetupGitCmd "github.com/cli/cli/v2/pkg/cmd/auth/setupgit" 9 | authStatusCmd "github.com/cli/cli/v2/pkg/cmd/auth/status" 10 | authTokenCmd "github.com/cli/cli/v2/pkg/cmd/auth/token" 11 | "github.com/cli/cli/v2/pkg/cmdutil" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "auth ", 18 | Short: "Authenticate pullpo and git with GitHub", 19 | GroupID: "core", 20 | } 21 | 22 | cmdutil.DisableAuthCheck(cmd) 23 | 24 | cmd.AddCommand(authLoginCmd.NewCmdLogin(f, nil)) 25 | cmd.AddCommand(authLogoutCmd.NewCmdLogout(f, nil)) 26 | cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil)) 27 | cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil)) 28 | cmd.AddCommand(gitCredentialCmd.NewCmdCredential(f, nil)) 29 | cmd.AddCommand(authSetupGitCmd.NewCmdSetupGit(f, nil)) 30 | cmd.AddCommand(authTokenCmd.NewCmdToken(f, nil)) 31 | 32 | return cmd 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmd/auth/shared/prompt.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | type Prompt interface { 4 | Select(string, string, []string) (int, error) 5 | Confirm(string, bool) (bool, error) 6 | InputHostname() (string, error) 7 | AuthToken() (string, error) 8 | Input(string, string) (string, error) 9 | Password(string) (string, error) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/cmd/auth/shared/writeable.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/cli/cli/v2/internal/config" 7 | ) 8 | 9 | func AuthTokenWriteable(authCfg *config.AuthConfig, hostname string) (string, bool) { 10 | token, src := authCfg.Token(hostname) 11 | return src, (token == "" || !strings.HasSuffix(src, "_TOKEN")) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/cmd/auth/token/token.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cli/cli/v2/internal/config" 7 | "github.com/cli/cli/v2/pkg/cmdutil" 8 | "github.com/cli/cli/v2/pkg/iostreams" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type TokenOptions struct { 13 | IO *iostreams.IOStreams 14 | Config func() (config.Config, error) 15 | 16 | Hostname string 17 | SecureStorage bool 18 | } 19 | 20 | func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Command { 21 | opts := &TokenOptions{ 22 | IO: f.IOStreams, 23 | Config: f.Config, 24 | } 25 | 26 | cmd := &cobra.Command{ 27 | Use: "token", 28 | Short: "Print the auth token pullpo is configured to use", 29 | Args: cobra.ExactArgs(0), 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | if runF != nil { 32 | return runF(opts) 33 | } 34 | 35 | return tokenRun(opts) 36 | }, 37 | } 38 | 39 | cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance authenticated with") 40 | cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Search only secure credential store for authentication token") 41 | _ = cmd.Flags().MarkHidden("secure-storage") 42 | 43 | return cmd 44 | } 45 | 46 | func tokenRun(opts *TokenOptions) error { 47 | cfg, err := opts.Config() 48 | if err != nil { 49 | return err 50 | } 51 | authCfg := cfg.Authentication() 52 | 53 | hostname := opts.Hostname 54 | if hostname == "" { 55 | hostname, _ = authCfg.DefaultHost() 56 | } 57 | 58 | var val string 59 | if opts.SecureStorage { 60 | val, _ = authCfg.TokenFromKeyring(hostname) 61 | } else { 62 | val, _ = authCfg.Token(hostname) 63 | } 64 | if val == "" { 65 | return fmt.Errorf("no oauth token") 66 | } 67 | 68 | if val != "" { 69 | fmt.Fprintf(opts.IO.Out, "%s\n", val) 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/cmd/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | cmdDelete "github.com/cli/cli/v2/pkg/cmd/cache/delete" 6 | cmdList "github.com/cli/cli/v2/pkg/cmd/cache/list" 7 | "github.com/cli/cli/v2/pkg/cmdutil" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func NewCmdCache(f *cmdutil.Factory) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "cache ", 14 | Short: "Manage Github Actions caches", 15 | Long: "Work with Github Actions caches.", 16 | Example: heredoc.Doc(` 17 | $ pullpo cache list 18 | $ pullpo cache delete --all 19 | `), 20 | GroupID: "actions", 21 | } 22 | 23 | cmdutil.EnableRepoOverride(cmd, f) 24 | 25 | cmd.AddCommand(cmdList.NewCmdList(f, nil)) 26 | cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) 27 | 28 | return cmd 29 | } 30 | -------------------------------------------------------------------------------- /pkg/cmd/codespace/code.go: -------------------------------------------------------------------------------- 1 | package codespace 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func newCodeCmd(app *App) *cobra.Command { 12 | var ( 13 | selector *CodespaceSelector 14 | useInsiders bool 15 | useWeb bool 16 | ) 17 | 18 | codeCmd := &cobra.Command{ 19 | Use: "code", 20 | Short: "Open a codespace in Visual Studio Code", 21 | Args: noArgsConstraint, 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | return app.VSCode(cmd.Context(), selector, useInsiders, useWeb) 24 | }, 25 | } 26 | 27 | selector = AddCodespaceSelector(codeCmd, app.apiClient) 28 | 29 | codeCmd.Flags().BoolVar(&useInsiders, "insiders", false, "Use the insiders version of Visual Studio Code") 30 | codeCmd.Flags().BoolVarP(&useWeb, "web", "w", false, "Use the web version of Visual Studio Code") 31 | 32 | return codeCmd 33 | } 34 | 35 | // VSCode opens a codespace in the local VS VSCode application. 36 | func (a *App) VSCode(ctx context.Context, selector *CodespaceSelector, useInsiders bool, useWeb bool) error { 37 | codespace, err := selector.Select(ctx) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | browseURL := vscodeProtocolURL(codespace.Name, useInsiders) 43 | if useWeb { 44 | browseURL = codespace.WebURL 45 | if useInsiders { 46 | u, err := url.Parse(browseURL) 47 | if err != nil { 48 | return err 49 | } 50 | q := u.Query() 51 | q.Set("vscodeChannel", "insiders") 52 | u.RawQuery = q.Encode() 53 | browseURL = u.String() 54 | } 55 | } 56 | 57 | if err := a.browser.Browse(browseURL); err != nil { 58 | return fmt.Errorf("error opening Visual Studio Code: %w", err) 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func vscodeProtocolURL(codespaceName string, useInsiders bool) string { 65 | application := "vscode" 66 | if useInsiders { 67 | application = "vscode-insiders" 68 | } 69 | return fmt.Sprintf("%s://github.codespaces/connect?name=%s&windowId=_blank", application, url.QueryEscape(codespaceName)) 70 | } 71 | -------------------------------------------------------------------------------- /pkg/cmd/codespace/logs_test.go: -------------------------------------------------------------------------------- 1 | package codespace 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/cli/cli/v2/internal/codespaces/api" 8 | "github.com/cli/cli/v2/pkg/iostreams" 9 | ) 10 | 11 | func TestPendingOperationDisallowsLogs(t *testing.T) { 12 | app := testingLogsApp() 13 | selector := &CodespaceSelector{api: app.apiClient, codespaceName: "disabledCodespace"} 14 | 15 | if err := app.Logs(context.Background(), selector, false); err != nil { 16 | if err.Error() != "codespace is disabled while it has a pending operation: Some pending operation" { 17 | t.Errorf("expected pending operation error, but got: %v", err) 18 | } 19 | } else { 20 | t.Error("expected pending operation error, but got nothing") 21 | } 22 | } 23 | 24 | func testingLogsApp() *App { 25 | disabledCodespace := &api.Codespace{ 26 | Name: "disabledCodespace", 27 | PendingOperation: true, 28 | PendingOperationDisabledReason: "Some pending operation", 29 | } 30 | apiMock := &apiClientMock{ 31 | GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) { 32 | if name == "disabledCodespace" { 33 | return disabledCodespace, nil 34 | } 35 | return nil, nil 36 | }, 37 | } 38 | 39 | ios, _, _, _ := iostreams.Test() 40 | return NewApp(ios, nil, apiMock, nil, nil) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/cmd/codespace/mock_prompter.go: -------------------------------------------------------------------------------- 1 | // Code generated by moq; DO NOT EDIT. 2 | // github.com/matryer/moq 3 | 4 | package codespace 5 | 6 | import ( 7 | "sync" 8 | ) 9 | 10 | // prompterMock is a mock implementation of prompter. 11 | // 12 | // func TestSomethingThatUsesprompter(t *testing.T) { 13 | // 14 | // // make and configure a mocked prompter 15 | // mockedprompter := &prompterMock{ 16 | // ConfirmFunc: func(message string) (bool, error) { 17 | // panic("mock out the Confirm method") 18 | // }, 19 | // } 20 | // 21 | // // use mockedprompter in code that requires prompter 22 | // // and then make assertions. 23 | // 24 | // } 25 | type prompterMock struct { 26 | // ConfirmFunc mocks the Confirm method. 27 | ConfirmFunc func(message string) (bool, error) 28 | 29 | // calls tracks calls to the methods. 30 | calls struct { 31 | // Confirm holds details about calls to the Confirm method. 32 | Confirm []struct { 33 | // Message is the message argument value. 34 | Message string 35 | } 36 | } 37 | lockConfirm sync.RWMutex 38 | } 39 | 40 | // Confirm calls ConfirmFunc. 41 | func (mock *prompterMock) Confirm(message string) (bool, error) { 42 | if mock.ConfirmFunc == nil { 43 | panic("prompterMock.ConfirmFunc: method is nil but prompter.Confirm was just called") 44 | } 45 | callInfo := struct { 46 | Message string 47 | }{ 48 | Message: message, 49 | } 50 | mock.lockConfirm.Lock() 51 | mock.calls.Confirm = append(mock.calls.Confirm, callInfo) 52 | mock.lockConfirm.Unlock() 53 | return mock.ConfirmFunc(message) 54 | } 55 | 56 | // ConfirmCalls gets all the calls that were made to Confirm. 57 | // Check the length with: 58 | // 59 | // len(mockedprompter.ConfirmCalls()) 60 | func (mock *prompterMock) ConfirmCalls() []struct { 61 | Message string 62 | } { 63 | var calls []struct { 64 | Message string 65 | } 66 | mock.lockConfirm.RLock() 67 | calls = mock.calls.Confirm 68 | mock.lockConfirm.RUnlock() 69 | return calls 70 | } 71 | -------------------------------------------------------------------------------- /pkg/cmd/codespace/rebuild_test.go: -------------------------------------------------------------------------------- 1 | package codespace 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/cli/cli/v2/internal/codespaces/api" 8 | "github.com/cli/cli/v2/pkg/iostreams" 9 | ) 10 | 11 | func TestAlreadyRebuildingCodespace(t *testing.T) { 12 | rebuildingCodespace := &api.Codespace{ 13 | Name: "rebuildingCodespace", 14 | State: api.CodespaceStateRebuilding, 15 | } 16 | app := testingRebuildApp(*rebuildingCodespace) 17 | selector := &CodespaceSelector{api: app.apiClient, codespaceName: "rebuildingCodespace"} 18 | 19 | err := app.Rebuild(context.Background(), selector, false) 20 | if err != nil { 21 | t.Errorf("rebuilding a codespace that was already rebuilding: %v", err) 22 | } 23 | } 24 | 25 | func testingRebuildApp(mockCodespace api.Codespace) *App { 26 | apiMock := &apiClientMock{ 27 | GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) { 28 | if name == mockCodespace.Name { 29 | return &mockCodespace, nil 30 | } 31 | return nil, nil 32 | }, 33 | } 34 | 35 | ios, _, _, _ := iostreams.Test() 36 | return NewApp(ios, nil, apiMock, nil, nil) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/cmd/codespace/root.go: -------------------------------------------------------------------------------- 1 | package codespace 2 | 3 | import ( 4 | codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api" 5 | 6 | "github.com/cli/cli/v2/pkg/cmdutil" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func NewCmdCodespace(f *cmdutil.Factory) *cobra.Command { 11 | root := &cobra.Command{ 12 | Use: "codespace", 13 | Short: "Connect to and manage codespaces", 14 | Aliases: []string{"cs"}, 15 | GroupID: "core", 16 | } 17 | 18 | app := NewApp( 19 | f.IOStreams, 20 | f, 21 | codespacesAPI.New(f), 22 | f.Browser, 23 | f.Remotes, 24 | ) 25 | 26 | root.AddCommand(newCodeCmd(app)) 27 | root.AddCommand(newCreateCmd(app)) 28 | root.AddCommand(newEditCmd(app)) 29 | root.AddCommand(newDeleteCmd(app)) 30 | root.AddCommand(newJupyterCmd(app)) 31 | root.AddCommand(newListCmd(app)) 32 | root.AddCommand(newViewCmd(app)) 33 | root.AddCommand(newLogsCmd(app)) 34 | root.AddCommand(newPortsCmd(app)) 35 | root.AddCommand(newSSHCmd(app)) 36 | root.AddCommand(newCpCmd(app)) 37 | root.AddCommand(newStopCmd(app)) 38 | root.AddCommand(newSelectCmd(app)) 39 | root.AddCommand(newRebuildCmd(app)) 40 | 41 | return root 42 | } 43 | -------------------------------------------------------------------------------- /pkg/cmd/codespace/select.go: -------------------------------------------------------------------------------- 1 | package codespace 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | type selectOptions struct { 12 | filePath string 13 | selector *CodespaceSelector 14 | } 15 | 16 | func newSelectCmd(app *App) *cobra.Command { 17 | var ( 18 | opts selectOptions 19 | ) 20 | 21 | selectCmd := &cobra.Command{ 22 | Use: "select", 23 | Short: "Select a Codespace", 24 | Hidden: true, 25 | Args: noArgsConstraint, 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | return app.Select(cmd.Context(), opts) 28 | }, 29 | } 30 | 31 | opts.selector = AddCodespaceSelector(selectCmd, app.apiClient) 32 | selectCmd.Flags().StringVarP(&opts.filePath, "file", "f", "", "Output file path") 33 | return selectCmd 34 | } 35 | 36 | // Hidden codespace `select` command allows to reuse existing codespace selection 37 | // dialog by external pullpo CLI extensions. By default output selected codespace name 38 | // into `stdout`. Pass `--file`(`-f`) flag along with a file path to output selected 39 | // codespace name into a file instead. 40 | // 41 | // ## Examples 42 | // 43 | // With `stdout` output: 44 | // 45 | // ```shell 46 | // 47 | // pullpo codespace select 48 | // 49 | // ``` 50 | // 51 | // With `into-a-file` output: 52 | // 53 | // ```shell 54 | // 55 | // pullpo codespace select --file /tmp/selected_codespace.txt 56 | // 57 | // ``` 58 | func (a *App) Select(ctx context.Context, opts selectOptions) (err error) { 59 | codespace, err := opts.selector.Select(ctx) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if opts.filePath != "" { 65 | f, err := os.Create(opts.filePath) 66 | if err != nil { 67 | return fmt.Errorf("failed to create output file: %w", err) 68 | } 69 | 70 | defer safeClose(f, &err) 71 | 72 | _, err = f.WriteString(codespace.Name) 73 | if err != nil { 74 | return fmt.Errorf("failed to write codespace name to output file: %w", err) 75 | } 76 | 77 | return nil 78 | } 79 | 80 | fmt.Fprintln(a.io.Out, codespace.Name) 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/cmd/config/clear-cache/clear_cache.go: -------------------------------------------------------------------------------- 1 | package clearcache 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/MakeNowJust/heredoc" 8 | "github.com/cli/cli/v2/pkg/cmdutil" 9 | "github.com/cli/cli/v2/pkg/iostreams" 10 | "github.com/cli/go-gh/v2/pkg/config" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | type ClearCacheOptions struct { 15 | IO *iostreams.IOStreams 16 | CacheDir string 17 | } 18 | 19 | func NewCmdConfigClearCache(f *cmdutil.Factory, runF func(*ClearCacheOptions) error) *cobra.Command { 20 | opts := &ClearCacheOptions{ 21 | IO: f.IOStreams, 22 | CacheDir: config.CacheDir(), 23 | } 24 | 25 | cmd := &cobra.Command{ 26 | Use: "clear-cache", 27 | Short: "Clear the cli cache", 28 | Example: heredoc.Doc(` 29 | # Clear the cli cache 30 | $ pullpo config clear-cache 31 | `), 32 | Args: cobra.ExactArgs(0), 33 | RunE: func(_ *cobra.Command, _ []string) error { 34 | if runF != nil { 35 | return runF(opts) 36 | } 37 | return clearCacheRun(opts) 38 | }, 39 | } 40 | 41 | return cmd 42 | } 43 | 44 | func clearCacheRun(opts *ClearCacheOptions) error { 45 | if err := os.RemoveAll(opts.CacheDir); err != nil { 46 | return err 47 | } 48 | cs := opts.IO.ColorScheme() 49 | fmt.Fprintf(opts.IO.Out, "%s Cleared the cache\n", cs.SuccessIcon()) 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/cmd/config/clear-cache/clear_cache_test.go: -------------------------------------------------------------------------------- 1 | package clearcache 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/cli/cli/v2/pkg/iostreams" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestClearCacheRun(t *testing.T) { 14 | cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache") 15 | ios, _, stdout, stderr := iostreams.Test() 16 | opts := &ClearCacheOptions{ 17 | IO: ios, 18 | CacheDir: cacheDir, 19 | } 20 | 21 | if err := os.Mkdir(opts.CacheDir, 0600); err != nil { 22 | assert.NoError(t, err) 23 | } 24 | 25 | if err := clearCacheRun(opts); err != nil { 26 | assert.NoError(t, err) 27 | } 28 | 29 | assert.NoDirExistsf(t, opts.CacheDir, fmt.Sprintf("Cache dir: %s still exists", opts.CacheDir)) 30 | assert.Equal(t, "✓ Cleared the cache\n", stdout.String()) 31 | assert.Equal(t, "", stderr.String()) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/cmd/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/cli/cli/v2/internal/config" 8 | cmdClearCache "github.com/cli/cli/v2/pkg/cmd/config/clear-cache" 9 | cmdGet "github.com/cli/cli/v2/pkg/cmd/config/get" 10 | cmdList "github.com/cli/cli/v2/pkg/cmd/config/list" 11 | cmdSet "github.com/cli/cli/v2/pkg/cmd/config/set" 12 | "github.com/cli/cli/v2/pkg/cmdutil" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { 17 | longDoc := strings.Builder{} 18 | longDoc.WriteString("Display or change configuration settings for gh.\n\n") 19 | longDoc.WriteString("Current respected settings:\n") 20 | for _, co := range config.ConfigOptions() { 21 | longDoc.WriteString(fmt.Sprintf("- %s: %s", co.Key, co.Description)) 22 | if co.DefaultValue != "" { 23 | longDoc.WriteString(fmt.Sprintf(" (default: %q)", co.DefaultValue)) 24 | } 25 | longDoc.WriteRune('\n') 26 | } 27 | 28 | cmd := &cobra.Command{ 29 | Use: "config ", 30 | Short: "Manage configuration for gh", 31 | Long: longDoc.String(), 32 | } 33 | 34 | cmdutil.DisableAuthCheck(cmd) 35 | 36 | cmd.AddCommand(cmdGet.NewCmdConfigGet(f, nil)) 37 | cmd.AddCommand(cmdSet.NewCmdConfigSet(f, nil)) 38 | cmd.AddCommand(cmdList.NewCmdConfigList(f, nil)) 39 | cmd.AddCommand(cmdClearCache.NewCmdConfigClearCache(f, nil)) 40 | 41 | return cmd 42 | } 43 | -------------------------------------------------------------------------------- /pkg/cmd/config/get/get.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/MakeNowJust/heredoc" 8 | "github.com/cli/cli/v2/internal/config" 9 | "github.com/cli/cli/v2/pkg/cmdutil" 10 | "github.com/cli/cli/v2/pkg/iostreams" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | type GetOptions struct { 15 | IO *iostreams.IOStreams 16 | Config config.Config 17 | 18 | Hostname string 19 | Key string 20 | } 21 | 22 | func NewCmdConfigGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { 23 | opts := &GetOptions{ 24 | IO: f.IOStreams, 25 | } 26 | 27 | cmd := &cobra.Command{ 28 | Use: "get ", 29 | Short: "Print the value of a given configuration key", 30 | Example: heredoc.Doc(` 31 | $ pullpo config get git_protocol 32 | https 33 | `), 34 | Args: cobra.ExactArgs(1), 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | config, err := f.Config() 37 | if err != nil { 38 | return err 39 | } 40 | opts.Config = config 41 | opts.Key = args[0] 42 | 43 | if runF != nil { 44 | return runF(opts) 45 | } 46 | 47 | return getRun(opts) 48 | }, 49 | } 50 | 51 | cmd.Flags().StringVarP(&opts.Hostname, "host", "h", "", "Get per-host setting") 52 | 53 | return cmd 54 | } 55 | 56 | func getRun(opts *GetOptions) error { 57 | // search keyring storage when fetching the `oauth_token` value 58 | if opts.Hostname != "" && opts.Key == "oauth_token" { 59 | token, _ := opts.Config.Authentication().Token(opts.Hostname) 60 | if token == "" { 61 | return errors.New(`could not find key "oauth_token"`) 62 | } 63 | fmt.Fprintf(opts.IO.Out, "%s\n", token) 64 | return nil 65 | } 66 | 67 | val, err := opts.Config.GetOrDefault(opts.Hostname, opts.Key) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | if val != "" { 73 | fmt.Fprintf(opts.IO.Out, "%s\n", val) 74 | } 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/cmd/config/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cli/cli/v2/internal/config" 7 | "github.com/cli/cli/v2/pkg/cmdutil" 8 | "github.com/cli/cli/v2/pkg/iostreams" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type ListOptions struct { 13 | IO *iostreams.IOStreams 14 | Config func() (config.Config, error) 15 | 16 | Hostname string 17 | } 18 | 19 | func NewCmdConfigList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { 20 | opts := &ListOptions{ 21 | IO: f.IOStreams, 22 | Config: f.Config, 23 | } 24 | 25 | cmd := &cobra.Command{ 26 | Use: "list", 27 | Short: "Print a list of configuration keys and values", 28 | Aliases: []string{"ls"}, 29 | Args: cobra.ExactArgs(0), 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | if runF != nil { 32 | return runF(opts) 33 | } 34 | 35 | return listRun(opts) 36 | }, 37 | } 38 | 39 | cmd.Flags().StringVarP(&opts.Hostname, "host", "h", "", "Get per-host configuration") 40 | 41 | return cmd 42 | } 43 | 44 | func listRun(opts *ListOptions) error { 45 | cfg, err := opts.Config() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | var host string 51 | if opts.Hostname != "" { 52 | host = opts.Hostname 53 | } else { 54 | host, _ = cfg.Authentication().DefaultHost() 55 | } 56 | 57 | configOptions := config.ConfigOptions() 58 | 59 | for _, key := range configOptions { 60 | val, err := cfg.GetOrDefault(host, key.Key) 61 | if err != nil { 62 | return err 63 | } 64 | fmt.Fprintf(opts.IO.Out, "%s=%s\n", key.Key, val) 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/cmd/extension/browse/rg.go: -------------------------------------------------------------------------------- 1 | package browse 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/cli/cli/v2/api" 8 | "github.com/cli/cli/v2/internal/ghrepo" 9 | "github.com/cli/cli/v2/pkg/cmd/repo/view" 10 | ) 11 | 12 | type readmeGetter struct { 13 | client *http.Client 14 | } 15 | 16 | func newReadmeGetter(client *http.Client, cacheTTL time.Duration) *readmeGetter { 17 | cachingClient := api.NewCachedHTTPClient(client, cacheTTL) 18 | return &readmeGetter{ 19 | client: cachingClient, 20 | } 21 | } 22 | 23 | func (g *readmeGetter) Get(repoFullName string) (string, error) { 24 | repo, err := ghrepo.FromFullName(repoFullName) 25 | if err != nil { 26 | return "", err 27 | } 28 | readme, err := view.RepositoryReadme(g.client, repo, "") 29 | if err != nil { 30 | return "", err 31 | } 32 | return readme.Content, nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmd/extension/ext_tmpls/buildScript.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "TODO implement this script." 3 | echo "It should build binaries in dist/-[.exe] as needed." 4 | exit 1 5 | -------------------------------------------------------------------------------- /pkg/cmd/extension/ext_tmpls/goBinMain.go.txt: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cli/go-gh/v2/pkg/api" 7 | ) 8 | 9 | func main() { 10 | fmt.Println("hi world, this is the %s extension!") 11 | client, err := api.DefaultRESTClient() 12 | if err != nil { 13 | fmt.Println(err) 14 | return 15 | } 16 | response := struct {Login string}{} 17 | err = client.Get("user", &response) 18 | if err != nil { 19 | fmt.Println(err) 20 | return 21 | } 22 | fmt.Printf("running as %%s\n", response.Login) 23 | } 24 | 25 | // For more examples of using go-gh, see: 26 | // https://github.com/cli/go-gh/blob/trunk/example_gh_test.go 27 | -------------------------------------------------------------------------------- /pkg/cmd/extension/ext_tmpls/goBinWorkflow.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | permissions: 7 | contents: write 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: cli/gh-extension-precompile@v1 15 | -------------------------------------------------------------------------------- /pkg/cmd/extension/ext_tmpls/otherBinWorkflow.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | permissions: 7 | contents: write 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: cli/gh-extension-precompile@v1 15 | with: 16 | build_script_override: "script/build.sh" 17 | -------------------------------------------------------------------------------- /pkg/cmd/extension/ext_tmpls/script.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | echo "Hello %[1]s!" 5 | 6 | # Snippets to help get started: 7 | 8 | # Determine if an executable is in the PATH 9 | # if ! type -p ruby >/dev/null; then 10 | # echo "Ruby not found on the system" >&2 11 | # exit 1 12 | # fi 13 | 14 | # Pass arguments throupullpo to another command 15 | # pullpo issue list "$@" -R cli/cli 16 | 17 | # Using the pullpo api command to retrieve and format information 18 | # QUERY=' 19 | # query($endCursor: String) { 20 | # viewer { 21 | # repositories(first: 100, after: $endCursor) { 22 | # nodes { 23 | # nameWithOwner 24 | # stargazerCount 25 | # } 26 | # } 27 | # } 28 | # } 29 | # ' 30 | # TEMPLATE=' 31 | # {{- range $repo := .data.viewer.repositories.nodes -}} 32 | # {{- printf "name: %%s - stargazers: %%v\n" $repo.nameWithOwner $repo.stargazerCount -}} 33 | # {{- end -}} 34 | # ' 35 | # exec pullpo api graphql -f query="${QUERY}" --paginate --template="${TEMPLATE}" 36 | -------------------------------------------------------------------------------- /pkg/cmd/extension/git.go: -------------------------------------------------------------------------------- 1 | package extension 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/cli/cli/v2/git" 7 | ) 8 | 9 | type gitClient interface { 10 | CheckoutBranch(branch string) error 11 | Clone(cloneURL string, args []string) (string, error) 12 | CommandOutput(args []string) ([]byte, error) 13 | Config(name string) (string, error) 14 | Fetch(remote string, refspec string) error 15 | ForRepo(repoDir string) gitClient 16 | Pull(remote, branch string) error 17 | Remotes() (git.RemoteSet, error) 18 | } 19 | 20 | type gitExecuter struct { 21 | client *git.Client 22 | } 23 | 24 | func (g *gitExecuter) CheckoutBranch(branch string) error { 25 | return g.client.CheckoutBranch(context.Background(), branch) 26 | } 27 | 28 | func (g *gitExecuter) Clone(cloneURL string, cloneArgs []string) (string, error) { 29 | return g.client.Clone(context.Background(), cloneURL, cloneArgs) 30 | } 31 | 32 | func (g *gitExecuter) CommandOutput(args []string) ([]byte, error) { 33 | cmd, err := g.client.Command(context.Background(), args...) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return cmd.Output() 38 | } 39 | 40 | func (g *gitExecuter) Config(name string) (string, error) { 41 | return g.client.Config(context.Background(), name) 42 | } 43 | 44 | func (g *gitExecuter) Fetch(remote string, refspec string) error { 45 | return g.client.Fetch(context.Background(), remote, refspec) 46 | } 47 | 48 | func (g *gitExecuter) ForRepo(repoDir string) gitClient { 49 | gc := g.client.Copy() 50 | gc.RepoDir = repoDir 51 | return &gitExecuter{client: gc} 52 | } 53 | 54 | func (g *gitExecuter) Pull(remote, branch string) error { 55 | return g.client.Pull(context.Background(), remote, branch) 56 | } 57 | 58 | func (g *gitExecuter) Remotes() (git.RemoteSet, error) { 59 | return g.client.Remotes(context.Background()) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/cmd/extension/mocks.go: -------------------------------------------------------------------------------- 1 | package extension 2 | 3 | import ( 4 | "github.com/cli/cli/v2/git" 5 | "github.com/stretchr/testify/mock" 6 | ) 7 | 8 | type mockGitClient struct { 9 | mock.Mock 10 | } 11 | 12 | func (g *mockGitClient) CheckoutBranch(branch string) error { 13 | args := g.Called(branch) 14 | return args.Error(0) 15 | } 16 | 17 | func (g *mockGitClient) Clone(cloneURL string, cloneArgs []string) (string, error) { 18 | args := g.Called(cloneURL, cloneArgs) 19 | return args.String(0), args.Error(1) 20 | } 21 | 22 | func (g *mockGitClient) CommandOutput(commandArgs []string) ([]byte, error) { 23 | args := g.Called(commandArgs) 24 | return []byte(args.String(0)), args.Error(1) 25 | } 26 | 27 | func (g *mockGitClient) Config(name string) (string, error) { 28 | args := g.Called(name) 29 | return args.String(0), args.Error(1) 30 | } 31 | 32 | func (g *mockGitClient) Fetch(remote string, refspec string) error { 33 | args := g.Called(remote, refspec) 34 | return args.Error(0) 35 | } 36 | 37 | func (g *mockGitClient) ForRepo(repoDir string) gitClient { 38 | args := g.Called(repoDir) 39 | if v, ok := args.Get(0).(*mockGitClient); ok { 40 | return v 41 | } 42 | return nil 43 | } 44 | 45 | func (g *mockGitClient) Pull(remote, branch string) error { 46 | args := g.Called(remote, branch) 47 | return args.Error(0) 48 | } 49 | 50 | func (g *mockGitClient) Remotes() (git.RemoteSet, error) { 51 | args := g.Called() 52 | return nil, args.Error(1) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/cmd/extension/symlink_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package extension 5 | 6 | import "os" 7 | 8 | func makeSymlink(oldname, newname string) error { 9 | return os.Symlink(oldname, newname) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/cmd/extension/symlink_windows.go: -------------------------------------------------------------------------------- 1 | package extension 2 | 3 | import "os" 4 | 5 | func makeSymlink(oldname, newname string) error { 6 | // Create a regular file that contains the location of the directory where to find this extension. We 7 | // avoid relying on symlinks because creating them on Windows requires administrator privileges. 8 | f, err := os.OpenFile(newname, os.O_WRONLY|os.O_CREATE, 0644) 9 | if err != nil { 10 | return err 11 | } 12 | defer f.Close() 13 | _, err = f.WriteString(oldname) 14 | return err 15 | } 16 | -------------------------------------------------------------------------------- /pkg/cmd/gist/gist.go: -------------------------------------------------------------------------------- 1 | package gist 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | gistCloneCmd "github.com/cli/cli/v2/pkg/cmd/gist/clone" 6 | gistCreateCmd "github.com/cli/cli/v2/pkg/cmd/gist/create" 7 | gistDeleteCmd "github.com/cli/cli/v2/pkg/cmd/gist/delete" 8 | gistEditCmd "github.com/cli/cli/v2/pkg/cmd/gist/edit" 9 | gistListCmd "github.com/cli/cli/v2/pkg/cmd/gist/list" 10 | gistRenameCmd "github.com/cli/cli/v2/pkg/cmd/gist/rename" 11 | gistViewCmd "github.com/cli/cli/v2/pkg/cmd/gist/view" 12 | "github.com/cli/cli/v2/pkg/cmdutil" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func NewCmdGist(f *cmdutil.Factory) *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "gist ", 19 | Short: "Manage gists", 20 | Long: `Work with GitHub gists.`, 21 | Annotations: map[string]string{ 22 | "help:arguments": heredoc.Doc(` 23 | A gist can be supplied as argument in either of the following formats: 24 | - by ID, e.g. 5b0e0062eb8e9654adad7bb1d81cc75f 25 | - by URL, e.g. "https://gist.github.com/OWNER/5b0e0062eb8e9654adad7bb1d81cc75f" 26 | `), 27 | }, 28 | GroupID: "core", 29 | } 30 | 31 | cmd.AddCommand(gistCloneCmd.NewCmdClone(f, nil)) 32 | cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil)) 33 | cmd.AddCommand(gistListCmd.NewCmdList(f, nil)) 34 | cmd.AddCommand(gistViewCmd.NewCmdView(f, nil)) 35 | cmd.AddCommand(gistEditCmd.NewCmdEdit(f, nil)) 36 | cmd.AddCommand(gistDeleteCmd.NewCmdDelete(f, nil)) 37 | cmd.AddCommand(gistRenameCmd.NewCmdRename(f, nil)) 38 | 39 | return cmd 40 | } 41 | -------------------------------------------------------------------------------- /pkg/cmd/gpg-key/add/http.go: -------------------------------------------------------------------------------- 1 | package add 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/cli/cli/v2/api" 11 | "github.com/cli/cli/v2/internal/ghinstance" 12 | ) 13 | 14 | var errScopesMissing = errors.New("insufficient OAuth scopes") 15 | var errDuplicateKey = errors.New("key already exists") 16 | var errWrongFormat = errors.New("key in wrong format") 17 | 18 | func gpgKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) error { 19 | url := ghinstance.RESTPrefix(hostname) + "user/gpg_keys" 20 | 21 | keyBytes, err := io.ReadAll(keyFile) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | payload := map[string]string{ 27 | "armored_public_key": string(keyBytes), 28 | } 29 | if title != "" { 30 | payload["name"] = title 31 | } 32 | 33 | payloadBytes, err := json.Marshal(payload) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | resp, err := httpClient.Do(req) 44 | if err != nil { 45 | return err 46 | } 47 | defer resp.Body.Close() 48 | 49 | if resp.StatusCode == 404 { 50 | return errScopesMissing 51 | } else if resp.StatusCode > 299 { 52 | err := api.HandleHTTPError(resp) 53 | var httpError api.HTTPError 54 | if errors.As(err, &httpError) { 55 | for _, e := range httpError.Errors { 56 | if resp.StatusCode == 422 && e.Field == "key_id" && e.Message == "key_id already exists" { 57 | return errDuplicateKey 58 | } 59 | } 60 | } 61 | if resp.StatusCode == 422 && !isGpgKeyArmored(keyBytes) { 62 | return errWrongFormat 63 | } 64 | return err 65 | } 66 | 67 | _, _ = io.Copy(io.Discard, resp.Body) 68 | return nil 69 | } 70 | 71 | func isGpgKeyArmored(keyBytes []byte) bool { 72 | return bytes.Contains(keyBytes, []byte("-----BEGIN ")) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/cmd/gpg-key/delete/http.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/cli/cli/v2/api" 10 | "github.com/cli/cli/v2/internal/ghinstance" 11 | ) 12 | 13 | type gpgKey struct { 14 | ID int 15 | KeyID string `json:"key_id"` 16 | } 17 | 18 | func deleteGPGKey(httpClient *http.Client, host, id string) error { 19 | url := fmt.Sprintf("%suser/gpg_keys/%s", ghinstance.RESTPrefix(host), id) 20 | req, err := http.NewRequest("DELETE", url, nil) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | resp, err := httpClient.Do(req) 26 | if err != nil { 27 | return err 28 | } 29 | defer resp.Body.Close() 30 | 31 | if resp.StatusCode > 299 { 32 | return api.HandleHTTPError(resp) 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func getGPGKeys(httpClient *http.Client, host string) ([]gpgKey, error) { 39 | resource := "user/gpg_keys" 40 | url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) 41 | req, err := http.NewRequest("GET", url, nil) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | resp, err := httpClient.Do(req) 47 | if err != nil { 48 | return nil, err 49 | } 50 | defer resp.Body.Close() 51 | 52 | if resp.StatusCode > 299 { 53 | return nil, api.HandleHTTPError(resp) 54 | } 55 | 56 | b, err := io.ReadAll(resp.Body) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | var keys []gpgKey 62 | err = json.Unmarshal(b, &keys) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | return keys, nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/cmd/gpg-key/gpg_key.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | cmdAdd "github.com/cli/cli/v2/pkg/cmd/gpg-key/add" 5 | cmdDelete "github.com/cli/cli/v2/pkg/cmd/gpg-key/delete" 6 | cmdList "github.com/cli/cli/v2/pkg/cmd/gpg-key/list" 7 | "github.com/cli/cli/v2/pkg/cmdutil" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func NewCmdGPGKey(f *cmdutil.Factory) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "gpg-key ", 14 | Short: "Manage GPG keys", 15 | Long: "Manage GPG keys registered with your GitHub account.", 16 | } 17 | 18 | cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil)) 19 | cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) 20 | cmd.AddCommand(cmdList.NewCmdList(f, nil)) 21 | 22 | return cmd 23 | } 24 | -------------------------------------------------------------------------------- /pkg/cmd/gpg-key/list/http.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/cli/cli/v2/api" 13 | "github.com/cli/cli/v2/internal/ghinstance" 14 | ) 15 | 16 | var errScopes = errors.New("insufficient OAuth scopes") 17 | 18 | type emails []email 19 | 20 | type email struct { 21 | Email string `json:"email"` 22 | } 23 | 24 | func (es emails) String() string { 25 | s := []string{} 26 | for _, e := range es { 27 | s = append(s, e.Email) 28 | } 29 | return strings.Join(s, ", ") 30 | } 31 | 32 | type gpgKey struct { 33 | KeyID string `json:"key_id"` 34 | PublicKey string `json:"public_key"` 35 | Emails emails `json:"emails"` 36 | CreatedAt time.Time `json:"created_at"` 37 | ExpiresAt time.Time `json:"expires_at"` 38 | } 39 | 40 | func userKeys(httpClient *http.Client, host, userHandle string) ([]gpgKey, error) { 41 | resource := "user/gpg_keys" 42 | if userHandle != "" { 43 | resource = fmt.Sprintf("users/%s/gpg_keys", userHandle) 44 | } 45 | url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) 46 | req, err := http.NewRequest("GET", url, nil) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | resp, err := httpClient.Do(req) 52 | if err != nil { 53 | return nil, err 54 | } 55 | defer resp.Body.Close() 56 | 57 | if resp.StatusCode == 404 { 58 | return nil, errScopes 59 | } else if resp.StatusCode > 299 { 60 | return nil, api.HandleHTTPError(resp) 61 | } 62 | 63 | b, err := io.ReadAll(resp.Body) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | var keys []gpgKey 69 | err = json.Unmarshal(b, &keys) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | return keys, nil 75 | } 76 | -------------------------------------------------------------------------------- /pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug or unexpected behavior 4 | title: Bug Report 5 | labels: bug 6 | 7 | --- 8 | 9 | I wanna report a bug -------------------------------------------------------------------------------- /pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Submit a request 3 | about: Propose an improvement 4 | title: Enhancement Proposal 5 | labels: enhancement 6 | 7 | --- 8 | 9 | I have a suggestion for an enhancement -------------------------------------------------------------------------------- /pkg/cmd/issue/list/fixtures/issueList.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "hasIssuesEnabled": true, 5 | "issues": { 6 | "totalCount": 3, 7 | "nodes": [ 8 | { 9 | "number": 1, 10 | "title": "number won", 11 | "url": "https://wow.com", 12 | "updatedAt": "2022-08-24T22:01:12Z", 13 | "labels": { 14 | "nodes": [ 15 | { 16 | "name": "label" 17 | } 18 | ], 19 | "totalCount": 1 20 | } 21 | }, 22 | { 23 | "number": 2, 24 | "title": "number too", 25 | "url": "https://wow.com", 26 | "updatedAt": "2022-07-20T19:01:12Z", 27 | "labels": { 28 | "nodes": [ 29 | { 30 | "name": "label" 31 | } 32 | ], 33 | "totalCount": 1 34 | } 35 | }, 36 | { 37 | "number": 4, 38 | "title": "number fore", 39 | "url": "https://wow.com", 40 | "updatedAt": "2020-01-26T19:01:12Z", 41 | "labels": { 42 | "nodes": [ 43 | { 44 | "name": "label" 45 | } 46 | ], 47 | "totalCount": 1 48 | } 49 | } 50 | ] 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/cmd/issue/list/fixtures/issueSearch.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "hasIssuesEnabled": true 5 | }, 6 | "search": { 7 | "issueCount": 3, 8 | "nodes": [ 9 | { 10 | "number": 1, 11 | "title": "number won", 12 | "url": "https://wow.com", 13 | "updatedAt": "2011-01-26T19:01:12Z", 14 | "labels": { 15 | "nodes": [ 16 | { 17 | "name": "label" 18 | } 19 | ], 20 | "totalCount": 1 21 | } 22 | }, 23 | { 24 | "number": 2, 25 | "title": "number too", 26 | "url": "https://wow.com", 27 | "updatedAt": "2011-01-26T19:01:12Z", 28 | "labels": { 29 | "nodes": [ 30 | { 31 | "name": "label" 32 | } 33 | ], 34 | "totalCount": 1 35 | } 36 | }, 37 | { 38 | "number": 4, 39 | "title": "number fore", 40 | "url": "https://wow.com", 41 | "updatedAt": "2011-01-26T19:01:12Z", 42 | "labels": { 43 | "nodes": [ 44 | { 45 | "name": "label" 46 | } 47 | ], 48 | "totalCount": 1 49 | } 50 | } 51 | ] 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/cmd/issue/shared/display.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/cli/cli/v2/api" 10 | "github.com/cli/cli/v2/internal/tableprinter" 11 | "github.com/cli/cli/v2/internal/text" 12 | prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" 13 | "github.com/cli/cli/v2/pkg/iostreams" 14 | ) 15 | 16 | func PrintIssues(io *iostreams.IOStreams, now time.Time, prefix string, totalCount int, issues []api.Issue) { 17 | cs := io.ColorScheme() 18 | isTTY := io.IsStdoutTTY() 19 | headers := []string{"ID"} 20 | if !isTTY { 21 | headers = append(headers, "STATE") 22 | } 23 | headers = append(headers, 24 | "TITLE", 25 | "LABELS", 26 | "UPDATED", 27 | ) 28 | table := tableprinter.New(io, tableprinter.WithHeader(headers...)) 29 | for _, issue := range issues { 30 | issueNum := strconv.Itoa(issue.Number) 31 | if isTTY { 32 | issueNum = "#" + issueNum 33 | } 34 | issueNum = prefix + issueNum 35 | table.AddField(issueNum, tableprinter.WithColor(cs.ColorFromString(prShared.ColorForIssueState(issue)))) 36 | if !isTTY { 37 | table.AddField(issue.State) 38 | } 39 | table.AddField(text.RemoveExcessiveWhitespace(issue.Title)) 40 | table.AddField(issueLabelList(&issue, cs, isTTY)) 41 | table.AddTimeField(now, issue.UpdatedAt, cs.Gray) 42 | table.EndRow() 43 | } 44 | _ = table.Render() 45 | remaining := totalCount - len(issues) 46 | if remaining > 0 { 47 | fmt.Fprintf(io.Out, cs.Gray("%sAnd %d more\n"), prefix, remaining) 48 | } 49 | } 50 | 51 | func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme, colorize bool) string { 52 | if len(issue.Labels.Nodes) == 0 { 53 | return "" 54 | } 55 | 56 | labelNames := make([]string, 0, len(issue.Labels.Nodes)) 57 | for _, label := range issue.Labels.Nodes { 58 | if colorize { 59 | labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name)) 60 | } else { 61 | labelNames = append(labelNames, label.Name) 62 | } 63 | } 64 | 65 | return strings.Join(labelNames, ", ") 66 | } 67 | -------------------------------------------------------------------------------- /pkg/cmd/issue/status/fixtures/issueStatus.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "hasIssuesEnabled": true, 5 | "assigned": { 6 | "totalCount": 2, 7 | "nodes": [ 8 | { 9 | "number": 9, 10 | "title": "corey thinks squash tastes bad" 11 | }, 12 | { 13 | "number": 10, 14 | "title": "broccoli is a superfood" 15 | } 16 | ] 17 | }, 18 | "mentioned": { 19 | "totalCount": 2, 20 | "nodes": [ 21 | { 22 | "number": 8, 23 | "title": "rabbits eat carrots" 24 | }, 25 | { 26 | "number": 11, 27 | "title": "swiss chard is neutral" 28 | } 29 | ] 30 | }, 31 | "authored": { 32 | "totalCount": 0, 33 | "nodes": [] 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/cmd/issue/view/fixtures/issueView_preview.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "hasIssuesEnabled": true, 5 | "issue": { 6 | "number": 123, 7 | "body": "**bold story**", 8 | "title": "ix of coins", 9 | "state": "OPEN", 10 | "createdAt": "2011-01-26T19:01:12Z", 11 | "author": { 12 | "login": "marseilles" 13 | }, 14 | "assignees": { 15 | "nodes": [], 16 | "totalCount": 0 17 | }, 18 | "labels": { 19 | "nodes": [], 20 | "totalCount": 0 21 | }, 22 | "projectcards": { 23 | "nodes": [], 24 | "totalCount": 0 25 | }, 26 | "milestone": { 27 | "title": "" 28 | }, 29 | "comments": { 30 | "totalCount": 9 31 | }, 32 | "url": "https://github.com/OWNER/REPO/issues/123" 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "hasIssuesEnabled": true, 5 | "issue": { 6 | "number": 123, 7 | "body": "**bold story**", 8 | "title": "ix of coins", 9 | "state": "CLOSED", 10 | "createdAt": "2011-01-26T19:01:12Z", 11 | "author": { 12 | "login": "marseilles" 13 | }, 14 | "labels": { 15 | "nodes": [ 16 | { 17 | "name": "tarot" 18 | } 19 | ] 20 | }, 21 | "comments": { 22 | "totalCount": 9 23 | }, 24 | "url": "https://github.com/OWNER/REPO/issues/123" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "hasIssuesEnabled": true, 5 | "issue": { 6 | "number": 123, 7 | "body": "", 8 | "title": "ix of coins", 9 | "state": "OPEN", 10 | "createdAt": "2011-01-26T19:01:12Z", 11 | "author": { 12 | "login": "marseilles" 13 | }, 14 | "labels": { 15 | "nodes": [ 16 | { 17 | "name": "tarot" 18 | } 19 | ] 20 | }, 21 | "comments": { 22 | "totalCount": 9 23 | }, 24 | "url": "https://github.com/OWNER/REPO/issues/123" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/cmd/issue/view/http.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/cli/cli/v2/api" 7 | "github.com/cli/cli/v2/internal/ghrepo" 8 | "github.com/shurcooL/githubv4" 9 | ) 10 | 11 | func preloadIssueComments(client *http.Client, repo ghrepo.Interface, issue *api.Issue) error { 12 | type response struct { 13 | Node struct { 14 | Issue struct { 15 | Comments *api.Comments `graphql:"comments(first: 100, after: $endCursor)"` 16 | } `graphql:"...on Issue"` 17 | PullRequest struct { 18 | Comments *api.Comments `graphql:"comments(first: 100, after: $endCursor)"` 19 | } `graphql:"...on PullRequest"` 20 | } `graphql:"node(id: $id)"` 21 | } 22 | 23 | variables := map[string]interface{}{ 24 | "id": githubv4.ID(issue.ID), 25 | "endCursor": (*githubv4.String)(nil), 26 | } 27 | if issue.Comments.PageInfo.HasNextPage { 28 | variables["endCursor"] = githubv4.String(issue.Comments.PageInfo.EndCursor) 29 | } else { 30 | issue.Comments.Nodes = issue.Comments.Nodes[0:0] 31 | } 32 | 33 | gql := api.NewClientFromHTTP(client) 34 | for { 35 | var query response 36 | err := gql.Query(repo.RepoHost(), "CommentsForIssue", &query, variables) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | comments := query.Node.Issue.Comments 42 | if comments == nil { 43 | comments = query.Node.PullRequest.Comments 44 | } 45 | 46 | issue.Comments.Nodes = append(issue.Comments.Nodes, comments.Nodes...) 47 | if !comments.PageInfo.HasNextPage { 48 | break 49 | } 50 | variables["endCursor"] = githubv4.String(comments.PageInfo.EndCursor) 51 | } 52 | 53 | issue.Comments.PageInfo.HasNextPage = false 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/cmd/label/http_test.go: -------------------------------------------------------------------------------- 1 | package label 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/cli/cli/v2/internal/ghrepo" 8 | "github.com/cli/cli/v2/pkg/httpmock" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestLabelList_pagination(t *testing.T) { 13 | reg := &httpmock.Registry{} 14 | client := &http.Client{Transport: reg} 15 | 16 | reg.Register( 17 | httpmock.GraphQL(`query LabelList\b`), 18 | httpmock.StringResponse(` 19 | { 20 | "data": { 21 | "repository": { 22 | "labels": { 23 | "totalCount": 2, 24 | "nodes": [ 25 | { 26 | "name": "bug", 27 | "color": "d73a4a", 28 | "description": "This is a bug label" 29 | } 30 | ], 31 | "pageInfo": { 32 | "hasNextPage": true, 33 | "endCursor": "Y3Vyc29yOnYyOpK5MjAxOS0xMC0xMVQwMTozODowMyswODowMM5f3HZq" 34 | } 35 | } 36 | } 37 | } 38 | }`), 39 | ) 40 | 41 | reg.Register( 42 | httpmock.GraphQL(`query LabelList\b`), 43 | httpmock.StringResponse(` 44 | { 45 | "data": { 46 | "repository": { 47 | "labels": { 48 | "totalCount": 2, 49 | "nodes": [ 50 | { 51 | "name": "docs", 52 | "color": "ffa8da", 53 | "description": "This is a docs label" 54 | } 55 | ], 56 | "pageInfo": { 57 | "hasNextPage": false, 58 | "endCursor": "Y3Vyc29yOnYyOpK5MjAyMi0wMS0zMVQxODo1NTo1MiswODowMM7hiAL3" 59 | } 60 | } 61 | } 62 | } 63 | }`), 64 | ) 65 | 66 | repo := ghrepo.New("OWNER", "REPO") 67 | labels, totalCount, err := listLabels(client, repo, listQueryOptions{Limit: 10}) 68 | assert.NoError(t, err) 69 | 70 | assert.Equal(t, 2, totalCount) 71 | assert.Equal(t, 2, len(labels)) 72 | 73 | assert.Equal(t, "bug", labels[0].Name) 74 | assert.Equal(t, "d73a4a", labels[0].Color) 75 | assert.Equal(t, "This is a bug label", labels[0].Description) 76 | 77 | assert.Equal(t, "docs", labels[1].Name) 78 | assert.Equal(t, "ffa8da", labels[1].Color) 79 | assert.Equal(t, "This is a docs label", labels[1].Description) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/cmd/label/label.go: -------------------------------------------------------------------------------- 1 | package label 2 | 3 | import ( 4 | "github.com/cli/cli/v2/pkg/cmdutil" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func NewCmdLabel(f *cmdutil.Factory) *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "label ", 11 | Short: "Manage labels", 12 | Long: `Work with GitHub labels.`, 13 | } 14 | cmdutil.EnableRepoOverride(cmd, f) 15 | 16 | cmd.AddCommand(newCmdList(f, nil)) 17 | cmd.AddCommand(newCmdCreate(f, nil)) 18 | cmd.AddCommand(newCmdClone(f, nil)) 19 | cmd.AddCommand(newCmdEdit(f, nil)) 20 | cmd.AddCommand(newCmdDelete(f, nil)) 21 | 22 | return cmd 23 | } 24 | -------------------------------------------------------------------------------- /pkg/cmd/label/shared.go: -------------------------------------------------------------------------------- 1 | package label 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | var labelFields = []string{ 10 | "color", 11 | "createdAt", 12 | "description", 13 | "id", 14 | "isDefault", 15 | "name", 16 | "updatedAt", 17 | "url", 18 | } 19 | 20 | type label struct { 21 | Color string `json:"color"` 22 | CreatedAt time.Time `json:"createdAt"` 23 | Description string `json:"description"` 24 | ID string `json:"node_id"` 25 | IsDefault bool `json:"isDefault"` 26 | Name string `json:"name"` 27 | URL string `json:"url"` 28 | UpdatedAt time.Time `json:"updatedAt"` 29 | } 30 | 31 | // ExportData implements cmdutil.exportable 32 | func (l *label) ExportData(fields []string) map[string]interface{} { 33 | v := reflect.ValueOf(l).Elem() 34 | data := map[string]interface{}{} 35 | 36 | for _, f := range fields { 37 | switch f { 38 | default: 39 | sf := fieldByName(v, f) 40 | data[f] = sf.Interface() 41 | } 42 | } 43 | 44 | return data 45 | } 46 | 47 | func fieldByName(v reflect.Value, field string) reflect.Value { 48 | return v.FieldByNameFunc(func(s string) bool { 49 | return strings.EqualFold(field, s) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/cmd/org/org.go: -------------------------------------------------------------------------------- 1 | package org 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | orgListCmd "github.com/cli/cli/v2/pkg/cmd/org/list" 6 | "github.com/cli/cli/v2/pkg/cmdutil" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func NewCmdOrg(f *cmdutil.Factory) *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "org ", 13 | Short: "Manage organizations", 14 | Long: "Work with Github organizations.", 15 | Example: heredoc.Doc(` 16 | $ pullpo org list 17 | `), 18 | GroupID: "core", 19 | } 20 | 21 | cmdutil.AddGroup(cmd, "General commands", orgListCmd.NewCmdList(f, nil)) 22 | 23 | return cmd 24 | } 25 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/allPassing.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "completedAt": "2020-08-27T19:00:12Z", 17 | "startedAt": "2020-08-27T18:58:46Z", 18 | "detailsUrl": "sweet link" 19 | }, 20 | { 21 | "conclusion": "SUCCESS", 22 | "status": "COMPLETED", 23 | "name": "rad tests", 24 | "completedAt": "2020-08-27T19:00:12Z", 25 | "startedAt": "2020-08-27T18:58:46Z", 26 | "detailsUrl": "sweet link" 27 | }, 28 | { 29 | "conclusion": "SUCCESS", 30 | "status": "COMPLETED", 31 | "name": "awesome tests", 32 | "completedAt": "2020-08-27T19:00:12Z", 33 | "startedAt": "2020-08-27T18:58:46Z", 34 | "detailsUrl": "sweet link" 35 | } 36 | ] 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/onlyRequired.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "completedAt": "2020-08-27T19:00:12Z", 17 | "startedAt": "2020-08-27T18:58:46Z", 18 | "detailsUrl": "sweet link", 19 | "isRequired": true 20 | }, 21 | { 22 | "conclusion": "SKIPPED", 23 | "status": "COMPLETED", 24 | "name": "rad tests", 25 | "completedAt": "2020-08-27T19:00:12Z", 26 | "startedAt": "2020-08-27T18:58:46Z", 27 | "detailsUrl": "sweet link", 28 | "isRequired": false 29 | }, 30 | { 31 | "conclusion": "SKIPPED", 32 | "status": "COMPLETED", 33 | "name": "skip tests", 34 | "completedAt": "2020-08-27T19:00:12Z", 35 | "startedAt": "2020-08-27T18:58:46Z", 36 | "detailsUrl": "sweet link", 37 | "isRequired": false 38 | } 39 | ] 40 | } 41 | } 42 | } 43 | } 44 | ] 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/someCancelled.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "completedAt": "2020-08-27T19:00:12Z", 17 | "startedAt": "2020-08-27T18:58:46Z", 18 | "detailsUrl": "sweet link" 19 | }, 20 | { 21 | "conclusion": "CANCELLED", 22 | "status": "COMPLETED", 23 | "name": "sad tests", 24 | "completedAt": "2020-08-27T19:00:12Z", 25 | "startedAt": "2020-08-27T18:58:46Z", 26 | "detailsUrl": "sweet link" 27 | }, 28 | { 29 | "conclusion": "SUCCESS", 30 | "status": "COMPLETED", 31 | "name": "awesome tests", 32 | "completedAt": "2020-08-27T19:00:12Z", 33 | "startedAt": "2020-08-27T18:58:46Z", 34 | "detailsUrl": "sweet link" 35 | } 36 | ] 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/someFailing.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "completedAt": "2020-08-27T19:00:12Z", 17 | "startedAt": "2020-08-27T18:58:46Z", 18 | "detailsUrl": "sweet link" 19 | }, 20 | { 21 | "conclusion": "FAILURE", 22 | "status": "COMPLETED", 23 | "name": "sad tests", 24 | "completedAt": "2020-08-27T19:00:12Z", 25 | "startedAt": "2020-08-27T18:58:46Z", 26 | "detailsUrl": "sweet link" 27 | }, 28 | { 29 | "conclusion": "", 30 | "status": "IN_PROGRESS", 31 | "name": "slow tests", 32 | "completedAt": "2020-08-27T19:00:12Z", 33 | "startedAt": "2020-08-27T18:58:46Z", 34 | "detailsUrl": "sweet link" 35 | } 36 | ] 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/somePending.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "completedAt": "2020-08-27T19:00:12Z", 17 | "startedAt": "2020-08-27T18:58:46Z", 18 | "detailsUrl": "sweet link" 19 | }, 20 | { 21 | "conclusion": "SUCCESS", 22 | "status": "COMPLETED", 23 | "name": "rad tests", 24 | "completedAt": "2020-08-27T19:00:12Z", 25 | "startedAt": "2020-08-27T18:58:46Z", 26 | "detailsUrl": "sweet link" 27 | }, 28 | { 29 | "conclusion": "", 30 | "status": "IN_PROGRESS", 31 | "name": "slow tests", 32 | "completedAt": "2020-08-27T19:00:12Z", 33 | "startedAt": "2020-08-27T18:58:46Z", 34 | "detailsUrl": "sweet link" 35 | }, 36 | { 37 | "conclusion": "CANCELLED", 38 | "status": "COMPLETED", 39 | "name": "sad tests", 40 | "completedAt": "2020-08-27T19:00:12Z", 41 | "startedAt": "2020-08-27T18:58:46Z", 42 | "detailsUrl": "sweet link" 43 | } 44 | ] 45 | } 46 | } 47 | } 48 | } 49 | ] 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/someSkipping.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "completedAt": "2020-08-27T19:00:12Z", 17 | "startedAt": "2020-08-27T18:58:46Z", 18 | "detailsUrl": "sweet link" 19 | }, 20 | { 21 | "conclusion": "SKIPPED", 22 | "status": "COMPLETED", 23 | "name": "rad tests", 24 | "completedAt": "2020-08-27T19:00:12Z", 25 | "startedAt": "2020-08-27T18:58:46Z", 26 | "detailsUrl": "sweet link" 27 | }, 28 | { 29 | "conclusion": "SKIPPED", 30 | "status": "COMPLETED", 31 | "name": "skip tests", 32 | "completedAt": "2020-08-27T19:00:12Z", 33 | "startedAt": "2020-08-27T18:58:46Z", 34 | "detailsUrl": "sweet link" 35 | } 36 | ] 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/withDescriptions.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "description": "cool description", 17 | "completedAt": "2020-08-27T19:00:12Z", 18 | "startedAt": "2020-08-27T18:58:46Z", 19 | "detailsUrl": "sweet link" 20 | }, 21 | { 22 | "conclusion": "SUCCESS", 23 | "status": "COMPLETED", 24 | "name": "rad tests", 25 | "description": "rad description", 26 | "completedAt": "2020-08-27T19:00:12Z", 27 | "startedAt": "2020-08-27T18:58:46Z", 28 | "detailsUrl": "sweet link" 29 | }, 30 | { 31 | "conclusion": "SUCCESS", 32 | "status": "COMPLETED", 33 | "name": "awesome tests", 34 | "description": "awesome description", 35 | "completedAt": "2020-08-27T19:00:12Z", 36 | "startedAt": "2020-08-27T18:58:46Z", 37 | "detailsUrl": "sweet link" 38 | } 39 | ] 40 | } 41 | } 42 | } 43 | } 44 | ] 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/withEvents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "description": "cool description", 17 | "completedAt": "2020-08-27T19:00:12Z", 18 | "startedAt": "2020-08-27T18:58:46Z", 19 | "detailsUrl": "sweet link", 20 | "checkSuite": { 21 | "workflowRun": { 22 | "event": "pull_request", 23 | "workflow": { 24 | "name": "tests" 25 | } 26 | } 27 | } 28 | }, 29 | { 30 | "conclusion": "SUCCESS", 31 | "status": "COMPLETED", 32 | "name": "cool tests", 33 | "description": "cool description", 34 | "completedAt": "2020-08-27T19:00:12Z", 35 | "startedAt": "2020-08-27T18:58:46Z", 36 | "detailsUrl": "sweet link", 37 | "checkSuite": { 38 | "workflowRun": { 39 | "event": "push", 40 | "workflow": { 41 | "name": "tests" 42 | } 43 | } 44 | } 45 | } 46 | ] 47 | } 48 | } 49 | } 50 | } 51 | ] 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/withStatuses.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "completedAt": "2020-08-27T19:00:12Z", 17 | "startedAt": "2020-08-27T18:58:46Z", 18 | "detailsUrl": "sweet link" 19 | }, 20 | { 21 | "conclusion": "SUCCESS", 22 | "status": "COMPLETED", 23 | "name": "rad tests", 24 | "completedAt": "2020-08-27T19:00:12Z", 25 | "startedAt": "2020-08-27T18:58:46Z", 26 | "detailsUrl": "sweet link" 27 | }, 28 | { 29 | "state": "FAILURE", 30 | "name": "a status", 31 | "targetUrl": "sweet link" 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | } 38 | ] 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /pkg/cmd/pr/checks/fixtures/withoutEvents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "node": { 4 | "statusCheckRollup": { 5 | "nodes": [ 6 | { 7 | "commit": { 8 | "oid": "abc", 9 | "statusCheckRollup": { 10 | "contexts": { 11 | "nodes": [ 12 | { 13 | "conclusion": "SUCCESS", 14 | "status": "COMPLETED", 15 | "name": "cool tests", 16 | "description": "cool description", 17 | "completedAt": "2020-08-27T19:00:12Z", 18 | "startedAt": "2020-08-27T18:58:46Z", 19 | "detailsUrl": "sweet link", 20 | "checkSuite": { 21 | "workflowRun": { 22 | "workflow": { 23 | "name": "tests" 24 | } 25 | } 26 | } 27 | }, 28 | { 29 | "conclusion": "SUCCESS", 30 | "status": "COMPLETED", 31 | "name": "cool tests", 32 | "description": "cool description", 33 | "completedAt": "2020-08-27T19:00:12Z", 34 | "startedAt": "2020-08-27T18:58:46Z", 35 | "detailsUrl": "sweet link", 36 | "checkSuite": { 37 | "workflowRun": { 38 | "workflow": { 39 | "name": "tests" 40 | } 41 | } 42 | } 43 | } 44 | ] 45 | } 46 | } 47 | } 48 | } 49 | ] 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug fix" 3 | about: Fix a bug 4 | 5 | --- 6 | 7 | Fixes a bug and Closes an issue -------------------------------------------------------------------------------- /pkg/cmd/pr/create/regexp_writer.go: -------------------------------------------------------------------------------- 1 | package create 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "regexp" 7 | ) 8 | 9 | func NewRegexpWriter(out io.Writer, re *regexp.Regexp, repl string) *RegexpWriter { 10 | return &RegexpWriter{out: out, re: *re, repl: repl} 11 | } 12 | 13 | type RegexpWriter struct { 14 | out io.Writer 15 | re regexp.Regexp 16 | repl string 17 | buf []byte 18 | } 19 | 20 | func (s *RegexpWriter) Write(data []byte) (int, error) { 21 | if len(data) == 0 { 22 | return 0, nil 23 | } 24 | 25 | filtered := []byte{} 26 | repl := []byte(s.repl) 27 | lines := bytes.SplitAfter(data, []byte("\n")) 28 | 29 | if len(s.buf) > 0 { 30 | lines[0] = append(s.buf, lines[0]...) 31 | } 32 | 33 | for i, line := range lines { 34 | if i == len(lines) { 35 | s.buf = line 36 | } else { 37 | f := s.re.ReplaceAll(line, repl) 38 | if len(f) > 0 { 39 | filtered = append(filtered, f...) 40 | } 41 | } 42 | } 43 | 44 | if len(filtered) != 0 { 45 | _, err := s.out.Write(filtered) 46 | if err != nil { 47 | return 0, err 48 | } 49 | } 50 | 51 | return len(data), nil 52 | } 53 | 54 | func (s *RegexpWriter) Flush() (int, error) { 55 | if len(s.buf) > 0 { 56 | repl := []byte(s.repl) 57 | filtered := s.re.ReplaceAll(s.buf, repl) 58 | if len(filtered) > 0 { 59 | return s.out.Write(filtered) 60 | } 61 | } 62 | 63 | return 0, nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/cmd/pr/list/fixtures/prList.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequests": { 5 | "totalCount": 3, 6 | "nodes": [ 7 | { 8 | "number": 32, 9 | "title": "New feature", 10 | "url": "https://github.com/monalisa/hello/pull/32", 11 | "createdAt": "2022-08-24T20:01:12Z", 12 | "headRefName": "feature", 13 | "state": "OPEN", 14 | "isDraft": true 15 | }, 16 | { 17 | "number": 29, 18 | "title": "Fixed bad bug", 19 | "url": "https://github.com/monalisa/hello/pull/29", 20 | "createdAt": "2022-07-20T19:01:12Z", 21 | "headRefName": "bug-fix", 22 | "state": "OPEN", 23 | "isDraft": false, 24 | "isCrossRepository": true, 25 | "headRepositoryOwner": { 26 | "login": "hubot" 27 | } 28 | }, 29 | { 30 | "number": 28, 31 | "state": "MERGED", 32 | "isDraft": false, 33 | "title": "Improve documentation", 34 | "createdAt": "2020-01-26T19:01:12Z", 35 | "url": "https://github.com/monalisa/hello/pull/28", 36 | "headRefName": "docs" 37 | } 38 | ], 39 | "pageInfo": { 40 | "hasNextPage": false, 41 | "endCursor": "" 42 | } 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pkg/cmd/pr/list/fixtures/prListWithDuplicates.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequests": { 5 | "nodes": [ 6 | { 7 | "number": 32, 8 | "title": "New feature", 9 | "url": "https://github.com/monalisa/hello/pull/32", 10 | "headRefName": "feature" 11 | }, 12 | { 13 | "number": 32, 14 | "title": "New feature", 15 | "url": "https://github.com/monalisa/hello/pull/32", 16 | "headRefName": "feature" 17 | }, 18 | { 19 | "number": 29, 20 | "title": "Fixed bad bug", 21 | "url": "https://github.com/monalisa/hello/pull/29", 22 | "headRefName": "bug-fix", 23 | "isCrossRepository": true, 24 | "headRepositoryOwner": { 25 | "login": "hubot" 26 | } 27 | }, 28 | { 29 | "node": { 30 | "number": 28, 31 | "title": "Improve documentation", 32 | "url": "https://github.com/monalisa/hello/pull/28", 33 | "headRefName": "docs" 34 | } 35 | } 36 | ], 37 | "pageInfo": { 38 | "hasNextPage": false, 39 | "endCursor": "" 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/cmd/pr/shared/completion.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | "github.com/cli/cli/v2/api" 11 | "github.com/cli/cli/v2/internal/ghrepo" 12 | ) 13 | 14 | func RequestableReviewersForCompletion(httpClient *http.Client, repo ghrepo.Interface) ([]string, error) { 15 | client := api.NewClientFromHTTP(api.NewCachedHTTPClient(httpClient, time.Minute*2)) 16 | 17 | metadata, err := api.RepoMetadata(client, repo, api.RepoMetadataInput{Reviewers: true}) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | results := []string{} 23 | for _, user := range metadata.AssignableUsers { 24 | if strings.EqualFold(user.Login, metadata.CurrentLogin) { 25 | continue 26 | } 27 | if user.Name != "" { 28 | results = append(results, fmt.Sprintf("%s\t%s", user.Login, user.Name)) 29 | } else { 30 | results = append(results, user.Login) 31 | } 32 | } 33 | for _, team := range metadata.Teams { 34 | results = append(results, fmt.Sprintf("%s/%s", repo.RepoOwner(), team.Slug)) 35 | } 36 | 37 | sort.Strings(results) 38 | return results, nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/cmd/pr/shared/preserve.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/cli/cli/v2/pkg/cmdutil" 9 | "github.com/cli/cli/v2/pkg/iostreams" 10 | ) 11 | 12 | func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr *error) func() { 13 | return func() { 14 | if !state.IsDirty() { 15 | return 16 | } 17 | 18 | if *createErr == nil { 19 | return 20 | } 21 | 22 | if cmdutil.IsUserCancellation(*createErr) { 23 | // these errors are user-initiated cancellations 24 | return 25 | } 26 | 27 | out := io.ErrOut 28 | 29 | // this extra newline guards against appending to the end of a survey line 30 | fmt.Fprintln(out) 31 | 32 | data, err := json.Marshal(state) 33 | if err != nil { 34 | fmt.Fprintf(out, "failed to save input to file: %s\n", err) 35 | fmt.Fprintln(out, "would have saved:") 36 | fmt.Fprintf(out, "%v\n", state) 37 | return 38 | } 39 | 40 | tmpfile, err := io.TempFile(os.TempDir(), "gh*.json") 41 | if err != nil { 42 | fmt.Fprintf(out, "failed to save input to file: %s\n", err) 43 | fmt.Fprintln(out, "would have saved:") 44 | fmt.Fprintf(out, "%v\n", state) 45 | return 46 | } 47 | 48 | _, err = tmpfile.Write(data) 49 | if err != nil { 50 | fmt.Fprintf(out, "failed to save input to file: %s\n", err) 51 | fmt.Fprintln(out, "would have saved:") 52 | fmt.Fprintln(out, string(data)) 53 | return 54 | } 55 | 56 | cs := io.ColorScheme() 57 | 58 | issueType := "pr" 59 | if state.Type == IssueMetadata { 60 | issueType = "issue" 61 | } 62 | 63 | fmt.Fprintf(out, "%s operation failed. To restore: pullpo %s create --recover %s\n", cs.FailureIcon(), issueType, tmpfile.Name()) 64 | 65 | // some whitespace before the actual error 66 | fmt.Fprintln(out) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/cmd/pr/shared/reaction_groups.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/cli/cli/v2/api" 8 | ) 9 | 10 | func ReactionGroupList(rgs api.ReactionGroups) string { 11 | var rs []string 12 | 13 | for _, rg := range rgs { 14 | if r := formatReactionGroup(rg); r != "" { 15 | rs = append(rs, r) 16 | } 17 | } 18 | 19 | return strings.Join(rs, " • ") 20 | } 21 | 22 | func formatReactionGroup(rg api.ReactionGroup) string { 23 | c := rg.Count() 24 | if c == 0 { 25 | return "" 26 | } 27 | e := rg.Emoji() 28 | if e == "" { 29 | return "" 30 | } 31 | return fmt.Sprintf("%v %s", c, e) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/cmd/pr/shared/state.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/cli/cli/v2/api" 8 | "github.com/cli/cli/v2/pkg/iostreams" 9 | ) 10 | 11 | type metadataStateType int 12 | 13 | const ( 14 | IssueMetadata metadataStateType = iota 15 | PRMetadata 16 | ) 17 | 18 | type IssueMetadataState struct { 19 | Type metadataStateType 20 | 21 | Draft bool 22 | 23 | Body string 24 | Title string 25 | 26 | Metadata []string 27 | Reviewers []string 28 | Assignees []string 29 | Labels []string 30 | Projects []string 31 | Milestones []string 32 | 33 | MetadataResult *api.RepoMetadataResult 34 | 35 | dirty bool // whether user i/o has modified this 36 | } 37 | 38 | func (tb *IssueMetadataState) MarkDirty() { 39 | tb.dirty = true 40 | } 41 | 42 | func (tb *IssueMetadataState) IsDirty() bool { 43 | return tb.dirty || tb.HasMetadata() 44 | } 45 | 46 | func (tb *IssueMetadataState) HasMetadata() bool { 47 | return len(tb.Reviewers) > 0 || 48 | len(tb.Assignees) > 0 || 49 | len(tb.Labels) > 0 || 50 | len(tb.Projects) > 0 || 51 | len(tb.Milestones) > 0 52 | } 53 | 54 | func FillFromJSON(io *iostreams.IOStreams, recoverFile string, state *IssueMetadataState) error { 55 | var data []byte 56 | var err error 57 | data, err = io.ReadUserFile(recoverFile) 58 | if err != nil { 59 | return fmt.Errorf("failed to read file %s: %w", recoverFile, err) 60 | } 61 | 62 | err = json.Unmarshal(data, state) 63 | if err != nil { 64 | return fmt.Errorf("JSON parsing failure: %w", err) 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/cmd/pr/status/fixtures/prStatusCurrentBranch.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequests": { 5 | "totalCount": 3, 6 | "edges": [ 7 | { 8 | "node": { 9 | "number": 10, 10 | "title": "Blueberries are certainly a good fruit", 11 | "state": "OPEN", 12 | "url": "https://github.com/PARENT/REPO/pull/10", 13 | "headRefName": "blueberries", 14 | "isDraft": false, 15 | "headRepositoryOwner": { 16 | "login": "OWNER" 17 | }, 18 | "isCrossRepository": false 19 | } 20 | }, 21 | { 22 | "node": { 23 | "number": 9, 24 | "title": "Blueberries are a good fruit", 25 | "state": "MERGED", 26 | "url": "https://github.com/PARENT/REPO/pull/9", 27 | "headRefName": "blueberries", 28 | "isDraft": false, 29 | "headRepositoryOwner": { 30 | "login": "OWNER" 31 | }, 32 | "isCrossRepository": false 33 | } 34 | }, 35 | { 36 | "node": { 37 | "number": 8, 38 | "title": "Blueberries are probably a good fruit", 39 | "state": "CLOSED", 40 | "url": "https://github.com/PARENT/REPO/pull/8", 41 | "headRefName": "blueberries", 42 | "isDraft": false, 43 | "headRepositoryOwner": { 44 | "login": "OWNER" 45 | }, 46 | "isCrossRepository": false 47 | } 48 | } 49 | ] 50 | } 51 | }, 52 | "viewerCreated": { 53 | "totalCount": 0, 54 | "edges": [] 55 | }, 56 | "reviewRequested": { 57 | "totalCount": 0, 58 | "edges": [] 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosed.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "defaultBranchRef": { "name": "master" }, 5 | "pullRequests": { 6 | "totalCount": 1, 7 | "edges": [ 8 | { 9 | "node": { 10 | "number": 8, 11 | "title": "Blueberries are a good fruit", 12 | "state": "CLOSED", 13 | "url": "https://github.com/cli/cli/pull/8", 14 | "headRefName": "blueberries", 15 | "reviewDecision": "CHANGES_REQUESTED" 16 | } 17 | } 18 | ] 19 | } 20 | }, 21 | "viewerCreated": { 22 | "totalCount": 0, 23 | "edges": [] 24 | }, 25 | "reviewRequested": { 26 | "totalCount": 0, 27 | "edges": [] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "defaultBranchRef": { "name": "blueberries" }, 5 | "pullRequests": { 6 | "totalCount": 1, 7 | "edges": [ 8 | { 9 | "node": { 10 | "number": 8, 11 | "title": "Blueberries are a good fruit", 12 | "state": "CLOSED", 13 | "url": "https://github.com/cli/cli/pull/8", 14 | "headRefName": "blueberries" 15 | } 16 | } 17 | ] 18 | } 19 | }, 20 | "viewerCreated": { 21 | "totalCount": 0, 22 | "edges": [] 23 | }, 24 | "reviewRequested": { 25 | "totalCount": 0, 26 | "edges": [] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMerged.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "defaultBranchRef": { "name": "master" }, 5 | "pullRequests": { 6 | "totalCount": 1, 7 | "edges": [ 8 | { 9 | "node": { 10 | "number": 8, 11 | "title": "Blueberries are a good fruit", 12 | "state": "MERGED", 13 | "url": "https://github.com/cli/cli/pull/8", 14 | "headRefName": "blueberries", 15 | "reviewDecision": "CHANGES_REQUESTED" 16 | } 17 | } 18 | ] 19 | } 20 | }, 21 | "viewerCreated": { 22 | "totalCount": 0, 23 | "edges": [] 24 | }, 25 | "reviewRequested": { 26 | "totalCount": 0, 27 | "edges": [] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "defaultBranchRef": { "name": "blueberries" }, 5 | "pullRequests": { 6 | "totalCount": 1, 7 | "edges": [ 8 | { 9 | "node": { 10 | "number": 8, 11 | "title": "Blueberries are a good fruit", 12 | "state": "MERGED", 13 | "url": "https://github.com/cli/cli/pull/8", 14 | "headRefName": "blueberries" 15 | } 16 | } 17 | ] 18 | } 19 | }, 20 | "viewerCreated": { 21 | "totalCount": 0, 22 | "edges": [] 23 | }, 24 | "reviewRequested": { 25 | "totalCount": 0, 26 | "edges": [] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/cmd/pr/view/fixtures/prViewPreview.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequest": { 5 | "number": 12, 6 | "title": "Blueberries are from a fork", 7 | "state": "OPEN", 8 | "body": "**blueberries taste good**", 9 | "url": "https://github.com/OWNER/REPO/pull/12", 10 | "author": { 11 | "login": "nobody" 12 | }, 13 | "additions": 100, 14 | "deletions": 10, 15 | "assignees": { 16 | "nodes": [], 17 | "totalcount": 0 18 | }, 19 | "labels": { 20 | "nodes": [], 21 | "totalcount": 0 22 | }, 23 | "projectcards": { 24 | "nodes": [], 25 | "totalcount": 0 26 | }, 27 | "milestone": { 28 | "title": "" 29 | }, 30 | "commits": { 31 | "totalCount": 12 32 | }, 33 | "baseRefName": "master", 34 | "headRefName": "blueberries", 35 | "headRepositoryOwner": { 36 | "login": "hubot" 37 | }, 38 | "isCrossRepository": true, 39 | "isDraft": false 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/cmd/pr/view/fixtures/prViewPreviewClosedState.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequest": { 5 | "number": 12, 6 | "title": "Blueberries are from a fork", 7 | "state": "CLOSED", 8 | "body": "**blueberries taste good**", 9 | "url": "https://github.com/OWNER/REPO/pull/12", 10 | "author": { 11 | "login": "nobody" 12 | }, 13 | "additions": 100, 14 | "deletions": 10, 15 | "commits": { 16 | "totalCount": 12 17 | }, 18 | "baseRefName": "master", 19 | "headRefName": "blueberries", 20 | "headRepositoryOwner": { 21 | "login": "hubot" 22 | }, 23 | "isCrossRepository": true 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/cmd/pr/view/fixtures/prViewPreviewDraftState.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequest": { 5 | "number": 12, 6 | "title": "Blueberries are from a fork", 7 | "state": "OPEN", 8 | "body": "**blueberries taste good**", 9 | "url": "https://github.com/OWNER/REPO/pull/12", 10 | "author": { 11 | "login": "nobody" 12 | }, 13 | "commits": { 14 | "totalCount": 12 15 | }, 16 | "additions": 100, 17 | "deletions": 10, 18 | "baseRefName": "master", 19 | "headRefName": "blueberries", 20 | "headRepositoryOwner": { 21 | "login": "hubot" 22 | }, 23 | "isCrossRepository": true, 24 | "isDraft": true 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequest": { 5 | "reviews": { 6 | "nodes": [ 7 | { 8 | "author": { 9 | "login": "123" 10 | }, 11 | "state": "COMMENTED" 12 | }, 13 | { 14 | "author": { 15 | "login": "def" 16 | }, 17 | "state": "CHANGES_REQUESTED" 18 | }, 19 | { 20 | "author": { 21 | "login": "abc" 22 | }, 23 | "state": "APPROVED" 24 | }, 25 | { 26 | "author": { 27 | "login": "DEF" 28 | }, 29 | "state": "COMMENTED" 30 | }, 31 | { 32 | "author": { 33 | "login": "xyz" 34 | }, 35 | "state": "APPROVED" 36 | }, 37 | { 38 | "author": { 39 | "login": "" 40 | }, 41 | "state": "APPROVED" 42 | }, 43 | { 44 | "author": { 45 | "login": "hubot" 46 | }, 47 | "state": "CHANGES_REQUESTED" 48 | }, 49 | { 50 | "author": { 51 | "login": "hubot" 52 | }, 53 | "state": "DISMISSED" 54 | }, 55 | { 56 | "author": { 57 | "login": "monalisa" 58 | }, 59 | "state": "PENDING" 60 | } 61 | ], 62 | "totalCount": 9 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pkg/cmd/pr/view/fixtures/prViewPreviewMergedState.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequest": { 5 | "number": 12, 6 | "title": "Blueberries are from a fork", 7 | "state": "MERGED", 8 | "body": "**blueberries taste good**", 9 | "url": "https://github.com/OWNER/REPO/pull/12", 10 | "author": { 11 | "login": "nobody" 12 | }, 13 | "commits": { 14 | "totalCount": 12 15 | }, 16 | "additions": 100, 17 | "deletions": 10, 18 | "baseRefName": "master", 19 | "headRefName": "blueberries", 20 | "headRepositoryOwner": { 21 | "login": "hubot" 22 | }, 23 | "isCrossRepository": true 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/cmd/pr/view/fixtures/prViewPreviewWithAutoMergeEnabled.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequest": { 5 | "number": 12, 6 | "title": "Blueberries are from a fork", 7 | "state": "OPEN", 8 | "body": "**blueberries taste good**", 9 | "url": "https://github.com/OWNER/REPO/pull/12", 10 | "author": { 11 | "login": "nobody" 12 | }, 13 | "autoMergeRequest": { 14 | "authorEmail": null, 15 | "commitBody": null, 16 | "commitHeadline": null, 17 | "mergeMethod": "SQUASH", 18 | "enabledAt": "2020-08-27T19:00:12Z", 19 | "enabledBy": { 20 | "login": "hubot" 21 | } 22 | }, 23 | "additions": 100, 24 | "deletions": 10, 25 | "reviewRequests": { 26 | "nodes": [], 27 | "totalcount": 0 28 | }, 29 | "assignees": { 30 | "nodes": [], 31 | "totalcount": 0 32 | }, 33 | "labels": { 34 | "nodes": [], 35 | "totalcount": 0 36 | }, 37 | "projectcards": { 38 | "nodes": [], 39 | "totalcount": 0 40 | }, 41 | "milestone": {}, 42 | "commits": { 43 | "totalCount": 12 44 | }, 45 | "baseRefName": "master", 46 | "headRefName": "blueberries", 47 | "headRepositoryOwner": { 48 | "login": "hubot" 49 | }, 50 | "isCrossRepository": true, 51 | "isDraft": false 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/cmd/pr/view/fixtures/prViewPreviewWithNoChecks.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequest": { 5 | "number": 12, 6 | "title": "Blueberries are from a fork", 7 | "state": "OPEN", 8 | "body": "**blueberries taste good**", 9 | "url": "https://github.com/OWNER/REPO/pull/12", 10 | "author": { 11 | "login": "nobody" 12 | }, 13 | "additions": 100, 14 | "deletions": 10, 15 | "assignees": { 16 | "nodes": [], 17 | "totalcount": 0 18 | }, 19 | "labels": { 20 | "nodes": [], 21 | "totalcount": 0 22 | }, 23 | "projectcards": { 24 | "nodes": [], 25 | "totalcount": 0 26 | }, 27 | "milestone": { 28 | "title": "" 29 | }, 30 | "commits": { 31 | "totalCount": 12 32 | }, 33 | "baseRefName": "master", 34 | "headRefName": "blueberries", 35 | "headRepositoryOwner": { 36 | "login": "hubot" 37 | }, 38 | "isCrossRepository": true, 39 | "isDraft": false, 40 | "statusCheckRollup": { 41 | "nodes": [ 42 | { 43 | "commit": { 44 | "oid": "abc", 45 | "statusCheckRollup": { 46 | "contexts": { 47 | "nodes": [ 48 | ] 49 | } 50 | } 51 | } 52 | } 53 | ] 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repository": { 4 | "pullRequest": { 5 | "number": 12, 6 | "title": "Blueberries are from a fork", 7 | "state": "OPEN", 8 | "body": "**blueberries taste good**", 9 | "url": "https://github.com/OWNER/REPO/pull/12", 10 | "author": { 11 | "login": "nobody" 12 | }, 13 | "additions": 100, 14 | "deletions": 10, 15 | "reviewRequests": { 16 | "nodes": [ 17 | { 18 | "requestedReviewer": { 19 | "__typename": "user", 20 | "login": "123" 21 | } 22 | }, 23 | { 24 | "requestedReviewer": { 25 | "__typename": "Team", 26 | "name": "Team 1", 27 | "slug": "team-1", 28 | "organization": {"login": "my-org"} 29 | } 30 | }, 31 | { 32 | "requestedReviewer": { 33 | "__typename": "user", 34 | "login": "abc" 35 | } 36 | } 37 | ], 38 | "totalcount": 1 39 | }, 40 | "assignees": { 41 | "nodes": [], 42 | "totalcount": 0 43 | }, 44 | "labels": { 45 | "nodes": [], 46 | "totalcount": 0 47 | }, 48 | "projectcards": { 49 | "nodes": [], 50 | "totalcount": 0 51 | }, 52 | "milestone": {}, 53 | "participants": { 54 | "nodes": [ 55 | { 56 | "login": "marseilles" 57 | } 58 | ], 59 | "totalcount": 1 60 | }, 61 | "commits": { 62 | "totalCount": 12 63 | }, 64 | "baseRefName": "master", 65 | "headRefName": "blueberries", 66 | "headRepositoryOwner": { 67 | "login": "hubot" 68 | }, 69 | "isCrossRepository": true, 70 | "isDraft": false 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/cmd/project/shared/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" 7 | "github.com/cli/cli/v2/pkg/cmdutil" 8 | ) 9 | 10 | func New(f *cmdutil.Factory) (*queries.Client, error) { 11 | if f.HttpClient == nil { 12 | // This is for compatibility with tests that exercise Cobra command functionality. 13 | // These tests do not define a `HttpClient` nor do they need to. 14 | return nil, nil 15 | } 16 | 17 | httpClient, err := f.HttpClient() 18 | if err != nil { 19 | return nil, err 20 | } 21 | return queries.NewClient(httpClient, os.Getenv("GH_HOST"), f.IOStreams), nil 22 | } 23 | -------------------------------------------------------------------------------- /pkg/cmd/release/release.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | cmdCreate "github.com/cli/cli/v2/pkg/cmd/release/create" 5 | cmdDelete "github.com/cli/cli/v2/pkg/cmd/release/delete" 6 | cmdDeleteAsset "github.com/cli/cli/v2/pkg/cmd/release/delete-asset" 7 | cmdDownload "github.com/cli/cli/v2/pkg/cmd/release/download" 8 | cmdUpdate "github.com/cli/cli/v2/pkg/cmd/release/edit" 9 | cmdList "github.com/cli/cli/v2/pkg/cmd/release/list" 10 | cmdUpload "github.com/cli/cli/v2/pkg/cmd/release/upload" 11 | cmdView "github.com/cli/cli/v2/pkg/cmd/release/view" 12 | "github.com/cli/cli/v2/pkg/cmdutil" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "release ", 19 | Short: "Manage releases", 20 | GroupID: "core", 21 | } 22 | 23 | cmdutil.EnableRepoOverride(cmd, f) 24 | 25 | cmdutil.AddGroup(cmd, "General commands", 26 | cmdList.NewCmdList(f, nil), 27 | cmdCreate.NewCmdCreate(f, nil), 28 | ) 29 | 30 | cmdutil.AddGroup(cmd, "Targeted commands", 31 | cmdView.NewCmdView(f, nil), 32 | cmdUpdate.NewCmdEdit(f, nil), 33 | cmdUpload.NewCmdUpload(f, nil), 34 | cmdDownload.NewCmdDownload(f, nil), 35 | cmdDelete.NewCmdDelete(f, nil), 36 | cmdDeleteAsset.NewCmdDeleteAsset(f, nil), 37 | ) 38 | 39 | return cmd 40 | } 41 | -------------------------------------------------------------------------------- /pkg/cmd/release/upload/upload_test.go: -------------------------------------------------------------------------------- 1 | package upload 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_SanitizeFileName(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | expected string 13 | }{ 14 | { 15 | name: "foo", 16 | expected: "foo", 17 | }, 18 | { 19 | name: "foo bar", 20 | expected: "foo.bar", 21 | }, 22 | { 23 | name: ".foo", 24 | expected: "default.foo", 25 | }, 26 | { 27 | name: "Foo bar", 28 | expected: "Foo.bar", 29 | }, 30 | { 31 | name: "Hello, दुनिया", 32 | expected: "default.Hello", 33 | }, 34 | { 35 | name: "this+has+plusses.jpg", 36 | expected: "this+has+plusses.jpg", 37 | }, 38 | { 39 | name: "this@has@at@signs.jpg", 40 | expected: "this@has@at@signs.jpg", 41 | }, 42 | { 43 | name: "façade.exposé", 44 | expected: "facade.expose", 45 | }, 46 | } 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | assert.Equal(t, tt.expected, sanitizeFileName(tt.name)) 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/cmd/repo/archive/http.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/cli/cli/v2/api" 7 | "github.com/shurcooL/githubv4" 8 | ) 9 | 10 | func archiveRepo(client *http.Client, repo *api.Repository) error { 11 | var mutation struct { 12 | ArchiveRepository struct { 13 | Repository struct { 14 | ID string 15 | } 16 | } `graphql:"archiveRepository(input: $input)"` 17 | } 18 | 19 | variables := map[string]interface{}{ 20 | "input": githubv4.ArchiveRepositoryInput{ 21 | RepositoryID: repo.ID, 22 | }, 23 | } 24 | 25 | gql := api.NewClientFromHTTP(client) 26 | err := gql.Mutate(repo.RepoHost(), "ArchiveRepository", &mutation, variables) 27 | return err 28 | } 29 | -------------------------------------------------------------------------------- /pkg/cmd/repo/create/fixtures/repoTempList.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repositoryOwner": { 4 | "login": "OWNER", 5 | "repositories": { 6 | "nodes": [ 7 | { 8 | "id": "REPOID", 9 | "name": "REPO", 10 | "isTemplate": true, 11 | "pushedAt": "2021-02-19T06:34:58Z", 12 | "defaultBranchRef": { 13 | "name": "main" 14 | } 15 | } 16 | ] 17 | }, 18 | "totalCount": 0, 19 | "pageInfo": { 20 | "hasNextPage": false, 21 | "endCursor": "" 22 | } 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /pkg/cmd/repo/delete/http.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/cli/cli/v2/api" 8 | "github.com/cli/cli/v2/internal/ghinstance" 9 | "github.com/cli/cli/v2/internal/ghrepo" 10 | ) 11 | 12 | func deleteRepo(client *http.Client, repo ghrepo.Interface) error { 13 | oldClient := *client 14 | client = &oldClient 15 | client.CheckRedirect = func(req *http.Request, via []*http.Request) error { 16 | return http.ErrUseLastResponse 17 | } 18 | 19 | url := fmt.Sprintf("%srepos/%s", 20 | ghinstance.RESTPrefix(repo.RepoHost()), 21 | ghrepo.FullName(repo)) 22 | 23 | request, err := http.NewRequest("DELETE", url, nil) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | resp, err := client.Do(request) 29 | if err != nil { 30 | return err 31 | } 32 | defer resp.Body.Close() 33 | 34 | if resp.StatusCode > 299 { 35 | return api.HandleHTTPError(api.EndpointNeedsScopes(resp, "delete_repo")) 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/cmd/repo/deploy-key/add/http.go: -------------------------------------------------------------------------------- 1 | package add 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/cli/cli/v2/api" 11 | "github.com/cli/cli/v2/internal/ghinstance" 12 | "github.com/cli/cli/v2/internal/ghrepo" 13 | ) 14 | 15 | func uploadDeployKey(httpClient *http.Client, repo ghrepo.Interface, keyFile io.Reader, title string, isWritable bool) error { 16 | path := fmt.Sprintf("repos/%s/%s/keys", repo.RepoOwner(), repo.RepoName()) 17 | url := ghinstance.RESTPrefix(repo.RepoHost()) + path 18 | 19 | keyBytes, err := io.ReadAll(keyFile) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | payload := map[string]interface{}{ 25 | "title": title, 26 | "key": string(keyBytes), 27 | "read_only": !isWritable, 28 | } 29 | 30 | payloadBytes, err := json.Marshal(payload) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | resp, err := httpClient.Do(req) 41 | if err != nil { 42 | return err 43 | } 44 | defer resp.Body.Close() 45 | 46 | if resp.StatusCode > 299 { 47 | return api.HandleHTTPError(resp) 48 | } 49 | 50 | _, err = io.Copy(io.Discard, resp.Body) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/cmd/repo/deploy-key/delete/delete.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/cli/cli/v2/internal/ghrepo" 8 | "github.com/cli/cli/v2/pkg/cmdutil" 9 | "github.com/cli/cli/v2/pkg/iostreams" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | type DeleteOptions struct { 14 | IO *iostreams.IOStreams 15 | HTTPClient func() (*http.Client, error) 16 | BaseRepo func() (ghrepo.Interface, error) 17 | 18 | KeyID string 19 | } 20 | 21 | func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { 22 | opts := &DeleteOptions{ 23 | HTTPClient: f.HttpClient, 24 | IO: f.IOStreams, 25 | } 26 | 27 | cmd := &cobra.Command{ 28 | Use: "delete ", 29 | Short: "Delete a deploy key from a GitHub repository", 30 | Args: cobra.ExactArgs(1), 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | opts.BaseRepo = f.BaseRepo 33 | opts.KeyID = args[0] 34 | 35 | if runF != nil { 36 | return runF(opts) 37 | } 38 | return deleteRun(opts) 39 | }, 40 | } 41 | 42 | return cmd 43 | } 44 | 45 | func deleteRun(opts *DeleteOptions) error { 46 | httpClient, err := opts.HTTPClient() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | repo, err := opts.BaseRepo() 52 | if err != nil { 53 | return err 54 | } 55 | 56 | if err := deleteDeployKey(httpClient, repo, opts.KeyID); err != nil { 57 | return err 58 | } 59 | 60 | if !opts.IO.IsStdoutTTY() { 61 | return nil 62 | } 63 | 64 | cs := opts.IO.ColorScheme() 65 | _, err = fmt.Fprintf(opts.IO.Out, "%s Deploy key deleted from %s\n", cs.SuccessIconWithColor(cs.Red), cs.Bold(ghrepo.FullName(repo))) 66 | return err 67 | } 68 | -------------------------------------------------------------------------------- /pkg/cmd/repo/deploy-key/delete/delete_test.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/cli/cli/v2/internal/ghrepo" 8 | "github.com/cli/cli/v2/pkg/httpmock" 9 | "github.com/cli/cli/v2/pkg/iostreams" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func Test_deleteRun(t *testing.T) { 14 | ios, _, stdout, stderr := iostreams.Test() 15 | ios.SetStdinTTY(false) 16 | ios.SetStdoutTTY(true) 17 | ios.SetStderrTTY(true) 18 | 19 | tr := httpmock.Registry{} 20 | defer tr.Verify(t) 21 | 22 | tr.Register( 23 | httpmock.REST("DELETE", "repos/OWNER/REPO/keys/1234"), 24 | httpmock.StringResponse(`{}`)) 25 | 26 | err := deleteRun(&DeleteOptions{ 27 | IO: ios, 28 | HTTPClient: func() (*http.Client, error) { 29 | return &http.Client{Transport: &tr}, nil 30 | }, 31 | BaseRepo: func() (ghrepo.Interface, error) { 32 | return ghrepo.New("OWNER", "REPO"), nil 33 | }, 34 | KeyID: "1234", 35 | }) 36 | assert.NoError(t, err) 37 | 38 | assert.Equal(t, "", stderr.String()) 39 | assert.Equal(t, "✓ Deploy key deleted from OWNER/REPO\n", stdout.String()) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/cmd/repo/deploy-key/delete/http.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/cli/cli/v2/api" 9 | "github.com/cli/cli/v2/internal/ghinstance" 10 | "github.com/cli/cli/v2/internal/ghrepo" 11 | ) 12 | 13 | func deleteDeployKey(httpClient *http.Client, repo ghrepo.Interface, id string) error { 14 | path := fmt.Sprintf("repos/%s/%s/keys/%s", repo.RepoOwner(), repo.RepoName(), id) 15 | url := ghinstance.RESTPrefix(repo.RepoHost()) + path 16 | 17 | req, err := http.NewRequest("DELETE", url, nil) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | resp, err := httpClient.Do(req) 23 | if err != nil { 24 | return err 25 | } 26 | defer resp.Body.Close() 27 | 28 | if resp.StatusCode > 299 { 29 | return api.HandleHTTPError(resp) 30 | } 31 | 32 | _, err = io.Copy(io.Discard, resp.Body) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/cmd/repo/deploy-key/deploy-key.go: -------------------------------------------------------------------------------- 1 | package deploykey 2 | 3 | import ( 4 | cmdAdd "github.com/cli/cli/v2/pkg/cmd/repo/deploy-key/add" 5 | cmdDelete "github.com/cli/cli/v2/pkg/cmd/repo/deploy-key/delete" 6 | cmdList "github.com/cli/cli/v2/pkg/cmd/repo/deploy-key/list" 7 | "github.com/cli/cli/v2/pkg/cmdutil" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func NewCmdDeployKey(f *cmdutil.Factory) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "deploy-key ", 14 | Short: "Manage deploy keys in a repository", 15 | } 16 | 17 | cmdutil.EnableRepoOverride(cmd, f) 18 | 19 | cmd.AddCommand(cmdList.NewCmdList(f, nil)) 20 | cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil)) 21 | cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) 22 | 23 | return cmd 24 | } 25 | -------------------------------------------------------------------------------- /pkg/cmd/repo/deploy-key/list/http.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/cli/cli/v2/api" 11 | "github.com/cli/cli/v2/internal/ghinstance" 12 | "github.com/cli/cli/v2/internal/ghrepo" 13 | ) 14 | 15 | type deployKey struct { 16 | ID int `json:"id"` 17 | Key string `json:"key"` 18 | Title string `json:"title"` 19 | CreatedAt time.Time `json:"created_at"` 20 | ReadOnly bool `json:"read_only"` 21 | } 22 | 23 | func repoKeys(httpClient *http.Client, repo ghrepo.Interface) ([]deployKey, error) { 24 | path := fmt.Sprintf("repos/%s/%s/keys?per_page=100", repo.RepoOwner(), repo.RepoName()) 25 | url := ghinstance.RESTPrefix(repo.RepoHost()) + path 26 | req, err := http.NewRequest("GET", url, nil) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | resp, err := httpClient.Do(req) 32 | if err != nil { 33 | return nil, err 34 | } 35 | defer resp.Body.Close() 36 | 37 | if resp.StatusCode > 299 { 38 | return nil, api.HandleHTTPError(resp) 39 | } 40 | 41 | b, err := io.ReadAll(resp.Body) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | var keys []deployKey 47 | err = json.Unmarshal(b, &keys) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return keys, nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/cmd/repo/fork/forkResult.json: -------------------------------------------------------------------------------- 1 | { 2 | "node_id": "123", 3 | "name": "REPO", 4 | "clone_url": "https://github.com/someone/repo.git", 5 | "created_at": "2011-01-26T19:01:12Z", 6 | "owner": { 7 | "login": "someone" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pkg/cmd/repo/list/fixtures/repoList.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "repositoryOwner": { 4 | "login": "octocat", 5 | "repositories": { 6 | "totalCount": 3, 7 | "nodes": [ 8 | { 9 | "nameWithOwner": "octocat/hello-world", 10 | "description": "My first repository", 11 | "isFork": false, 12 | "isPrivate": false, 13 | "isArchived": false, 14 | "pushedAt": "2021-02-19T06:34:58Z", 15 | "visibility": "PUBLIC" 16 | }, 17 | { 18 | "nameWithOwner": "octocat/cli", 19 | "description": "GitHub CLI", 20 | "isFork": true, 21 | "isPrivate": false, 22 | "isArchived": false, 23 | "pushedAt": "2021-02-19T06:06:06Z", 24 | "visibility": "PUBLIC" 25 | }, 26 | { 27 | "nameWithOwner": "octocat/testing", 28 | "description": null, 29 | "isFork": false, 30 | "isPrivate": true, 31 | "isArchived": false, 32 | "pushedAt": "2021-02-11T22:32:05Z", 33 | "visibility": "PRIVATE" 34 | } 35 | ], 36 | "pageInfo": { 37 | "hasNextPage": false, 38 | "endCursor": "" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/cmd/repo/list/fixtures/repoSearch.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "search": { 4 | "repositoryCount": 3, 5 | "nodes": [ 6 | { 7 | "nameWithOwner": "octocat/hello-world", 8 | "description": "My first repository", 9 | "isFork": false, 10 | "isPrivate": false, 11 | "isArchived": false, 12 | "pushedAt": "2021-02-19T06:34:58Z" 13 | }, 14 | { 15 | "nameWithOwner": "octocat/cli", 16 | "description": "GitHub CLI", 17 | "isFork": true, 18 | "isPrivate": false, 19 | "isArchived": false, 20 | "pushedAt": "2021-02-19T06:06:06Z" 21 | }, 22 | { 23 | "nameWithOwner": "octocat/testing", 24 | "description": null, 25 | "isFork": false, 26 | "isPrivate": true, 27 | "isArchived": false, 28 | "pushedAt": "2021-02-11T22:32:05Z" 29 | } 30 | ], 31 | "pageInfo": { 32 | "hasNextPage": false, 33 | "endCursor": "" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/cmd/repo/shared/repo.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var invalidCharactersRE = regexp.MustCompile(`[^\w._-]+`) 9 | 10 | // NormalizeRepoName takes in the repo name the user inputted and normalizes it using the same logic as GitHub (GitHub.com/new) 11 | func NormalizeRepoName(repoName string) string { 12 | newName := invalidCharactersRE.ReplaceAllString(repoName, "-") 13 | return strings.TrimSuffix(newName, ".git") 14 | } 15 | -------------------------------------------------------------------------------- /pkg/cmd/repo/shared/repo_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNormalizeRepoName(t *testing.T) { 8 | // confirmed using GitHub.com/new 9 | tests := []struct { 10 | LocalName string 11 | NormalizedName string 12 | }{ 13 | { 14 | LocalName: "cli", 15 | NormalizedName: "cli", 16 | }, 17 | { 18 | LocalName: "cli.git", 19 | NormalizedName: "cli", 20 | }, 21 | { 22 | LocalName: "@-#$^", 23 | NormalizedName: "---", 24 | }, 25 | { 26 | LocalName: "[cli]", 27 | NormalizedName: "-cli-", 28 | }, 29 | { 30 | LocalName: "Hello World, I'm a new repo!", 31 | NormalizedName: "Hello-World-I-m-a-new-repo-", 32 | }, 33 | { 34 | LocalName: " @E3H*(#$#_$-ZVp,n.7lGq*_eMa-(-zAZSJYg!", 35 | NormalizedName: "-E3H-_--ZVp-n.7lGq-_eMa---zAZSJYg-", 36 | }, 37 | { 38 | LocalName: "I'm a crazy .git repo name .git.git .git", 39 | NormalizedName: "I-m-a-crazy-.git-repo-name-.git.git-", 40 | }, 41 | } 42 | for _, tt := range tests { 43 | output := NormalizeRepoName(tt.LocalName) 44 | if output != tt.NormalizedName { 45 | t.Errorf("Expected %q, got %q", tt.NormalizedName, output) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/cmd/repo/sync/mocks.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | ) 6 | 7 | type mockGitClient struct { 8 | mock.Mock 9 | } 10 | 11 | func (g *mockGitClient) UpdateBranch(b, r string) error { 12 | args := g.Called(b, r) 13 | return args.Error(0) 14 | } 15 | 16 | func (g *mockGitClient) CreateBranch(b, r, u string) error { 17 | args := g.Called(b, r, u) 18 | return args.Error(0) 19 | } 20 | 21 | func (g *mockGitClient) CurrentBranch() (string, error) { 22 | args := g.Called() 23 | return args.String(0), args.Error(1) 24 | } 25 | 26 | func (g *mockGitClient) Fetch(a, b string) error { 27 | args := g.Called(a, b) 28 | return args.Error(0) 29 | } 30 | 31 | func (g *mockGitClient) HasLocalBranch(a string) bool { 32 | args := g.Called(a) 33 | return args.Bool(0) 34 | } 35 | 36 | func (g *mockGitClient) IsAncestor(a, b string) (bool, error) { 37 | args := g.Called(a, b) 38 | return args.Bool(0), args.Error(1) 39 | } 40 | 41 | func (g *mockGitClient) IsDirty() (bool, error) { 42 | args := g.Called() 43 | return args.Bool(0), args.Error(1) 44 | } 45 | 46 | func (g *mockGitClient) MergeFastForward(a string) error { 47 | args := g.Called(a) 48 | return args.Error(0) 49 | } 50 | 51 | func (g *mockGitClient) ResetHard(a string) error { 52 | args := g.Called(a) 53 | return args.Error(0) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/cmd/repo/unarchive/http.go: -------------------------------------------------------------------------------- 1 | package unarchive 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/cli/cli/v2/api" 7 | "github.com/shurcooL/githubv4" 8 | ) 9 | 10 | func unarchiveRepo(client *http.Client, repo *api.Repository) error { 11 | var mutation struct { 12 | UnarchiveRepository struct { 13 | Repository struct { 14 | ID string 15 | } 16 | } `graphql:"unarchiveRepository(input: $input)"` 17 | } 18 | 19 | variables := map[string]interface{}{ 20 | "input": githubv4.UnarchiveRepositoryInput{ 21 | RepositoryID: repo.ID, 22 | }, 23 | } 24 | 25 | gql := api.NewClientFromHTTP(client) 26 | err := gql.Mutate(repo.RepoHost(), "UnarchiveRepository", &mutation, variables) 27 | return err 28 | } 29 | -------------------------------------------------------------------------------- /pkg/cmd/repo/view/http.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | 11 | "github.com/cli/cli/v2/api" 12 | "github.com/cli/cli/v2/internal/ghrepo" 13 | "github.com/cli/go-gh/v2/pkg/asciisanitizer" 14 | "golang.org/x/text/transform" 15 | ) 16 | 17 | var NotFoundError = errors.New("not found") 18 | 19 | type RepoReadme struct { 20 | Filename string 21 | Content string 22 | BaseURL string 23 | } 24 | 25 | func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string) (*RepoReadme, error) { 26 | apiClient := api.NewClientFromHTTP(client) 27 | var response struct { 28 | Name string 29 | Content string 30 | HTMLURL string `json:"html_url"` 31 | } 32 | 33 | err := apiClient.REST(repo.RepoHost(), "GET", getReadmePath(repo, branch), nil, &response) 34 | if err != nil { 35 | var httpError api.HTTPError 36 | if errors.As(err, &httpError) && httpError.StatusCode == 404 { 37 | return nil, NotFoundError 38 | } 39 | return nil, err 40 | } 41 | 42 | decoded, err := base64.StdEncoding.DecodeString(response.Content) 43 | if err != nil { 44 | return nil, fmt.Errorf("failed to decode readme: %w", err) 45 | } 46 | 47 | sanitized, err := io.ReadAll(transform.NewReader(bytes.NewReader(decoded), &asciisanitizer.Sanitizer{})) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return &RepoReadme{ 53 | Filename: response.Name, 54 | Content: string(sanitized), 55 | BaseURL: response.HTMLURL, 56 | }, nil 57 | } 58 | 59 | func getReadmePath(repo ghrepo.Interface, branch string) string { 60 | path := fmt.Sprintf("repos/%s/readme", ghrepo.FullName(repo)) 61 | if branch != "" { 62 | path = fmt.Sprintf("%s?ref=%s", path, branch) 63 | } 64 | return path 65 | } 66 | -------------------------------------------------------------------------------- /pkg/cmd/root/extension.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/exec" 7 | 8 | "github.com/cli/cli/v2/pkg/extensions" 9 | "github.com/cli/cli/v2/pkg/iostreams" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | type ExternalCommandExitError struct { 14 | *exec.ExitError 15 | } 16 | 17 | func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ext extensions.Extension) *cobra.Command { 18 | return &cobra.Command{ 19 | Use: ext.Name(), 20 | Short: fmt.Sprintf("Extension %s", ext.Name()), 21 | RunE: func(c *cobra.Command, args []string) error { 22 | args = append([]string{ext.Name()}, args...) 23 | if _, err := em.Dispatch(args, io.In, io.Out, io.ErrOut); err != nil { 24 | var execError *exec.ExitError 25 | if errors.As(err, &execError) { 26 | return &ExternalCommandExitError{execError} 27 | } 28 | return fmt.Errorf("failed to run extension: %w\n", err) 29 | } 30 | return nil 31 | }, 32 | GroupID: "extension", 33 | Annotations: map[string]string{ 34 | "skipAuthCheck": "true", 35 | }, 36 | DisableFlagParsing: true, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/cmd/root/help_reference.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "strings" 8 | 9 | "github.com/cli/cli/v2/pkg/iostreams" 10 | "github.com/cli/cli/v2/pkg/markdown" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // longPager provides a pager over a commands Long message. 15 | // It is currently only used for the reference command 16 | func longPager(io *iostreams.IOStreams) func(*cobra.Command, []string) { 17 | return func(cmd *cobra.Command, args []string) { 18 | wrapWidth := 0 19 | if io.IsStdoutTTY() { 20 | io.DetectTerminalTheme() 21 | wrapWidth = io.TerminalWidth() 22 | } 23 | 24 | md, err := markdown.Render(cmd.Long, 25 | markdown.WithTheme(io.TerminalTheme()), 26 | markdown.WithWrap(wrapWidth)) 27 | if err != nil { 28 | fmt.Fprintln(io.ErrOut, err) 29 | return 30 | } 31 | 32 | if !io.IsStdoutTTY() { 33 | fmt.Fprint(io.Out, dedent(md)) 34 | return 35 | } 36 | 37 | _ = io.StartPager() 38 | defer io.StopPager() 39 | fmt.Fprint(io.Out, md) 40 | } 41 | } 42 | 43 | func stringifyReference(cmd *cobra.Command) string { 44 | buf := bytes.NewBufferString("# pullpo reference\n\n") 45 | for _, c := range cmd.Commands() { 46 | if c.Hidden { 47 | continue 48 | } 49 | cmdRef(buf, c, 2) 50 | } 51 | return buf.String() 52 | } 53 | 54 | func cmdRef(w io.Writer, cmd *cobra.Command, depth int) { 55 | // Name + Description 56 | fmt.Fprintf(w, "%s `%s`\n\n", strings.Repeat("#", depth), cmd.UseLine()) 57 | fmt.Fprintf(w, "%s\n\n", cmd.Short) 58 | 59 | // Flags 60 | // TODO: fold in InheritedFlags/PersistentFlags, but omit `--help` due to repetitiveness 61 | if flagUsages := cmd.Flags().FlagUsages(); flagUsages != "" { 62 | fmt.Fprintf(w, "```\n%s````\n\n", dedent(flagUsages)) 63 | } 64 | 65 | // Subcommands 66 | for _, c := range cmd.Commands() { 67 | if c.Hidden { 68 | continue 69 | } 70 | cmdRef(w, c, depth+1) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pkg/cmd/root/help_test.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDedent(t *testing.T) { 8 | type c struct { 9 | input string 10 | expected string 11 | } 12 | 13 | cases := []c{ 14 | { 15 | input: " --help Show help for command\n --version Show pullpo version\n", 16 | expected: "--help Show help for command\n--version Show pullpo version\n", 17 | }, 18 | { 19 | input: " --help Show help for command\n -R, --repo OWNER/REPO Select another repository using the OWNER/REPO format\n", 20 | expected: " --help Show help for command\n-R, --repo OWNER/REPO Select another repository using the OWNER/REPO format\n", 21 | }, 22 | { 23 | input: " line 1\n\n line 2\n line 3", 24 | expected: " line 1\n\n line 2\nline 3", 25 | }, 26 | { 27 | input: " line 1\n line 2\n line 3\n\n", 28 | expected: "line 1\nline 2\nline 3\n\n", 29 | }, 30 | { 31 | input: "\n\n\n\n\n\n", 32 | expected: "\n\n\n\n\n\n", 33 | }, 34 | { 35 | input: "", 36 | expected: "", 37 | }, 38 | } 39 | 40 | for _, tt := range cases { 41 | got := dedent(tt.input) 42 | if got != tt.expected { 43 | t.Errorf("expected: %q, got: %q", tt.expected, got) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pkg/cmd/ruleset/check/fixtures/rulesetCheck.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "commit_author_email_pattern", 4 | "parameters": { 5 | "name": "", 6 | "negate": false, 7 | "pattern": "@example.com", 8 | "operator": "ends_with" 9 | }, 10 | "ruleset_source_type": "Organization", 11 | "ruleset_source": "my-org", 12 | "ruleset_id": 1234 13 | }, 14 | { 15 | "type": "commit_message_pattern", 16 | "parameters": { 17 | "name": "", 18 | "negate": false, 19 | "pattern": "fff", 20 | "operator": "starts_with" 21 | }, 22 | "ruleset_source_type": "Organization", 23 | "ruleset_source": "my-org", 24 | "ruleset_id": 1234 25 | }, 26 | { 27 | "type": "required_signatures", 28 | "ruleset_source_type": "Organization", 29 | "ruleset_source": "my-org", 30 | "ruleset_id": 1234 31 | }, 32 | { 33 | "type": "commit_message_pattern", 34 | "parameters": { 35 | "name": "", 36 | "negate": false, 37 | "pattern": "asdf", 38 | "operator": "contains" 39 | }, 40 | "ruleset_source_type": "Repository", 41 | "ruleset_source": "my-org/repo-name", 42 | "ruleset_id": 5678 43 | }, 44 | { 45 | "type": "commit_author_email_pattern", 46 | "parameters": { 47 | "name": "", 48 | "negate": false, 49 | "pattern": "@example.com", 50 | "operator": "ends_with" 51 | }, 52 | "ruleset_source_type": "Repository", 53 | "ruleset_source": "my-org/repo-name", 54 | "ruleset_id": 5678 55 | }, 56 | { 57 | "type": "creation", 58 | "ruleset_source_type": "Repository", 59 | "ruleset_source": "my-org/repo-name", 60 | "ruleset_id": 5678 61 | } 62 | ] 63 | -------------------------------------------------------------------------------- /pkg/cmd/ruleset/ruleset.go: -------------------------------------------------------------------------------- 1 | package ruleset 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | cmdCheck "github.com/cli/cli/v2/pkg/cmd/ruleset/check" 6 | cmdList "github.com/cli/cli/v2/pkg/cmd/ruleset/list" 7 | cmdView "github.com/cli/cli/v2/pkg/cmd/ruleset/view" 8 | "github.com/cli/cli/v2/pkg/cmdutil" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func NewCmdRuleset(f *cmdutil.Factory) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "ruleset ", 15 | Short: "View info about repo rulesets", 16 | Long: heredoc.Doc(` 17 | Repository rulesets are a way to define a set of rules that apply to a repository. 18 | These commands allow you to view information about them. 19 | `), 20 | Aliases: []string{"rs"}, 21 | Example: heredoc.Doc(` 22 | $ pullpo ruleset list 23 | $ pullpo ruleset view --repo OWNER/REPO --web 24 | $ pullpo ruleset check branch-name 25 | `), 26 | } 27 | 28 | cmdutil.EnableRepoOverride(cmd, f) 29 | cmd.AddCommand(cmdList.NewCmdList(f, nil)) 30 | cmd.AddCommand(cmdView.NewCmdView(f, nil)) 31 | cmd.AddCommand(cmdCheck.NewCmdCheck(f, nil)) 32 | 33 | return cmd 34 | } 35 | -------------------------------------------------------------------------------- /pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "level": { 4 | "rulesets": { 5 | "totalCount": 2, 6 | "nodes": [ 7 | { 8 | "databaseId": 74, 9 | "name": "My Org Ruleset", 10 | "target": "BRANCH", 11 | "enforcement": "EVALUATE", 12 | "source": { 13 | "__typename": "Organization", 14 | "owner": "my-owner" 15 | }, 16 | "rules": { 17 | "totalCount": 3 18 | } 19 | }, 20 | { 21 | "databaseId": 42, 22 | "name": "Test Ruleset", 23 | "target": "BRANCH", 24 | "enforcement": "ACTIVE", 25 | "source": { 26 | "__typename": "Repository", 27 | "owner": "my-owner/repo-name" 28 | }, 29 | "rules": { 30 | "totalCount": 3 31 | } 32 | } 33 | ], 34 | "pageInfo": { 35 | "hasNextPage": false, 36 | "endCursor": "Mg" 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 74, 3 | "name": "My Org Ruleset", 4 | "target": "branch", 5 | "source_type": "Organization", 6 | "source": "my-owner", 7 | "enforcement": "evaluate", 8 | "conditions": { 9 | "ref_name": { 10 | "exclude": [], 11 | "include": [ 12 | "~ALL" 13 | ] 14 | }, 15 | "repository_name": { 16 | "exclude": [], 17 | "include": [ 18 | "~ALL" 19 | ], 20 | "protected": true 21 | } 22 | }, 23 | "rules": [ 24 | { 25 | "type": "commit_message_pattern", 26 | "parameters": { 27 | "name": "", 28 | "negate": false, 29 | "pattern": "asdf", 30 | "operator": "contains" 31 | } 32 | }, 33 | { 34 | "type": "commit_author_email_pattern", 35 | "parameters": { 36 | "name": "", 37 | "negate": false, 38 | "pattern": "@example.com", 39 | "operator": "ends_with" 40 | } 41 | }, 42 | { 43 | "type": "creation" 44 | } 45 | ], 46 | "node_id": "RRS_lACqUmVwb3NpdG9yec4dwx_uzSNG", 47 | "_links": { 48 | "self": { 49 | "href": "https://api.github.com/repos/my-owner/repo-name/rulesets/74" 50 | }, 51 | "html": { 52 | "href": "https://github.com/organizations/my-owner/settings/rules/74" 53 | } 54 | }, 55 | "created_at": "2023-05-01T13:53:37.185-04:00", 56 | "updated_at": "2023-06-29T17:38:03.722-04:00", 57 | "bypass_actors": [] 58 | } 59 | -------------------------------------------------------------------------------- /pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 42, 3 | "name": "Test Ruleset", 4 | "target": "branch", 5 | "source_type": "Repository", 6 | "source": "my-owner/repo-name", 7 | "enforcement": "active", 8 | "current_user_can_bypass": "pull_requests_only", 9 | "conditions": { 10 | "ref_name": { 11 | "exclude": [], 12 | "include": [ 13 | "~ALL" 14 | ] 15 | } 16 | }, 17 | "rules": [ 18 | { 19 | "type": "commit_message_pattern", 20 | "parameters": { 21 | "name": "", 22 | "negate": false, 23 | "pattern": "asdf", 24 | "operator": "contains" 25 | } 26 | }, 27 | { 28 | "type": "commit_author_email_pattern", 29 | "parameters": { 30 | "name": "", 31 | "negate": false, 32 | "pattern": "@example.com", 33 | "operator": "ends_with" 34 | } 35 | }, 36 | { 37 | "type": "creation" 38 | } 39 | ], 40 | "node_id": "RRS_lACqUmVwb3NpdG9yec4dwx_uzSNG", 41 | "_links": { 42 | "self": { 43 | "href": "https://api.github.com/repos/my-owner/repo-name/rulesets/42" 44 | }, 45 | "html": { 46 | "href": "https://github.com/my-owner/repo-name/rules/42" 47 | } 48 | }, 49 | "created_at": "2023-05-01T13:53:37.185-04:00", 50 | "updated_at": "2023-06-29T17:38:03.722-04:00", 51 | "bypass_actors": [ 52 | { 53 | "actor_id": 5, 54 | "actor_type": "RepositoryRole", 55 | "bypass_mode": "always" 56 | }, 57 | { 58 | "actor_id": 1, 59 | "actor_type": "OrganizationAdmin", 60 | "bypass_mode": "always" 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /pkg/cmd/ruleset/view/http.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/cli/cli/v2/api" 8 | "github.com/cli/cli/v2/internal/ghrepo" 9 | "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" 10 | ) 11 | 12 | func viewRepoRuleset(httpClient *http.Client, repo ghrepo.Interface, databaseId string) (*shared.RulesetREST, error) { 13 | path := fmt.Sprintf("repos/%s/%s/rulesets/%s", repo.RepoOwner(), repo.RepoName(), databaseId) 14 | return viewRuleset(httpClient, repo.RepoHost(), path) 15 | } 16 | 17 | func viewOrgRuleset(httpClient *http.Client, orgLogin string, databaseId string, host string) (*shared.RulesetREST, error) { 18 | path := fmt.Sprintf("orgs/%s/rulesets/%s", orgLogin, databaseId) 19 | return viewRuleset(httpClient, host, path) 20 | } 21 | 22 | func viewRuleset(httpClient *http.Client, hostname string, path string) (*shared.RulesetREST, error) { 23 | apiClient := api.NewClientFromHTTP(httpClient) 24 | result := shared.RulesetREST{} 25 | 26 | err := apiClient.REST(hostname, "GET", path, nil, &result) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return &result, nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/cmd/run/download/fixtures/myproject.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pullpo-io/cli/0567c8540f11d780406fb2e5eebf923b5e8d9e47/pkg/cmd/run/download/fixtures/myproject.zip -------------------------------------------------------------------------------- /pkg/cmd/run/download/http.go: -------------------------------------------------------------------------------- 1 | package download 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/cli/cli/v2/api" 11 | "github.com/cli/cli/v2/internal/ghrepo" 12 | "github.com/cli/cli/v2/pkg/cmd/run/shared" 13 | ) 14 | 15 | type apiPlatform struct { 16 | client *http.Client 17 | repo ghrepo.Interface 18 | } 19 | 20 | func (p *apiPlatform) List(runID string) ([]shared.Artifact, error) { 21 | return shared.ListArtifacts(p.client, p.repo, runID) 22 | } 23 | 24 | func (p *apiPlatform) Download(url string, dir string) error { 25 | return downloadArtifact(p.client, url, dir) 26 | } 27 | 28 | func downloadArtifact(httpClient *http.Client, url, destDir string) error { 29 | req, err := http.NewRequest("GET", url, nil) 30 | if err != nil { 31 | return err 32 | } 33 | // The server rejects this :( 34 | //req.Header.Set("Accept", "application/zip") 35 | 36 | resp, err := httpClient.Do(req) 37 | if err != nil { 38 | return err 39 | } 40 | defer resp.Body.Close() 41 | 42 | if resp.StatusCode > 299 { 43 | return api.HandleHTTPError(resp) 44 | } 45 | 46 | tmpfile, err := os.CreateTemp("", "gh-artifact.*.zip") 47 | if err != nil { 48 | return fmt.Errorf("error initializing temporary file: %w", err) 49 | } 50 | defer func() { 51 | _ = tmpfile.Close() 52 | _ = os.Remove(tmpfile.Name()) 53 | }() 54 | 55 | size, err := io.Copy(tmpfile, resp.Body) 56 | if err != nil { 57 | return fmt.Errorf("error writing zip archive: %w", err) 58 | } 59 | 60 | zipfile, err := zip.NewReader(tmpfile, size) 61 | if err != nil { 62 | return fmt.Errorf("error extracting zip archive: %w", err) 63 | } 64 | if err := extractZip(zipfile, destDir); err != nil { 65 | return fmt.Errorf("error extracting zip archive: %w", err) 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/cmd/run/download/zip.go: -------------------------------------------------------------------------------- 1 | package download 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | dirMode os.FileMode = 0755 14 | fileMode os.FileMode = 0644 15 | execMode os.FileMode = 0755 16 | ) 17 | 18 | func extractZip(zr *zip.Reader, destDir string) error { 19 | for _, zf := range zr.File { 20 | fpath := filepath.Join(destDir, filepath.FromSlash(zf.Name)) 21 | if !filepathDescendsFrom(fpath, destDir) { 22 | continue 23 | } 24 | if err := extractZipFile(zf, fpath); err != nil { 25 | return fmt.Errorf("error extracting %q: %w", zf.Name, err) 26 | } 27 | } 28 | return nil 29 | } 30 | 31 | func extractZipFile(zf *zip.File, dest string) (extractErr error) { 32 | zm := zf.Mode() 33 | if zm.IsDir() { 34 | extractErr = os.MkdirAll(dest, dirMode) 35 | return 36 | } 37 | 38 | var f io.ReadCloser 39 | f, extractErr = zf.Open() 40 | if extractErr != nil { 41 | return 42 | } 43 | defer f.Close() 44 | 45 | if dir := filepath.Dir(dest); dir != "." { 46 | if extractErr = os.MkdirAll(dir, dirMode); extractErr != nil { 47 | return 48 | } 49 | } 50 | 51 | var df *os.File 52 | if df, extractErr = os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, getPerm(zm)); extractErr != nil { 53 | return 54 | } 55 | 56 | defer func() { 57 | if err := df.Close(); extractErr == nil && err != nil { 58 | extractErr = err 59 | } 60 | }() 61 | 62 | _, extractErr = io.Copy(df, f) 63 | return 64 | } 65 | 66 | func getPerm(m os.FileMode) os.FileMode { 67 | if m&0111 == 0 { 68 | return fileMode 69 | } 70 | return execMode 71 | } 72 | 73 | func filepathDescendsFrom(p, dir string) bool { 74 | p = filepath.Clean(p) 75 | dir = filepath.Clean(dir) 76 | if dir == "." && !filepath.IsAbs(p) { 77 | return !strings.HasPrefix(p, ".."+string(filepath.Separator)) 78 | } 79 | if !strings.HasSuffix(dir, string(filepath.Separator)) { 80 | dir += string(filepath.Separator) 81 | } 82 | return strings.HasPrefix(p, dir) 83 | } 84 | -------------------------------------------------------------------------------- /pkg/cmd/run/run.go: -------------------------------------------------------------------------------- 1 | package run 2 | 3 | import ( 4 | cmdCancel "github.com/cli/cli/v2/pkg/cmd/run/cancel" 5 | cmdDelete "github.com/cli/cli/v2/pkg/cmd/run/delete" 6 | cmdDownload "github.com/cli/cli/v2/pkg/cmd/run/download" 7 | cmdList "github.com/cli/cli/v2/pkg/cmd/run/list" 8 | cmdRerun "github.com/cli/cli/v2/pkg/cmd/run/rerun" 9 | cmdView "github.com/cli/cli/v2/pkg/cmd/run/view" 10 | cmdWatch "github.com/cli/cli/v2/pkg/cmd/run/watch" 11 | "github.com/cli/cli/v2/pkg/cmdutil" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func NewCmdRun(f *cmdutil.Factory) *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "run ", 18 | Short: "View details about workflow runs", 19 | Long: "List, view, and watch recent workflow runs from GitHub Actions.", 20 | GroupID: "actions", 21 | } 22 | cmdutil.EnableRepoOverride(cmd, f) 23 | 24 | cmd.AddCommand(cmdList.NewCmdList(f, nil)) 25 | cmd.AddCommand(cmdView.NewCmdView(f, nil)) 26 | cmd.AddCommand(cmdRerun.NewCmdRerun(f, nil)) 27 | cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil)) 28 | cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil)) 29 | cmd.AddCommand(cmdCancel.NewCmdCancel(f, nil)) 30 | cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) 31 | 32 | return cmd 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cmd/run/shared/presentation.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/cli/cli/v2/pkg/iostreams" 8 | ) 9 | 10 | func RenderRunHeader(cs *iostreams.ColorScheme, run Run, ago, prNumber string, attempt uint64) string { 11 | title := fmt.Sprintf("%s %s%s", 12 | cs.Bold(run.HeadBranch), run.WorkflowName(), prNumber) 13 | symbol, symbolColor := Symbol(cs, run.Status, run.Conclusion) 14 | id := cs.Cyanf("%d", run.ID) 15 | 16 | attemptLabel := "" 17 | if attempt > 0 { 18 | attemptLabel = fmt.Sprintf(" (Attempt #%d)", attempt) 19 | } 20 | 21 | header := "" 22 | header += fmt.Sprintf("%s %s · %s%s\n", symbolColor(symbol), title, id, attemptLabel) 23 | header += fmt.Sprintf("Triggered via %s %s", run.Event, ago) 24 | 25 | return header 26 | } 27 | 28 | func RenderJobs(cs *iostreams.ColorScheme, jobs []Job, verbose bool) string { 29 | lines := []string{} 30 | for _, job := range jobs { 31 | elapsed := job.CompletedAt.Sub(job.StartedAt) 32 | elapsedStr := fmt.Sprintf(" in %s", elapsed) 33 | if elapsed < 0 { 34 | elapsedStr = "" 35 | } 36 | symbol, symbolColor := Symbol(cs, job.Status, job.Conclusion) 37 | id := cs.Cyanf("%d", job.ID) 38 | lines = append(lines, fmt.Sprintf("%s %s%s (ID %s)", symbolColor(symbol), cs.Bold(job.Name), elapsedStr, id)) 39 | if verbose || IsFailureState(job.Conclusion) { 40 | for _, step := range job.Steps { 41 | stepSymbol, stepSymColor := Symbol(cs, step.Status, step.Conclusion) 42 | lines = append(lines, fmt.Sprintf(" %s %s", stepSymColor(stepSymbol), step.Name)) 43 | } 44 | } 45 | } 46 | 47 | return strings.Join(lines, "\n") 48 | } 49 | 50 | func RenderAnnotations(cs *iostreams.ColorScheme, annotations []Annotation) string { 51 | lines := []string{} 52 | 53 | for _, a := range annotations { 54 | lines = append(lines, fmt.Sprintf("%s %s", AnnotationSymbol(cs, a), a.Message)) 55 | lines = append(lines, cs.Grayf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine)) 56 | } 57 | 58 | return strings.Join(lines, "\n") 59 | } 60 | -------------------------------------------------------------------------------- /pkg/cmd/run/view/fixtures/run_log.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pullpo-io/cli/0567c8540f11d780406fb2e5eebf923b5e8d9e47/pkg/cmd/run/view/fixtures/run_log.zip -------------------------------------------------------------------------------- /pkg/cmd/search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "github.com/cli/cli/v2/pkg/cmdutil" 5 | "github.com/spf13/cobra" 6 | 7 | searchCodeCmd "github.com/cli/cli/v2/pkg/cmd/search/code" 8 | searchCommitsCmd "github.com/cli/cli/v2/pkg/cmd/search/commits" 9 | searchIssuesCmd "github.com/cli/cli/v2/pkg/cmd/search/issues" 10 | searchPrsCmd "github.com/cli/cli/v2/pkg/cmd/search/prs" 11 | searchReposCmd "github.com/cli/cli/v2/pkg/cmd/search/repos" 12 | ) 13 | 14 | func NewCmdSearch(f *cmdutil.Factory) *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "search ", 17 | Short: "Search for repositories, issues, and pull requests", 18 | Long: "Search across all of GitHub.", 19 | } 20 | 21 | cmd.AddCommand(searchCodeCmd.NewCmdCode(f, nil)) 22 | cmd.AddCommand(searchCommitsCmd.NewCmdCommits(f, nil)) 23 | cmd.AddCommand(searchIssuesCmd.NewCmdIssues(f, nil)) 24 | cmd.AddCommand(searchPrsCmd.NewCmdPrs(f, nil)) 25 | cmd.AddCommand(searchReposCmd.NewCmdRepos(f, nil)) 26 | 27 | return cmd 28 | } 29 | -------------------------------------------------------------------------------- /pkg/cmd/secret/secret.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | cmdDelete "github.com/cli/cli/v2/pkg/cmd/secret/delete" 6 | cmdList "github.com/cli/cli/v2/pkg/cmd/secret/list" 7 | cmdSet "github.com/cli/cli/v2/pkg/cmd/secret/set" 8 | "github.com/cli/cli/v2/pkg/cmdutil" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "secret ", 15 | Short: "Manage GitHub secrets", 16 | Long: heredoc.Docf(` 17 | Secrets can be set at the repository, or organization level for use in 18 | GitHub Actions or Dependabot. User, organization, and repository secrets can be set for 19 | use in GitHub Codespaces. Environment secrets can be set for use in 20 | GitHub Actions. Run %[1]spullpo help secret set%[1]s to learn how to get started. 21 | `, "`"), 22 | } 23 | 24 | cmdutil.EnableRepoOverride(cmd, f) 25 | 26 | cmd.AddCommand(cmdList.NewCmdList(f, nil)) 27 | cmd.AddCommand(cmdSet.NewCmdSet(f, nil)) 28 | cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) 29 | 30 | return cmd 31 | } 32 | -------------------------------------------------------------------------------- /pkg/cmd/ssh-key/delete/http.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/cli/cli/v2/api" 10 | "github.com/cli/cli/v2/internal/ghinstance" 11 | ) 12 | 13 | type sshKey struct { 14 | Title string 15 | } 16 | 17 | func deleteSSHKey(httpClient *http.Client, host string, keyID string) error { 18 | url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) 19 | req, err := http.NewRequest("DELETE", url, nil) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | resp, err := httpClient.Do(req) 25 | if err != nil { 26 | return err 27 | } 28 | defer resp.Body.Close() 29 | 30 | if resp.StatusCode > 299 { 31 | return api.HandleHTTPError(resp) 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func getSSHKey(httpClient *http.Client, host string, keyID string) (*sshKey, error) { 38 | url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) 39 | req, err := http.NewRequest("GET", url, nil) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | resp, err := httpClient.Do(req) 45 | if err != nil { 46 | return nil, err 47 | } 48 | defer resp.Body.Close() 49 | 50 | if resp.StatusCode > 299 { 51 | return nil, api.HandleHTTPError(resp) 52 | } 53 | 54 | b, err := io.ReadAll(resp.Body) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | var key sshKey 60 | err = json.Unmarshal(b, &key) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | return &key, nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/cmd/ssh-key/ssh_key.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | cmdAdd "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" 5 | cmdDelete "github.com/cli/cli/v2/pkg/cmd/ssh-key/delete" 6 | cmdList "github.com/cli/cli/v2/pkg/cmd/ssh-key/list" 7 | "github.com/cli/cli/v2/pkg/cmdutil" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "ssh-key ", 14 | Short: "Manage SSH keys", 15 | Long: "Manage SSH keys registered with your GitHub account.", 16 | } 17 | 18 | cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil)) 19 | cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) 20 | cmd.AddCommand(cmdList.NewCmdList(f, nil)) 21 | 22 | return cmd 23 | } 24 | -------------------------------------------------------------------------------- /pkg/cmd/variable/shared/shared.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | type Visibility string 8 | 9 | const ( 10 | All = "all" 11 | Private = "private" 12 | Selected = "selected" 13 | ) 14 | 15 | type VariableEntity string 16 | 17 | const ( 18 | Repository = "repository" 19 | Organization = "organization" 20 | Environment = "environment" 21 | ) 22 | 23 | func GetVariableEntity(orgName, envName string) (VariableEntity, error) { 24 | orgSet := orgName != "" 25 | envSet := envName != "" 26 | 27 | if orgSet && envSet { 28 | return "", errors.New("cannot specify multiple variable entities") 29 | } 30 | 31 | if orgSet { 32 | return Organization, nil 33 | } 34 | if envSet { 35 | return Environment, nil 36 | } 37 | return Repository, nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/cmd/variable/shared/shared_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetVariableEntity(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | orgName string 13 | envName string 14 | want VariableEntity 15 | wantErr bool 16 | }{ 17 | { 18 | name: "org", 19 | orgName: "myOrg", 20 | want: Organization, 21 | }, 22 | { 23 | name: "env", 24 | envName: "myEnv", 25 | want: Environment, 26 | }, 27 | { 28 | name: "defaults to repo", 29 | want: Repository, 30 | }, 31 | { 32 | name: "errors when both org and env are set", 33 | orgName: "myOrg", 34 | envName: "myEnv", 35 | wantErr: true, 36 | }, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | entity, err := GetVariableEntity(tt.orgName, tt.envName) 41 | if tt.wantErr { 42 | assert.Error(t, err) 43 | } else { 44 | assert.NoError(t, err) 45 | assert.Equal(t, tt.want, entity) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/cmd/variable/variable.go: -------------------------------------------------------------------------------- 1 | package variable 2 | 3 | import ( 4 | "github.com/MakeNowJust/heredoc" 5 | cmdDelete "github.com/cli/cli/v2/pkg/cmd/variable/delete" 6 | cmdList "github.com/cli/cli/v2/pkg/cmd/variable/list" 7 | cmdSet "github.com/cli/cli/v2/pkg/cmd/variable/set" 8 | "github.com/cli/cli/v2/pkg/cmdutil" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func NewCmdVariable(f *cmdutil.Factory) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "variable ", 15 | Short: "Manage GitHub Actions variables", 16 | Long: heredoc.Docf(` 17 | Variables can be set at the repository, environment or organization level for use in 18 | GitHub Actions or Dependabot. Run %[1]spullpo help variable set%[1]s to learn how to get started. 19 | `, "`"), 20 | } 21 | 22 | cmdutil.EnableRepoOverride(cmd, f) 23 | 24 | cmd.AddCommand(cmdSet.NewCmdSet(f, nil)) 25 | cmd.AddCommand(cmdList.NewCmdList(f, nil)) 26 | cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) 27 | 28 | return cmd 29 | } 30 | -------------------------------------------------------------------------------- /pkg/cmd/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/cli/cli/v2/pkg/cmdutil" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func NewCmdVersion(f *cmdutil.Factory, version, buildDate string) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "version", 15 | Hidden: true, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Fprint(f.IOStreams.Out, cmd.Root().Annotations["versionInfo"]) 18 | }, 19 | } 20 | 21 | cmdutil.DisableAuthCheck(cmd) 22 | 23 | return cmd 24 | } 25 | 26 | func Format(version, buildDate string) string { 27 | version = strings.TrimPrefix(version, "v") 28 | 29 | var dateStr string 30 | if buildDate != "" { 31 | dateStr = fmt.Sprintf(" (%s)", buildDate) 32 | } 33 | 34 | return fmt.Sprintf("pullpo version %s%s\n%s\n", version, dateStr, changelogURL(version)) 35 | } 36 | 37 | func changelogURL(version string) string { 38 | path := "https://github.com/pullpo-io/cli" 39 | r := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-[\w.]+)?$`) 40 | if !r.MatchString(version) { 41 | return fmt.Sprintf("%s/releases/latest", path) 42 | } 43 | 44 | url := fmt.Sprintf("%s/releases/tag/v%s", path, strings.TrimPrefix(version, "v")) 45 | return url 46 | } 47 | -------------------------------------------------------------------------------- /pkg/cmd/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFormat(t *testing.T) { 8 | expects := "pullpo version 1.4.0 (2020-12-15)\nhttps://github.com/cli/cli/releases/tag/v1.4.0\n" 9 | if got := Format("1.4.0", "2020-12-15"); got != expects { 10 | t.Errorf("Format() = %q, wants %q", got, expects) 11 | } 12 | } 13 | 14 | func TestChangelogURL(t *testing.T) { 15 | tag := "0.3.2" 16 | url := "https://github.com/cli/cli/releases/tag/v0.3.2" 17 | result := changelogURL(tag) 18 | if result != url { 19 | t.Errorf("expected %s to create url %s but got %s", tag, url, result) 20 | } 21 | 22 | tag = "v0.3.2" 23 | url = "https://github.com/cli/cli/releases/tag/v0.3.2" 24 | result = changelogURL(tag) 25 | if result != url { 26 | t.Errorf("expected %s to create url %s but got %s", tag, url, result) 27 | } 28 | 29 | tag = "0.3.2-pre.1" 30 | url = "https://github.com/cli/cli/releases/tag/v0.3.2-pre.1" 31 | result = changelogURL(tag) 32 | if result != url { 33 | t.Errorf("expected %s to create url %s but got %s", tag, url, result) 34 | } 35 | 36 | tag = "0.3.5-90-gdd3f0e0" 37 | url = "https://github.com/cli/cli/releases/latest" 38 | result = changelogURL(tag) 39 | if result != url { 40 | t.Errorf("expected %s to create url %s but got %s", tag, url, result) 41 | } 42 | 43 | tag = "deadbeef" 44 | url = "https://github.com/cli/cli/releases/latest" 45 | result = changelogURL(tag) 46 | if result != url { 47 | t.Errorf("expected %s to create url %s but got %s", tag, url, result) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/cmd/workflow/shared/test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | var AWorkflow = Workflow{ 4 | Name: "a workflow", 5 | ID: 123, 6 | Path: ".github/workflows/flow.yml", 7 | State: Active, 8 | } 9 | var AWorkflowContent = `{"content":"bmFtZTogYSB3b3JrZmxvdwo="}` 10 | 11 | var DisabledWorkflow = Workflow{ 12 | Name: "a disabled workflow", 13 | ID: 456, 14 | Path: ".github/workflows/disabled.yml", 15 | State: DisabledManually, 16 | } 17 | 18 | var DisabledInactivityWorkflow = Workflow{ 19 | Name: "a disabled inactivity workflow", 20 | ID: 1206, 21 | Path: ".github/workflows/disabledInactivity.yml", 22 | State: DisabledInactivity, 23 | } 24 | 25 | var AnotherDisabledWorkflow = Workflow{ 26 | Name: "a disabled workflow", 27 | ID: 1213, 28 | Path: ".github/workflows/anotherDisabled.yml", 29 | State: DisabledManually, 30 | } 31 | 32 | var UniqueDisabledWorkflow = Workflow{ 33 | Name: "terrible workflow", 34 | ID: 1314, 35 | Path: ".github/workflows/terrible.yml", 36 | State: DisabledManually, 37 | } 38 | 39 | var AnotherWorkflow = Workflow{ 40 | Name: "another workflow", 41 | ID: 789, 42 | Path: ".github/workflows/another.yml", 43 | State: Active, 44 | } 45 | var AnotherWorkflowContent = `{"content":"bmFtZTogYW5vdGhlciB3b3JrZmxvdwo="}` 46 | 47 | var YetAnotherWorkflow = Workflow{ 48 | Name: "another workflow", 49 | ID: 1011, 50 | Path: ".github/workflows/yetanother.yml", 51 | State: Active, 52 | } 53 | -------------------------------------------------------------------------------- /pkg/cmd/workflow/workflow.go: -------------------------------------------------------------------------------- 1 | package workflow 2 | 3 | import ( 4 | cmdDisable "github.com/cli/cli/v2/pkg/cmd/workflow/disable" 5 | cmdEnable "github.com/cli/cli/v2/pkg/cmd/workflow/enable" 6 | cmdList "github.com/cli/cli/v2/pkg/cmd/workflow/list" 7 | cmdRun "github.com/cli/cli/v2/pkg/cmd/workflow/run" 8 | cmdView "github.com/cli/cli/v2/pkg/cmd/workflow/view" 9 | "github.com/cli/cli/v2/pkg/cmdutil" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func NewCmdWorkflow(f *cmdutil.Factory) *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "workflow ", 16 | Short: "View details about GitHub Actions workflows", 17 | Long: "List, view, and run workflows in GitHub Actions.", 18 | GroupID: "actions", 19 | } 20 | cmdutil.EnableRepoOverride(cmd, f) 21 | 22 | cmd.AddCommand(cmdList.NewCmdList(f, nil)) 23 | cmd.AddCommand(cmdEnable.NewCmdEnable(f, nil)) 24 | cmd.AddCommand(cmdDisable.NewCmdDisable(f, nil)) 25 | cmd.AddCommand(cmdView.NewCmdView(f, nil)) 26 | cmd.AddCommand(cmdRun.NewCmdRun(f, nil)) 27 | 28 | return cmd 29 | } 30 | -------------------------------------------------------------------------------- /pkg/cmdutil/args.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/pflag" 8 | ) 9 | 10 | func MinimumArgs(n int, msg string) cobra.PositionalArgs { 11 | if msg == "" { 12 | return cobra.MinimumNArgs(1) 13 | } 14 | 15 | return func(cmd *cobra.Command, args []string) error { 16 | if len(args) < n { 17 | return FlagErrorf("%s", msg) 18 | } 19 | return nil 20 | } 21 | } 22 | 23 | func ExactArgs(n int, msg string) cobra.PositionalArgs { 24 | return func(cmd *cobra.Command, args []string) error { 25 | if len(args) > n { 26 | return FlagErrorf("too many arguments") 27 | } 28 | 29 | if len(args) < n { 30 | return FlagErrorf("%s", msg) 31 | } 32 | 33 | return nil 34 | } 35 | } 36 | 37 | func NoArgsQuoteReminder(cmd *cobra.Command, args []string) error { 38 | if len(args) < 1 { 39 | return nil 40 | } 41 | 42 | errMsg := fmt.Sprintf("unknown argument %q", args[0]) 43 | if len(args) > 1 { 44 | errMsg = fmt.Sprintf("unknown arguments %q", args) 45 | } 46 | 47 | hasValueFlag := false 48 | cmd.Flags().Visit(func(f *pflag.Flag) { 49 | if f.Value.Type() != "bool" { 50 | hasValueFlag = true 51 | } 52 | }) 53 | 54 | if hasValueFlag { 55 | errMsg += "; please quote all values that have spaces" 56 | } 57 | 58 | return FlagErrorf("%s", errMsg) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/cmdutil/args_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import "testing" 4 | 5 | func TestMinimumArgs(t *testing.T) { 6 | tests := []struct { 7 | N int 8 | Args []string 9 | }{ 10 | { 11 | N: 1, 12 | Args: []string{"v1.2.3"}, 13 | }, 14 | { 15 | N: 2, 16 | Args: []string{"v1.2.3", "cli/cli"}, 17 | }, 18 | } 19 | 20 | for _, test := range tests { 21 | if got := MinimumArgs(test.N, "")(nil, test.Args); got != nil { 22 | t.Errorf("Got: %v, Want: (nil)", got) 23 | } 24 | } 25 | } 26 | 27 | func TestMinimumNs_with_error(t *testing.T) { 28 | tests := []struct { 29 | N int 30 | CustomMessage string 31 | WantMessage string 32 | }{ 33 | { 34 | N: 1, 35 | CustomMessage: "A custom msg", 36 | WantMessage: "A custom msg", 37 | }, 38 | { 39 | N: 1, 40 | CustomMessage: "", 41 | WantMessage: "requires at least 1 arg(s), only received 0", 42 | }, 43 | } 44 | 45 | for _, test := range tests { 46 | if got := MinimumArgs(test.N, test.CustomMessage)(nil, nil); got.Error() != test.WantMessage { 47 | t.Errorf("Got: %v, Want: %v", got, test.WantMessage) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/cmdutil/auth_check.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "github.com/cli/cli/v2/internal/config" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func DisableAuthCheck(cmd *cobra.Command) { 9 | if cmd.Annotations == nil { 10 | cmd.Annotations = map[string]string{} 11 | } 12 | 13 | cmd.Annotations["skipAuthCheck"] = "true" 14 | } 15 | 16 | func CheckAuth(cfg config.Config) bool { 17 | if cfg.Authentication().HasEnvToken() { 18 | return true 19 | } 20 | 21 | if len(cfg.Authentication().Hosts()) > 0 { 22 | return true 23 | } 24 | 25 | return false 26 | } 27 | 28 | func IsAuthCheckEnabled(cmd *cobra.Command) bool { 29 | switch cmd.Name() { 30 | case "help", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd: 31 | return false 32 | } 33 | 34 | for c := cmd; c.Parent() != nil; c = c.Parent() { 35 | if c.Annotations != nil && c.Annotations["skipAuthCheck"] == "true" { 36 | return false 37 | } 38 | } 39 | 40 | return true 41 | } 42 | -------------------------------------------------------------------------------- /pkg/cmdutil/auth_check_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cli/cli/v2/internal/config" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_CheckAuth(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | cfgStubs func(*config.ConfigMock) 14 | expected bool 15 | }{ 16 | { 17 | name: "no known hosts, no env auth token", 18 | cfgStubs: func(c *config.ConfigMock) {}, 19 | expected: false, 20 | }, 21 | { 22 | name: "no known hosts, env auth token", 23 | cfgStubs: func(c *config.ConfigMock) { 24 | c.AuthenticationFunc = func() *config.AuthConfig { 25 | authCfg := &config.AuthConfig{} 26 | authCfg.SetToken("token", "GITHUB_TOKEN") 27 | return authCfg 28 | } 29 | }, 30 | expected: true, 31 | }, 32 | { 33 | name: "known host", 34 | cfgStubs: func(c *config.ConfigMock) { 35 | c.Set("github.com", "oauth_token", "token") 36 | }, 37 | expected: true, 38 | }, 39 | { 40 | name: "enterprise token", 41 | cfgStubs: func(c *config.ConfigMock) { 42 | t.Setenv("GH_ENTERPRISE_TOKEN", "token") 43 | }, 44 | expected: true, 45 | }, 46 | } 47 | 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | cfg := config.NewBlankConfig() 51 | tt.cfgStubs(cfg) 52 | result := CheckAuth(cfg) 53 | assert.Equal(t, tt.expected, result) 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/cmdutil/cmdgroup.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | func AddGroup(parent *cobra.Command, title string, cmds ...*cobra.Command) { 6 | g := &cobra.Group{ 7 | Title: title, 8 | ID: title, 9 | } 10 | parent.AddGroup(g) 11 | for _, c := range cmds { 12 | c.GroupID = g.ID 13 | parent.AddCommand(c) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pkg/cmdutil/errors.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/AlecAivazis/survey/v2/terminal" 8 | ) 9 | 10 | // FlagErrorf returns a new FlagError that wraps an error produced by 11 | // fmt.Errorf(format, args...). 12 | func FlagErrorf(format string, args ...interface{}) error { 13 | return FlagErrorWrap(fmt.Errorf(format, args...)) 14 | } 15 | 16 | // FlagError returns a new FlagError that wraps the specified error. 17 | func FlagErrorWrap(err error) error { return &FlagError{err} } 18 | 19 | // A *FlagError indicates an error processing command-line flags or other arguments. 20 | // Such errors cause the application to display the usage message. 21 | type FlagError struct { 22 | // Note: not struct{error}: only *FlagError should satisfy error. 23 | err error 24 | } 25 | 26 | func (fe *FlagError) Error() string { 27 | return fe.err.Error() 28 | } 29 | 30 | func (fe *FlagError) Unwrap() error { 31 | return fe.err 32 | } 33 | 34 | // SilentError is an error that triggers exit code 1 without any error messaging 35 | var SilentError = errors.New("SilentError") 36 | 37 | // CancelError signals user-initiated cancellation 38 | var CancelError = errors.New("CancelError") 39 | 40 | // PendingError signals nothing failed but something is pending 41 | var PendingError = errors.New("PendingError") 42 | 43 | func IsUserCancellation(err error) bool { 44 | return errors.Is(err, CancelError) || errors.Is(err, terminal.InterruptErr) 45 | } 46 | 47 | func MutuallyExclusive(message string, conditions ...bool) error { 48 | numTrue := 0 49 | for _, ok := range conditions { 50 | if ok { 51 | numTrue++ 52 | } 53 | } 54 | if numTrue > 1 { 55 | return FlagErrorf("%s", message) 56 | } 57 | return nil 58 | } 59 | 60 | type NoResultsError struct { 61 | message string 62 | } 63 | 64 | func (e NoResultsError) Error() string { 65 | return e.message 66 | } 67 | 68 | func NewNoResultsError(message string) NoResultsError { 69 | return NoResultsError{message: message} 70 | } 71 | -------------------------------------------------------------------------------- /pkg/cmdutil/file_input.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "io" 5 | "os" 6 | ) 7 | 8 | func ReadFile(filename string, stdin io.ReadCloser) ([]byte, error) { 9 | if filename == "-" { 10 | b, err := io.ReadAll(stdin) 11 | _ = stdin.Close() 12 | return b, err 13 | } 14 | 15 | return os.ReadFile(filename) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/cmdutil/legacy.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/cli/cli/v2/internal/config" 8 | ) 9 | 10 | // TODO: consider passing via Factory 11 | // TODO: support per-hostname settings 12 | func DetermineEditor(cf func() (config.Config, error)) (string, error) { 13 | editorCommand := os.Getenv("GH_EDITOR") 14 | if editorCommand == "" { 15 | cfg, err := cf() 16 | if err != nil { 17 | return "", fmt.Errorf("could not read config: %w", err) 18 | } 19 | editorCommand = cfg.Editor("") 20 | } 21 | 22 | return editorCommand, nil 23 | } 24 | -------------------------------------------------------------------------------- /pkg/extensions/extension.go: -------------------------------------------------------------------------------- 1 | package extensions 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/cli/cli/v2/internal/ghrepo" 7 | ) 8 | 9 | type ExtTemplateType int 10 | 11 | const ( 12 | GitTemplateType ExtTemplateType = 0 13 | GoBinTemplateType ExtTemplateType = 1 14 | OtherBinTemplateType ExtTemplateType = 2 15 | ) 16 | 17 | //go:generate moq -rm -out extension_mock.go . Extension 18 | type Extension interface { 19 | Name() string // Extension Name without gh- 20 | Path() string // Path to executable 21 | URL() string 22 | CurrentVersion() string 23 | LatestVersion() string 24 | IsPinned() bool 25 | UpdateAvailable() bool 26 | IsBinary() bool 27 | IsLocal() bool 28 | Owner() string 29 | } 30 | 31 | //go:generate moq -rm -out manager_mock.go . ExtensionManager 32 | type ExtensionManager interface { 33 | List() []Extension 34 | Install(ghrepo.Interface, string) error 35 | InstallLocal(dir string) error 36 | Upgrade(name string, force bool) error 37 | Remove(name string) error 38 | Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) 39 | Create(name string, tmplType ExtTemplateType) error 40 | EnableDryRunMode() 41 | } 42 | -------------------------------------------------------------------------------- /pkg/findsh/find.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package findsh 5 | 6 | import "os/exec" 7 | 8 | // Find locates the `sh` interpreter on the system. 9 | func Find() (string, error) { 10 | return exec.LookPath("sh") 11 | } 12 | -------------------------------------------------------------------------------- /pkg/findsh/find_windows.go: -------------------------------------------------------------------------------- 1 | package findsh 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/cli/safeexec" 8 | ) 9 | 10 | func Find() (string, error) { 11 | shPath, shErr := safeexec.LookPath("sh") 12 | if shErr == nil { 13 | return shPath, nil 14 | } 15 | 16 | gitPath, err := safeexec.LookPath("git") 17 | if err != nil { 18 | return "", shErr 19 | } 20 | gitDir := filepath.Dir(gitPath) 21 | 22 | // regular Git for Windows install 23 | shPath = filepath.Join(gitDir, "..", "bin", "sh.exe") 24 | if _, err := os.Stat(shPath); err == nil { 25 | return filepath.Clean(shPath), nil 26 | } 27 | 28 | // git as a scoop shim 29 | shPath = filepath.Join(gitDir, "..", "apps", "git", "current", "bin", "sh.exe") 30 | if _, err := os.Stat(shPath); err == nil { 31 | return filepath.Clean(shPath), nil 32 | } 33 | 34 | return "", shErr 35 | } 36 | -------------------------------------------------------------------------------- /pkg/httpmock/legacy.go: -------------------------------------------------------------------------------- 1 | package httpmock 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // TODO: clean up methods in this file when there are no more callers 8 | 9 | func (r *Registry) StubRepoInfoResponse(owner, repo, branch string) { 10 | r.Register( 11 | GraphQL(`query RepositoryInfo\b`), 12 | StringResponse(fmt.Sprintf(` 13 | { "data": { "repository": { 14 | "id": "REPOID", 15 | "name": "%s", 16 | "owner": {"login": "%s"}, 17 | "description": "", 18 | "defaultBranchRef": {"name": "%s"}, 19 | "hasIssuesEnabled": true, 20 | "viewerPermission": "WRITE" 21 | } } } 22 | `, repo, owner, branch))) 23 | } 24 | 25 | func (r *Registry) StubRepoResponse(owner, repo string) { 26 | r.StubRepoResponseWithPermission(owner, repo, "WRITE") 27 | } 28 | 29 | func (r *Registry) StubRepoResponseWithPermission(owner, repo, permission string) { 30 | r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubResponse(owner, repo, "master", permission))) 31 | } 32 | 33 | func RepoNetworkStubResponse(owner, repo, defaultBranch, permission string) string { 34 | return fmt.Sprintf(` 35 | { "data": { "repo_000": { 36 | "id": "REPOID", 37 | "name": "%s", 38 | "owner": {"login": "%s"}, 39 | "defaultBranchRef": { 40 | "name": "%s" 41 | }, 42 | "viewerPermission": "%s" 43 | } } } 44 | `, repo, owner, defaultBranch, permission) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/httpmock/registry.go: -------------------------------------------------------------------------------- 1 | package httpmock 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | ) 8 | 9 | // Replace http.Client transport layer with registry so all requests get 10 | // recorded. 11 | func ReplaceTripper(client *http.Client, reg *Registry) { 12 | client.Transport = reg 13 | } 14 | 15 | type Registry struct { 16 | mu sync.Mutex 17 | stubs []*Stub 18 | Requests []*http.Request 19 | } 20 | 21 | func (r *Registry) Register(m Matcher, resp Responder) { 22 | r.stubs = append(r.stubs, &Stub{ 23 | Matcher: m, 24 | Responder: resp, 25 | }) 26 | } 27 | 28 | type Testing interface { 29 | Errorf(string, ...interface{}) 30 | Helper() 31 | } 32 | 33 | func (r *Registry) Verify(t Testing) { 34 | n := 0 35 | for _, s := range r.stubs { 36 | if !s.matched { 37 | n++ 38 | } 39 | } 40 | if n > 0 { 41 | t.Helper() 42 | // NOTE: stubs offer no useful reflection, so we can't print details 43 | // about dead stubs and what they were trying to match 44 | t.Errorf("%d unmatched HTTP stubs", n) 45 | } 46 | } 47 | 48 | // RoundTrip satisfies http.RoundTripper 49 | func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) { 50 | var stub *Stub 51 | 52 | r.mu.Lock() 53 | for _, s := range r.stubs { 54 | if s.matched || !s.Matcher(req) { 55 | continue 56 | } 57 | // TODO: reinstate this check once the legacy layer has been cleaned up 58 | // if stub != nil { 59 | // r.mu.Unlock() 60 | // return nil, fmt.Errorf("more than 1 stub matched %v", req) 61 | // } 62 | stub = s 63 | break // TODO: remove 64 | } 65 | if stub != nil { 66 | stub.matched = true 67 | } 68 | 69 | if stub == nil { 70 | r.mu.Unlock() 71 | return nil, fmt.Errorf("no registered stubs matched %v", req) 72 | } 73 | 74 | r.Requests = append(r.Requests, req) 75 | r.mu.Unlock() 76 | 77 | return stub.Responder(req) 78 | } 79 | -------------------------------------------------------------------------------- /pkg/iostreams/console.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package iostreams 5 | 6 | import "os" 7 | 8 | func hasAlternateScreenBuffer(hasTrueColor bool) bool { 9 | // on non-Windows, we just assume that alternate screen buffer is supported in most cases 10 | return os.Getenv("TERM") != "dumb" 11 | } 12 | -------------------------------------------------------------------------------- /pkg/iostreams/console_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package iostreams 5 | 6 | func hasAlternateScreenBuffer(hasTrueColor bool) bool { 7 | // on Windows we just assume that alternate screen buffer is supported if we 8 | // enabled virtual terminal processing, which in turn enables truecolor 9 | return hasTrueColor 10 | } 11 | -------------------------------------------------------------------------------- /pkg/iostreams/epipe_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package iostreams 5 | 6 | import ( 7 | "errors" 8 | "syscall" 9 | ) 10 | 11 | func isEpipeError(err error) bool { 12 | return errors.Is(err, syscall.EPIPE) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/iostreams/epipe_windows.go: -------------------------------------------------------------------------------- 1 | package iostreams 2 | 3 | import ( 4 | "errors" 5 | "syscall" 6 | ) 7 | 8 | func isEpipeError(err error) bool { 9 | // 232 is Windows error code ERROR_NO_DATA, "The pipe is being closed". 10 | return errors.Is(err, syscall.Errno(232)) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/iostreams/iostreams_test.go: -------------------------------------------------------------------------------- 1 | package iostreams 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestStopAlternateScreenBuffer(t *testing.T) { 11 | ios, _, stdout, _ := Test() 12 | ios.SetAlternateScreenBufferEnabled(true) 13 | 14 | ios.StartAlternateScreenBuffer() 15 | fmt.Fprint(ios.Out, "test") 16 | ios.StopAlternateScreenBuffer() 17 | 18 | // Stopping a subsequent time should no-op. 19 | ios.StopAlternateScreenBuffer() 20 | 21 | const want = "\x1b[?1049htest\x1b[?1049l" 22 | if got := stdout.String(); got != want { 23 | t.Errorf("after IOStreams.StopAlternateScreenBuffer() got %q, want %q", got, want) 24 | } 25 | } 26 | 27 | func TestIOStreams_pager(t *testing.T) { 28 | t.Skip("TODO: fix this test in race detection mode") 29 | ios, _, stdout, _ := Test() 30 | ios.SetStdoutTTY(true) 31 | ios.SetPager(fmt.Sprintf("%s -test.run=TestHelperProcess --", os.Args[0])) 32 | t.Setenv("GH_WANT_HELPER_PROCESS", "1") 33 | if err := ios.StartPager(); err != nil { 34 | t.Fatal(err) 35 | } 36 | if _, err := fmt.Fprintln(ios.Out, "line1"); err != nil { 37 | t.Errorf("error writing line 1: %v", err) 38 | } 39 | if _, err := fmt.Fprintln(ios.Out, "line2"); err != nil { 40 | t.Errorf("error writing line 2: %v", err) 41 | } 42 | ios.StopPager() 43 | wants := "pager: line1\npager: line2\n" 44 | if got := stdout.String(); got != wants { 45 | t.Errorf("expected %q, got %q", wants, got) 46 | } 47 | } 48 | 49 | func TestHelperProcess(t *testing.T) { 50 | if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" { 51 | return 52 | } 53 | scanner := bufio.NewScanner(os.Stdin) 54 | for scanner.Scan() { 55 | fmt.Printf("pager: %s\n", scanner.Text()) 56 | } 57 | if err := scanner.Err(); err != nil { 58 | fmt.Fprintf(os.Stderr, "error reading stdin: %v", err) 59 | os.Exit(1) 60 | } 61 | os.Exit(0) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/markdown/markdown.go: -------------------------------------------------------------------------------- 1 | package markdown 2 | 3 | import ( 4 | "github.com/charmbracelet/glamour" 5 | ghMarkdown "github.com/cli/go-gh/v2/pkg/markdown" 6 | ) 7 | 8 | func WithoutIndentation() glamour.TermRendererOption { 9 | return ghMarkdown.WithoutIndentation() 10 | } 11 | 12 | // WithoutWrap is a rendering option that set the character limit for soft 13 | // wrapping the markdown rendering. There is a max limit of 120 characters. 14 | // If 0 is passed then wrapping is disabled. 15 | func WithWrap(w int) glamour.TermRendererOption { 16 | if w > 120 { 17 | w = 120 18 | } 19 | return ghMarkdown.WithWrap(w) 20 | } 21 | 22 | func WithTheme(theme string) glamour.TermRendererOption { 23 | return ghMarkdown.WithTheme(theme) 24 | } 25 | 26 | func WithBaseURL(u string) glamour.TermRendererOption { 27 | return ghMarkdown.WithBaseURL(u) 28 | } 29 | 30 | func Render(text string, opts ...glamour.TermRendererOption) (string, error) { 31 | return ghMarkdown.Render(text, opts...) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/set/string_set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | var exists = struct{}{} 4 | 5 | type stringSet struct { 6 | v []string 7 | m map[string]struct{} 8 | } 9 | 10 | func NewStringSet() *stringSet { 11 | s := &stringSet{} 12 | s.m = make(map[string]struct{}) 13 | s.v = []string{} 14 | return s 15 | } 16 | 17 | func (s *stringSet) Add(value string) { 18 | if s.Contains(value) { 19 | return 20 | } 21 | s.m[value] = exists 22 | s.v = append(s.v, value) 23 | } 24 | 25 | func (s *stringSet) AddValues(values []string) { 26 | for _, v := range values { 27 | s.Add(v) 28 | } 29 | } 30 | 31 | func (s *stringSet) Remove(value string) { 32 | if !s.Contains(value) { 33 | return 34 | } 35 | delete(s.m, value) 36 | s.v = sliceWithout(s.v, value) 37 | } 38 | 39 | func sliceWithout(s []string, v string) []string { 40 | idx := -1 41 | for i, item := range s { 42 | if item == v { 43 | idx = i 44 | break 45 | } 46 | } 47 | if idx < 0 { 48 | return s 49 | } 50 | return append(s[:idx], s[idx+1:]...) 51 | } 52 | 53 | func (s *stringSet) RemoveValues(values []string) { 54 | for _, v := range values { 55 | s.Remove(v) 56 | } 57 | } 58 | 59 | func (s *stringSet) Contains(value string) bool { 60 | _, c := s.m[value] 61 | return c 62 | } 63 | 64 | func (s *stringSet) Len() int { 65 | return len(s.m) 66 | } 67 | 68 | func (s *stringSet) ToSlice() []string { 69 | return s.v 70 | } 71 | 72 | func (s1 *stringSet) Equal(s2 *stringSet) bool { 73 | if s1.Len() != s2.Len() { 74 | return false 75 | } 76 | isEqual := true 77 | for _, v := range s1.v { 78 | if !s2.Contains(v) { 79 | isEqual = false 80 | break 81 | } 82 | } 83 | return isEqual 84 | } 85 | -------------------------------------------------------------------------------- /pkg/set/string_set_test.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_StringSlice_ToSlice(t *testing.T) { 10 | s := NewStringSet() 11 | s.Add("one") 12 | s.Add("two") 13 | s.Add("three") 14 | s.Add("two") 15 | assert.Equal(t, []string{"one", "two", "three"}, s.ToSlice()) 16 | } 17 | 18 | func Test_StringSlice_Remove(t *testing.T) { 19 | s := NewStringSet() 20 | s.Add("one") 21 | s.Add("two") 22 | s.Add("three") 23 | s.Remove("two") 24 | assert.Equal(t, []string{"one", "three"}, s.ToSlice()) 25 | assert.False(t, s.Contains("two")) 26 | assert.Equal(t, 2, s.Len()) 27 | } 28 | -------------------------------------------------------------------------------- /readme/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pullpo-io/cli/0567c8540f11d780406fb2e5eebf923b5e8d9e47/readme/banner.png -------------------------------------------------------------------------------- /readme/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pullpo-io/cli/0567c8540f11d780406fb2e5eebf923b5e8d9e47/readme/demo.gif -------------------------------------------------------------------------------- /readme/install-pullpo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pullpo-io/cli/0567c8540f11d780406fb2e5eebf923b5e8d9e47/readme/install-pullpo.gif -------------------------------------------------------------------------------- /script/build.bat: -------------------------------------------------------------------------------- 1 | go run script\build.go %* 2 | -------------------------------------------------------------------------------- /script/createrepo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | mkdir createrepo 4 | cat > createrepo/Dockerfile << EOF 5 | FROM fedora:32 6 | RUN yum install -y createrepo_c 7 | ENTRYPOINT ["createrepo", "/packages"] 8 | EOF 9 | 10 | docker build -t createrepo createrepo/ 11 | docker run --rm --volume "$PWD/dist":/packages createrepo 12 | rm -rf createrepo 13 | -------------------------------------------------------------------------------- /script/label-assets: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [ $# -eq 0 ]; then 5 | echo "usage: script/label-assets dist/pullpo_*" >&2 6 | exit 1 7 | fi 8 | 9 | for asset; do 10 | label="$(basename "$asset")" 11 | label="${label%.*}" 12 | label="${label%.tar}" 13 | label="Pullpo CLI $(tr '_' ' ' <<<"${label#pullpo_}")" 14 | case "$asset" in 15 | *.msi ) label="${label} installer" ;; 16 | *.deb ) label="${label} deb" ;; 17 | *.rpm ) label="${label} RPM" ;; 18 | esac 19 | printf '"%s#%s"\n' "$asset" "$label" 20 | done -------------------------------------------------------------------------------- /script/nolint-insert: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Usage: script/nolint-insert 3 | # script/nolint-insert 'nolint:staticcheck // ' 4 | set -e 5 | 6 | insert-line() { 7 | local n=$'\n' 8 | sed -i.bak "${2}i\\${n}${3}${n}" "$1" 9 | rm "$1.bak" 10 | } 11 | 12 | reverse() { 13 | awk '{a[i++]=$0} END {for (j=i-1; j>=0;) print a[j--] }' 14 | } 15 | 16 | comment="${1}" 17 | 18 | golangci-lint run --out-format json | jq -r '.Issues[] | [.Pos.Filename, .Pos.Line, .FromLinter, .Text] | @tsv' | reverse | while IFS=$'\t' read -r filename line linter text; do 19 | directive="nolint:${linter} // $text" 20 | [ -z "$comment" ] || directive="$comment" 21 | insert-line "$filename" "$line" "//${directive}" 22 | done 23 | 24 | go fmt ./... 25 | -------------------------------------------------------------------------------- /script/override.ubuntu: -------------------------------------------------------------------------------- 1 | pullpo Priority optional 2 | pullpo Section Development 3 | -------------------------------------------------------------------------------- /script/rpmmacros: -------------------------------------------------------------------------------- 1 | %_gpg_name GitHub CLI 2 | -------------------------------------------------------------------------------- /script/sign: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # usage: script/sign 3 | # 4 | # Signs macOS binaries using codesign, notarizes macOS zip archives using notarytool, and signs 5 | # Windows EXE and MSI files using osslsigncode. 6 | # 7 | set -e 8 | 9 | sign_windows() { 10 | if [ -z "$CERT_FILE" ]; then 11 | echo "skipping Windows code-signing; CERT_FILE not set" >&2 12 | return 0 13 | fi 14 | 15 | if [ ! -f "$CERT_FILE" ]; then 16 | echo "error Windows code-signing; file '$CERT_FILE' not found" >&2 17 | return 1 18 | fi 19 | 20 | if [ -z "$CERT_PASSWORD" ]; then 21 | echo "error Windows code-signing; no value for CERT_PASSWORD" >&2 22 | return 1 23 | fi 24 | 25 | osslsigncode sign -n "GitHub CLI" -t http://timestamp.digicert.com \ 26 | -pkcs12 "$CERT_FILE" -readpass <(printf "%s" "$CERT_PASSWORD") -h sha256 \ 27 | -in "$1" -out "$1"~ 28 | 29 | mv "$1"~ "$1" 30 | } 31 | 32 | sign_macos() { 33 | if [ -z "$APPLE_DEVELOPER_ID" ]; then 34 | echo "skipping macOS code-signing; APPLE_DEVELOPER_ID not set" >&2 35 | return 0 36 | fi 37 | 38 | if [[ $1 == *.zip ]]; then 39 | xcrun notarytool submit "$1" --apple-id "${APPLE_ID?}" --team-id "${APPLE_DEVELOPER_ID?}" --password "${APPLE_ID_PASSWORD?}" 40 | else 41 | codesign --timestamp --options=runtime -s "${APPLE_DEVELOPER_ID?}" -v "$1" 42 | fi 43 | } 44 | 45 | if [ $# -eq 0 ]; then 46 | echo "usage: script/sign " >&2 47 | exit 1 48 | fi 49 | 50 | platform="$(uname -s)" 51 | 52 | for input_file; do 53 | case "$input_file" in 54 | *.exe | *.msi ) 55 | sign_windows "$input_file" 56 | ;; 57 | * ) 58 | if [ "$platform" = "Darwin" ]; then 59 | sign_macos "$input_file" 60 | else 61 | printf "warning: don't know how to sign %s on %s\n" "$1", "$platform" >&2 62 | fi 63 | ;; 64 | esac 65 | done -------------------------------------------------------------------------------- /script/sign.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | if "%CERT_FILE%" == "" ( 4 | echo skipping Windows code-signing; CERT_FILE not set 5 | exit /b 6 | ) 7 | 8 | .\script\signtool sign /d "GitHub CLI" /f "%CERT_FILE%" /p "%CERT_PASSWORD%" /fd sha256 /tr http://timestamp.digicert.com /v "%1" -------------------------------------------------------------------------------- /script/signtool.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pullpo-io/cli/0567c8540f11d780406fb2e5eebf923b5e8d9e47/script/signtool.exe -------------------------------------------------------------------------------- /test/helpers.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | ) 7 | 8 | // TODO copypasta from command package 9 | type CmdOut struct { 10 | OutBuf *bytes.Buffer 11 | ErrBuf *bytes.Buffer 12 | BrowsedURL string 13 | } 14 | 15 | func (c CmdOut) String() string { 16 | return c.OutBuf.String() 17 | } 18 | 19 | func (c CmdOut) Stderr() string { 20 | return c.ErrBuf.String() 21 | } 22 | 23 | // OutputStub implements a simple utils.Runnable 24 | type OutputStub struct { 25 | Out []byte 26 | Error error 27 | } 28 | 29 | func (s OutputStub) Output() ([]byte, error) { 30 | if s.Error != nil { 31 | return s.Out, s.Error 32 | } 33 | return s.Out, nil 34 | } 35 | 36 | func (s OutputStub) Run() error { 37 | if s.Error != nil { 38 | return s.Error 39 | } 40 | return nil 41 | } 42 | 43 | type T interface { 44 | Helper() 45 | Errorf(string, ...interface{}) 46 | } 47 | 48 | // Deprecated: prefer exact matches for command output 49 | func ExpectLines(t T, output string, lines ...string) { 50 | t.Helper() 51 | var r *regexp.Regexp 52 | for _, l := range lines { 53 | r = regexp.MustCompile(l) 54 | if !r.MatchString(output) { 55 | t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) 56 | return 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "golang.org/x/term" 8 | ) 9 | 10 | func IsDebugEnabled() (bool, string) { 11 | debugValue, isDebugSet := os.LookupEnv("GH_DEBUG") 12 | legacyDebugValue := os.Getenv("DEBUG") 13 | 14 | if !isDebugSet { 15 | switch legacyDebugValue { 16 | case "true", "1", "yes", "api": 17 | return true, legacyDebugValue 18 | default: 19 | return false, legacyDebugValue 20 | } 21 | } 22 | 23 | switch debugValue { 24 | case "false", "0", "no", "": 25 | return false, debugValue 26 | default: 27 | return true, debugValue 28 | } 29 | } 30 | 31 | var TerminalSize = func(w interface{}) (int, int, error) { 32 | if f, isFile := w.(*os.File); isFile { 33 | return term.GetSize(int(f.Fd())) 34 | } 35 | 36 | return 0, 0, fmt.Errorf("%v is not a file", w) 37 | } 38 | --------------------------------------------------------------------------------