From c5f674111ab7a61418829c66b240573400d46ae4 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 18 Oct 2026 20:56:33 +0000 Subject: [PATCH] =?UTF-8?q?revert:=20tea=201.0=20rewrite=20(#1122=E2=80=93?= =?UTF-8?q?#1126)=20(#1146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts #1126, #1125, #1124, #1123 and #1122. `go.mod` keeps the Go 1.26 line with toolchain `go1.26.9` (instead of the original `go1.26.6`) so the security fix from #1131 isn't lost. reopens #69 reopens #141 reopens #162 reopens #172 reopens #197 reopens #232 reopens #240 reopens #246 reopens #323 reopens #325 reopens #326 reopens #328 reopens #344 reopens #351 reopens #373 reopens #403 reopens #406 reopens #417 reopens #430 reopens #457 reopens #480 reopens #482 reopens #489 reopens #490 reopens #498 reopens #500 reopens #504 reopens #510 reopens #516 reopens #523 reopens #556 reopens #560 reopens #569 reopens #571 reopens #602 reopens #632 reopens #633 reopens #643 reopens #666 reopens #670 reopens #672 reopens #677 reopens #683 reopens #697 reopens #726 reopens #737 reopens #770 reopens #779 reopens #804 reopens #817 reopens #831 reopens #833 reopens #834 reopens #908 reopens #966 reopens #1023 reopens #1029 reopens #1032 reopens #1050 reopens #1052 reopens #1069 reopens #1073 reopens #1087 reopens #1091 reopens #1094 reopens #1110 reopens #1115 reopens #1116 reopens #1118 Reviewed-on: https://gitea.com/gitea/tea/pulls/1146 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com> --- .gitea/workflows/release-nightly.yml | 4 + .gitea/workflows/release-tag.yml | 4 + .gitea/workflows/test-pr.yml | 14 +- .gitignore | 7 - .golangci.yml | 1 - .goreleaser.yaml | 10 +- CONTRIBUTING.md | 87 +- Makefile | 68 +- README.md | 127 +- cmd/actions.go | 47 + cmd/actions/runs.go | 31 + cmd/actions/runs/delete.go | 71 + cmd/actions/runs/list.go | 148 + cmd/actions/runs/list_test.go | 111 + cmd/actions/runs/logs.go | 175 ++ cmd/actions/runs/view.go | 82 + cmd/actions/secrets.go | 30 + cmd/actions/secrets/create.go | 74 + cmd/actions/secrets/create_test.go | 56 + cmd/actions/secrets/delete.go | 66 + cmd/actions/secrets/delete_test.go | 93 + cmd/actions/secrets/list.go | 49 + cmd/actions/secrets/list_test.go | 98 + cmd/actions/variables.go | 30 + cmd/actions/variables/delete.go | 66 + cmd/actions/variables/delete_test.go | 98 + cmd/actions/variables/list.go | 61 + cmd/actions/variables/list_test.go | 98 + cmd/actions/variables/set.go | 108 + cmd/actions/variables/set_test.go | 213 ++ cmd/actions/workflows.go | 32 + cmd/actions/workflows/disable.go | 65 + cmd/actions/workflows/dispatch.go | 174 ++ cmd/actions/workflows/enable.go | 48 + cmd/actions/workflows/list.go | 50 + cmd/actions/workflows/view.go | 50 + cmd/admin.go | 63 + cmd/admin/users/create.go | 220 ++ cmd/admin/users/delete.go | 77 + cmd/admin/users/edit.go | 373 +++ cmd/admin/users/list.go | 56 + cmd/admin/users/shared.go | 32 + cmd/api.go | 408 +++ cmd/api_test.go | 632 +++++ cmd/attachments.go | 28 + cmd/attachments/create.go | 71 + cmd/attachments/delete.go | 97 + cmd/attachments/list.go | 61 + cmd/branches.go | 39 + cmd/branches/list.go | 76 + cmd/branches/protect.go | 107 + cmd/branches/rename.go | 78 + cmd/branches/rename_test.go | 46 + cmd/categories.go | 11 + cmd/clone.go | 101 + cmd/cmd.go | 111 + cmd/comment.go | 31 + cmd/comments/add.go | 96 + cmd/comments/body.go | 37 + cmd/comments/body_test.go | 101 + cmd/comments/delete.go | 55 + cmd/comments/edit.go | 105 + cmd/comments/list.go | 62 + cmd/detail_json.go | 93 + cmd/flags/body.go | 73 + cmd/flags/body_test.go | 161 ++ cmd/flags/csvflag.go | 53 + cmd/flags/generic.go | 190 ++ cmd/flags/generic_test.go | 152 ++ cmd/flags/issue_pr.go | 279 ++ cmd/issues.go | 174 ++ cmd/issues/close.go | 64 + cmd/issues/create.go | 95 + cmd/issues/create_test.go | 41 + cmd/issues/edit.go | 81 + cmd/issues/list.go | 119 + cmd/issues/list_test.go | 115 + cmd/issues/reopen.go | 27 + cmd/issues_test.go | 360 +++ cmd/labels.go | 42 + cmd/labels/create.go | 116 + cmd/labels/create_test.go | 51 + cmd/labels/delete.go | 58 + cmd/labels/list.go | 107 + cmd/labels/update.go | 79 + cmd/login.go | 57 + cmd/login/add.go | 171 ++ cmd/login/default.go | 38 + cmd/login/delete.go | 43 + cmd/login/edit.go | 45 + cmd/login/helper.go | 155 ++ cmd/login/list.go | 34 + cmd/login/oauth_refresh.go | 71 + cmd/login/status.go | 59 + cmd/logout.go | 20 + cmd/man.go | 64 + cmd/milestones.go | 60 + cmd/milestones/close.go | 33 + cmd/milestones/create.go | 89 + cmd/milestones/delete.go | 38 + cmd/milestones/issues.go | 198 ++ cmd/milestones/list.go | 74 + cmd/milestones/reopen.go | 73 + cmd/notifications.go | 28 + cmd/notifications/list.go | 110 + cmd/notifications/list_test.go | 55 + cmd/notifications/mark_as.go | 158 ++ cmd/open.go | 88 + cmd/organizations.go | 48 + cmd/organizations/create.go | 94 + cmd/organizations/delete.go | 49 + cmd/organizations/list.go | 47 + cmd/pulls.go | 198 ++ cmd/pulls/approve.go | 31 + cmd/pulls/checkout.go | 59 + cmd/pulls/clean.go | 56 + cmd/pulls/close.go | 26 + cmd/pulls/create.go | 166 ++ cmd/pulls/create_app_test.go | 48 + cmd/pulls/create_test.go | 45 + cmd/pulls/edit.go | 168 ++ cmd/pulls/list.go | 80 + cmd/pulls/merge.go | 72 + cmd/pulls/reject.go | 30 + cmd/pulls/reopen.go | 27 + cmd/pulls/reply.go | 29 + cmd/pulls/reply_test.go | 70 + cmd/pulls/resolve.go | 30 + cmd/pulls/review.go | 59 + cmd/pulls/review_comments.go | 63 + cmd/pulls/review_helpers.go | 128 + cmd/pulls/review_test.go | 61 + cmd/pulls/unresolve.go | 30 + cmd/releases.go | 31 + cmd/releases/create.go | 130 + cmd/releases/delete.go | 74 + cmd/releases/edit.go | 103 + cmd/releases/list.go | 49 + cmd/releases/utils.go | 36 + cmd/repos.go | 65 + cmd/repos/create.go | 160 ++ cmd/repos/create_from_template.go | 125 + cmd/repos/delete.go | 89 + cmd/repos/edit.go | 111 + cmd/repos/flags.go | 36 + cmd/repos/fork.go | 64 + cmd/repos/list.go | 187 ++ cmd/repos/migrate.go | 175 ++ cmd/repos/search.go | 134 + cmd/sshkeys.go | 33 + cmd/sshkeys/add.go | 75 + cmd/sshkeys/add_test.go | 33 + cmd/sshkeys/delete.go | 76 + cmd/sshkeys/list.go | 48 + cmd/times.go | 30 + cmd/times/add.go | 61 + cmd/times/delete.go | 54 + cmd/times/list.go | 141 + cmd/times/reset.go | 48 + cmd/webhooks.go | 92 + cmd/webhooks/create.go | 120 + cmd/webhooks/create_test.go | 365 +++ cmd/webhooks/delete.go | 86 + cmd/webhooks/delete_test.go | 443 +++ cmd/webhooks/list.go | 56 + cmd/webhooks/list_test.go | 331 +++ cmd/webhooks/update.go | 153 ++ cmd/webhooks/update_test.go | 503 ++++ cmd/whoami.go | 32 + cmd/wiki.go | 33 + cmd/wiki/read.go | 139 + cmd/wiki/read_test.go | 133 + cmd/wiki/write.go | 170 ++ cmd/wiki/write_test.go | 131 + cmd/wiki_test.go | 22 + contrib/autocomplete.ps1 | 9 + contrib/autocomplete.sh | 21 + contrib/autocomplete.zsh | 23 + demo.gif | Bin 0 -> 636222 bytes docs/CLI.md | 2404 +++++++++++++++++ docs/docs.go | 29 + docs/example-workflows.md | 136 +- docs/migration-1.0.md | 358 --- flake.nix | 2 +- go.mod | 60 +- go.sum | 103 +- internal/aliases/aliases.go | 154 -- internal/aliases/aliases_test.go | 65 - internal/app/action_jobs.go | 37 - internal/app/action_logs.go | 111 - internal/app/action_logs_test.go | 66 - internal/app/action_values.go | 156 -- internal/app/action_values_test.go | 34 - internal/app/actions.go | 289 -- internal/app/actions_integration_test.go | 78 - internal/app/actions_test.go | 90 - internal/app/admin_users.go | 210 -- internal/app/admin_users_test.go | 40 - internal/app/alias_completion_test.go | 32 - internal/app/api.go | 254 -- internal/app/api_integration_test.go | 68 - internal/app/api_test.go | 189 -- internal/app/app.go | 474 ---- internal/app/app_test.go | 150 - internal/app/args.go | 209 -- internal/app/args_test.go | 53 - internal/app/artifacts.go | 61 - internal/app/artifacts_test.go | 54 - internal/app/auth.go | 362 --- internal/app/auth_test.go | 522 ---- internal/app/branches.go | 142 - internal/app/branches_test.go | 45 - internal/app/browser.go | 14 - internal/app/browser_test.go | 70 - internal/app/checkout.go | 207 -- internal/app/checkout_test.go | 116 - internal/app/checks.go | 68 - internal/app/checks_test.go | 86 - internal/app/clone_test.go | 171 -- internal/app/comments.go | 140 - internal/app/comments_test.go | 34 - internal/app/config.go | 353 --- internal/app/config_test.go | 104 - internal/app/deadline.go | 29 - internal/app/deadline_test.go | 50 - internal/app/diff_review.go | 74 - internal/app/diff_review_test.go | 46 - internal/app/discussion_search.go | 89 - internal/app/discussion_search_test.go | 66 - internal/app/discussion_view.go | 237 -- internal/app/discussion_view_test.go | 143 - internal/app/doctor.go | 30 - internal/app/doctor_test.go | 35 - internal/app/download.go | 63 - internal/app/download_test.go | 49 - internal/app/editor.go | 128 - internal/app/editor_test.go | 310 --- internal/app/git.go | 38 - internal/app/git_credentials.go | 153 -- .../app/git_credentials_integration_test.go | 166 -- internal/app/git_credentials_test.go | 86 - internal/app/helpers_test.go | 293 -- internal/app/human_output.go | 142 - internal/app/human_output_test.go | 212 -- internal/app/integration_helpers_test.go | 292 -- internal/app/integration_test.go | 491 ---- internal/app/issue_attachments.go | 156 -- internal/app/issue_attachments_test.go | 77 - internal/app/issue_bulk_edit_test.go | 66 - internal/app/issue_create_form_test.go | 101 - internal/app/issue_create_test.go | 106 - internal/app/issue_edit.go | 179 -- internal/app/issue_edit_form_test.go | 108 - internal/app/issue_filters_test.go | 129 - internal/app/issue_inputs_test.go | 97 - internal/app/issues.go | 422 --- internal/app/label_export.go | 47 - internal/app/label_export_test.go | 88 - internal/app/label_options_test.go | 49 - internal/app/labelfile.go | 72 - internal/app/labelfile_test.go | 70 - internal/app/man.go | 69 - internal/app/man_test.go | 68 - internal/app/metadata.go | 360 --- internal/app/milestone_bulk_test.go | 46 - internal/app/milestone_create_form_test.go | 69 - internal/app/milestone_description_test.go | 67 - internal/app/milestone_issues.go | 106 - internal/app/milestone_names_test.go | 117 - internal/app/milestone_state.go | 66 - internal/app/mutations.go | 475 ---- internal/app/notifications.go | 191 -- internal/app/notifications_test.go | 35 - internal/app/organizations.go | 166 -- internal/app/output_fields_test.go | 117 - internal/app/pull_clean.go | 168 -- internal/app/pull_clean_test.go | 125 - internal/app/pull_create.go | 342 --- internal/app/pull_create_form_test.go | 131 - internal/app/pull_create_test.go | 301 --- internal/app/pull_edit.go | 153 -- internal/app/pull_edit_test.go | 69 - internal/app/pull_integration_test.go | 355 --- internal/app/pull_merge_form_test.go | 74 - internal/app/pull_merge_title_test.go | 121 - internal/app/pull_review.go | 336 --- internal/app/pull_review_form_test.go | 147 - internal/app/pull_review_lifecycle.go | 144 - internal/app/pull_review_requests.go | 46 - internal/app/pull_review_requests_test.go | 32 - internal/app/pull_review_test.go | 100 - internal/app/pull_view_current_test.go | 33 - internal/app/pulls.go | 116 - internal/app/pulls_test.go | 23 - internal/app/release_assets.go | 187 -- internal/app/release_assets_test.go | 69 - internal/app/releases.go | 223 -- internal/app/releases_test.go | 24 - internal/app/repos.go | 101 - internal/app/repository_argument_test.go | 42 - internal/app/repository_create.go | 156 -- internal/app/repository_create_test.go | 64 - internal/app/repository_edit_test.go | 28 - internal/app/repository_migrate.go | 159 -- internal/app/repository_sync.go | 69 - internal/app/search.go | 69 - internal/app/ssh_keys.go | 101 - internal/app/ssh_keys_test.go | 22 - internal/app/status.go | 81 - internal/app/status_test.go | 77 - internal/app/target.go | 296 -- internal/app/target_test.go | 252 -- internal/app/teams.go | 219 -- internal/app/time.go | 124 - internal/app/time_test.go | 31 - internal/app/validation_test.go | 345 --- internal/app/webhooks.go | 235 -- internal/app/wiki.go | 159 -- internal/auth/credentials.go | 152 -- internal/auth/gitcredential.go | 172 -- internal/auth/gitcredential_test.go | 54 - internal/auth/httpsign.go | 205 -- internal/auth/httpsign_agent.go | 30 - internal/auth/httpsign_agent_test.go | 53 - internal/auth/httpsign_agent_windows.go | 21 - internal/auth/httpsign_test.go | 123 - internal/auth/manager.go | 110 - internal/auth/manager_test.go | 179 -- internal/auth/oauth.go | 210 -- internal/auth/oauth_test.go | 138 - internal/clierr/errors.go | 53 - internal/clierr/errors_test.go | 33 - internal/git/agit.go | 68 - internal/git/agit_test.go | 66 - internal/git/checkout.go | 156 -- internal/git/checkout_test.go | 172 -- internal/git/clean.go | 208 -- internal/git/clean_test.go | 139 - internal/git/credentials.go | 108 - internal/git/credentials_test.go | 64 - internal/git/discover.go | 63 - internal/git/discover_test.go | 52 - internal/git/runner.go | 162 -- internal/git/runner_test.go | 86 - internal/git/ssh.go | 24 - internal/git/sync.go | 94 - internal/git/sync_test.go | 132 - internal/services/action_logs.go | 168 -- internal/services/action_logs_test.go | 157 -- internal/services/action_values.go | 221 -- internal/services/action_values_test.go | 141 - internal/services/actions.go | 304 --- internal/services/actions_test.go | 239 -- internal/services/admin_users.go | 152 -- internal/services/admin_users_test.go | 110 - internal/services/api.go | 155 -- internal/services/api_test.go | 41 - internal/services/artifacts.go | 55 - internal/services/artifacts_test.go | 48 - internal/services/attachments.go | 83 - internal/services/branches.go | 93 - internal/services/branches_test.go | 72 - internal/services/checks.go | 131 - internal/services/checks_test.go | 94 - internal/services/comments.go | 82 - internal/services/comments_test.go | 63 - internal/services/convert.go | 66 - internal/services/convert_test.go | 69 - internal/services/errors.go | 9 - internal/services/helpers.go | 97 - internal/services/helpers_test.go | 40 - internal/services/identity.go | 49 - internal/services/identity_test.go | 81 - internal/services/issue_attachments.go | 75 - internal/services/issue_create_selectables.go | 52 - .../services/issue_create_selectables_test.go | 126 - internal/services/issue_dependencies.go | 69 - internal/services/issue_labels_test.go | 108 - internal/services/issues.go | 140 - internal/services/issues_test.go | 90 - internal/services/label_selection.go | 102 - internal/services/label_selection_test.go | 93 - internal/services/metadata.go | 388 --- internal/services/metadata_test.go | 77 - internal/services/milestone_deadline_test.go | 36 - internal/services/milestone_selection.go | 35 - internal/services/milestone_selection_test.go | 43 - internal/services/notifications.go | 176 -- internal/services/notifications_test.go | 121 - internal/services/organizations.go | 160 -- internal/services/organizations_test.go | 130 - internal/services/pagination_test.go | 92 - internal/services/pull_create_selectables.go | 42 - internal/services/pulls.go | 378 --- internal/services/pulls_test.go | 329 --- internal/services/raw.go | 73 - internal/services/raw_test.go | 27 - internal/services/releases.go | 157 -- internal/services/releases_test.go | 236 -- internal/services/repositories.go | 155 -- internal/services/repositories_test.go | 68 - internal/services/repository_search.go | 130 - internal/services/repository_search_test.go | 142 - internal/services/repository_setup.go | 97 - internal/services/repository_setup_test.go | 91 - internal/services/review_requests.go | 112 - internal/services/review_requests_test.go | 73 - internal/services/reviews.go | 239 -- internal/services/reviews_test.go | 205 -- internal/services/sdk.go | 166 -- internal/services/sdk_test.go | 185 -- internal/services/search.go | 132 - internal/services/search_test.go | 71 - internal/services/ssh_keys.go | 68 - internal/services/ssh_keys_test.go | 63 - internal/services/status.go | 120 - internal/services/status_test.go | 80 - internal/services/teams.go | 174 -- internal/services/teams_test.go | 92 - internal/services/time.go | 106 - internal/services/time_test.go | 66 - internal/services/webhooks.go | 212 -- internal/services/webhooks_test.go | 150 - internal/services/wiki.go | 156 -- internal/services/wiki_test.go | 119 - internal/settings/remote.go | 139 - internal/settings/remote_test.go | 74 - internal/settings/ssh.go | 32 - internal/settings/ssh_test.go | 34 - internal/settings/store.go | 397 --- internal/settings/store_test.go | 137 - internal/terminal/browser.go | 59 - internal/terminal/browser_test.go | 37 - internal/terminal/color.go | 62 - internal/terminal/color_test.go | 42 - internal/terminal/editor.go | 125 - internal/terminal/editor_test.go | 129 - internal/terminal/issue_edit_form.go | 89 - internal/terminal/issue_edit_form_test.go | 45 - internal/terminal/issue_form.go | 175 -- internal/terminal/issue_form_test.go | 36 - internal/terminal/merge_form.go | 39 - internal/terminal/milestone_form.go | 37 - internal/terminal/milestone_form_test.go | 18 - internal/terminal/output.go | 124 - internal/terminal/output_test.go | 24 - internal/terminal/pager.go | 69 - internal/terminal/pager_test.go | 88 - internal/terminal/pull_form.go | 120 - internal/terminal/pull_form_test.go | 105 - internal/terminal/review_form.go | 94 - internal/terminal/review_form_test.go | 34 - internal/terminal/streams.go | 130 - internal/terminal/streams_test.go | 63 - internal/terminal/table.go | 119 - internal/terminal/table_test.go | 71 - internal/transport/api.go | 140 - internal/transport/api_test.go | 203 -- internal/transport/client.go | 227 -- internal/transport/client_test.go | 166 -- internal/transport/debug.go | 58 - internal/transport/download.go | 83 - internal/transport/download_test.go | 158 -- main.go | 44 +- main_test.go | 76 - modules/api/client.go | 104 + modules/auth/oauth.go | 461 ++++ modules/auth/oauth_test.go | 129 + modules/config/config.go | 157 ++ modules/config/credstore.go | 72 + modules/config/lock.go | 85 + modules/config/lock_test.go | 196 ++ modules/config/login.go | 549 ++++ modules/config/login_test.go | 143 + modules/config/testtools.go | 11 + modules/context/context.go | 190 ++ modules/context/context_login.go | 51 + modules/context/context_prompt_test.go | 52 + modules/context/context_remote.go | 58 + modules/context/context_repo.go | 83 + modules/context/context_require.go | 41 + modules/context/context_require_test.go | 114 + modules/context/context_test.go | 67 + modules/debug/debug.go | 44 + {internal => modules}/filelock/filelock.go | 0 .../filelock/filelock_test.go | 51 +- .../filelock/filelock_unix.go | 0 .../filelock/filelock_windows.go | 0 modules/git/auth.go | 70 + modules/git/backend.go | 103 + modules/git/backend_test.go | 74 + modules/git/branch.go | 226 ++ modules/git/cli_backend.go | 402 +++ modules/git/cli_backend_test.go | 40 + modules/git/network.go | 28 + modules/git/remote.go | 68 + modules/git/repo.go | 94 + modules/git/repo_cli_test.go | 136 + modules/git/types.go | 163 ++ modules/git/url.go | 58 + modules/git/url_test.go | 55 + modules/httputil/httputil.go | 46 + modules/httputil/transport.go | 57 + modules/httputil/transport_stall_test.go | 91 + modules/interact/comments.go | 82 + modules/interact/issue_create.go | 218 ++ modules/interact/issue_edit.go | 193 ++ modules/interact/login.go | 328 +++ modules/interact/login_test.go | 69 + modules/interact/milestone_create.go | 70 + modules/interact/print.go | 21 + modules/interact/prompts.go | 201 ++ modules/interact/pull_create.go | 152 ++ modules/interact/pull_merge.go | 108 + modules/interact/pull_review.go | 100 + modules/print/actions.go | 76 + modules/print/actions_runs.go | 215 ++ modules/print/actions_runs_test.go | 256 ++ modules/print/actions_test.go | 215 ++ modules/print/attachment.go | 34 + modules/print/branch.go | 85 + modules/print/branch_test.go | 33 + modules/print/comment.go | 99 + modules/print/formatters.go | 111 + modules/print/issue.go | 156 ++ modules/print/label.go | 37 + modules/print/login.go | 54 + modules/print/login_status.go | 145 + modules/print/login_status_test.go | 38 + modules/print/markdown.go | 62 + modules/print/milestone.go | 87 + modules/print/notification.go | 82 + modules/print/organization.go | 50 + modules/print/pull.go | 290 ++ modules/print/pull_review_comment.go | 73 + modules/print/pull_test.go | 200 ++ modules/print/release.go | 37 + modules/print/repo.go | 141 + modules/print/repo_test.go | 33 + modules/print/sshkey.go | 44 + modules/print/table.go | 235 ++ modules/print/table_test.go | 116 + modules/print/times.go | 63 + modules/print/user.go | 134 + modules/print/webhook.go | 90 + modules/print/webhook_test.go | 384 +++ modules/print/wiki.go | 209 ++ modules/print/wiki_test.go | 110 + modules/task/assignees.go | 49 + modules/task/issue_create.go | 28 + modules/task/issue_edit.go | 120 + modules/task/labels.go | 112 + modules/task/labels_export.go | 27 + modules/task/login_create.go | 258 ++ modules/task/login_create_test.go | 89 + modules/task/login_httpsign.go | 93 + modules/task/login_ssh.go | 92 + modules/task/login_status.go | 67 + modules/task/login_status_test.go | 75 + modules/task/milestone_create.go | 36 + modules/task/pull_checkout.go | 158 ++ modules/task/pull_clean.go | 105 + modules/task/pull_create.go | 219 ++ modules/task/pull_create_test.go | 27 + modules/task/pull_edit.go | 87 + modules/task/pull_merge.go | 54 + modules/task/pull_merge_test.go | 146 + modules/task/pull_review.go | 130 + modules/task/pull_review_comment.go | 85 + modules/task/repo_clone.go | 75 + modules/task/wiki.go | 61 + modules/task/wiki_test.go | 83 + modules/theme/background.go | 31 + modules/theme/theme.go | 29 + modules/theme/theme_test.go | 64 + {internal/services => modules/utils}/draft.go | 8 +- .../services => modules/utils}/draft_test.go | 6 +- modules/utils/input.go | 87 + modules/utils/parse.go | 49 + modules/utils/parse_test.go | 47 + modules/utils/path.go | 72 + modules/utils/utils.go | 42 + modules/utils/validate.go | 38 + {internal => modules}/version/version.go | 15 +- scripts/integration-tokens.py | 45 - snap/snapcraft.yaml | 102 +- tests/README.md | 10 + tests/integration/admin_users_test.go | 139 + tests/integration/cmd_labels_test.go | 162 ++ tests/integration/comments_test.go | 117 + tests/integration/config_lock_unix_test.go | 80 + tests/integration/context_init_test.go | 79 + tests/integration/git_branch_test.go | 98 + tests/integration/git_repo_test.go | 57 + tests/integration/helpers_test.go | 106 + tests/integration/pulls_reply_test.go | 114 + tests/integration/repos_create_test.go | 84 + tests/integration/sshkeys_test.go | 115 + tests/integration/task_issue_test.go | 131 + tests/integration/task_labels_test.go | 124 + tests/integration/task_pull_test.go | 156 ++ tests/integration/wiki_test.go | 170 ++ tools/cli-docs/main.go | 109 - tools/cli-docs/main_test.go | 68 - 604 files changed, 33711 insertions(+), 35545 deletions(-) create mode 100644 cmd/actions.go create mode 100644 cmd/actions/runs.go create mode 100644 cmd/actions/runs/delete.go create mode 100644 cmd/actions/runs/list.go create mode 100644 cmd/actions/runs/list_test.go create mode 100644 cmd/actions/runs/logs.go create mode 100644 cmd/actions/runs/view.go create mode 100644 cmd/actions/secrets.go create mode 100644 cmd/actions/secrets/create.go create mode 100644 cmd/actions/secrets/create_test.go create mode 100644 cmd/actions/secrets/delete.go create mode 100644 cmd/actions/secrets/delete_test.go create mode 100644 cmd/actions/secrets/list.go create mode 100644 cmd/actions/secrets/list_test.go create mode 100644 cmd/actions/variables.go create mode 100644 cmd/actions/variables/delete.go create mode 100644 cmd/actions/variables/delete_test.go create mode 100644 cmd/actions/variables/list.go create mode 100644 cmd/actions/variables/list_test.go create mode 100644 cmd/actions/variables/set.go create mode 100644 cmd/actions/variables/set_test.go create mode 100644 cmd/actions/workflows.go create mode 100644 cmd/actions/workflows/disable.go create mode 100644 cmd/actions/workflows/dispatch.go create mode 100644 cmd/actions/workflows/enable.go create mode 100644 cmd/actions/workflows/list.go create mode 100644 cmd/actions/workflows/view.go create mode 100644 cmd/admin.go create mode 100644 cmd/admin/users/create.go create mode 100644 cmd/admin/users/delete.go create mode 100644 cmd/admin/users/edit.go create mode 100644 cmd/admin/users/list.go create mode 100644 cmd/admin/users/shared.go create mode 100644 cmd/api.go create mode 100644 cmd/api_test.go create mode 100644 cmd/attachments.go create mode 100644 cmd/attachments/create.go create mode 100644 cmd/attachments/delete.go create mode 100644 cmd/attachments/list.go create mode 100644 cmd/branches.go create mode 100644 cmd/branches/list.go create mode 100644 cmd/branches/protect.go create mode 100644 cmd/branches/rename.go create mode 100644 cmd/branches/rename_test.go create mode 100644 cmd/categories.go create mode 100644 cmd/clone.go create mode 100644 cmd/cmd.go create mode 100644 cmd/comment.go create mode 100644 cmd/comments/add.go create mode 100644 cmd/comments/body.go create mode 100644 cmd/comments/body_test.go create mode 100644 cmd/comments/delete.go create mode 100644 cmd/comments/edit.go create mode 100644 cmd/comments/list.go create mode 100644 cmd/detail_json.go create mode 100644 cmd/flags/body.go create mode 100644 cmd/flags/body_test.go create mode 100644 cmd/flags/csvflag.go create mode 100644 cmd/flags/generic.go create mode 100644 cmd/flags/generic_test.go create mode 100644 cmd/flags/issue_pr.go create mode 100644 cmd/issues.go create mode 100644 cmd/issues/close.go create mode 100644 cmd/issues/create.go create mode 100644 cmd/issues/create_test.go create mode 100644 cmd/issues/edit.go create mode 100644 cmd/issues/list.go create mode 100644 cmd/issues/list_test.go create mode 100644 cmd/issues/reopen.go create mode 100644 cmd/issues_test.go create mode 100644 cmd/labels.go create mode 100644 cmd/labels/create.go create mode 100644 cmd/labels/create_test.go create mode 100644 cmd/labels/delete.go create mode 100644 cmd/labels/list.go create mode 100644 cmd/labels/update.go create mode 100644 cmd/login.go create mode 100644 cmd/login/add.go create mode 100644 cmd/login/default.go create mode 100644 cmd/login/delete.go create mode 100644 cmd/login/edit.go create mode 100644 cmd/login/helper.go create mode 100644 cmd/login/list.go create mode 100644 cmd/login/oauth_refresh.go create mode 100644 cmd/login/status.go create mode 100644 cmd/logout.go create mode 100644 cmd/man.go create mode 100644 cmd/milestones.go create mode 100644 cmd/milestones/close.go create mode 100644 cmd/milestones/create.go create mode 100644 cmd/milestones/delete.go create mode 100644 cmd/milestones/issues.go create mode 100644 cmd/milestones/list.go create mode 100644 cmd/milestones/reopen.go create mode 100644 cmd/notifications.go create mode 100644 cmd/notifications/list.go create mode 100644 cmd/notifications/list_test.go create mode 100644 cmd/notifications/mark_as.go create mode 100644 cmd/open.go create mode 100644 cmd/organizations.go create mode 100644 cmd/organizations/create.go create mode 100644 cmd/organizations/delete.go create mode 100644 cmd/organizations/list.go create mode 100644 cmd/pulls.go create mode 100644 cmd/pulls/approve.go create mode 100644 cmd/pulls/checkout.go create mode 100644 cmd/pulls/clean.go create mode 100644 cmd/pulls/close.go create mode 100644 cmd/pulls/create.go create mode 100644 cmd/pulls/create_app_test.go create mode 100644 cmd/pulls/create_test.go create mode 100644 cmd/pulls/edit.go create mode 100644 cmd/pulls/list.go create mode 100644 cmd/pulls/merge.go create mode 100644 cmd/pulls/reject.go create mode 100644 cmd/pulls/reopen.go create mode 100644 cmd/pulls/reply.go create mode 100644 cmd/pulls/reply_test.go create mode 100644 cmd/pulls/resolve.go create mode 100644 cmd/pulls/review.go create mode 100644 cmd/pulls/review_comments.go create mode 100644 cmd/pulls/review_helpers.go create mode 100644 cmd/pulls/review_test.go create mode 100644 cmd/pulls/unresolve.go create mode 100644 cmd/releases.go create mode 100644 cmd/releases/create.go create mode 100644 cmd/releases/delete.go create mode 100644 cmd/releases/edit.go create mode 100644 cmd/releases/list.go create mode 100644 cmd/releases/utils.go create mode 100644 cmd/repos.go create mode 100644 cmd/repos/create.go create mode 100644 cmd/repos/create_from_template.go create mode 100644 cmd/repos/delete.go create mode 100644 cmd/repos/edit.go create mode 100644 cmd/repos/flags.go create mode 100644 cmd/repos/fork.go create mode 100644 cmd/repos/list.go create mode 100644 cmd/repos/migrate.go create mode 100644 cmd/repos/search.go create mode 100644 cmd/sshkeys.go create mode 100644 cmd/sshkeys/add.go create mode 100644 cmd/sshkeys/add_test.go create mode 100644 cmd/sshkeys/delete.go create mode 100644 cmd/sshkeys/list.go create mode 100644 cmd/times.go create mode 100644 cmd/times/add.go create mode 100644 cmd/times/delete.go create mode 100644 cmd/times/list.go create mode 100644 cmd/times/reset.go create mode 100644 cmd/webhooks.go create mode 100644 cmd/webhooks/create.go create mode 100644 cmd/webhooks/create_test.go create mode 100644 cmd/webhooks/delete.go create mode 100644 cmd/webhooks/delete_test.go create mode 100644 cmd/webhooks/list.go create mode 100644 cmd/webhooks/list_test.go create mode 100644 cmd/webhooks/update.go create mode 100644 cmd/webhooks/update_test.go create mode 100644 cmd/whoami.go create mode 100644 cmd/wiki.go create mode 100644 cmd/wiki/read.go create mode 100644 cmd/wiki/read_test.go create mode 100644 cmd/wiki/write.go create mode 100644 cmd/wiki/write_test.go create mode 100644 cmd/wiki_test.go create mode 100644 contrib/autocomplete.ps1 create mode 100644 contrib/autocomplete.sh create mode 100644 contrib/autocomplete.zsh create mode 100644 demo.gif create mode 100644 docs/CLI.md create mode 100644 docs/docs.go delete mode 100644 docs/migration-1.0.md delete mode 100644 internal/aliases/aliases.go delete mode 100644 internal/aliases/aliases_test.go delete mode 100644 internal/app/action_jobs.go delete mode 100644 internal/app/action_logs.go delete mode 100644 internal/app/action_logs_test.go delete mode 100644 internal/app/action_values.go delete mode 100644 internal/app/action_values_test.go delete mode 100644 internal/app/actions.go delete mode 100644 internal/app/actions_integration_test.go delete mode 100644 internal/app/actions_test.go delete mode 100644 internal/app/admin_users.go delete mode 100644 internal/app/admin_users_test.go delete mode 100644 internal/app/alias_completion_test.go delete mode 100644 internal/app/api.go delete mode 100644 internal/app/api_integration_test.go delete mode 100644 internal/app/api_test.go delete mode 100644 internal/app/app.go delete mode 100644 internal/app/app_test.go delete mode 100644 internal/app/args.go delete mode 100644 internal/app/args_test.go delete mode 100644 internal/app/artifacts.go delete mode 100644 internal/app/artifacts_test.go delete mode 100644 internal/app/auth.go delete mode 100644 internal/app/auth_test.go delete mode 100644 internal/app/branches.go delete mode 100644 internal/app/branches_test.go delete mode 100644 internal/app/browser.go delete mode 100644 internal/app/browser_test.go delete mode 100644 internal/app/checkout.go delete mode 100644 internal/app/checkout_test.go delete mode 100644 internal/app/checks.go delete mode 100644 internal/app/checks_test.go delete mode 100644 internal/app/clone_test.go delete mode 100644 internal/app/comments.go delete mode 100644 internal/app/comments_test.go delete mode 100644 internal/app/config.go delete mode 100644 internal/app/config_test.go delete mode 100644 internal/app/deadline.go delete mode 100644 internal/app/deadline_test.go delete mode 100644 internal/app/diff_review.go delete mode 100644 internal/app/diff_review_test.go delete mode 100644 internal/app/discussion_search.go delete mode 100644 internal/app/discussion_search_test.go delete mode 100644 internal/app/discussion_view.go delete mode 100644 internal/app/discussion_view_test.go delete mode 100644 internal/app/doctor.go delete mode 100644 internal/app/doctor_test.go delete mode 100644 internal/app/download.go delete mode 100644 internal/app/download_test.go delete mode 100644 internal/app/editor.go delete mode 100644 internal/app/editor_test.go delete mode 100644 internal/app/git.go delete mode 100644 internal/app/git_credentials.go delete mode 100644 internal/app/git_credentials_integration_test.go delete mode 100644 internal/app/git_credentials_test.go delete mode 100644 internal/app/helpers_test.go delete mode 100644 internal/app/human_output.go delete mode 100644 internal/app/human_output_test.go delete mode 100644 internal/app/integration_helpers_test.go delete mode 100644 internal/app/integration_test.go delete mode 100644 internal/app/issue_attachments.go delete mode 100644 internal/app/issue_attachments_test.go delete mode 100644 internal/app/issue_bulk_edit_test.go delete mode 100644 internal/app/issue_create_form_test.go delete mode 100644 internal/app/issue_create_test.go delete mode 100644 internal/app/issue_edit.go delete mode 100644 internal/app/issue_edit_form_test.go delete mode 100644 internal/app/issue_filters_test.go delete mode 100644 internal/app/issue_inputs_test.go delete mode 100644 internal/app/issues.go delete mode 100644 internal/app/label_export.go delete mode 100644 internal/app/label_export_test.go delete mode 100644 internal/app/label_options_test.go delete mode 100644 internal/app/labelfile.go delete mode 100644 internal/app/labelfile_test.go delete mode 100644 internal/app/man.go delete mode 100644 internal/app/man_test.go delete mode 100644 internal/app/metadata.go delete mode 100644 internal/app/milestone_bulk_test.go delete mode 100644 internal/app/milestone_create_form_test.go delete mode 100644 internal/app/milestone_description_test.go delete mode 100644 internal/app/milestone_issues.go delete mode 100644 internal/app/milestone_names_test.go delete mode 100644 internal/app/milestone_state.go delete mode 100644 internal/app/mutations.go delete mode 100644 internal/app/notifications.go delete mode 100644 internal/app/notifications_test.go delete mode 100644 internal/app/organizations.go delete mode 100644 internal/app/output_fields_test.go delete mode 100644 internal/app/pull_clean.go delete mode 100644 internal/app/pull_clean_test.go delete mode 100644 internal/app/pull_create.go delete mode 100644 internal/app/pull_create_form_test.go delete mode 100644 internal/app/pull_create_test.go delete mode 100644 internal/app/pull_edit.go delete mode 100644 internal/app/pull_edit_test.go delete mode 100644 internal/app/pull_integration_test.go delete mode 100644 internal/app/pull_merge_form_test.go delete mode 100644 internal/app/pull_merge_title_test.go delete mode 100644 internal/app/pull_review.go delete mode 100644 internal/app/pull_review_form_test.go delete mode 100644 internal/app/pull_review_lifecycle.go delete mode 100644 internal/app/pull_review_requests.go delete mode 100644 internal/app/pull_review_requests_test.go delete mode 100644 internal/app/pull_review_test.go delete mode 100644 internal/app/pull_view_current_test.go delete mode 100644 internal/app/pulls.go delete mode 100644 internal/app/pulls_test.go delete mode 100644 internal/app/release_assets.go delete mode 100644 internal/app/release_assets_test.go delete mode 100644 internal/app/releases.go delete mode 100644 internal/app/releases_test.go delete mode 100644 internal/app/repos.go delete mode 100644 internal/app/repository_argument_test.go delete mode 100644 internal/app/repository_create.go delete mode 100644 internal/app/repository_create_test.go delete mode 100644 internal/app/repository_edit_test.go delete mode 100644 internal/app/repository_migrate.go delete mode 100644 internal/app/repository_sync.go delete mode 100644 internal/app/search.go delete mode 100644 internal/app/ssh_keys.go delete mode 100644 internal/app/ssh_keys_test.go delete mode 100644 internal/app/status.go delete mode 100644 internal/app/status_test.go delete mode 100644 internal/app/target.go delete mode 100644 internal/app/target_test.go delete mode 100644 internal/app/teams.go delete mode 100644 internal/app/time.go delete mode 100644 internal/app/time_test.go delete mode 100644 internal/app/validation_test.go delete mode 100644 internal/app/webhooks.go delete mode 100644 internal/app/wiki.go delete mode 100644 internal/auth/credentials.go delete mode 100644 internal/auth/gitcredential.go delete mode 100644 internal/auth/gitcredential_test.go delete mode 100644 internal/auth/httpsign.go delete mode 100644 internal/auth/httpsign_agent.go delete mode 100644 internal/auth/httpsign_agent_test.go delete mode 100644 internal/auth/httpsign_agent_windows.go delete mode 100644 internal/auth/httpsign_test.go delete mode 100644 internal/auth/manager.go delete mode 100644 internal/auth/manager_test.go delete mode 100644 internal/auth/oauth.go delete mode 100644 internal/auth/oauth_test.go delete mode 100644 internal/clierr/errors.go delete mode 100644 internal/clierr/errors_test.go delete mode 100644 internal/git/agit.go delete mode 100644 internal/git/agit_test.go delete mode 100644 internal/git/checkout.go delete mode 100644 internal/git/checkout_test.go delete mode 100644 internal/git/clean.go delete mode 100644 internal/git/clean_test.go delete mode 100644 internal/git/credentials.go delete mode 100644 internal/git/credentials_test.go delete mode 100644 internal/git/discover.go delete mode 100644 internal/git/discover_test.go delete mode 100644 internal/git/runner.go delete mode 100644 internal/git/runner_test.go delete mode 100644 internal/git/ssh.go delete mode 100644 internal/git/sync.go delete mode 100644 internal/git/sync_test.go delete mode 100644 internal/services/action_logs.go delete mode 100644 internal/services/action_logs_test.go delete mode 100644 internal/services/action_values.go delete mode 100644 internal/services/action_values_test.go delete mode 100644 internal/services/actions.go delete mode 100644 internal/services/actions_test.go delete mode 100644 internal/services/admin_users.go delete mode 100644 internal/services/admin_users_test.go delete mode 100644 internal/services/api.go delete mode 100644 internal/services/api_test.go delete mode 100644 internal/services/artifacts.go delete mode 100644 internal/services/artifacts_test.go delete mode 100644 internal/services/attachments.go delete mode 100644 internal/services/branches.go delete mode 100644 internal/services/branches_test.go delete mode 100644 internal/services/checks.go delete mode 100644 internal/services/checks_test.go delete mode 100644 internal/services/comments.go delete mode 100644 internal/services/comments_test.go delete mode 100644 internal/services/convert.go delete mode 100644 internal/services/convert_test.go delete mode 100644 internal/services/errors.go delete mode 100644 internal/services/helpers.go delete mode 100644 internal/services/helpers_test.go delete mode 100644 internal/services/identity.go delete mode 100644 internal/services/identity_test.go delete mode 100644 internal/services/issue_attachments.go delete mode 100644 internal/services/issue_create_selectables.go delete mode 100644 internal/services/issue_create_selectables_test.go delete mode 100644 internal/services/issue_dependencies.go delete mode 100644 internal/services/issue_labels_test.go delete mode 100644 internal/services/issues.go delete mode 100644 internal/services/issues_test.go delete mode 100644 internal/services/label_selection.go delete mode 100644 internal/services/label_selection_test.go delete mode 100644 internal/services/metadata.go delete mode 100644 internal/services/metadata_test.go delete mode 100644 internal/services/milestone_deadline_test.go delete mode 100644 internal/services/milestone_selection.go delete mode 100644 internal/services/milestone_selection_test.go delete mode 100644 internal/services/notifications.go delete mode 100644 internal/services/notifications_test.go delete mode 100644 internal/services/organizations.go delete mode 100644 internal/services/organizations_test.go delete mode 100644 internal/services/pagination_test.go delete mode 100644 internal/services/pull_create_selectables.go delete mode 100644 internal/services/pulls.go delete mode 100644 internal/services/pulls_test.go delete mode 100644 internal/services/raw.go delete mode 100644 internal/services/raw_test.go delete mode 100644 internal/services/releases.go delete mode 100644 internal/services/releases_test.go delete mode 100644 internal/services/repositories.go delete mode 100644 internal/services/repositories_test.go delete mode 100644 internal/services/repository_search.go delete mode 100644 internal/services/repository_search_test.go delete mode 100644 internal/services/repository_setup.go delete mode 100644 internal/services/repository_setup_test.go delete mode 100644 internal/services/review_requests.go delete mode 100644 internal/services/review_requests_test.go delete mode 100644 internal/services/reviews.go delete mode 100644 internal/services/reviews_test.go delete mode 100644 internal/services/sdk.go delete mode 100644 internal/services/sdk_test.go delete mode 100644 internal/services/search.go delete mode 100644 internal/services/search_test.go delete mode 100644 internal/services/ssh_keys.go delete mode 100644 internal/services/ssh_keys_test.go delete mode 100644 internal/services/status.go delete mode 100644 internal/services/status_test.go delete mode 100644 internal/services/teams.go delete mode 100644 internal/services/teams_test.go delete mode 100644 internal/services/time.go delete mode 100644 internal/services/time_test.go delete mode 100644 internal/services/webhooks.go delete mode 100644 internal/services/webhooks_test.go delete mode 100644 internal/services/wiki.go delete mode 100644 internal/services/wiki_test.go delete mode 100644 internal/settings/remote.go delete mode 100644 internal/settings/remote_test.go delete mode 100644 internal/settings/ssh.go delete mode 100644 internal/settings/ssh_test.go delete mode 100644 internal/settings/store.go delete mode 100644 internal/settings/store_test.go delete mode 100644 internal/terminal/browser.go delete mode 100644 internal/terminal/browser_test.go delete mode 100644 internal/terminal/color.go delete mode 100644 internal/terminal/color_test.go delete mode 100644 internal/terminal/editor.go delete mode 100644 internal/terminal/editor_test.go delete mode 100644 internal/terminal/issue_edit_form.go delete mode 100644 internal/terminal/issue_edit_form_test.go delete mode 100644 internal/terminal/issue_form.go delete mode 100644 internal/terminal/issue_form_test.go delete mode 100644 internal/terminal/merge_form.go delete mode 100644 internal/terminal/milestone_form.go delete mode 100644 internal/terminal/milestone_form_test.go delete mode 100644 internal/terminal/output.go delete mode 100644 internal/terminal/output_test.go delete mode 100644 internal/terminal/pager.go delete mode 100644 internal/terminal/pager_test.go delete mode 100644 internal/terminal/pull_form.go delete mode 100644 internal/terminal/pull_form_test.go delete mode 100644 internal/terminal/review_form.go delete mode 100644 internal/terminal/review_form_test.go delete mode 100644 internal/terminal/streams.go delete mode 100644 internal/terminal/streams_test.go delete mode 100644 internal/terminal/table.go delete mode 100644 internal/terminal/table_test.go delete mode 100644 internal/transport/api.go delete mode 100644 internal/transport/api_test.go delete mode 100644 internal/transport/client.go delete mode 100644 internal/transport/client_test.go delete mode 100644 internal/transport/debug.go delete mode 100644 internal/transport/download.go delete mode 100644 internal/transport/download_test.go delete mode 100644 main_test.go create mode 100644 modules/api/client.go create mode 100644 modules/auth/oauth.go create mode 100644 modules/auth/oauth_test.go create mode 100644 modules/config/config.go create mode 100644 modules/config/credstore.go create mode 100644 modules/config/lock.go create mode 100644 modules/config/lock_test.go create mode 100644 modules/config/login.go create mode 100644 modules/config/login_test.go create mode 100644 modules/config/testtools.go create mode 100644 modules/context/context.go create mode 100644 modules/context/context_login.go create mode 100644 modules/context/context_prompt_test.go create mode 100644 modules/context/context_remote.go create mode 100644 modules/context/context_repo.go create mode 100644 modules/context/context_require.go create mode 100644 modules/context/context_require_test.go create mode 100644 modules/context/context_test.go create mode 100644 modules/debug/debug.go rename {internal => modules}/filelock/filelock.go (100%) rename {internal => modules}/filelock/filelock_test.go (55%) rename {internal => modules}/filelock/filelock_unix.go (100%) rename {internal => modules}/filelock/filelock_windows.go (100%) create mode 100644 modules/git/auth.go create mode 100644 modules/git/backend.go create mode 100644 modules/git/backend_test.go create mode 100644 modules/git/branch.go create mode 100644 modules/git/cli_backend.go create mode 100644 modules/git/cli_backend_test.go create mode 100644 modules/git/network.go create mode 100644 modules/git/remote.go create mode 100644 modules/git/repo.go create mode 100644 modules/git/repo_cli_test.go create mode 100644 modules/git/types.go create mode 100644 modules/git/url.go create mode 100644 modules/git/url_test.go create mode 100644 modules/httputil/httputil.go create mode 100644 modules/httputil/transport.go create mode 100644 modules/httputil/transport_stall_test.go create mode 100644 modules/interact/comments.go create mode 100644 modules/interact/issue_create.go create mode 100644 modules/interact/issue_edit.go create mode 100644 modules/interact/login.go create mode 100644 modules/interact/login_test.go create mode 100644 modules/interact/milestone_create.go create mode 100644 modules/interact/print.go create mode 100644 modules/interact/prompts.go create mode 100644 modules/interact/pull_create.go create mode 100644 modules/interact/pull_merge.go create mode 100644 modules/interact/pull_review.go create mode 100644 modules/print/actions.go create mode 100644 modules/print/actions_runs.go create mode 100644 modules/print/actions_runs_test.go create mode 100644 modules/print/actions_test.go create mode 100644 modules/print/attachment.go create mode 100644 modules/print/branch.go create mode 100644 modules/print/branch_test.go create mode 100644 modules/print/comment.go create mode 100644 modules/print/formatters.go create mode 100644 modules/print/issue.go create mode 100644 modules/print/label.go create mode 100644 modules/print/login.go create mode 100644 modules/print/login_status.go create mode 100644 modules/print/login_status_test.go create mode 100644 modules/print/markdown.go create mode 100644 modules/print/milestone.go create mode 100644 modules/print/notification.go create mode 100644 modules/print/organization.go create mode 100644 modules/print/pull.go create mode 100644 modules/print/pull_review_comment.go create mode 100644 modules/print/pull_test.go create mode 100644 modules/print/release.go create mode 100644 modules/print/repo.go create mode 100644 modules/print/repo_test.go create mode 100644 modules/print/sshkey.go create mode 100644 modules/print/table.go create mode 100644 modules/print/table_test.go create mode 100644 modules/print/times.go create mode 100644 modules/print/user.go create mode 100644 modules/print/webhook.go create mode 100644 modules/print/webhook_test.go create mode 100644 modules/print/wiki.go create mode 100644 modules/print/wiki_test.go create mode 100644 modules/task/assignees.go create mode 100644 modules/task/issue_create.go create mode 100644 modules/task/issue_edit.go create mode 100644 modules/task/labels.go create mode 100644 modules/task/labels_export.go create mode 100644 modules/task/login_create.go create mode 100644 modules/task/login_create_test.go create mode 100644 modules/task/login_httpsign.go create mode 100644 modules/task/login_ssh.go create mode 100644 modules/task/login_status.go create mode 100644 modules/task/login_status_test.go create mode 100644 modules/task/milestone_create.go create mode 100644 modules/task/pull_checkout.go create mode 100644 modules/task/pull_clean.go create mode 100644 modules/task/pull_create.go create mode 100644 modules/task/pull_create_test.go create mode 100644 modules/task/pull_edit.go create mode 100644 modules/task/pull_merge.go create mode 100644 modules/task/pull_merge_test.go create mode 100644 modules/task/pull_review.go create mode 100644 modules/task/pull_review_comment.go create mode 100644 modules/task/repo_clone.go create mode 100644 modules/task/wiki.go create mode 100644 modules/task/wiki_test.go create mode 100644 modules/theme/background.go create mode 100644 modules/theme/theme.go create mode 100644 modules/theme/theme_test.go rename {internal/services => modules/utils}/draft.go (85%) rename {internal/services => modules/utils}/draft_test.go (88%) create mode 100644 modules/utils/input.go create mode 100644 modules/utils/parse.go create mode 100644 modules/utils/parse_test.go create mode 100644 modules/utils/path.go create mode 100644 modules/utils/utils.go create mode 100644 modules/utils/validate.go rename {internal => modules}/version/version.go (73%) delete mode 100644 scripts/integration-tokens.py create mode 100644 tests/README.md create mode 100644 tests/integration/admin_users_test.go create mode 100644 tests/integration/cmd_labels_test.go create mode 100644 tests/integration/comments_test.go create mode 100644 tests/integration/config_lock_unix_test.go create mode 100644 tests/integration/context_init_test.go create mode 100644 tests/integration/git_branch_test.go create mode 100644 tests/integration/git_repo_test.go create mode 100644 tests/integration/helpers_test.go create mode 100644 tests/integration/pulls_reply_test.go create mode 100644 tests/integration/repos_create_test.go create mode 100644 tests/integration/sshkeys_test.go create mode 100644 tests/integration/task_issue_test.go create mode 100644 tests/integration/task_labels_test.go create mode 100644 tests/integration/task_pull_test.go create mode 100644 tests/integration/wiki_test.go delete mode 100644 tools/cli-docs/main.go delete mode 100644 tools/cli-docs/main_test.go diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index a71bf4d6..54c3be66 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -33,6 +33,9 @@ jobs: with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} + - name: get SDK version + id: sdk_version + run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT" - name: goreleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: @@ -40,6 +43,7 @@ jobs: version: "~> v2" args: release --nightly env: + SDK_VERSION: ${{ steps.sdk_version.outputs.version }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_BUCKET: ${{ secrets.R2_BUCKET }} diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index 6faef0f9..4d658c19 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -34,6 +34,9 @@ jobs: with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} + - name: get SDK version + id: sdk_version + run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT" - name: goreleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: @@ -41,6 +44,7 @@ jobs: version: "~> v2" args: release env: + SDK_VERSION: ${{ steps.sdk_version.outputs.version }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_BUCKET: ${{ secrets.R2_BUCKET }} diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index bce2230b..b5c81466 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -27,6 +27,7 @@ jobs: make vet make lint make fmt-check + make docs-check make build - name: unit test and coverage run: | @@ -38,9 +39,8 @@ jobs: env: HTTP_PROXY: "" GITEA_TEA_TEST_URL: "http://gitea:3000" - GITEA_TEA_TEST_TOKEN_FILE: /tmp/tea-ci-admin-token - GITEA_TEA_TEST_REVIEWER_TOKEN_FILE: /tmp/tea-ci-reviewer-token - GITEA_TEA_TEST_REPOSITORY_ROOT: /tmp/data + GITEA_TEA_TEST_USERNAME: "test01" + GITEA_TEA_TEST_PASSWORD: "test01" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 @@ -59,14 +59,12 @@ jobs: done echo "::error::gitea did not become ready within 60s" exit 1 - - name: create private token fixtures - run: python3 scripts/integration-tokens.py - name: integration test run: | make integration-test services: gitea: - image: docker.gitea.com/gitea:28.0.0 + image: docker.gitea.com/gitea:1.27.0 cmd: - bash - -c @@ -79,7 +77,6 @@ jobs: && echo "INSTALL_LOCK = true" >> /tmp/conf/app.ini && echo "SECRET_KEY = 2crAW4UANgvLipDS6U5obRcFosjSJHQANll6MNfX7P0G3se3fKcCwwK3szPyGcbo" >> /tmp/conf/app.ini && echo "PASSWORD_COMPLEXITY = off" >> /tmp/conf/app.ini - && echo "IMPORT_LOCAL_PATHS = true" >> /tmp/conf/app.ini && echo "[database]" >> /tmp/conf/app.ini && echo "DB_TYPE = sqlite3" >> /tmp/conf/app.ini && echo "[repository]" >> /tmp/conf/app.ini @@ -87,6 +84,5 @@ jobs: && echo "[server]" >> /tmp/conf/app.ini && echo "ROOT_URL = http://gitea:3000" >> /tmp/conf/app.ini && gitea migrate -c /tmp/conf/app.ini - && gitea admin user create --username=integration --password=tea-local-integration-pass --email=integration@example.test --admin=true --must-change-password=false -c /tmp/conf/app.ini - && gitea admin user create --username=tea-reviewer --password=tea-local-reviewer-pass --email=tea-reviewer@example.test --must-change-password=false -c /tmp/conf/app.ini + && gitea admin user create --username=test01 --password=test01 --email=test01@gitea.io --admin=true --must-change-password=false --access-token -c /tmp/conf/app.ini && gitea web -c /tmp/conf/app.ini diff --git a/.gitignore b/.gitignore index c63687bc..1672762d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,13 +13,6 @@ coverage.out dist/ -# Generated by `make docs`; shipped in the release docs archive, never committed -docs/generated/ - -# Release docs archive produced by `make docs-archive` / goreleaser before hooks -tea-*-docs.tar.xz -tea-*-docs.tar.xz.sha256 - # Nix-specific .direnv/ result diff --git a/.golangci.yml b/.golangci.yml index 43bde9d1..85020787 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -12,7 +12,6 @@ linters: - misspell - ineffassign - unused - - copyloopvar settings: revive: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2eb8c1b0..1fa2b71e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,11 +3,6 @@ version: 2 before: hooks: - go mod tidy - # Regenerate references/man/completions, then pack a versioned docs - # archive outside dist/ (goreleaser requires dist/ empty after hooks). - # tea-{{ .Version }}-docs.tar.xz is picked up by release/publisher/ - # checksum ./**.xz globs alongside binary .xz artifacts. - - make docs-archive DOCS_ARCHIVE=tea-{{ .Version }}-docs.tar.xz builds: - env: @@ -63,7 +58,7 @@ builds: flags: - -trimpath ldflags: - - -s -w -X "gitea.dev/tea/internal/version.Version={{ trimprefix .Summary "v" }}" -X "gitea.dev/tea/internal/version.Tags=" + - -s -w -X "gitea.dev/tea/modules/version.Version={{ trimprefix .Summary "v" }}" -X "gitea.dev/tea/modules/version.Tags=" -X "gitea.dev/tea/modules/version.SDK={{ .Env.SDK_VERSION }}" binary: >- {{ .ProjectName }}- {{- .Version }}- @@ -149,9 +144,6 @@ gitea_urls: download: https://gitea.com release: - # Includes per-platform binary .xz artifacts and the docs archive - # tea--docs.tar.xz (references, man pages, bash/zsh/fish/powershell - # completions) produced by the before.hooks docs-archive step. extra_files: - glob: ./**.xz - glob: ./**.xz.sha256 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e6bc51c..a762dd2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,27 +56,9 @@ high-level discussions. ## Testing redux -Before sending code out for review, run the unit tests with `make test`. +Before sending code out for review, run all the test by executing: `make test` Since TEA is an cli tool it should be obvious to test your feature locally first. -Tests live beside the packages they exercise. `make integration-test` runs the -`TestGitea*` tests against a disposable Gitea instance with an administrator -account named `integration` and a reviewer named `tea-reviewer`. Never point -these tests at a production instance: they create and delete resources. - -```sh -GITEA_TEA_TEST_URL=http://127.0.0.1:3000 \ -GITEA_TEA_TEST_TOKEN_FILE=/private/path/admin-token \ -GITEA_TEA_TEST_REVIEWER_TOKEN_FILE=/private/path/reviewer-token \ - make integration-test -``` - -Token files contain scoped PATs for their accounts and should be `0600`; -`scripts/integration-tokens.py` creates them for the CI accounts. Optional -coverage is enabled by `GITEA_TEA_TEST_REPOSITORY_ROOT` (the server's repository -root, with `[repository] IMPORT_LOCAL_PATHS = true`, for migration) and -`GITEA_TEA_TEST_SSH_ENDPOINT` (an SSH clone endpoint). - ## Code review Changes to TEA must be reviewed before they are accepted—no matter who @@ -99,49 +81,52 @@ Some of the key points: ## Styleguide ### Commands +- Subcommands should follow the following structure: + ``` + tea [] [] + ``` -Use explicit singular noun/verb commands, such as `tea issue list` and -`tea pr create`. Bare command groups show help. Commands validate input, collect -missing fields when the shared prompting policy permits it, invoke services, and -render results through injected streams. Destructive commands require confirmation -or `--yes`; `--no-input` disables implicit interaction. JSON results go to stdout; -diagnostics and prompts go to stderr. Lists register `--limit`/`--page` with -`pageFlags` and print through `writeList`, which renders one `terminal.Table` -(ID or number first, then title or name, then state) through the pager. + for example: -### Internal Package Structure + ``` + tea issues list + tea pulls create + tea teams add user --team x --user y + ``` +- Commands should accept nouns as singular & plural by making use of the `Aliases` field. +- The default action without a verb is `list`. +- There is a standard set of verbs: `list/ls`, `create`, `edit`, `delete` + - `ls` lists objects with filter options, and applies pagination where available. + - `delete` should show info what is deleted and ask user again, if force flag`-y` is not set + - Verbs that accept large numbers of flags provide an interactive mode when called without any arguments or flags. +- Try to reuse as many flag definitions as possible, see `cmd/flags/flags.go`. +- Always make sure that the help texts are properly set, and as concise as possible. -- `internal/app`: Cobra commands and application dependency construction. -- `internal/services`: typed workflows over `gitea.dev/sdk`, independent of CLI flags - and terminal I/O, including pagination policy. Services map SDK types to tea's - output types; requests the SDK cannot make yet go through the raw helpers in - `raw.go`, each marked `TODO(sdk)` with the missing SDK piece. -- `internal/settings`: split configuration, validation, persistence, target resolution, - and remote URL parsing. -- `internal/auth`: secure credentials and OAuth lifecycle. -- `internal/transport`: the tea HTTP client shared by the SDK and raw requests: TLS, - timeouts, User-Agent, `--debug`, retries, token, Sudo and SSH-signature - authentication, and sanitised API errors. Its raw requests serve `tea api`, signed - downloads and log streaming. -- `internal/git`: repository discovery and cancellable Git subprocesses. -- `internal/terminal`: streams, input, editor, browser, and rendering. -- `internal/clierr`: typed errors and executable exit codes. -- `internal/aliases`: user command alias expansion. -- `internal/filelock`: cross-process locks for configuration writes. -- `internal/version`: build version metadata. - -Unit and integration tests live beside their packages; see [Testing redux](#testing-redux). +### Internal Module Structure +- `cmd`: only contains command/flag options for `urfave/cli` + - subcommands are in a subpackage named after its parent command +- `modules/task`: contain func for doing something with gitea + (e.g. create token by user/passwd) +- `modules/print`: contain all functions that print to stdout +- `modules/config`: config tea & login things +- `modules/interact`: contain functions to interact with user by prompts +- `modules/git`: do git related stuff (get info/push/pull/checkout/...) +- `modules/utils`: helper functions used by other functions ### Code Style Use `make fmt`, check with `make lint`. -Group imports into the standard library, then all other packages (local and -external) in one sorted block, as gofumpt keeps them: +For imports you should use the following format (_without_ the comments) ```go import ( + // stdlib "encoding/json" "fmt" - "gitea.dev/tea/internal/services" + // local packages + "code.gitea.io/gitea/models" + "gitea.dev/sdk" + + // external packages "github.com/foo/bar" "gopkg.io/baz.v1" ) diff --git a/Makefile b/Makefile index 54e41cee..0376dbdf 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,8 @@ export PATH := $($(GO) env GOPATH)/bin:$(PATH) GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go") # Tool packages with pinned versions -GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.12.0 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 +GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 ifneq ($(DRONE_TAG),) VERSION ?= $(subst v,,$(DRONE_TAG)) @@ -24,13 +24,17 @@ endif TEA_VERSION_TAG ?= $(shell sed 's/+/_/' <<< $(TEA_VERSION)) TAGS ?= -LDFLAGS := -X "gitea.dev/tea/internal/version.Version=$(TEA_VERSION)" -X "gitea.dev/tea/internal/version.Tags=$(TAGS)" -s -w +SDK ?= $(shell $(GO) list -f '{{.Version}}' -m gitea.dev/sdk) +LDFLAGS := -X "gitea.dev/tea/modules/version.Version=$(TEA_VERSION)" -X "gitea.dev/tea/modules/version.Tags=$(TAGS)" -X "gitea.dev/tea/modules/version.SDK=$(SDK)" -s -w # override to allow passing additional goflags via make CLI override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -PACKAGES ?= $(shell $(GO) list ./...) +PACKAGES ?= $(shell $(GO) list ./... | grep -v '^gitea.dev/tea/tests') UNIT_PACKAGES ?= $(PACKAGES) +INTEGRATION_PACKAGES ?= $(shell $(GO) list ./tests/... 2>/dev/null) +INTEGRATION_TEST_TAGS ?= testtools +INTEGRATION_TEST_GOFLAGS ?= -v SOURCES ?= $(shell find . -name "*.go" -type f) # OS specific vars. @@ -64,11 +68,11 @@ vet: .PHONY: lint lint: - $(GO) run $(GOLANGCI_LINT_PACKAGE) run + $(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools .PHONY: lint-fix lint-fix: - $(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix + $(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools --fix .PHONY: fmt-check fmt-check: @@ -80,39 +84,18 @@ fmt-check: exit 1; \ fi; -# Man pages are dated by the last commit unless SOURCE_DATE_EPOCH is already set. .PHONY: docs docs: - SOURCE_DATE_EPOCH=$${SOURCE_DATE_EPOCH:-$$(git log -1 --format=%ct 2>/dev/null)} TZ=UTC $(GO) run ./tools/cli-docs --out docs/generated + $(GO) run docs/docs.go --out docs/CLI.md -# Versioned docs archive for release uploads (references, man pages, completions). -# Keep the artifact outside dist/: goreleaser requires dist/ empty after before hooks. -# The .tar.xz suffix matches existing release/publisher/checksum ./**.xz globs. -DOCS_ARCHIVE ?= tea-$(TEA_VERSION_TAG)-docs.tar.xz - -.PHONY: docs-archive -docs-archive: docs - @rm -f "$(DOCS_ARCHIVE)" "$(DOCS_ARCHIVE).sha256" - tar -C docs/generated -c README.md reference man completion | xz -9 > "$(DOCS_ARCHIVE)" - @bash .goreleaser.checksum.sh "$(DOCS_ARCHIVE)" - -# Install generated man pages and shell completions for packagers / local installs. -# Binary install remains `make install` (GOPATH/bin). Override PREFIX/DESTDIR as needed. -PREFIX ?= /usr/local -DESTDIR ?= - -.PHONY: install-docs -install-docs: docs - install -d "$(DESTDIR)$(PREFIX)/share/man/man1" - install -m 644 docs/generated/man/*.1 "$(DESTDIR)$(PREFIX)/share/man/man1/" - install -d "$(DESTDIR)$(PREFIX)/share/bash-completion/completions" - install -m 644 docs/generated/completion/tea.bash "$(DESTDIR)$(PREFIX)/share/bash-completion/completions/tea" - install -d "$(DESTDIR)$(PREFIX)/share/zsh/site-functions" - install -m 644 docs/generated/completion/_tea "$(DESTDIR)$(PREFIX)/share/zsh/site-functions/_tea" - install -d "$(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d" - install -m 644 docs/generated/completion/tea.fish "$(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/tea.fish" - install -d "$(DESTDIR)$(PREFIX)/share/powershell/Modules/tea" - install -m 644 docs/generated/completion/tea.ps1 "$(DESTDIR)$(PREFIX)/share/powershell/Modules/tea/tea.ps1" +.PHONY: docs-check +docs-check: + @DIFF=$$($(GO) run docs/docs.go | diff docs/CLI.md -); \ + if [ -n "$$DIFF" ]; then \ + echo "Please run 'make docs' and commit the result:"; \ + echo "$$DIFF"; \ + exit 1; \ + fi; .PHONY: unit-test unit-test: @@ -120,17 +103,18 @@ unit-test: .PHONY: integration-test integration-test: - @test -n "$$GITEA_TEA_TEST_URL" || { echo 'Set GITEA_TEA_TEST_URL to a disposable Gitea instance' >&2; exit 1; } - @test -r "$$GITEA_TEA_TEST_TOKEN_FILE" || { echo 'Set GITEA_TEA_TEST_TOKEN_FILE to a readable administrator token file' >&2; exit 1; } - @test -r "$$GITEA_TEA_TEST_REVIEWER_TOKEN_FILE" || { echo 'Set GITEA_TEA_TEST_REVIEWER_TOKEN_FILE to a readable reviewer token file' >&2; exit 1; } - $(GO) test -count=1 -race -run '^TestGitea' $(PACKAGES) + @if [ -n "$(INTEGRATION_PACKAGES)" ]; then \ + $(GO) test $(INTEGRATION_TEST_GOFLAGS) -tags='$(INTEGRATION_TEST_TAGS)' $(INTEGRATION_PACKAGES); \ + else \ + echo "No integration test packages found"; \ + fi .PHONY: test -test: unit-test +test: unit-test integration-test .PHONY: unit-test-coverage unit-test-coverage: - $(GO) test -race -cover -coverprofile coverage.out $(UNIT_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1 + $(GO) test -cover -coverprofile coverage.out $(UNIT_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1 .PHONY: tidy tidy: diff --git a/README.md b/README.md index cbb0cff5..2293b841 100644 --- a/README.md +++ b/README.md @@ -8,29 +8,96 @@ ## The official CLI for Gitea -Tea manages repositories, issues, pull requests, releases, and other Gitea resources -with explicit commands suitable for local development and CI. +![demo gif](./demo.gif) -```sh -tea auth login --host https://gitea.example.org --with-token < token-file -tea issue list --repo owner/repository -tea pr view 123 --repo owner/repository --json -tea alias set bugs 'issue list --label bug' -tea help +``` +NAME: + tea - command line tool to interact with Gitea + +USAGE: + tea [global options] [command [command options]] + +VERSION: + Version: 0.10.1+15-g8876fe3 golang: 1.25.0 go-sdk: v0.21.0 + +DESCRIPTION: + tea is a productivity helper for Gitea. It can be used to manage most entities on + one or multiple Gitea instances & provides local helpers like 'tea pr checkout'. + + tea tries to make use of context provided by the repository in $PWD if available. + tea works best in a upstream/fork workflow, when the local main branch tracks the + upstream repo. tea assumes that local git state is published on the remote before + doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea. + +COMMANDS: + help, h Shows a list of commands or help for one command + + ENTITIES: + issues, issue, i List, create and update issues + pulls, pull, pr Manage and checkout pull requests + labels, label Manage issue labels + milestones, milestone, ms List and create milestones + releases, release, r Manage releases + times, time, t Operate on tracked times of a repository's issues & pulls + organizations, organization, org List, create, delete organizations + repos, repo Show repository details + branches, branch, b Consult branches + actions Manage repository actions (secrets, variables) + comment, c Add a comment to an issue / pr + webhooks, webhook Manage repository webhooks + + HELPERS: + open, o Open something of the repository in web browser + notifications, notification, n Show notifications + clone, C Clone a repository locally + + MISCELLANEOUS: + whoami Show current logged in user + admin, a Operations requiring admin access on the Gitea instance + + SETUP: + logins, login Log in to a Gitea server + logout Log out from a Gitea server + +GLOBAL OPTIONS: + --debug, --vvv Enable debug mode (default: false) + --help, -h show help + --version, -v print the version + +EXAMPLES + tea login add # add a login once to get started + + tea pulls # list open pulls for the repo in $PWD + tea pulls --repo $HOME/foo # list open pulls for the repo in $HOME/foo + tea pulls --remote upstream # list open pulls for the repo pointed at by + # your local "upstream" git remote + # list open pulls for any gitea repo at the given login instance + tea pulls --repo gitea/tea --login gitea.com + + tea milestone issues 0.7.0 # view open issues for milestone '0.7.0' + tea issue 189 # view contents of issue 189 + tea open 189 # open web ui for issue 189 + tea open milestones # open web ui for milestones + + tea actions secrets list # list all repository action secrets + tea actions secrets create API_KEY # create a new secret (will prompt for value) + tea actions variables list # list all repository action variables + tea actions variables set API_URL https://api.example.com + + tea webhooks list # list repository webhooks + tea webhooks list --org myorg # list organization webhooks + tea webhooks create https://example.com/hook --events push,pull_request + + # send gitea desktop notifications every 5 minutes (bash + libnotify) + while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done + +ABOUT + Written & maintained by The Gitea Authors. + If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea. + More info about Gitea itself on https://about.gitea.com. ``` -tea 1.0 is a breaking release: it requires Gitea 28 or newer, commands, flags and -output formats changed, it uses separate `config.yml` and `hosts.yml` files, and it -requires fresh authentication. -Existing combined configuration files are left untouched; archive the old file or -choose an isolated `TEA_CONFIG_DIR` before setup. See -[Migrating to tea 1.0](docs/migration-1.0.md) for the old-to-new mapping. -Run `tea --help` or `tea --help` for supported commands and flags; the -full reference, man pages, and shell completions ship in each release's docs archive. -Credentials use the operating-system keyring by default; explicitly -select `--credential-store file` when private file storage is needed. For CI, -supply `GITEA_TOKEN` and its instance through `TEA_HOST` or `GITEA_INSTANCE_URL`; -environment credentials are never persisted. +- tea uses [gitea.dev/sdk](https://gitea.dev/sdk) and interacts with the Gitea API. ## Installation @@ -61,15 +128,15 @@ Gitea can use many different authentication schemes, and not every authenticatio 3. In your "user settings", generate an application token with at least **user read** permissions. If you want to do anything useful with the token add additional permissions/scopes. -4. Save the token in a private file and run `tea auth login --host https://your-instance --with-token < token-file`. Credentials use the operating-system keyring by default. +4. Run `tea login add`, select **application token** authentication when asked for authentication type, and answer **yes** to the question if you have a token. Paste the generated token when asked for one. You should now be logged in to your gitea instance from tea. -Alternatively, `tea auth login --host https://your-instance --oauth` authorizes tea in your browser. OAuth may not be available on all Gitea deployments, and needs a browser on the machine running tea. +Since 0.10 Gitea supports the much simpler oauth workflow but oauth may not be available on all Gitea deployments, and gets much more complex when running tea on a remote system. ### Shell completion -If you installed from source or the package does not provide the completions with it you can add them yourself with `tea completion ` command. To generate the completions run one of the following commands depending on your shell. +If you installed from source or the package does not provide the completions with it you can add them yourself with `tea completion ` command which is not visible in help. To generate the completions run one of the following commands depending on your shell. ```shell # .bashrc @@ -81,8 +148,8 @@ source <(tea completion zsh) # fish tea completion fish > ~/.config/fish/completions/tea.fish -# PowerShell profile -tea completion powershell | Out-String | Invoke-Expression +# Powershell +Output the script to path/to/autocomplete/tea.ps1 an run it. ``` ### Man Page @@ -97,16 +164,16 @@ man <(tea man) man (tea man | psub) # write man page to a file -tea man --output-file ./tea.man +tea man --out ./tea.man ``` ## Compilation -Make sure you have a current Go version installed (1.27 or newer). +Make sure you have a current Go version installed (1.26 or newer). - To compile the source yourself with the recommended flags & tags: ```sh - git clone https://gitea.com/gitea/tea.git # or: tea repo clone https://gitea.com/gitea/tea ;) + git clone https://gitea.com/gitea/tea.git # or: tea clone gitea.com/gitea/tea ;) cd tea make ``` @@ -126,10 +193,8 @@ Make sure you have a current Go version installed (1.27 or newer). Fork -> Patch -> Push -> Pull Request -- `make test` run unit tests +- `make test` run testsuite - `make vet` run checks (check the order of imports; preventing failure on CI pipeline beforehand) -- `make build` builds the production CLI -- `make docs` regenerates references, man pages, and shell completions in `docs/generated` (not committed; built on release) - ... (for other development tasks, check the `Makefile`) **Please** read the [CONTRIBUTING](CONTRIBUTING.md) documentation, it will tell you about internal structures and concepts. diff --git a/cmd/actions.go b/cmd/actions.go new file mode 100644 index 00000000..447949c5 --- /dev/null +++ b/cmd/actions.go @@ -0,0 +1,47 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/actions" + + "github.com/urfave/cli/v3" +) + +// CmdActions represents the actions command for managing Gitea Actions +var CmdActions = cli.Command{ + Name: "actions", + Aliases: []string{"action"}, + Category: catEntities, + Usage: "Manage repository actions", + Description: "Manage repository actions including secrets, variables, and workflow runs", + Action: runActionsDefault, + Commands: []*cli.Command{ + &actions.CmdActionsSecrets, + &actions.CmdActionsVariables, + &actions.CmdActionsRuns, + &actions.CmdActionsWorkflows, + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "repo", + Usage: "repository to operate on", + }, + &cli.StringFlag{ + Name: "login", + Usage: "gitea login instance to use", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "output format [table, csv, simple, tsv, yaml, json]", + }, + }, +} + +func runActionsDefault(_ stdctx.Context, cmd *cli.Command) error { + return cli.ShowSubcommandHelp(cmd) +} diff --git a/cmd/actions/runs.go b/cmd/actions/runs.go new file mode 100644 index 00000000..0b34d690 --- /dev/null +++ b/cmd/actions/runs.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/actions/runs" + + "github.com/urfave/cli/v3" +) + +// CmdActionsRuns represents the actions runs command +var CmdActionsRuns = cli.Command{ + Name: "runs", + Aliases: []string{"run"}, + Usage: "Manage workflow runs", + Description: "List, view, and manage workflow runs for repository actions", + Action: runRunsDefault, + Commands: []*cli.Command{ + &runs.CmdRunsList, + &runs.CmdRunsView, + &runs.CmdRunsDelete, + &runs.CmdRunsLogs, + }, +} + +func runRunsDefault(ctx stdctx.Context, cmd *cli.Command) error { + return runs.RunRunsList(ctx, cmd) +} diff --git a/cmd/actions/runs/delete.go b/cmd/actions/runs/delete.go new file mode 100644 index 00000000..310afedd --- /dev/null +++ b/cmd/actions/runs/delete.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "strconv" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdRunsDelete represents a sub command to delete/cancel workflow runs +var CmdRunsDelete = cli.Command{ + Name: "delete", + Aliases: []string{"remove", "rm", "cancel"}, + Usage: "Delete or cancel a workflow run", + Description: "Delete (cancel) a workflow run from the repository", + ArgsUsage: "", + Action: runRunsDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", + }, + }, flags.AllDefaultFlags...), +} + +func runRunsDelete(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("run ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + runIDStr := cmd.Args().First() + runID, err := strconv.ParseInt(runIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %s", runIDStr) + } + + if !cmd.Bool("confirm") { + fmt.Printf("Are you sure you want to delete run %d? [y/N] ", runID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Deletion canceled.") + return nil + } + } + + _, err = client.Actions.DeleteRepoRun(ctx, c.Owner, c.Repo, runID) + if err != nil { + return fmt.Errorf("failed to delete run: %w", err) + } + + fmt.Printf("Run %d deleted successfully\n", runID) + return nil +} diff --git a/cmd/actions/runs/list.go b/cmd/actions/runs/list.go new file mode 100644 index 00000000..40f6fc1c --- /dev/null +++ b/cmd/actions/runs/list.go @@ -0,0 +1,148 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "time" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdRunsList represents a sub command to list workflow runs +var CmdRunsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List workflow runs", + Description: "List workflow runs for repository actions with optional filtering", + Action: RunRunsList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + &cli.StringFlag{ + Name: "status", + Usage: "Filter by status (success, failure, pending, queued, in_progress, skipped, canceled)", + }, + &cli.StringFlag{ + Name: "branch", + Usage: "Filter by branch name", + }, + &cli.StringFlag{ + Name: "event", + Usage: "Filter by event type (push, pull_request, etc.)", + }, + &cli.StringFlag{ + Name: "actor", + Usage: "Filter by actor username (who triggered the run)", + }, + &cli.StringFlag{ + Name: "since", + Usage: "Show runs started after this time (e.g., '24h', '2024-01-01')", + }, + &cli.StringFlag{ + Name: "until", + Usage: "Show runs started before this time (e.g., '2024-01-01')", + }, + }, flags.AllDefaultFlags...), +} + +// parseTimeFlag parses time flags like "24h" or "2024-01-01" +func parseTimeFlag(value string) (time.Time, error) { + if value == "" { + return time.Time{}, nil + } + + // Try parsing as duration (e.g., "24h", "168h") + if duration, err := time.ParseDuration(value); err == nil { + return time.Now().Add(-duration), nil + } + + // Try parsing as date + formats := []string{ + "2006-01-02", + "2006-01-02 15:04", + "2006-01-02T15:04:05", + time.RFC3339, + } + + for _, format := range formats { + if t, err := time.Parse(format, value); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse time: %s", value) +} + +// RunRunsList lists workflow runs +func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + // Parse time filters + since, err := parseTimeFlag(cmd.String("since")) + if err != nil { + return fmt.Errorf("invalid --since value: %w", err) + } + + until, err := parseTimeFlag(cmd.String("until")) + if err != nil { + return fmt.Errorf("invalid --until value: %w", err) + } + + // Build list options + listOpts := flags.GetListOptions(cmd) + + runs, _, err := client.Actions.ListRepoRuns(ctx, c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{ + ListOptions: listOpts, + Status: cmd.String("status"), + Branch: cmd.String("branch"), + Event: cmd.String("event"), + Actor: cmd.String("actor"), + }) + if err != nil { + return err + } + + if runs == nil { + return print.ActionRunsList(nil, c.Output) + } + + // Filter by time if specified + filteredRuns := filterRunsByTime(runs.WorkflowRuns, since, until) + + return print.ActionRunsList(filteredRuns, c.Output) +} + +// filterRunsByTime filters runs based on time range +func filterRunsByTime(runs []*gitea.ActionWorkflowRun, since, until time.Time) []*gitea.ActionWorkflowRun { + if since.IsZero() && until.IsZero() { + return runs + } + + var filtered []*gitea.ActionWorkflowRun + for _, run := range runs { + if !since.IsZero() && run.StartedAt.Before(since) { + continue + } + if !until.IsZero() && run.StartedAt.After(until) { + continue + } + filtered = append(filtered, run) + } + + return filtered +} diff --git a/cmd/actions/runs/list_test.go b/cmd/actions/runs/list_test.go new file mode 100644 index 00000000..82258996 --- /dev/null +++ b/cmd/actions/runs/list_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + "os" + "testing" + "time" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/modules/config" +) + +func TestFilterRunsByTime(t *testing.T) { + now := time.Now() + runs := []*gitea.ActionWorkflowRun{ + {ID: 1, StartedAt: now.Add(-1 * time.Hour)}, + {ID: 2, StartedAt: now.Add(-2 * time.Hour)}, + {ID: 3, StartedAt: now.Add(-3 * time.Hour)}, + {ID: 4, StartedAt: now.Add(-4 * time.Hour)}, + {ID: 5, StartedAt: now.Add(-5 * time.Hour)}, + } + + tests := []struct { + name string + since time.Time + until time.Time + expected []int64 + }{ + { + name: "no filter", + since: time.Time{}, + until: time.Time{}, + expected: []int64{1, 2, 3, 4, 5}, + }, + { + name: "since 2.5 hours ago", + since: now.Add(-150 * time.Minute), + until: time.Time{}, + expected: []int64{1, 2}, + }, + { + name: "until 2.5 hours ago", + since: time.Time{}, + until: now.Add(-150 * time.Minute), + expected: []int64{3, 4, 5}, + }, + { + name: "between 2 and 4 hours ago", + since: now.Add(-4 * time.Hour), + until: now.Add(-2 * time.Hour), + expected: []int64{2, 3, 4}, + }, + { + name: "filter excludes all", + since: now.Add(-30 * time.Minute), + until: time.Time{}, + expected: []int64{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := filterRunsByTime(runs, tt.since, tt.until) + + if len(result) != len(tt.expected) { + t.Errorf("filterRunsByTime() returned %d runs, want %d", len(result), len(tt.expected)) + return + } + + for i, run := range result { + if run.ID != tt.expected[i] { + t.Errorf("filterRunsByTime()[%d].ID = %d, want %d", i, run.ID, tt.expected[i]) + } + } + }) + } +} + +func TestRunRunsListRequiresRepoContext(t *testing.T) { + oldWd, err := os.Getwd() + require.NoError(t, err) + + require.NoError(t, os.Chdir(t.TempDir())) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test", + URL: "https://gitea.example.com", + Token: "token", + User: "tester", + Default: true, + }}, + }) + + cmd := &cli.Command{ + Name: CmdRunsList.Name, + Flags: CmdRunsList.Flags, + } + require.NoError(t, cmd.Set("login", "test")) + + err = RunRunsList(t.Context(), cmd) + require.ErrorContains(t, err, "remote repository required") +} diff --git a/cmd/actions/runs/logs.go b/cmd/actions/runs/logs.go new file mode 100644 index 00000000..f6b4735b --- /dev/null +++ b/cmd/actions/runs/logs.go @@ -0,0 +1,175 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "strconv" + "time" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "github.com/urfave/cli/v3" +) + +// CmdRunsLogs represents a sub command to view workflow run logs +var CmdRunsLogs = cli.Command{ + Name: "logs", + Aliases: []string{"log"}, + Usage: "View workflow run logs", + Description: "View logs for a workflow run or specific job", + ArgsUsage: "", + Action: runRunsLogs, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "job", + Usage: "specific job ID to view logs for (if omitted, shows all jobs)", + }, + &cli.BoolFlag{ + Name: "follow", + Aliases: []string{"f"}, + Usage: "follow log output (like tail -f), requires job to be in progress", + }, + }, flags.AllDefaultFlags...), +} + +func runRunsLogs(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("run ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + runIDStr := cmd.Args().First() + runID, err := strconv.ParseInt(runIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %s", runIDStr) + } + + // Check if follow mode is enabled + follow := cmd.Bool("follow") + + // If specific job ID provided, fetch only that job's logs + jobIDStr := cmd.String("job") + if jobIDStr != "" { + jobID, err := strconv.ParseInt(jobIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid job ID: %s", jobIDStr) + } + + if follow { + return followJobLogs(ctx, client, c, jobID, "") + } + + logs, _, err := client.Actions.GetRepoRunJobLogs(ctx, c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get logs for job %d: %w", jobID, err) + } + + fmt.Printf("Logs for job %d:\n", jobID) + fmt.Printf("---\n%s\n", string(logs)) + return nil + } + + // Otherwise, fetch all jobs and their logs + jobs, _, err := client.Actions.ListRepoJobsByRun(ctx, c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + + if len(jobs.Jobs) == 0 { + fmt.Printf("No jobs found for run %d\n", runID) + return nil + } + + // If following and multiple jobs, require --job flag + if follow && len(jobs.Jobs) > 1 { + return fmt.Errorf("--follow requires --job when run has multiple jobs (found %d jobs)", len(jobs.Jobs)) + } + + // If following with single job, follow it + if follow && len(jobs.Jobs) == 1 { + return followJobLogs(ctx, client, c, jobs.Jobs[0].ID, jobs.Jobs[0].Name) + } + + // Fetch logs for each job + for i, job := range jobs.Jobs { + if i > 0 { + fmt.Println() + } + + fmt.Printf("Job: %s (ID: %d)\n", job.Name, job.ID) + fmt.Printf("Status: %s\n", job.Status) + fmt.Println("---") + + logs, _, err := client.Actions.GetRepoRunJobLogs(ctx, c.Owner, c.Repo, job.ID) + if err != nil { + fmt.Printf("Error fetching logs: %v\n", err) + continue + } + + fmt.Println(string(logs)) + } + + return nil +} + +// followJobLogs continuously fetches and displays logs for a running job +func followJobLogs(requestCtx stdctx.Context, client *gitea.Client, c *context.TeaContext, jobID int64, jobName string) error { + var lastLogLength int + + if jobName != "" { + fmt.Printf("Following logs for job '%s' (ID: %d) - press Ctrl+C to stop...\n", jobName, jobID) + } else { + fmt.Printf("Following logs for job %d (press Ctrl+C to stop)...\n", jobID) + } + fmt.Println("---") + + for { + // Fetch job status + job, _, err := client.Actions.GetRepoRunJob(requestCtx, c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get job: %w", err) + } + + // Check if job is still running + isRunning := job.Status == "in_progress" || job.Status == "queued" || job.Status == "pending" + + // Fetch logs + logs, _, err := client.Actions.GetRepoRunJobLogs(requestCtx, c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get logs: %w", err) + } + + // Display new content only + if len(logs) > lastLogLength { + newLogs := string(logs)[lastLogLength:] + fmt.Print(newLogs) + lastLogLength = len(logs) + } + + // If job is complete, exit + if !isRunning { + fmt.Printf("\n---\nJob completed with status: %s\n", job.Status) + break + } + + // Wait before next poll + time.Sleep(2 * time.Second) + } + + return nil +} diff --git a/cmd/actions/runs/view.go b/cmd/actions/runs/view.go new file mode 100644 index 00000000..efd4dfa6 --- /dev/null +++ b/cmd/actions/runs/view.go @@ -0,0 +1,82 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "strconv" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdRunsView represents a sub command to view workflow run details +var CmdRunsView = cli.Command{ + Name: "view", + Aliases: []string{"show", "get"}, + Usage: "View workflow run details", + Description: "View details of a specific workflow run including jobs", + ArgsUsage: "", + Action: runRunsView, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "jobs", + Usage: "show jobs table", + Value: true, + }, + }, flags.AllDefaultFlags...), +} + +func runRunsView(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("run ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + runIDStr := cmd.Args().First() + runID, err := strconv.ParseInt(runIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %s", runIDStr) + } + + // Fetch run details + run, _, err := client.Actions.GetRepoRun(ctx, c.Owner, c.Repo, runID) + if err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + + // Print run details + print.ActionRunDetails(run) + + // Fetch and print jobs if requested + if cmd.Bool("jobs") { + jobs, _, err := client.Actions.ListRepoJobsByRun(ctx, c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + + if jobs != nil && len(jobs.Jobs) > 0 { + fmt.Printf("\nJobs:\n\n") + if err := print.ActionWorkflowJobsList(jobs.Jobs, c.Output); err != nil { + return err + } + } + } + + return nil +} diff --git a/cmd/actions/secrets.go b/cmd/actions/secrets.go new file mode 100644 index 00000000..0380de39 --- /dev/null +++ b/cmd/actions/secrets.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/actions/secrets" + + "github.com/urfave/cli/v3" +) + +// CmdActionsSecrets represents the actions secrets command +var CmdActionsSecrets = cli.Command{ + Name: "secrets", + Aliases: []string{"secret"}, + Usage: "Manage repository action secrets", + Description: "Manage secrets used by repository actions and workflows", + Action: runSecretsDefault, + Commands: []*cli.Command{ + &secrets.CmdSecretsList, + &secrets.CmdSecretsCreate, + &secrets.CmdSecretsDelete, + }, +} + +func runSecretsDefault(ctx stdctx.Context, cmd *cli.Command) error { + return secrets.RunSecretsList(ctx, cmd) +} diff --git a/cmd/actions/secrets/create.go b/cmd/actions/secrets/create.go new file mode 100644 index 00000000..1f070844 --- /dev/null +++ b/cmd/actions/secrets/create.go @@ -0,0 +1,74 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + "github.com/urfave/cli/v3" +) + +// CmdSecretsCreate represents a sub command to create action secrets +var CmdSecretsCreate = cli.Command{ + Name: "create", + Aliases: []string{"add", "set"}, + Usage: "Create an action secret", + Description: "Create a secret for use in repository actions and workflows", + ArgsUsage: " [secret-value]", + Action: runSecretsCreate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "file", + Usage: "read secret value from file", + }, + &cli.BoolFlag{ + Name: "stdin", + Usage: "read secret value from stdin", + }, + }, flags.AllDefaultFlags...), +} + +func runSecretsCreate(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("secret name is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + secretName := cmd.Args().First() + + // Read secret value using the utility + secretValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{ + ResourceName: "secret", + PromptMsg: fmt.Sprintf("Enter secret value for '%s'", secretName), + Hidden: true, + AllowEmpty: false, + }) + if err != nil { + return err + } + + _, err = client.Actions.CreateRepoSecret(ctx, c.Owner, c.Repo, secretName, gitea.CreateOrUpdateSecretOption{ + Data: secretValue, + }) + if err != nil { + return err + } + + fmt.Printf("Secret '%s' created successfully\n", secretName) + return nil +} diff --git a/cmd/actions/secrets/create_test.go b/cmd/actions/secrets/create_test.go new file mode 100644 index 00000000..5ac0869c --- /dev/null +++ b/cmd/actions/secrets/create_test.go @@ -0,0 +1,56 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "testing" +) + +func TestGetSecretSourceArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "valid args", + args: []string{"VALID_SECRET", "secret_value"}, + wantErr: false, + }, + { + name: "missing name", + args: []string{}, + wantErr: true, + }, + { + name: "too many args", + args: []string{"SECRET_NAME", "value", "extra"}, + wantErr: true, + }, + { + name: "invalid secret name", + args: []string{"invalid_secret", "value"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test argument validation only + if len(tt.args) == 0 { + if !tt.wantErr { + t.Error("Expected error for empty args") + } + return + } + + if len(tt.args) > 2 { + if !tt.wantErr { + t.Error("Expected error for too many args") + } + return + } + }) + } +} diff --git a/cmd/actions/secrets/delete.go b/cmd/actions/secrets/delete.go new file mode 100644 index 00000000..2bd555a1 --- /dev/null +++ b/cmd/actions/secrets/delete.go @@ -0,0 +1,66 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdSecretsDelete represents a sub command to delete action secrets +var CmdSecretsDelete = cli.Command{ + Name: "delete", + Aliases: []string{"remove", "rm"}, + Usage: "Delete an action secret", + Description: "Delete a secret used by repository actions", + ArgsUsage: "", + Action: runSecretsDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", + }, + }, flags.AllDefaultFlags...), +} + +func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("secret name is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + secretName := cmd.Args().First() + + if !cmd.Bool("confirm") { + fmt.Printf("Are you sure you want to delete secret '%s'? [y/N] ", secretName) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Deletion canceled.") + return nil + } + } + + _, err = client.Actions.DeleteRepoSecret(ctx, c.Owner, c.Repo, secretName) + if err != nil { + return err + } + + fmt.Printf("Secret '%s' deleted successfully\n", secretName) + return nil +} diff --git a/cmd/actions/secrets/delete_test.go b/cmd/actions/secrets/delete_test.go new file mode 100644 index 00000000..798b8917 --- /dev/null +++ b/cmd/actions/secrets/delete_test.go @@ -0,0 +1,93 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "fmt" + "testing" +) + +func TestSecretsDeleteValidation(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "valid secret name", + args: []string{"VALID_SECRET"}, + wantErr: false, + }, + { + name: "no args", + args: []string{}, + wantErr: true, + }, + { + name: "too many args", + args: []string{"SECRET1", "SECRET2"}, + wantErr: true, + }, + { + name: "invalid secret name but client does not validate", + args: []string{"invalid_secret"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDeleteArgs(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("validateDeleteArgs() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestSecretsDeleteFlags(t *testing.T) { + cmd := CmdSecretsDelete + + // Test command properties + if cmd.Name != "delete" { + t.Errorf("Expected command name 'delete', got %s", cmd.Name) + } + + // Check that rm is one of the aliases + hasRmAlias := false + for _, alias := range cmd.Aliases { + if alias == "rm" { + hasRmAlias = true + break + } + } + if !hasRmAlias { + t.Error("Expected 'rm' to be one of the aliases for delete command") + } + + if cmd.ArgsUsage != "" { + t.Errorf("Expected ArgsUsage '', got %s", cmd.ArgsUsage) + } + + if cmd.Usage == "" { + t.Error("Delete command should have usage text") + } + + if cmd.Description == "" { + t.Error("Delete command should have description") + } +} + +// validateDeleteArgs validates arguments for the delete command +func validateDeleteArgs(args []string) error { + if len(args) == 0 { + return fmt.Errorf("secret name is required") + } + + if len(args) > 1 { + return fmt.Errorf("only one secret name allowed") + } + + return nil +} diff --git a/cmd/actions/secrets/list.go b/cmd/actions/secrets/list.go new file mode 100644 index 00000000..0a698ba7 --- /dev/null +++ b/cmd/actions/secrets/list.go @@ -0,0 +1,49 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdSecretsList represents a sub command to list action secrets +var CmdSecretsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List action secrets", + Description: "List secrets configured for repository actions", + Action: RunSecretsList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunSecretsList list action secrets +func RunSecretsList(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + secrets, _, err := client.Actions.ListRepoSecrets(ctx, c.Owner, c.Repo, gitea.ListRepoActionSecretOption{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + return print.ActionSecretsList(secrets, c.Output) +} diff --git a/cmd/actions/secrets/list_test.go b/cmd/actions/secrets/list_test.go new file mode 100644 index 00000000..063229b2 --- /dev/null +++ b/cmd/actions/secrets/list_test.go @@ -0,0 +1,98 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/modules/config" +) + +func TestSecretsListFlags(t *testing.T) { + cmd := CmdSecretsList + + // Test that required flags exist + expectedFlags := []string{"output", "remote", "login", "repo"} + + for _, flagName := range expectedFlags { + found := false + for _, flag := range cmd.Flags { + if flag.Names()[0] == flagName { + found = true + break + } + } + + if !found { + t.Errorf("Expected flag %s not found in CmdSecretsList", flagName) + } + } + + // Test command properties + if cmd.Name != "list" { + t.Errorf("Expected command name 'list', got %s", cmd.Name) + } + + if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "ls" { + t.Errorf("Expected alias 'ls' for list command") + } + + if cmd.Usage == "" { + t.Error("List command should have usage text") + } + + if cmd.Description == "" { + t.Error("List command should have description") + } +} + +func TestSecretsListValidation(t *testing.T) { + // Basic validation that the command accepts the expected arguments + // More detailed testing would require mocking the Gitea client + + // Test that list command doesn't require arguments + args := []string{} + if len(args) > 0 { + t.Error("List command should not require arguments") + } + + // Test that extra arguments are ignored + extraArgs := []string{"extra", "args"} + if len(extraArgs) > 0 { + // This is fine - list commands typically ignore extra args + } +} + +func TestRunSecretsListRequiresRepoContext(t *testing.T) { + oldWd, err := os.Getwd() + require.NoError(t, err) + + require.NoError(t, os.Chdir(t.TempDir())) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test", + URL: "https://gitea.example.com", + Token: "token", + User: "tester", + Default: true, + }}, + }) + + cmd := &cli.Command{ + Name: CmdSecretsList.Name, + Flags: CmdSecretsList.Flags, + } + require.NoError(t, cmd.Set("login", "test")) + + err = RunSecretsList(t.Context(), cmd) + require.ErrorContains(t, err, "remote repository required") +} diff --git a/cmd/actions/variables.go b/cmd/actions/variables.go new file mode 100644 index 00000000..6e6c723f --- /dev/null +++ b/cmd/actions/variables.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/actions/variables" + + "github.com/urfave/cli/v3" +) + +// CmdActionsVariables represents the actions variables command +var CmdActionsVariables = cli.Command{ + Name: "variables", + Aliases: []string{"variable", "vars", "var"}, + Usage: "Manage repository action variables", + Description: "Manage variables used by repository actions and workflows", + Action: runVariablesDefault, + Commands: []*cli.Command{ + &variables.CmdVariablesList, + &variables.CmdVariablesSet, + &variables.CmdVariablesDelete, + }, +} + +func runVariablesDefault(ctx stdctx.Context, cmd *cli.Command) error { + return variables.RunVariablesList(ctx, cmd) +} diff --git a/cmd/actions/variables/delete.go b/cmd/actions/variables/delete.go new file mode 100644 index 00000000..0db6e0c8 --- /dev/null +++ b/cmd/actions/variables/delete.go @@ -0,0 +1,66 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package variables + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdVariablesDelete represents a sub command to delete action variables +var CmdVariablesDelete = cli.Command{ + Name: "delete", + Aliases: []string{"remove", "rm"}, + Usage: "Delete an action variable", + Description: "Delete a variable used by repository actions", + ArgsUsage: "", + Action: runVariablesDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", + }, + }, flags.AllDefaultFlags...), +} + +func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("variable name is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + variableName := cmd.Args().First() + + if !cmd.Bool("confirm") { + fmt.Printf("Are you sure you want to delete variable '%s'? [y/N] ", variableName) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Deletion canceled.") + return nil + } + } + + _, err = client.Actions.DeleteRepoVariable(ctx, c.Owner, c.Repo, variableName) + if err != nil { + return err + } + + fmt.Printf("Variable '%s' deleted successfully\n", variableName) + return nil +} diff --git a/cmd/actions/variables/delete_test.go b/cmd/actions/variables/delete_test.go new file mode 100644 index 00000000..2a7cb6b1 --- /dev/null +++ b/cmd/actions/variables/delete_test.go @@ -0,0 +1,98 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package variables + +import ( + "fmt" + "testing" +) + +func TestVariablesDeleteValidation(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "valid variable name", + args: []string{"VALID_VARIABLE"}, + wantErr: false, + }, + { + name: "valid lowercase name", + args: []string{"valid_variable"}, + wantErr: false, + }, + { + name: "no args", + args: []string{}, + wantErr: true, + }, + { + name: "too many args", + args: []string{"VARIABLE1", "VARIABLE2"}, + wantErr: true, + }, + { + name: "invalid variable name", + args: []string{"invalid-variable"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateVariableDeleteArgs(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("validateVariableDeleteArgs() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestVariablesDeleteFlags(t *testing.T) { + cmd := CmdVariablesDelete + + // Test command properties + if cmd.Name != "delete" { + t.Errorf("Expected command name 'delete', got %s", cmd.Name) + } + + // Check that rm is one of the aliases + hasRmAlias := false + for _, alias := range cmd.Aliases { + if alias == "rm" { + hasRmAlias = true + break + } + } + if !hasRmAlias { + t.Error("Expected 'rm' to be one of the aliases for delete command") + } + + if cmd.ArgsUsage != "" { + t.Errorf("Expected ArgsUsage '', got %s", cmd.ArgsUsage) + } + + if cmd.Usage == "" { + t.Error("Delete command should have usage text") + } + + if cmd.Description == "" { + t.Error("Delete command should have description") + } +} + +// validateVariableDeleteArgs validates arguments for the delete command +func validateVariableDeleteArgs(args []string) error { + if len(args) == 0 { + return fmt.Errorf("variable name is required") + } + + if len(args) > 1 { + return fmt.Errorf("only one variable name allowed") + } + + return validateVariableName(args[0]) +} diff --git a/cmd/actions/variables/list.go b/cmd/actions/variables/list.go new file mode 100644 index 00000000..d3b2c96c --- /dev/null +++ b/cmd/actions/variables/list.go @@ -0,0 +1,61 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package variables + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdVariablesList represents a sub command to list action variables +var CmdVariablesList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List action variables", + Description: "List variables configured for repository actions", + Action: RunVariablesList, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "show specific variable by name", + }, + }, flags.AllDefaultFlags...), +} + +// RunVariablesList list action variables +func RunVariablesList(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + if name := cmd.String("name"); name != "" { + // Get specific variable + variable, _, err := client.Actions.GetRepoVariable(ctx, c.Owner, c.Repo, name) + if err != nil { + return err + } + + print.ActionVariableDetails(variable) + return nil + } + + // List all variables - Note: SDK doesn't have ListRepoActionVariables yet + // This is a limitation of the current SDK + fmt.Println("Note: Listing all variables is not yet supported by the Gitea SDK.") + fmt.Println("Use 'tea actions variables list --name ' to get a specific variable.") + fmt.Println("You can also check your repository's Actions settings in the web interface.") + + return nil +} diff --git a/cmd/actions/variables/list_test.go b/cmd/actions/variables/list_test.go new file mode 100644 index 00000000..6d018574 --- /dev/null +++ b/cmd/actions/variables/list_test.go @@ -0,0 +1,98 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package variables + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/modules/config" +) + +func TestVariablesListFlags(t *testing.T) { + cmd := CmdVariablesList + + // Test that required flags exist + expectedFlags := []string{"output", "remote", "login", "repo"} + + for _, flagName := range expectedFlags { + found := false + for _, flag := range cmd.Flags { + if flag.Names()[0] == flagName { + found = true + break + } + } + + if !found { + t.Errorf("Expected flag %s not found in CmdVariablesList", flagName) + } + } + + // Test command properties + if cmd.Name != "list" { + t.Errorf("Expected command name 'list', got %s", cmd.Name) + } + + if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "ls" { + t.Errorf("Expected alias 'ls' for list command") + } + + if cmd.Usage == "" { + t.Error("List command should have usage text") + } + + if cmd.Description == "" { + t.Error("List command should have description") + } +} + +func TestVariablesListValidation(t *testing.T) { + // Basic validation that the command accepts the expected arguments + // More detailed testing would require mocking the Gitea client + + // Test that list command doesn't require arguments + args := []string{} + if len(args) > 0 { + t.Error("List command should not require arguments") + } + + // Test that extra arguments are ignored + extraArgs := []string{"extra", "args"} + if len(extraArgs) > 0 { + // This is fine - list commands typically ignore extra args + } +} + +func TestRunVariablesListRequiresRepoContext(t *testing.T) { + oldWd, err := os.Getwd() + require.NoError(t, err) + + require.NoError(t, os.Chdir(t.TempDir())) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test", + URL: "https://gitea.example.com", + Token: "token", + User: "tester", + Default: true, + }}, + }) + + cmd := &cli.Command{ + Name: CmdVariablesList.Name, + Flags: CmdVariablesList.Flags, + } + require.NoError(t, cmd.Set("login", "test")) + + err = RunVariablesList(t.Context(), cmd) + require.ErrorContains(t, err, "remote repository required") +} diff --git a/cmd/actions/variables/set.go b/cmd/actions/variables/set.go new file mode 100644 index 00000000..d808771d --- /dev/null +++ b/cmd/actions/variables/set.go @@ -0,0 +1,108 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package variables + +import ( + stdctx "context" + "fmt" + "regexp" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdVariablesSet represents a sub command to set action variables +var CmdVariablesSet = cli.Command{ + Name: "set", + Aliases: []string{"create", "update"}, + Usage: "Set an action variable", + Description: "Set a variable for use in repository actions and workflows", + ArgsUsage: " [variable-value]", + Action: runVariablesSet, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "file", + Usage: "read variable value from file", + }, + &cli.BoolFlag{ + Name: "stdin", + Usage: "read variable value from stdin", + }, + }, flags.AllDefaultFlags...), +} + +func runVariablesSet(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("variable name is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + variableName := cmd.Args().First() + if err := validateVariableName(variableName); err != nil { + return err + } + + // Read variable value using the utility + variableValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{ + ResourceName: "variable", + PromptMsg: fmt.Sprintf("Enter variable value for '%s'", variableName), + Hidden: false, + AllowEmpty: false, + }) + if err != nil { + return err + } + + if err := validateVariableValue(variableValue); err != nil { + return err + } + + _, err = client.Actions.CreateRepoVariable(ctx, c.Owner, c.Repo, variableName, variableValue) + if err != nil { + return err + } + + fmt.Printf("Variable '%s' set successfully\n", variableName) + return nil +} + +// validateVariableName validates that a variable name follows the required format +func validateVariableName(name string) error { + if name == "" { + return fmt.Errorf("variable name cannot be empty") + } + + // Variable names can contain letters (upper/lower), numbers, and underscores + // Cannot start with a number + // Cannot contain spaces or special characters (except underscore) + validPattern := regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + if !validPattern.MatchString(name) { + return fmt.Errorf("variable name must contain only letters, numbers, and underscores, and cannot start with a number") + } + + return nil +} + +// validateVariableValue validates that a variable value is acceptable +func validateVariableValue(value string) error { + // Variables can be empty or contain whitespace, unlike secrets + + // Check for maximum size (64KB limit) + if len(value) > 65536 { + return fmt.Errorf("variable value cannot exceed 64KB") + } + + return nil +} diff --git a/cmd/actions/variables/set_test.go b/cmd/actions/variables/set_test.go new file mode 100644 index 00000000..99b89f21 --- /dev/null +++ b/cmd/actions/variables/set_test.go @@ -0,0 +1,213 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package variables + +import ( + "strings" + "testing" +) + +func TestValidateVariableName(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid name", + input: "VALID_VARIABLE_NAME", + wantErr: false, + }, + { + name: "valid name with numbers", + input: "VARIABLE_123", + wantErr: false, + }, + { + name: "valid lowercase", + input: "valid_variable", + wantErr: false, + }, + { + name: "valid mixed case", + input: "Mixed_Case_Variable", + wantErr: false, + }, + { + name: "invalid - spaces", + input: "INVALID VARIABLE", + wantErr: true, + }, + { + name: "invalid - special chars", + input: "INVALID-VARIABLE!", + wantErr: true, + }, + { + name: "invalid - starts with number", + input: "1INVALID", + wantErr: true, + }, + { + name: "invalid - empty", + input: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateVariableName(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("validateVariableName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + }) + } +} + +func TestGetVariableSourceArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "valid args", + args: []string{"VALID_VARIABLE", "variable_value"}, + wantErr: false, + }, + { + name: "valid lowercase", + args: []string{"valid_variable", "value"}, + wantErr: false, + }, + { + name: "missing name", + args: []string{}, + wantErr: true, + }, + { + name: "too many args", + args: []string{"VARIABLE_NAME", "value", "extra"}, + wantErr: true, + }, + { + name: "invalid variable name", + args: []string{"invalid-variable", "value"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test argument validation only + if len(tt.args) == 0 { + if !tt.wantErr { + t.Error("Expected error for empty args") + } + return + } + + if len(tt.args) > 2 { + if !tt.wantErr { + t.Error("Expected error for too many args") + } + return + } + + // Test variable name validation + err := validateVariableName(tt.args[0]) + if (err != nil) != tt.wantErr { + t.Errorf("validateVariableName() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestVariableNameValidation(t *testing.T) { + // Test that variable names follow GitHub Actions/Gitea Actions conventions + validNames := []string{ + "VALID_VARIABLE", + "API_URL", + "DATABASE_HOST", + "VARIABLE_123", + "mixed_Case_Variable", + "lowercase_variable", + "UPPERCASE_VARIABLE", + } + + invalidNames := []string{ + "Invalid-Dashes", + "INVALID SPACES", + "123_STARTS_WITH_NUMBER", + "", // Empty + "INVALID!@#", // Special chars + } + + for _, name := range validNames { + t.Run("valid_"+name, func(t *testing.T) { + err := validateVariableName(name) + if err != nil { + t.Errorf("validateVariableName(%q) should be valid, got error: %v", name, err) + } + }) + } + + for _, name := range invalidNames { + t.Run("invalid_"+name, func(t *testing.T) { + err := validateVariableName(name) + if err == nil { + t.Errorf("validateVariableName(%q) should be invalid, got no error", name) + } + }) + } +} + +func TestVariableValueValidation(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + { + name: "valid value", + value: "variable123", + wantErr: false, + }, + { + name: "valid complex value", + value: "https://api.example.com/v1", + wantErr: false, + }, + { + name: "valid multiline value", + value: "line1\nline2\nline3", + wantErr: false, + }, + { + name: "empty value allowed", + value: "", + wantErr: false, // Variables can be empty unlike secrets + }, + { + name: "whitespace only allowed", + value: " \t\n ", + wantErr: false, // Variables can contain whitespace + }, + { + name: "very long value", + value: strings.Repeat("a", 65537), // Over 64KB + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateVariableValue(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("validateVariableValue() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/cmd/actions/workflows.go b/cmd/actions/workflows.go new file mode 100644 index 00000000..a7e692d7 --- /dev/null +++ b/cmd/actions/workflows.go @@ -0,0 +1,32 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/actions/workflows" + + "github.com/urfave/cli/v3" +) + +// CmdActionsWorkflows represents the actions workflows command +var CmdActionsWorkflows = cli.Command{ + Name: "workflows", + Aliases: []string{"workflow"}, + Usage: "Manage repository workflows", + Description: "List and manage repository action workflows", + Action: runWorkflowsDefault, + Commands: []*cli.Command{ + &workflows.CmdWorkflowsList, + &workflows.CmdWorkflowsView, + &workflows.CmdWorkflowsDispatch, + &workflows.CmdWorkflowsEnable, + &workflows.CmdWorkflowsDisable, + }, +} + +func runWorkflowsDefault(ctx stdctx.Context, cmd *cli.Command) error { + return workflows.RunWorkflowsList(ctx, cmd) +} diff --git a/cmd/actions/workflows/disable.go b/cmd/actions/workflows/disable.go new file mode 100644 index 00000000..8ed5b0d3 --- /dev/null +++ b/cmd/actions/workflows/disable.go @@ -0,0 +1,65 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsDisable represents a sub command to disable a workflow +var CmdWorkflowsDisable = cli.Command{ + Name: "disable", + Usage: "Disable a workflow", + Description: "Disable a workflow in the repository", + ArgsUsage: "", + Action: runWorkflowsDisable, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm disable without prompting", + }, + }, flags.AllDefaultFlags...), +} + +func runWorkflowsDisable(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("workflow ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + workflowID := cmd.Args().First() + + if !cmd.Bool("confirm") { + fmt.Printf("Are you sure you want to disable workflow %s? [y/N] ", workflowID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Disable canceled.") + return nil + } + } + + _, err = client.Actions.DisableRepoWorkflow(ctx, c.Owner, c.Repo, workflowID) + if err != nil { + return fmt.Errorf("failed to disable workflow: %w", err) + } + + fmt.Printf("Workflow %s disabled successfully\n", workflowID) + return nil +} diff --git a/cmd/actions/workflows/dispatch.go b/cmd/actions/workflows/dispatch.go new file mode 100644 index 00000000..7d28b235 --- /dev/null +++ b/cmd/actions/workflows/dispatch.go @@ -0,0 +1,174 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + "strings" + "time" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsDispatch represents a sub command to dispatch a workflow +var CmdWorkflowsDispatch = cli.Command{ + Name: "dispatch", + Aliases: []string{"trigger", "run"}, + Usage: "Dispatch a workflow run", + Description: "Trigger a workflow_dispatch event for a workflow", + ArgsUsage: "", + Action: runWorkflowsDispatch, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "ref", + Aliases: []string{"r"}, + Usage: "branch or tag to dispatch on (default: current branch)", + }, + &cli.StringSliceFlag{ + Name: "input", + Aliases: []string{"i"}, + Usage: "workflow input in key=value format (can be specified multiple times)", + }, + &cli.BoolFlag{ + Name: "follow", + Aliases: []string{"f"}, + Usage: "follow log output after dispatching", + }, + }, flags.AllDefaultFlags...), +} + +func runWorkflowsDispatch(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("workflow ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + workflowID := cmd.Args().First() + + ref := cmd.String("ref") + if ref == "" { + if c.LocalRepo != nil { + branchName, _, localErr := c.LocalRepo.TeaGetCurrentBranchNameAndSHA() + if localErr == nil && branchName != "" { + ref = branchName + } + } + if ref == "" { + return fmt.Errorf("--ref is required (no local branch detected)") + } + } + + inputs := make(map[string]string) + for _, input := range cmd.StringSlice("input") { + key, value, ok := strings.Cut(input, "=") + if !ok { + return fmt.Errorf("invalid input format %q, expected key=value", input) + } + inputs[key] = value + } + + opt := gitea.CreateActionWorkflowDispatchOption{ + Ref: ref, + Inputs: inputs, + } + + details, _, err := client.Actions.DispatchRepoWorkflow(ctx, c.Owner, c.Repo, workflowID, opt, true) + if err != nil { + return fmt.Errorf("failed to dispatch workflow: %w", err) + } + + print.ActionWorkflowDispatchResult(details) + + if cmd.Bool("follow") && details != nil && details.WorkflowRunID > 0 { + return followDispatchedRun(ctx, client, c, details.WorkflowRunID) + } + + return nil +} + +const ( + followPollInterval = 2 * time.Second + followMaxDuration = 30 * time.Minute +) + +// followDispatchedRun waits for the dispatched run to start, then follows its logs +func followDispatchedRun(ctx stdctx.Context, client *gitea.Client, c *context.TeaContext, runID int64) error { + fmt.Printf("\nWaiting for run %d to start...\n", runID) + + var jobs *gitea.ActionWorkflowJobsResponse + for range 30 { + time.Sleep(followPollInterval) + + var err error + jobs, _, err = client.Actions.ListRepoJobsByRun(ctx, c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{}) + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + if len(jobs.Jobs) > 0 { + break + } + } + + if jobs == nil || len(jobs.Jobs) == 0 { + return fmt.Errorf("timed out waiting for jobs to appear") + } + + jobID := jobs.Jobs[0].ID + jobName := jobs.Jobs[0].Name + fmt.Printf("Following logs for job '%s' (ID: %d) - press Ctrl+C to stop...\n", jobName, jobID) + fmt.Println("---") + + deadline := time.Now().Add(followMaxDuration) + var lastLogLength int + for time.Now().Before(deadline) { + job, _, err := client.Actions.GetRepoRunJob(ctx, c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get job: %w", err) + } + + isRunning := job.Status == "in_progress" || job.Status == "queued" || job.Status == "pending" + + logs, _, logErr := client.Actions.GetRepoRunJobLogs(ctx, c.Owner, c.Repo, jobID) + if logErr != nil && isRunning { + time.Sleep(followPollInterval) + continue + } + + if logErr == nil && len(logs) > lastLogLength { + fmt.Print(string(logs[lastLogLength:])) + lastLogLength = len(logs) + } + + if !isRunning { + if logErr != nil { + fmt.Printf("\n---\nJob completed with status: %s (failed to fetch final logs: %v)\n", job.Status, logErr) + } else { + fmt.Printf("\n---\nJob completed with status: %s\n", job.Status) + } + break + } + + time.Sleep(followPollInterval) + } + + if time.Now().After(deadline) { + return fmt.Errorf("timed out after %s following logs", followMaxDuration) + } + + return nil +} diff --git a/cmd/actions/workflows/enable.go b/cmd/actions/workflows/enable.go new file mode 100644 index 00000000..dbe2e1e4 --- /dev/null +++ b/cmd/actions/workflows/enable.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsEnable represents a sub command to enable a workflow +var CmdWorkflowsEnable = cli.Command{ + Name: "enable", + Usage: "Enable a workflow", + Description: "Enable a disabled workflow in the repository", + ArgsUsage: "", + Action: runWorkflowsEnable, + Flags: flags.AllDefaultFlags, +} + +func runWorkflowsEnable(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("workflow ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + workflowID := cmd.Args().First() + _, err = client.Actions.EnableRepoWorkflow(ctx, c.Owner, c.Repo, workflowID) + if err != nil { + return fmt.Errorf("failed to enable workflow: %w", err) + } + + fmt.Printf("Workflow %s enabled successfully\n", workflowID) + return nil +} diff --git a/cmd/actions/workflows/list.go b/cmd/actions/workflows/list.go new file mode 100644 index 00000000..31a88646 --- /dev/null +++ b/cmd/actions/workflows/list.go @@ -0,0 +1,50 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsList represents a sub command to list workflows +var CmdWorkflowsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List repository workflows", + Description: "List workflows in the repository with their status", + Action: RunWorkflowsList, + Flags: flags.AllDefaultFlags, +} + +// RunWorkflowsList lists workflows in the repository using the workflow API +func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + resp, _, err := client.Actions.ListRepoWorkflows(ctx, c.Owner, c.Repo) + if err != nil { + return fmt.Errorf("failed to list workflows: %w", err) + } + + var workflows []*gitea.ActionWorkflow + if resp != nil { + workflows = resp.Workflows + } + + return print.ActionWorkflowsList(workflows, c.Output) +} diff --git a/cmd/actions/workflows/view.go b/cmd/actions/workflows/view.go new file mode 100644 index 00000000..0d86de47 --- /dev/null +++ b/cmd/actions/workflows/view.go @@ -0,0 +1,50 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsView represents a sub command to view workflow details +var CmdWorkflowsView = cli.Command{ + Name: "view", + Aliases: []string{"show", "get"}, + Usage: "View workflow details", + Description: "View details of a specific workflow", + ArgsUsage: "", + Action: runWorkflowsView, + Flags: flags.AllDefaultFlags, +} + +func runWorkflowsView(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("workflow ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + workflowID := cmd.Args().First() + wf, _, err := client.Actions.GetRepoWorkflow(ctx, c.Owner, c.Repo, workflowID) + if err != nil { + return fmt.Errorf("failed to get workflow: %w", err) + } + + print.ActionWorkflowDetails(wf) + return nil +} diff --git a/cmd/admin.go b/cmd/admin.go new file mode 100644 index 00000000..306226be --- /dev/null +++ b/cmd/admin.go @@ -0,0 +1,63 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/admin/users" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" +) + +// CmdAdmin represents the namespace of admin commands. +// The command itself has no functionality, but hosts subcommands. +var CmdAdmin = cli.Command{ + Name: "admin", + Usage: "Operations requiring admin access on the Gitea instance", + Aliases: []string{"a"}, + Category: catMisc, + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + return cli.ShowSubcommandHelp(cmd) + }, + Commands: []*cli.Command{ + &cmdAdminUsers, + }, +} + +var cmdAdminUsers = cli.Command{ + Name: "users", + Aliases: []string{"u"}, + Usage: "Manage registered users", + Action: func(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 1 { + return runAdminUserDetail(ctx, cmd, cmd.Args().First()) + } + return users.RunUserList(ctx, cmd) + }, + Commands: []*cli.Command{ + &users.CmdUserList, + &users.CmdUserCreate, + &users.CmdUserEdit, + &users.CmdUserDelete, + }, + Flags: users.CmdUserList.Flags, +} + +func runAdminUserDetail(requestCtx stdctx.Context, cmd *cli.Command, u string) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := ctx.Login.Client() + user, _, err := client.Users.GetUserInfo(requestCtx, u) + if err != nil { + return err + } + + print.UserDetails(user) + return nil +} diff --git a/cmd/admin/users/create.go b/cmd/admin/users/create.go new file mode 100644 index 00000000..f685639e --- /dev/null +++ b/cmd/admin/users/create.go @@ -0,0 +1,220 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package users + +import ( + stdctx "context" + "fmt" + "io" + "os" + "strings" + "syscall" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// CmdUserCreate represents a sub command of users to create a user +var CmdUserCreate = cli.Command{ + Name: "create", + Aliases: []string{"add", "new"}, + Usage: "Create a new user", + Description: "Create a new user account", + ArgsUsage: " ", // command does not accept arguments + Action: RunUserCreate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "Username for the new user (required)", + Required: true, + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "Password for the new user (will prompt if not provided)", + }, + &cli.StringFlag{ + Name: "password-file", + Usage: "Read password from file", + }, + &cli.BoolFlag{ + Name: "password-stdin", + Usage: "Read password from stdin", + }, + &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "Email address for the new user (required)", + Required: true, + }, + &cli.StringFlag{ + Name: "full-name", + Usage: "Full name for the new user", + }, + &cli.BoolFlag{ + Name: "admin", + Usage: "Make the user an administrator", + }, + &cli.BoolFlag{ + Name: "restricted", + Usage: "Make the user restricted", + }, + &cli.BoolFlag{ + Name: "prohibit-login", + Usage: "Prohibit the user from logging in", + }, + &cli.BoolFlag{ + Name: "no-must-change-password", + Usage: "Don't require the user to change password on first login (default: password change required)", + }, + &cli.StringFlag{ + Name: "visibility", + Usage: "Visibility of the user profile (public, limited, private)", + Value: "public", + }, + }, flags.AllDefaultFlags...), +} + +// RunUserCreate creates a new user +func RunUserCreate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + username := ctx.String("username") + password := ctx.String("password") + email := ctx.String("email") + fullName := ctx.String("full-name") + isAdmin := ctx.Bool("admin") + restricted := ctx.Bool("restricted") + prohibitLogin := ctx.Bool("prohibit-login") + noMustChangePassword := ctx.Bool("no-must-change-password") + visibility := ctx.String("visibility") + + // Get password from various sources in priority order + if password == "" { + if ctx.String("password-file") != "" { + // Read from file + content, err := os.ReadFile(ctx.String("password-file")) + if err != nil { + return fmt.Errorf("failed to read password file: %w", err) + } + password = strings.TrimSpace(string(content)) + } else if ctx.Bool("password-stdin") { + // Read from stdin + content, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read password from stdin: %w", err) + } + password = strings.TrimSpace(string(content)) + } else { + // Interactive prompt (hidden input) + fmt.Printf("Enter password for '%s': ", username) + bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + fmt.Println() // Add newline after hidden input + password = string(bytePassword) + + if password == "" { + return fmt.Errorf("password cannot be empty") + } + + // Confirm password (only for interactive mode) + fmt.Printf("Confirm password for '%s': ", username) + bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password confirmation: %w", err) + } + fmt.Println() // Add newline after hidden input + passwordConfirm := string(bytePasswordConfirm) + + if password != passwordConfirm { + return fmt.Errorf("passwords do not match") + } + } + } + + if password == "" { + return fmt.Errorf("password cannot be empty") + } + + if email == "" { + return fmt.Errorf("email is required") + } + + client := ctx.Login.Client() + + // Build create options + createOpts := gitea.CreateUserOption{ + LoginName: username, + Username: username, + Password: password, + Email: email, + FullName: fullName, + SendNotify: false, + } + + // Set must change password flag (pointer to bool required) + // By default, require user to change password on first login + // Only set to false if --no-must-change-password flag is explicitly set + mustChangePassword := !noMustChangePassword + createOpts.MustChangePassword = &mustChangePassword + + vis, err := parseUserVisibility(visibility) + if err != nil { + return err + } + createOpts.Visibility = vis + + // Create the user + user, _, err := client.Admin.CreateUser(requestCtx, createOpts) + if err != nil { + return err + } + + // Admin, Restricted, and ProhibitLogin cannot be set during user creation + // We need to update them via AdminEditUser after creation if any of these flags are set + if isAdmin || restricted || prohibitLogin { + editOpts := gitea.EditUserOption{ + LoginName: username, // Required field + } + + if isAdmin { + editOpts.Admin = &isAdmin + } + + if restricted { + editOpts.Restricted = &restricted + } + + if prohibitLogin { + editOpts.ProhibitLogin = &prohibitLogin + } + + // Update user with admin/restricted/prohibit-login settings + _, err = client.Admin.EditUser(requestCtx, username, editOpts) + if err != nil { + return fmt.Errorf("user created but failed to update admin/restricted/prohibit-login status: %w", err) + } + + // Refresh user info to reflect the changes + user, _, err = client.Users.GetUserInfo(requestCtx, username) + if err != nil { + return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err) + } + } + + print.UserDetails(user) + + return nil +} diff --git a/cmd/admin/users/delete.go b/cmd/admin/users/delete.go new file mode 100644 index 00000000..f0dcc90a --- /dev/null +++ b/cmd/admin/users/delete.go @@ -0,0 +1,77 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package users + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdUserDelete represents a sub command of users to delete a user +var CmdUserDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm", "remove"}, + Usage: "Delete a user", + Description: "Delete a user account", + ArgsUsage: "", + Action: RunUserDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", + }, + }, flags.AllDefaultFlags...), +} + +// RunUserDelete deletes a user +func RunUserDelete(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("username is required") + } + + client := ctx.Login.Client() + username := ctx.Args().First() + + // Get user details first to show what we're deleting + user, _, err := client.Users.GetUserInfo(requestCtx, username) + if err != nil { + return fmt.Errorf("failed to get user info: %w", err) + } + + if !ctx.Bool("confirm") { + userInfo := fmt.Sprintf("%s (ID: %d)", user.UserName, user.ID) + if user.Email != "" { + userInfo += fmt.Sprintf(" - %s", user.Email) + } + if user.IsAdmin { + userInfo += " [admin]" + } + fmt.Printf("Are you sure you want to delete user %s? [y/N] ", userInfo) + var response string + fmt.Scanln(&response) + if !isConfirmationAccepted(response) { + fmt.Println("Deletion canceled.") + return nil + } + } + + _, err = client.Admin.DeleteUser(requestCtx, username) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + + fmt.Printf("User '%s' deleted successfully\n", username) + return nil +} diff --git a/cmd/admin/users/edit.go b/cmd/admin/users/edit.go new file mode 100644 index 00000000..fa1475ee --- /dev/null +++ b/cmd/admin/users/edit.go @@ -0,0 +1,373 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package users + +import ( + stdctx "context" + "fmt" + "io" + "os" + "strings" + "syscall" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// CmdUserEdit represents a sub command of users to edit a user +var CmdUserEdit = cli.Command{ + Name: "edit", + Aliases: []string{"update", "e", "u"}, + Usage: "Edit a user", + Description: "Edit user account properties", + ArgsUsage: "", + Action: RunUserEdit, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "password", + Usage: "New password (use empty value --password=\"\" to trigger interactive prompt)", + Value: "", + }, + &cli.StringFlag{ + Name: "password-file", + Usage: "Read password from file", + }, + &cli.BoolFlag{ + Name: "password-stdin", + Usage: "Read password from stdin", + }, + &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "Email address", + }, + &cli.StringFlag{ + Name: "full-name", + Usage: "Full name", + }, + &cli.StringFlag{ + Name: "description", + Usage: "User description", + }, + &cli.StringFlag{ + Name: "website", + Usage: "Website URL", + }, + &cli.StringFlag{ + Name: "location", + Usage: "Location", + }, + &cli.BoolFlag{ + Name: "admin", + Usage: "Make the user an administrator", + }, + &cli.BoolFlag{ + Name: "no-admin", + Usage: "Remove administrator status", + }, + &cli.BoolFlag{ + Name: "restricted", + Usage: "Make the user restricted", + }, + &cli.BoolFlag{ + Name: "no-restricted", + Usage: "Remove restricted status", + }, + &cli.BoolFlag{ + Name: "prohibit-login", + Usage: "Prohibit the user from logging in", + }, + &cli.BoolFlag{ + Name: "allow-login", + Usage: "Allow the user to log in", + }, + &cli.BoolFlag{ + Name: "active", + Usage: "Activate the user", + }, + &cli.BoolFlag{ + Name: "inactive", + Usage: "Deactivate the user", + }, + &cli.BoolFlag{ + Name: "no-must-change-password", + Usage: "Don't require the user to change password on next login (default: password change required)", + }, + &cli.StringFlag{ + Name: "visibility", + Usage: "Visibility of the user profile (public, limited, private)", + }, + &cli.IntFlag{ + Name: "max-repo-creation", + Usage: "Maximum number of repositories the user can create (-1 for unlimited)", + }, + &cli.BoolFlag{ + Name: "allow-git-hook", + Usage: "Allow the user to use git hooks", + }, + &cli.BoolFlag{ + Name: "no-allow-git-hook", + Usage: "Disallow the user from using git hooks", + }, + &cli.BoolFlag{ + Name: "allow-import-local", + Usage: "Allow the user to import local repositories", + }, + &cli.BoolFlag{ + Name: "no-allow-import-local", + Usage: "Disallow the user from importing local repositories", + }, + &cli.BoolFlag{ + Name: "allow-create-organization", + Usage: "Allow the user to create organizations", + }, + &cli.BoolFlag{ + Name: "no-allow-create-organization", + Usage: "Disallow the user from creating organizations", + }, + }, flags.AllDefaultFlags...), +} + +// RunUserEdit edits an existing user +func RunUserEdit(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("username is required") + } + + client := ctx.Login.Client() + username := ctx.Args().First() + + // Verify the user exists before attempting an update. + _, _, err = client.Users.GetUserInfo(requestCtx, username) + if err != nil { + return fmt.Errorf("failed to get user info: %w", err) + } + + // Build edit options, starting with required LoginName + editOpts := gitea.EditUserOption{ + LoginName: username, + } + + // Update email if set + if ctx.IsSet("email") { + email := ctx.String("email") + editOpts.Email = &email + } + + // Update full name if set + if ctx.IsSet("full-name") { + fullName := ctx.String("full-name") + editOpts.FullName = &fullName + } + + // Update description if set + if ctx.IsSet("description") { + description := ctx.String("description") + editOpts.Description = &description + } + + // Update website if set + if ctx.IsSet("website") { + website := ctx.String("website") + editOpts.Website = &website + } + + // Update location if set + if ctx.IsSet("location") { + location := ctx.String("location") + editOpts.Location = &location + } + + // Handle admin status + if ctx.IsSet("admin") { + admin := ctx.Bool("admin") + editOpts.Admin = &admin + } else if ctx.IsSet("no-admin") { + admin := false + editOpts.Admin = &admin + } + + // Handle restricted status + if ctx.IsSet("restricted") { + restricted := ctx.Bool("restricted") + editOpts.Restricted = &restricted + } else if ctx.IsSet("no-restricted") { + restricted := false + editOpts.Restricted = &restricted + } + + // Handle prohibit login status + if ctx.IsSet("prohibit-login") { + prohibitLogin := ctx.Bool("prohibit-login") + editOpts.ProhibitLogin = &prohibitLogin + } else if ctx.IsSet("allow-login") { + prohibitLogin := false + editOpts.ProhibitLogin = &prohibitLogin + } + + // Handle active status + if ctx.IsSet("active") { + active := ctx.Bool("active") + editOpts.Active = &active + } else if ctx.IsSet("inactive") { + active := false + editOpts.Active = &active + } + + // Handle must change password - will be set when password is changed unless flag is set + + // Handle visibility + if ctx.IsSet("visibility") { + vis, err := parseUserVisibility(ctx.String("visibility")) + if err != nil { + return err + } + editOpts.Visibility = vis + } + + // Handle max repo creation + if ctx.IsSet("max-repo-creation") { + maxRepoCreation := ctx.Int("max-repo-creation") + editOpts.MaxRepoCreation = &maxRepoCreation + } + + // Handle allow git hook + if ctx.IsSet("allow-git-hook") { + allowGitHook := ctx.Bool("allow-git-hook") + editOpts.AllowGitHook = &allowGitHook + } else if ctx.IsSet("no-allow-git-hook") { + allowGitHook := false + editOpts.AllowGitHook = &allowGitHook + } + + // Handle allow import local + if ctx.IsSet("allow-import-local") { + allowImportLocal := ctx.Bool("allow-import-local") + editOpts.AllowImportLocal = &allowImportLocal + } else if ctx.IsSet("no-allow-import-local") { + allowImportLocal := false + editOpts.AllowImportLocal = &allowImportLocal + } + + // Handle allow create organization + if ctx.IsSet("allow-create-organization") { + allowCreateOrg := ctx.Bool("allow-create-organization") + editOpts.AllowCreateOrganization = &allowCreateOrg + } else if ctx.IsSet("no-allow-create-organization") { + allowCreateOrg := false + editOpts.AllowCreateOrganization = &allowCreateOrg + } + + // Handle password if any password flag is set or if password flag was provided (even without value) + shouldChangePassword := ctx.IsSet("password") || ctx.IsSet("password-file") || ctx.Bool("password-stdin") + if shouldChangePassword { + password := ctx.String("password") + + // Get password from various sources in priority order + if password == "" { + if ctx.IsSet("password-file") && ctx.String("password-file") != "" { + // Read from file + content, err := os.ReadFile(ctx.String("password-file")) + if err != nil { + return fmt.Errorf("failed to read password file: %w", err) + } + password = strings.TrimSpace(string(content)) + } else if ctx.Bool("password-stdin") { + // Read from stdin + content, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read password from stdin: %w", err) + } + password = strings.TrimSpace(string(content)) + } else { + // Interactive prompt (hidden input) - triggered when --password is used without value + fmt.Printf("Enter new password for '%s': ", username) + bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + fmt.Println() // Add newline after hidden input + password = string(bytePassword) + + if password == "" { + return fmt.Errorf("password cannot be empty") + } + + // Confirm password (only for interactive mode) + fmt.Printf("Confirm new password for '%s': ", username) + bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password confirmation: %w", err) + } + fmt.Println() // Add newline after hidden input + passwordConfirm := string(bytePasswordConfirm) + + if password != passwordConfirm { + return fmt.Errorf("passwords do not match") + } + } + } + + if password == "" { + return fmt.Errorf("password cannot be empty") + } + + editOpts.Password = password + + // When password is changed, require user to change password on next login by default + // Only set to false if --no-must-change-password flag is explicitly set + if !ctx.IsSet("no-must-change-password") { + mustChangePassword := true + editOpts.MustChangePassword = &mustChangePassword + } else { + mustChangePassword := false + editOpts.MustChangePassword = &mustChangePassword + } + } + + // Only proceed with update if at least one field is being modified + hasChanges := editOpts.Email != nil || + editOpts.FullName != nil || + editOpts.Description != nil || + editOpts.Website != nil || + editOpts.Location != nil || + editOpts.Admin != nil || + editOpts.Restricted != nil || + editOpts.ProhibitLogin != nil || + editOpts.Active != nil || + editOpts.Visibility != nil || + editOpts.MaxRepoCreation != nil || + editOpts.AllowGitHook != nil || + editOpts.AllowImportLocal != nil || + editOpts.AllowCreateOrganization != nil || + editOpts.Password != "" + + if !hasChanges { + return fmt.Errorf("no changes specified") + } + + // Update the user + _, err = client.Admin.EditUser(requestCtx, username, editOpts) + if err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + // Refresh user info to reflect the changes + updatedUser, _, err := client.Users.GetUserInfo(requestCtx, username) + if err != nil { + return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err) + } + + print.UserDetails(updatedUser) + return nil +} diff --git a/cmd/admin/users/list.go b/cmd/admin/users/list.go new file mode 100644 index 00000000..715d25af --- /dev/null +++ b/cmd/admin/users/list.go @@ -0,0 +1,56 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package users + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +var userFieldsFlag = flags.FieldsFlag(print.UserFields, []string{ + "id", "login", "full_name", "email", "activated", +}) + +// CmdUserList represents a sub command of users to list users +var CmdUserList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List Users", + Description: "List users", + Action: RunUserList, + Flags: append([]cli.Flag{ + userFieldsFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunUserList list users +func RunUserList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + fields, err := userFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + + client := ctx.Login.Client() + users, _, err := client.Admin.ListUsers(requestCtx, gitea.AdminListUsersOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + return print.UserList(users, ctx.Output, fields) +} diff --git a/cmd/admin/users/shared.go b/cmd/admin/users/shared.go new file mode 100644 index 00000000..5abbaec5 --- /dev/null +++ b/cmd/admin/users/shared.go @@ -0,0 +1,32 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package users + +import ( + "fmt" + "strings" + + "gitea.dev/sdk" +) + +func parseUserVisibility(visibility string) (*gitea.VisibleType, error) { + switch visibility { + case "public": + vis := gitea.VisibleTypePublic + return &vis, nil + case "limited": + vis := gitea.VisibleTypeLimited + return &vis, nil + case "private": + vis := gitea.VisibleTypePrivate + return &vis, nil + default: + return nil, fmt.Errorf("invalid visibility: %s (must be public, limited, or private)", visibility) + } +} + +func isConfirmationAccepted(response string) bool { + trimmed := strings.TrimSpace(response) + return strings.EqualFold(trimmed, "y") || strings.EqualFold(trimmed, "yes") +} diff --git a/cmd/api.go b/cmd/api.go new file mode 100644 index 00000000..6c5c22a5 --- /dev/null +++ b/cmd/api.go @@ -0,0 +1,408 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + stdctx "context" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/api" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// apiFlags returns a fresh set of flag instances for the api command. +// This is a factory function so that each invocation gets independent flag +// objects, avoiding shared hasBeenSet state across tests. +func apiFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "method", + Aliases: []string{"X"}, + Usage: "HTTP method (GET, POST, PUT, PATCH, DELETE)", + Value: "GET", + }, + &cli.StringSliceFlag{ + Name: "field", + Aliases: []string{"f"}, + Usage: "Add a string field to the request body (key=value)", + }, + &cli.StringSliceFlag{ + Name: "Field", + Aliases: []string{"F"}, + Usage: "Add a typed field to the request body (key=value, @file, or @- for stdin)", + }, + &cli.StringSliceFlag{ + Name: "header", + Aliases: []string{"H"}, + Usage: "Add a custom header (key:value)", + }, + &cli.StringFlag{ + Name: "data", + Aliases: []string{"d"}, + Usage: "Raw JSON request body (use @file to read from file, @- for stdin)", + }, + &cli.BoolFlag{ + Name: "include", + Aliases: []string{"i"}, + Usage: "Include HTTP status and response headers in output (written to stderr)", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Write response body to file instead of stdout (use '-' for stdout)", + }, + } +} + +// CmdApi represents the api command +var CmdApi = cli.Command{ + Name: "api", + Category: catHelpers, + DisableSliceFlagSeparator: true, + Usage: "Make an authenticated API request", + Description: `Makes an authenticated HTTP request to the Gitea API and prints the response. + +The endpoint argument is the path to the API endpoint, which will be prefixed +with /api/v1/ if it doesn't start with /api/ or http(s)://. + +Placeholders like {owner} and {repo} in the endpoint will be replaced with +values from the current repository context. + +Use -f for string fields and -F for typed fields (numbers, booleans, null). +With -F, prefix value with @ to read from file (@- for stdin). Values starting +with [ or { are parsed as JSON arrays/objects. Wrap values in quotes to force +string type (e.g., -F key="null" for literal string "null"). + +Use -d/--data to send a raw JSON body. Use @file to read from a file, or @- +to read from stdin. The -d flag cannot be combined with -f or -F. + +When a request body is provided via -f, -F, or -d, the method defaults to POST +unless explicitly set with -X/--method. + +Note: if your endpoint contains ? or &, quote it to prevent shell expansion +(e.g., '/repos/{owner}/{repo}/issues?state=open').`, + ArgsUsage: "", + Action: runApi, + Flags: append(apiFlags(), flags.LoginRepoFlags...), +} + +type preparedAPIRequest struct { + Method string + Endpoint string + Headers map[string]string + Body []byte +} + +func runApi(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + request, err := prepareAPIRequest(cmd, ctx) + if err != nil { + return err + } + + var body io.Reader + if request.Body != nil { + body = bytes.NewReader(request.Body) + } + + // Create API client and make request + client := api.NewClient(ctx.Login) + resp, err := client.Do(request.Method, request.Endpoint, body, request.Headers) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close response body: %v\n", closeErr) + } + }() + + // Print headers to stderr if requested (so redirects/pipes work correctly) + if cmd.Bool("include") { + fmt.Fprintf(os.Stderr, "%s %s\n", resp.Proto, resp.Status) + for key, values := range resp.Header { + for _, value := range values { + fmt.Fprintf(os.Stderr, "%s: %s\n", key, value) + } + } + fmt.Fprintln(os.Stderr) + } + + // Determine output destination + outputPath := cmd.String("output") + forceStdout := outputPath == "-" + outputToStdout := outputPath == "" || forceStdout + + // Check for binary output to terminal (skip warning if user explicitly forced stdout) + if outputToStdout && !forceStdout && term.IsTerminal(int(os.Stdout.Fd())) && !isTextContentType(resp.Header.Get("Content-Type")) { + fmt.Fprintln(os.Stderr, "Warning: Binary output detected. Use '-o ' to save to a file,") + fmt.Fprintln(os.Stderr, "or '-o -' to force output to terminal.") + return nil + } + + var output io.Writer = os.Stdout + if !outputToStdout { + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", closeErr) + } + }() + output = file + } + + // Copy response body to output + _, err = io.Copy(output, resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + // Add newline for better terminal display + if outputToStdout && term.IsTerminal(int(os.Stdout.Fd())) { + fmt.Println() + } + + return nil +} + +func prepareAPIRequest(cmd *cli.Command, ctx *context.TeaContext) (*preparedAPIRequest, error) { + var err error + + // Get the endpoint argument + if cmd.NArg() < 1 { + return nil, fmt.Errorf("endpoint argument required") + } + endpoint := cmd.Args().First() + + // Expand placeholders in endpoint + endpoint = expandPlaceholders(endpoint, ctx) + + // Parse headers + headers := make(map[string]string) + for _, h := range cmd.StringSlice("header") { + parts := strings.SplitN(h, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid header format: %q (expected key:value)", h) + } + headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + // Build request body from fields + var bodyBytes []byte + stringFields := cmd.StringSlice("field") + typedFields := cmd.StringSlice("Field") + dataRaw := cmd.String("data") + + if dataRaw != "" && (len(stringFields) > 0 || len(typedFields) > 0) { + return nil, fmt.Errorf("--data/-d cannot be combined with --field/-f or --Field/-F") + } + + if dataRaw != "" { + var dataBytes []byte + var dataSource string + if strings.HasPrefix(dataRaw, "@") { + filename := dataRaw[1:] + if filename == "-" { + dataBytes, err = io.ReadAll(os.Stdin) + dataSource = "stdin" + } else { + dataBytes, err = os.ReadFile(filename) + dataSource = filename + } + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", dataRaw, err) + } + } else { + dataBytes = []byte(dataRaw) + } + if !json.Valid(dataBytes) { + if dataSource != "" { + return nil, fmt.Errorf("--data/-d value from %s is not valid JSON", dataSource) + } + return nil, fmt.Errorf("--data/-d value is not valid JSON") + } + bodyBytes = dataBytes + } else if len(stringFields) > 0 || len(typedFields) > 0 { + bodyMap := make(map[string]any) + + // Process string fields (-f) + for _, f := range stringFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + key := parts[0] + if key == "" { + return nil, fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return nil, fmt.Errorf("duplicate field key %q", key) + } + bodyMap[key] = parts[1] + } + + // Process typed fields (-F) + for _, f := range typedFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + key := parts[0] + if key == "" { + return nil, fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return nil, fmt.Errorf("duplicate field key %q", key) + } + value := parts[1] + + parsedValue, err := parseTypedValue(value) + if err != nil { + return nil, fmt.Errorf("failed to parse field %q: %w", key, err) + } + bodyMap[key] = parsedValue + } + + bodyBytes, err = json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("failed to encode request body: %w", err) + } + } + method := strings.ToUpper(cmd.String("method")) + if !cmd.IsSet("method") { + if bodyBytes != nil { + method = "POST" + } else { + method = "GET" + } + } + + return &preparedAPIRequest{ + Method: method, + Endpoint: endpoint, + Headers: headers, + Body: bodyBytes, + }, nil +} + +// parseTypedValue parses a value for -F flag, handling: +// - @filename: read content from file +// - @-: read content from stdin +// - "quoted": literal string (prevents type parsing) +// - true/false: boolean +// - null: nil +// - numbers: int or float +// - []/{}: JSON arrays/objects +// - otherwise: string +func parseTypedValue(value string) (any, error) { + // Handle file references. + // Note: if multiple fields use @- (stdin), only the first will get data; + // subsequent reads will return empty since stdin is consumed once. + if strings.HasPrefix(value, "@") { + filename := value[1:] + var content []byte + var err error + + if filename == "-" { + content, err = io.ReadAll(os.Stdin) + } else { + content, err = os.ReadFile(filename) + } + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", value, err) + } + return strings.TrimSuffix(string(content), "\n"), nil + } + + // Handle quoted strings (literal strings, no type parsing). + // Uses strconv.Unquote so escape sequences like \" are handled correctly. + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + unquoted, err := strconv.Unquote(value) + if err != nil { + return nil, fmt.Errorf("invalid quoted string %s: %w", value, err) + } + return unquoted, nil + } + + // Handle null + if value == "null" { + return nil, nil + } + + // Handle booleans + if value == "true" { + return true, nil + } + if value == "false" { + return false, nil + } + + // Handle integers + if i, err := strconv.ParseInt(value, 10, 64); err == nil { + return i, nil + } + + // Handle floats + if f, err := strconv.ParseFloat(value, 64); err == nil { + return f, nil + } + + // Handle JSON arrays and objects + if len(value) > 0 && (value[0] == '[' || value[0] == '{') { + var jsonVal any + if err := json.Unmarshal([]byte(value), &jsonVal); err == nil { + return jsonVal, nil + } + } + + // Default to string + return value, nil +} + +// isTextContentType returns true if the content type indicates text data +func isTextContentType(contentType string) bool { + if contentType == "" { + return true // assume text if unknown + } + contentType = strings.ToLower(strings.Split(contentType, ";")[0]) // strip charset + + return strings.HasPrefix(contentType, "text/") || + strings.Contains(contentType, "json") || + strings.Contains(contentType, "xml") || + strings.Contains(contentType, "javascript") || + strings.Contains(contentType, "yaml") || + strings.Contains(contentType, "toml") +} + +// expandPlaceholders replaces {owner}, {repo}, and {branch} in the endpoint +func expandPlaceholders(endpoint string, ctx *context.TeaContext) string { + endpoint = strings.ReplaceAll(endpoint, "{owner}", ctx.Owner) + endpoint = strings.ReplaceAll(endpoint, "{repo}", ctx.Repo) + + // Get current branch if available + if ctx.LocalRepo != nil { + if branch, err := ctx.LocalRepo.Head(); err == nil { + branchName := branch.Name().Short() + endpoint = strings.ReplaceAll(endpoint, "{branch}", branchName) + } + } + + return endpoint +} diff --git a/cmd/api_test.go b/cmd/api_test.go new file mode 100644 index 00000000..8e92b5e0 --- /dev/null +++ b/cmd/api_test.go @@ -0,0 +1,632 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "testing" + + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/context" + tea_git "gitea.dev/tea/modules/git" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestParseTypedValue(t *testing.T) { + t.Run("null", func(t *testing.T) { + v, err := parseTypedValue("null") + require.NoError(t, err) + assert.Nil(t, v) + }) + + t.Run("bool true", func(t *testing.T) { + v, err := parseTypedValue("true") + require.NoError(t, err) + assert.Equal(t, true, v) + }) + + t.Run("bool false", func(t *testing.T) { + v, err := parseTypedValue("false") + require.NoError(t, err) + assert.Equal(t, false, v) + }) + + t.Run("integer", func(t *testing.T) { + v, err := parseTypedValue("42") + require.NoError(t, err) + assert.Equal(t, int64(42), v) + }) + + t.Run("float", func(t *testing.T) { + v, err := parseTypedValue("3.14") + require.NoError(t, err) + assert.Equal(t, 3.14, v) + }) + + t.Run("string", func(t *testing.T) { + v, err := parseTypedValue("hello") + require.NoError(t, err) + assert.Equal(t, "hello", v) + }) + + t.Run("JSON array", func(t *testing.T) { + v, err := parseTypedValue("[1,2,3]") + require.NoError(t, err) + assert.Equal(t, []any{float64(1), float64(2), float64(3)}, v) + }) + + t.Run("JSON object", func(t *testing.T) { + v, err := parseTypedValue(`{"key":"val"}`) + require.NoError(t, err) + assert.Equal(t, map[string]any{"key": "val"}, v) + }) + + t.Run("invalid JSON array falls back to string", func(t *testing.T) { + v, err := parseTypedValue("[not json") + require.NoError(t, err) + assert.Equal(t, "[not json", v) + }) + + t.Run("invalid JSON object falls back to string", func(t *testing.T) { + v, err := parseTypedValue("{not json") + require.NoError(t, err) + assert.Equal(t, "{not json", v) + }) + + t.Run("file reference", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("file content\n"), 0o644)) + v, err := parseTypedValue("@" + tmpFile) + require.NoError(t, err) + assert.Equal(t, "file content", v) + }) + + t.Run("file reference without trailing newline", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("no newline"), 0o644)) + v, err := parseTypedValue("@" + tmpFile) + require.NoError(t, err) + assert.Equal(t, "no newline", v) + }) + + t.Run("empty file reference", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "empty.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte(""), 0o644)) + v, err := parseTypedValue("@" + tmpFile) + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("nonexistent file reference", func(t *testing.T) { + _, err := parseTypedValue("@/nonexistent/file.txt") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read") + }) + + t.Run("negative integer", func(t *testing.T) { + v, err := parseTypedValue("-42") + require.NoError(t, err) + assert.Equal(t, int64(-42), v) + }) + + t.Run("negative float", func(t *testing.T) { + v, err := parseTypedValue("-3.14") + require.NoError(t, err) + assert.Equal(t, -3.14, v) + }) + + t.Run("scientific notation", func(t *testing.T) { + v, err := parseTypedValue("1.5e10") + require.NoError(t, err) + assert.Equal(t, 1.5e10, v) + }) + + t.Run("empty string", func(t *testing.T) { + v, err := parseTypedValue("") + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("string starting with number", func(t *testing.T) { + v, err := parseTypedValue("123abc") + require.NoError(t, err) + assert.Equal(t, "123abc", v) + }) + + t.Run("nested JSON object", func(t *testing.T) { + v, err := parseTypedValue(`{"user":{"name":"alice","id":1}}`) + require.NoError(t, err) + expected := map[string]any{ + "user": map[string]any{ + "name": "alice", + "id": float64(1), + }, + } + assert.Equal(t, expected, v) + }) + + t.Run("complex JSON array", func(t *testing.T) { + v, err := parseTypedValue(`[{"id":1},{"id":2}]`) + require.NoError(t, err) + expected := []any{ + map[string]any{"id": float64(1)}, + map[string]any{"id": float64(2)}, + } + assert.Equal(t, expected, v) + }) + + t.Run("quoted string prevents type parsing", func(t *testing.T) { + v, err := parseTypedValue(`"null"`) + require.NoError(t, err) + assert.Equal(t, "null", v) + }) + + t.Run("quoted true becomes string", func(t *testing.T) { + v, err := parseTypedValue(`"true"`) + require.NoError(t, err) + assert.Equal(t, "true", v) + }) + + t.Run("quoted false becomes string", func(t *testing.T) { + v, err := parseTypedValue(`"false"`) + require.NoError(t, err) + assert.Equal(t, "false", v) + }) + + t.Run("quoted number becomes string", func(t *testing.T) { + v, err := parseTypedValue(`"123"`) + require.NoError(t, err) + assert.Equal(t, "123", v) + }) + + t.Run("quoted empty string", func(t *testing.T) { + v, err := parseTypedValue(`""`) + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("quoted string with spaces", func(t *testing.T) { + v, err := parseTypedValue(`"hello world"`) + require.NoError(t, err) + assert.Equal(t, "hello world", v) + }) + + t.Run("single quote not treated as quote", func(t *testing.T) { + v, err := parseTypedValue(`'hello'`) + require.NoError(t, err) + assert.Equal(t, "'hello'", v) + }) + + t.Run("unmatched quote at start only", func(t *testing.T) { + v, err := parseTypedValue(`"hello`) + require.NoError(t, err) + assert.Equal(t, `"hello`, v) + }) + + t.Run("unmatched quote at end only", func(t *testing.T) { + v, err := parseTypedValue(`hello"`) + require.NoError(t, err) + assert.Equal(t, `hello"`, v) + }) + + t.Run("quoted string with escaped quote", func(t *testing.T) { + v, err := parseTypedValue(`"hello \"world\""`) + require.NoError(t, err) + assert.Equal(t, `hello "world"`, v) + }) + + t.Run("quoted string with backslash-n", func(t *testing.T) { + v, err := parseTypedValue(`"line1\nline2"`) + require.NoError(t, err) + assert.Equal(t, "line1\nline2", v) + }) + + t.Run("quoted string with tab escape", func(t *testing.T) { + v, err := parseTypedValue(`"col1\tcol2"`) + require.NoError(t, err) + assert.Equal(t, "col1\tcol2", v) + }) + + t.Run("quoted string with backslash", func(t *testing.T) { + v, err := parseTypedValue(`"path\\to\\file"`) + require.NoError(t, err) + assert.Equal(t, `path\to\file`, v) + }) + + t.Run("invalid escape sequence in quoted string", func(t *testing.T) { + _, err := parseTypedValue(`"bad \z escape"`) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid quoted string") + }) +} + +// runApiWithArgs configures a test login, parses the command line, and captures +// the prepared request without opening sockets or making HTTP requests. +func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, err error) { + t.Helper() + + var capturedMethod string + var capturedBody []byte + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: "https://gitea.example.com", + Token: "test-token", + User: "testUser", + Default: true, + }}, + }) + + // Use the apiFlags factory to get fresh flag instances, avoiding shared + // hasBeenSet state between tests. Append minimal login/repo flags needed + // for the test harness. + cmd := cli.Command{ + Name: "api", + DisableSliceFlagSeparator: true, + Action: func(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + request, err := prepareAPIRequest(cmd, ctx) + if err != nil { + return err + } + capturedMethod = request.Method + capturedBody = append([]byte(nil), request.Body...) + return nil + }, + Flags: append(apiFlags(), []cli.Flag{ + &cli.StringFlag{Name: "login", Aliases: []string{"l"}}, + &cli.StringFlag{Name: "repo", Aliases: []string{"r"}}, + &cli.StringFlag{Name: "remote", Aliases: []string{"R"}}, + }...), + Writer: io.Discard, + ErrWriter: io.Discard, + } + + fullArgs := append([]string{"api", "--login", "testLogin"}, args...) + runErr := cmd.Run(t.Context(), fullArgs) + + return capturedMethod, capturedBody, runErr +} + +func TestApiCommaInFieldValue(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{"-f", "body=hello, world", "-X", "POST", "/test"}) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "hello, world", parsed["body"]) +} + +func TestApiRawDataFlag(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{"-d", `{"title":"test","body":"hello"}`, "/test"}) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "test", parsed["title"]) + assert.Equal(t, "hello", parsed["body"]) +} + +func TestApiDataFieldMutualExclusion(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-f", "key=val", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--data/-d cannot be combined with --field/-f or --Field/-F") +} + +func TestApiMethodAutoDefault(t *testing.T) { + t.Run("POST when body provided without explicit method", func(t *testing.T) { + method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "/test"}) + require.NoError(t, err) + assert.Equal(t, "POST", method) + }) + + t.Run("explicit method overrides auto-POST", func(t *testing.T) { + method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-X", "PATCH", "/test"}) + require.NoError(t, err) + assert.Equal(t, "PATCH", method) + }) + + t.Run("GET when no body", func(t *testing.T) { + method, _, err := runApiWithArgs(t, []string{"/test"}) + require.NoError(t, err) + assert.Equal(t, "GET", method) + }) +} + +func TestApiMultipleFields(t *testing.T) { + t.Run("multiple -f flags", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-f", "title=Test Issue", + "-f", "body=Description here", + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "Test Issue", parsed["title"]) + assert.Equal(t, "Description here", parsed["body"]) + }) + + t.Run("multiple -F flags with different types", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", "milestone=5", + "-F", "closed=true", + "-F", "title=Test", + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, float64(5), parsed["milestone"]) + assert.Equal(t, true, parsed["closed"]) + assert.Equal(t, "Test", parsed["title"]) + }) + + t.Run("combining -f and -F flags", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-f", "title=Test", + "-F", "milestone=3", + "-F", "closed=false", + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "Test", parsed["title"]) + assert.Equal(t, float64(3), parsed["milestone"]) + assert.Equal(t, false, parsed["closed"]) + }) + + t.Run("-F with JSON array", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", `labels=["bug","enhancement"]`, + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, []any{"bug", "enhancement"}, parsed["labels"]) + }) + + t.Run("-F with JSON object", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", `assignee={"login":"alice","id":123}`, + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assignee, ok := parsed["assignee"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "alice", assignee["login"]) + assert.Equal(t, float64(123), assignee["id"]) + }) + + t.Run("-F with quoted string to prevent type parsing", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", `status="null"`, + "-F", `enabled="true"`, + "-F", `count="42"`, + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "null", parsed["status"]) + assert.Equal(t, "true", parsed["enabled"]) + assert.Equal(t, "42", parsed["count"]) + }) +} + +func TestApiDataFromFile(t *testing.T) { + t.Run("read JSON from file", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "data.json") + jsonData := `{"title":"From File","body":"File content"}` + require.NoError(t, os.WriteFile(tmpFile, []byte(jsonData), 0o644)) + + _, body, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"}) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "From File", parsed["title"]) + assert.Equal(t, "File content", parsed["body"]) + }) + + t.Run("invalid JSON in --data flag", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-d", `{invalid json}`, "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not valid JSON") + }) + + t.Run("invalid JSON from file includes filename", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(tmpFile, []byte("not json"), 0o644)) + + _, _, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not valid JSON") + assert.Contains(t, err.Error(), "bad.json") + }) +} + +func TestApiErrorHandling(t *testing.T) { + t.Run("missing endpoint argument", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "endpoint argument required") + }) + + t.Run("invalid field format", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "invalidformat", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid field format") + }) + + t.Run("invalid Field format", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-F", "noequalsign", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid field format") + }) + + t.Run("empty field key with -f", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "=value", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "field key cannot be empty") + }) + + t.Run("empty field key with -F", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-F", "=123", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "field key cannot be empty") + }) + + t.Run("duplicate field key in -f flags", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "key=first", "-f", "key=second", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate field key") + }) + + t.Run("duplicate field key in -F flags", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-F", "key=1", "-F", "key=2", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate field key") + }) + + t.Run("duplicate field key across -f and -F flags", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "key=string", "-F", "key=123", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate field key") + }) +} + +func TestExpandPlaceholders(t *testing.T) { + t.Run("replaces owner and repo", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "myorg", + Repo: "myrepo", + } + result := expandPlaceholders("/repos/{owner}/{repo}/issues", ctx) + assert.Equal(t, "/repos/myorg/myrepo/issues", result) + }) + + t.Run("replaces multiple occurrences", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + } + result := expandPlaceholders("/repos/{owner}/{repo}/branches?owner={owner}", ctx) + assert.Equal(t, "/repos/alice/proj/branches?owner=alice", result) + }) + + t.Run("no placeholders returns unchanged", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + } + result := expandPlaceholders("/api/v1/version", ctx) + assert.Equal(t, "/api/v1/version", result) + }) + + t.Run("empty owner and repo produce empty replacements", func(t *testing.T) { + ctx := &context.TeaContext{} + result := expandPlaceholders("/repos/{owner}/{repo}", ctx) + assert.Equal(t, "/repos//", result) + }) + + t.Run("branch left unreplaced when no local repo", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + } + result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx) + assert.Equal(t, "/repos/alice/proj/branches/{branch}", result) + }) + + t.Run("replaces branch from local repo HEAD", func(t *testing.T) { + tmpDir := t.TempDir() + runGit := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = tmpDir + require.NoError(t, cmd.Run()) + } + + runGit("init") + runGit("config", "user.email", "test@test.com") + runGit("config", "user.name", "test") + + // Create an initial commit so HEAD points to a branch. + tmpFile := filepath.Join(tmpDir, "init.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("init"), 0o644)) + runGit("add", "init.txt") + runGit("commit", "-m", "initial commit") + + // Create and checkout a feature branch. + runGit("checkout", "-b", "feature/my-branch") + + repo, err := tea_git.RepoFromPath(tmpDir) + require.NoError(t, err) + + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + LocalRepo: repo, + } + result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx) + assert.Equal(t, "/repos/alice/proj/branches/feature/my-branch", result) + }) +} + +func TestIsTextContentType(t *testing.T) { + tests := []struct { + name string + contentType string + want bool + }{ + {"empty string defaults to text", "", true}, + {"plain text", "text/plain", true}, + {"html", "text/html", true}, + {"json", "application/json", true}, + {"json with charset", "application/json; charset=utf-8", true}, + {"xml", "application/xml", true}, + {"javascript", "application/javascript", true}, + {"yaml", "application/yaml", true}, + {"toml", "application/toml", true}, + {"binary", "application/octet-stream", false}, + {"image", "image/png", false}, + {"pdf", "application/pdf", false}, + {"zip", "application/zip", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isTextContentType(tt.contentType) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/attachments.go b/cmd/attachments.go new file mode 100644 index 00000000..52f14020 --- /dev/null +++ b/cmd/attachments.go @@ -0,0 +1,28 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "gitea.dev/tea/cmd/attachments" + "gitea.dev/tea/cmd/flags" + + "github.com/urfave/cli/v3" +) + +// CmdReleaseAttachments represents a release attachment (file attachment) +var CmdReleaseAttachments = cli.Command{ + Name: "assets", + Aliases: []string{"asset", "a"}, + Category: catEntities, + Usage: "Manage release assets", + Description: "Manage release assets", + ArgsUsage: " ", // command does not accept arguments + Action: attachments.RunReleaseAttachmentList, + Commands: []*cli.Command{ + &attachments.CmdReleaseAttachmentList, + &attachments.CmdReleaseAttachmentCreate, + &attachments.CmdReleaseAttachmentDelete, + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/attachments/create.go b/cmd/attachments/create.go new file mode 100644 index 00000000..d35c5646 --- /dev/null +++ b/cmd/attachments/create.go @@ -0,0 +1,71 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package attachments + +import ( + stdctx "context" + "fmt" + "os" + "path/filepath" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/cmd/releases" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdReleaseAttachmentCreate represents a sub command of Release Attachments to create a release attachment +var CmdReleaseAttachmentCreate = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create one or more release attachments", + Description: `Create one or more release attachments`, + ArgsUsage: " [...]", + Action: runReleaseAttachmentCreate, + Flags: flags.AllDefaultFlags, +} + +func runReleaseAttachmentCreate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + if ctx.Args().Len() < 2 { + return fmt.Errorf("no release tag or assets specified.\nUsage:\t%s", ctx.Command.UsageText) + } + + tag := ctx.Args().First() + if len(tag) == 0 { + return fmt.Errorf("release tag needed to create attachment") + } + + release, err := releases.GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) + if err != nil { + return err + } + + for _, asset := range ctx.Args().Slice()[1:] { + var file *os.File + if file, err = os.Open(asset); err != nil { + return err + } + + filePath := filepath.Base(asset) + + if _, _, err = ctx.Login.Client().Releases.CreateReleaseAttachment(requestCtx, ctx.Owner, ctx.Repo, release.ID, file, filePath); err != nil { + file.Close() + return err + } + + file.Close() + } + + return nil +} diff --git a/cmd/attachments/delete.go b/cmd/attachments/delete.go new file mode 100644 index 00000000..7a806a15 --- /dev/null +++ b/cmd/attachments/delete.go @@ -0,0 +1,97 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package attachments + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/cmd/releases" + "gitea.dev/tea/modules/context" + "github.com/urfave/cli/v3" +) + +// CmdReleaseAttachmentDelete represents a sub command of Release Attachments to delete a release attachment +var CmdReleaseAttachmentDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete one or more release attachments", + Description: `Delete one or more release attachments`, + ArgsUsage: " [...]", + Action: runReleaseAttachmentDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "Confirm deletion (required)", + }, + }, flags.AllDefaultFlags...), +} + +func runReleaseAttachmentDelete(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + if ctx.Args().Len() < 2 { + return fmt.Errorf("no release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText) + } + + tag := ctx.Args().First() + if len(tag) == 0 { + return fmt.Errorf("release tag needed to delete attachment") + } + + if !ctx.Bool("confirm") { + fmt.Println("Are you sure? Please confirm with -y or --confirm.") + return nil + } + + release, err := releases.GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) + if err != nil { + return err + } + + var existing []*gitea.Attachment + for page := 1; ; { + page_attachments, resp, err := client.Releases.ListReleaseAttachments(requestCtx, ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return err + } + existing = append(existing, page_attachments...) + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage + } + + for _, name := range ctx.Args().Slice()[1:] { + var attachment *gitea.Attachment + for _, a := range existing { + if a.Name == name { + attachment = a + } + } + if attachment == nil { + return fmt.Errorf("release does not have attachment named '%s'", name) + } + + _, err = client.Releases.DeleteReleaseAttachment(requestCtx, ctx.Owner, ctx.Repo, release.ID, attachment.ID) + if err != nil { + return err + } + } + + return nil +} diff --git a/cmd/attachments/list.go b/cmd/attachments/list.go new file mode 100644 index 00000000..4b512915 --- /dev/null +++ b/cmd/attachments/list.go @@ -0,0 +1,61 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package attachments + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/cmd/releases" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdReleaseAttachmentList represents a sub command of release attachment to list release attachments +var CmdReleaseAttachmentList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List Release Attachments", + Description: "List Release Attachments", + ArgsUsage: "", // command does not accept arguments + Action: RunReleaseAttachmentList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunReleaseAttachmentList list release attachments +func RunReleaseAttachmentList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + tag := ctx.Args().First() + if len(tag) == 0 { + return fmt.Errorf("release tag needed to list attachments") + } + + release, err := releases.GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) + if err != nil { + return err + } + + attachments, _, err := ctx.Login.Client().Releases.ListReleaseAttachments(requestCtx, ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + return print.ReleaseAttachmentsList(attachments, ctx.Output) +} diff --git a/cmd/branches.go b/cmd/branches.go new file mode 100644 index 00000000..6b646d6b --- /dev/null +++ b/cmd/branches.go @@ -0,0 +1,39 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + "gitea.dev/tea/cmd/branches" + + "github.com/urfave/cli/v3" +) + +// CmdBranches represents to login a gitea server. +var CmdBranches = cli.Command{ + Name: "branches", + Aliases: []string{"branch", "b"}, + Category: catEntities, + Usage: "Consult branches", + Description: `Lists branches when called without argument. If a branch is provided, will show it in detail.`, + ArgsUsage: "[]", + Action: runBranches, + Commands: []*cli.Command{ + &branches.CmdBranchesList, + &branches.CmdBranchesProtect, + &branches.CmdBranchesUnprotect, + &branches.CmdBranchesRename, + }, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "comments", + Usage: "Whether to display comments (will prompt if not provided & run interactively)", + }, + }, branches.CmdBranchesList.Flags...), +} + +func runBranches(ctx context.Context, cmd *cli.Command) error { + return branches.RunBranchesList(ctx, cmd) +} diff --git a/cmd/branches/list.go b/cmd/branches/list.go new file mode 100644 index 00000000..6c559616 --- /dev/null +++ b/cmd/branches/list.go @@ -0,0 +1,76 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package branches + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +var branchFieldsFlag = flags.FieldsFlag(print.BranchFields, []string{ + "name", "protected", "user-can-merge", "user-can-push", +}) + +// CmdBranchesListFlags Flags for command list +var CmdBranchesListFlags = append([]cli.Flag{ + branchFieldsFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, +}, flags.AllDefaultFlags...) + +// CmdBranchesList represents a sub command of branches to list branches +var CmdBranchesList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List branches of the repository", + Description: `List branches of the repository`, + ArgsUsage: " ", // command does not accept arguments + Action: RunBranchesList, + Flags: CmdBranchesListFlags, +} + +// RunBranchesList list branches +func RunBranchesList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + owner := ctx.Owner + if ctx.IsSet("owner") { + owner = ctx.String("owner") + } + + var branches []*gitea.Branch + var protections []*gitea.BranchProtection + branches, _, err = ctx.Login.Client().Repositories.ListRepoBranches(requestCtx, owner, ctx.Repo, gitea.ListRepoBranchesOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + protections, _, err = ctx.Login.Client().Repositories.ListBranchProtections(requestCtx, owner, ctx.Repo, gitea.ListBranchProtectionsOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + fields, err := branchFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + + return print.BranchesList(branches, protections, ctx.Output, fields) +} diff --git a/cmd/branches/protect.go b/cmd/branches/protect.go new file mode 100644 index 00000000..84417fe4 --- /dev/null +++ b/cmd/branches/protect.go @@ -0,0 +1,107 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package branches + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "github.com/urfave/cli/v3" +) + +// CmdBranchesProtectFlags Flags for command protect/unprotect +var CmdBranchesProtectFlags = append([]cli.Flag{ + branchFieldsFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, +}, flags.AllDefaultFlags...) + +// CmdBranchesProtect represents a sub command of branches to protect a branch +var CmdBranchesProtect = cli.Command{ + Name: "protect", + Aliases: []string{"P"}, + Usage: "Protect branches", + Description: `Block actions push/merge on specified branches`, + ArgsUsage: "", + Action: RunBranchesProtect, + Flags: CmdBranchesProtectFlags, +} + +// CmdBranchesUnprotect represents a sub command of branches to protect a branch +var CmdBranchesUnprotect = cli.Command{ + Name: "unprotect", + Aliases: []string{"U"}, + Usage: "Unprotect branches", + Description: `Suppress existing protections on specified branches`, + ArgsUsage: "", + Action: RunBranchesProtect, + Flags: CmdBranchesProtectFlags, +} + +// RunBranchesProtect function to protect/unprotect a list of branches +func RunBranchesProtect(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if !cmd.Args().Present() { + return fmt.Errorf("must specify at least one branch") + } + + owner := ctx.Owner + if ctx.IsSet("owner") { + owner = ctx.String("owner") + } + + for _, branch := range ctx.Args().Slice() { + + var err error + command := ctx.Command.Name + if command == "protect" { + _, _, err = ctx.Login.Client().Repositories.CreateBranchProtection(requestCtx, owner, ctx.Repo, gitea.CreateBranchProtectionOption{ + BranchName: branch, + RuleName: "", + EnablePush: false, + EnablePushWhitelist: false, + PushWhitelistUsernames: []string{}, + PushWhitelistTeams: []string{}, + PushWhitelistDeployKeys: false, + EnableMergeWhitelist: false, + MergeWhitelistUsernames: []string{}, + MergeWhitelistTeams: []string{}, + EnableStatusCheck: false, + StatusCheckContexts: []string{}, + RequiredApprovals: 1, + EnableApprovalsWhitelist: false, + ApprovalsWhitelistUsernames: []string{}, + ApprovalsWhitelistTeams: []string{}, + BlockOnRejectedReviews: false, + BlockOnOfficialReviewRequests: false, + BlockOnOutdatedBranch: false, + DismissStaleApprovals: false, + RequireSignedCommits: false, + ProtectedFilePatterns: "", + UnprotectedFilePatterns: "", + }) + } else if command == "unprotect" { + _, err = ctx.Login.Client().Repositories.DeleteBranchProtection(requestCtx, owner, ctx.Repo, branch) + } else { + return fmt.Errorf("command %s is not supported", command) + } + + if err != nil { + return err + } + } + + return nil +} diff --git a/cmd/branches/rename.go b/cmd/branches/rename.go new file mode 100644 index 00000000..85a0935c --- /dev/null +++ b/cmd/branches/rename.go @@ -0,0 +1,78 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package branches + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "github.com/urfave/cli/v3" +) + +// CmdBranchesRenameFlags Flags for command rename +var CmdBranchesRenameFlags = append([]cli.Flag{ + branchFieldsFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, +}, flags.AllDefaultFlags...) + +// CmdBranchesRename represents a sub command of branches to rename a branch +var CmdBranchesRename = cli.Command{ + Name: "rename", + Aliases: []string{"rn"}, + Usage: "Rename a branch", + Description: `Rename a branch in a repository`, + ArgsUsage: " ", + Action: RunBranchesRename, + Flags: CmdBranchesRenameFlags, +} + +// RunBranchesRename function to rename a branch +func RunBranchesRename(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if err := ValidateRenameArgs(ctx.Args().Slice()); err != nil { + return err + } + + oldBranchName := ctx.Args().Get(0) + newBranchName := ctx.Args().Get(1) + + owner := ctx.Owner + if ctx.IsSet("owner") { + owner = ctx.String("owner") + } + + successful, _, err := ctx.Login.Client().Repositories.RenameRepoBranch(requestCtx, owner, ctx.Repo, oldBranchName, gitea.RenameRepoBranchOption{ + Name: newBranchName, + }) + if err != nil { + return fmt.Errorf("failed to rename branch: %w", err) + } + if !successful { + return fmt.Errorf("failed to rename branch") + } + + fmt.Printf("Successfully renamed branch '%s' to '%s'\n", oldBranchName, newBranchName) + + return nil +} + +// ValidateRenameArgs validates arguments for the rename command +func ValidateRenameArgs(args []string) error { + if len(args) != 2 { + return fmt.Errorf("must specify exactly two arguments: ") + } + return nil +} diff --git a/cmd/branches/rename_test.go b/cmd/branches/rename_test.go new file mode 100644 index 00000000..cf3efd7a --- /dev/null +++ b/cmd/branches/rename_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package branches + +import ( + "testing" +) + +func TestBranchesRenameArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "valid args", + args: []string{"main", "develop"}, + wantErr: false, + }, + { + name: "missing both args", + args: []string{}, + wantErr: true, + }, + { + name: "missing new branch name", + args: []string{"main"}, + wantErr: true, + }, + { + name: "too many args", + args: []string{"main", "develop", "extra"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRenameArgs(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateRenameArgs() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/cmd/categories.go b/cmd/categories.go new file mode 100644 index 00000000..b914532c --- /dev/null +++ b/cmd/categories.go @@ -0,0 +1,11 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +var ( + catSetup = "SETUP" + catEntities = "ENTITIES" + catHelpers = "HELPERS" + catMisc = "MISCELLANEOUS" +) diff --git a/cmd/clone.go b/cmd/clone.go new file mode 100644 index 00000000..c636a39f --- /dev/null +++ b/cmd/clone.go @@ -0,0 +1,101 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/debug" + "gitea.dev/tea/modules/git" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdRepoClone represents a sub command of repos to create a local copy +var CmdRepoClone = cli.Command{ + Name: "clone", + Aliases: []string{"C"}, + Usage: "Clone a repository locally", + Description: `Clone a repository locally, without a local git installation required. +The repo slug can be specified in different formats: + gitea/tea + tea + gitea.com/gitea/tea + git@gitea.com:gitea/tea + https://gitea.com/gitea/tea + ssh://gitea.com:22/gitea/tea +When a host is specified in the repo-slug, it will override the login specified with --login. + `, + Category: catHelpers, + Action: runRepoClone, + ArgsUsage: " [target dir]", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "depth", + Aliases: []string{"d"}, + Usage: "num commits to fetch, defaults to all", + }, + &flags.LoginFlag, + }, +} + +func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error { + teaCmd, err := context.InitCommand(cmd) + if err != nil { + return err + } + + args := teaCmd.Args() + if args.Len() < 1 { + return cli.ShowCommandHelp(ctx, cmd, "clone") + } + dir := args.Get(1) + + var ( + login *config.Login = teaCmd.Login + owner string + repo string + ) + + // parse first arg as repo specifier + repoSlug := args.Get(0) + url, err := git.ParseURL(repoSlug) + if err != nil { + return err + } + + debug.Printf("Cloning repository %s into %s", url.String(), dir) + + owner, repo = utils.GetOwnerAndRepo(url.Path, login.User) + if url.Host != "" { + var lookupErr error + login, lookupErr = config.GetLoginByHost(url.Host) + if lookupErr != nil { + return lookupErr + } + if login == nil { + return fmt.Errorf("no login configured matching host '%s', run 'tea login add' first", url.Host) + } + debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host) + } + + _, err = task.RepoClone( + ctx, + dir, + login, + owner, + repo, + interact.PromptPassword, + teaCmd.Int("depth"), + ) + + return err +} diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 00000000..a2306d9c --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,111 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Tea is command line tool for Gitea. +package cmd // import "gitea.dev/tea" + +import ( + "fmt" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/modules/version" +) + +// App creates and returns a tea Command with all subcommands set +// it was separated from main so docs can be generated for it +func App() *cli.Command { + // make parsing tea --version easier, by printing /just/ the version string + cli.VersionPrinter = func(c *cli.Command) { fmt.Fprintln(c.Writer, c.Version) } + + return &cli.Command{ + Name: "tea", + Usage: "command line tool to interact with Gitea", + Description: appDescription, + CustomHelpTemplate: helpTemplate, + Version: version.Format(), + Commands: []*cli.Command{ + &CmdLogin, + &CmdLogout, + &CmdWhoami, + + &CmdIssues, + &CmdPulls, + &CmdLabels, + &CmdMilestones, + &CmdReleases, + &CmdTrackedTimes, + &CmdOrgs, + &CmdRepos, + &CmdBranches, + &CmdActions, + &CmdWiki, + &CmdWebhooks, + &CmdComments, + + &CmdOpen, + &CmdNotifications, + &CmdRepoClone, + + &CmdSSHKeys, + + &CmdAdmin, + + &CmdApi, + &CmdGenerateManPage, + }, + EnableShellCompletion: true, + } +} + +var appDescription = `tea is a productivity helper for Gitea. It can be used to manage most entities on +one or multiple Gitea instances & provides local helpers like 'tea pr checkout'. + +tea tries to make use of context provided by the repository in $PWD if available. +tea works best in a upstream/fork workflow, when the local main branch tracks the +upstream repo. tea assumes that local git state is published on the remote before +doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea. +` + +var helpTemplate = fmt.Sprintf("\033[1m%s\033[0m", ` + {{.Name}}{{if .Usage}} - {{.Usage}}{{end}}`) + ` + {{if .Version}}{{if not .HideVersion}}version {{.Version}}{{end}}{{end}} + + USAGE + {{if .UsageText}}{{.UsageText}}{{else}}{{.HelpName}}{{if .Commands}} command [subcommand] [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Description}} + + DESCRIPTION + {{.Description | nindent 3 | trim}}{{end}}{{if .VisibleCommands}} + + COMMANDS{{range .VisibleCategories}}{{if .Name}} + {{.Name}}:{{range .VisibleCommands}} + {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{else}}{{range .VisibleCommands}} + {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{end}}{{end}}{{end}}{{if .VisibleFlags}} + + OPTIONS + {{range $index, $option := .VisibleFlags}}{{if $index}} + {{end}}{{$option}}{{end}}{{end}} + + EXAMPLES + tea login add # add a login once to get started + + tea pulls # list open pulls for the repo in $PWD + tea pulls --repo $HOME/foo # list open pulls for the repo in $HOME/foo + tea pulls --remote upstream # list open pulls for the repo pointed at by + # your local "upstream" git remote + # list open pulls for any gitea repo at the given login instance + tea pulls --repo gitea/tea --login gitea.com + + tea milestone issues 0.7.0 # view open issues for milestone '0.7.0' + tea issue 189 # view contents of issue 189 + tea open 189 # open web ui for issue 189 + tea open milestones # open web ui for milestones + + # send gitea desktop notifications every 5 minutes (bash + libnotify) + while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done + + ABOUT + Written & maintained by The Gitea Authors. + If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea. + More info about Gitea itself on https://about.gitea.com. +` diff --git a/cmd/comment.go b/cmd/comment.go new file mode 100644 index 00000000..1434c70b --- /dev/null +++ b/cmd/comment.go @@ -0,0 +1,31 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "gitea.dev/tea/cmd/comments" + + "github.com/urfave/cli/v3" +) + +// CmdComments is the top-level command for managing comments on issues and pull requests. +var CmdComments = cli.Command{ + Name: "comments", + Aliases: []string{"comment", "c"}, + Category: catEntities, + Usage: "Manage comments on issues and pull requests", + Description: `Manage comments on issues and pull requests. + +When invoked with an issue/PR index and an optional body, behaves like 'tea comments add' +(this preserves the historical 'tea comment ""' shorthand).`, + ArgsUsage: " []", + Action: comments.RunCommentsAdd, + Flags: comments.CmdCommentsAdd.Flags, + Commands: []*cli.Command{ + &comments.CmdCommentsAdd, + &comments.CmdCommentsList, + &comments.CmdCommentsEdit, + &comments.CmdCommentsDelete, + }, +} diff --git a/cmd/comments/add.go b/cmd/comments/add.go new file mode 100644 index 00000000..601006c7 --- /dev/null +++ b/cmd/comments/add.go @@ -0,0 +1,96 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import ( + stdctx "context" + "errors" + "fmt" + "strings" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/theme" + "gitea.dev/tea/modules/utils" + + "charm.land/huh/v2" + "github.com/urfave/cli/v3" +) + +// CmdCommentsAdd adds a comment to an issue or pull request. +var CmdCommentsAdd = cli.Command{ + Name: "add", + Aliases: []string{"a"}, + Usage: "Add a comment to an issue or pull request", + Description: "Add a comment to an issue or pull request.", + ArgsUsage: " []", + Action: RunCommentsAdd, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "description", + Aliases: []string{"d"}, + Usage: "comment body (alternative to the positional argument)", + }, + }, flags.AllDefaultFlags...), +} + +// RunCommentsAdd creates a new comment. +func RunCommentsAdd(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("please specify issue / pr index") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + stdinPiped := interact.IsStdinPiped() + body, err := resolveBody(strings.Join(ctx.Args().Tail(), " "), ctx.String("description"), stdinPiped, ctx.Reader) + if err != nil { + return err + } + if len(body) == 0 && !stdinPiped { + if err := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Comment(markdown):"). + ExternalEditor(config.GetPreferences().Editor). + EditorExtension("md"). + Value(&body), + ), + ).WithTheme(theme.GetTheme()). + Run(); err != nil { + return err + } + } + + if len(body) == 0 { + return errors.New("no comment content provided") + } + + client := ctx.Login.Client() + comment, _, err := client.Issues.CreateIssueComment(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.CreateIssueCommentOption{ + Body: body, + }) + if err != nil { + return err + } + + print.Comment(comment) + return nil +} diff --git a/cmd/comments/body.go b/cmd/comments/body.go new file mode 100644 index 00000000..dcda8314 --- /dev/null +++ b/cmd/comments/body.go @@ -0,0 +1,37 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import "io" + +// resolveBody picks the comment body from the non-interactive sources, in +// precedence order: +// +// 1. the positional argument (kept first for back-compat with the historical +// 'tea comment ""' shorthand), +// 2. the -d/--description flag (mirrors the body flag on 'issue create', +// 'issue edit' and 'pr create'), +// 3. piped stdin. +// +// stdin is only read when stdinPiped is true (a non-TTY stdin, e.g. CI, +// subshells or agent harnesses) and no body was supplied otherwise, so the +// command never blocks reading an interactive terminal when a body is already +// given. An empty result means the caller should fall back to the editor (when +// interactive) or error out. +func resolveBody(positional, description string, stdinPiped bool, stdin io.Reader) (string, error) { + if len(positional) != 0 { + return positional, nil + } + if len(description) != 0 { + return description, nil + } + if stdinPiped { + stdinBytes, err := io.ReadAll(stdin) + if err != nil { + return "", err + } + return string(stdinBytes), nil + } + return "", nil +} diff --git a/cmd/comments/body_test.go b/cmd/comments/body_test.go new file mode 100644 index 00000000..4c3ab1e3 --- /dev/null +++ b/cmd/comments/body_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import ( + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveBody(t *testing.T) { + testCases := []struct { + name string + positional string + description string + stdinPiped bool + stdin string + expected string + }{ + { + name: "positional only", + positional: "from positional", + expected: "from positional", + }, + { + name: "description flag only", + description: "from -d", + expected: "from -d", + }, + { + name: "positional wins over description for back-compat", + positional: "from positional", + description: "from -d", + expected: "from positional", + }, + { + name: "description wins over piped stdin", + description: "from -d", + stdinPiped: true, + stdin: "from stdin", + expected: "from -d", + }, + { + name: "piped stdin used when nothing else given", + stdinPiped: true, + stdin: "from stdin", + expected: "from stdin", + }, + { + name: "stdin ignored when not piped (interactive terminal)", + stdinPiped: false, + stdin: "should never be read", + expected: "", + }, + { + name: "empty when no source provided", + stdinPiped: false, + expected: "", + }, + { + name: "piped but empty stdin yields empty body", + stdinPiped: true, + stdin: "", + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + body, err := resolveBody(tc.positional, tc.description, tc.stdinPiped, strings.NewReader(tc.stdin)) + require.NoError(t, err) + assert.Equal(t, tc.expected, body) + }) + } +} + +// TestResolveBodyDoesNotReadStdinWhenBodyGiven guards the original bug: when a +// body is supplied positionally (or via -d), stdin must not be consumed, so the +// command can never block on a non-TTY stdin under CI / agent harnesses. +func TestResolveBodyDoesNotReadStdinWhenBodyGiven(t *testing.T) { + reader := &trackingReader{} + + body, err := resolveBody("positional body", "", true, reader) + require.NoError(t, err) + assert.Equal(t, "positional body", body) + assert.False(t, reader.read, "stdin must not be read when a body is supplied") +} + +// trackingReader records whether Read was ever called. +type trackingReader struct { + read bool +} + +func (r *trackingReader) Read(p []byte) (int, error) { + r.read = true + return 0, io.EOF +} diff --git a/cmd/comments/delete.go b/cmd/comments/delete.go new file mode 100644 index 00000000..b4d94a80 --- /dev/null +++ b/cmd/comments/delete.go @@ -0,0 +1,55 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdCommentsDelete deletes one or more comments by ID. +var CmdCommentsDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete one or more comments by ID", + Description: "Delete one or more comments by their comment ID. Use 'tea comments list ' to find IDs.", + ArgsUsage: " [...]", + Action: RunCommentsDelete, + Flags: flags.AllDefaultFlags, +} + +// RunCommentsDelete removes one or more comments. +func RunCommentsDelete(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("please specify at least one comment id") + } + + ids, err := utils.ArgsToIndices(ctx.Args().Slice()) + if err != nil { + return fmt.Errorf("invalid comment id: %s", err) + } + + client := ctx.Login.Client() + for _, id := range ids { + if _, err := client.Issues.DeleteIssueComment(requestCtx, ctx.Owner, ctx.Repo, id); err != nil { + return fmt.Errorf("could not delete comment %d: %s", id, err) + } + fmt.Printf("Deleted comment %d\n", id) + } + return nil +} diff --git a/cmd/comments/edit.go b/cmd/comments/edit.go new file mode 100644 index 00000000..45da15d3 --- /dev/null +++ b/cmd/comments/edit.go @@ -0,0 +1,105 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import ( + stdctx "context" + "errors" + "fmt" + "strings" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/theme" + "gitea.dev/tea/modules/utils" + + "charm.land/huh/v2" + "github.com/urfave/cli/v3" +) + +// CmdCommentsEdit edits an existing comment. +var CmdCommentsEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit the body of an existing comment", + Description: `Edit the body of an existing comment by its comment ID. Use 'tea comments list ' to find IDs. + +The new body can be supplied as a positional argument, via -d/--description, piped on stdin, or (if none is given and stdin is a terminal) entered in your $EDITOR.`, + ArgsUsage: " []", + Action: RunCommentsEdit, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "description", + Aliases: []string{"d"}, + Usage: "new comment body (alternative to the positional argument)", + }, + }, flags.AllDefaultFlags...), +} + +// RunCommentsEdit updates the body of an existing comment. +func RunCommentsEdit(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("please specify comment id") + } + + id, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return fmt.Errorf("invalid comment id %q: %s", ctx.Args().First(), err) + } + + stdinPiped := interact.IsStdinPiped() + body, err := resolveBody(strings.Join(ctx.Args().Tail(), " "), ctx.String("description"), stdinPiped, ctx.Reader) + if err != nil { + return err + } + if len(body) == 0 && !stdinPiped { + // Fetch current body to pre-populate the editor. + client := ctx.Login.Client() + current, _, fetchErr := client.Issues.GetIssueComment(requestCtx, ctx.Owner, ctx.Repo, id) + if fetchErr != nil { + return fmt.Errorf("could not fetch comment %d: %s", id, fetchErr) + } + body = current.Body + if err := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Comment(markdown):"). + ExternalEditor(config.GetPreferences().Editor). + EditorExtension("md"). + Value(&body), + ), + ).WithTheme(theme.GetTheme()). + Run(); err != nil { + return err + } + } + + if len(body) == 0 { + return errors.New("no comment content provided") + } + + client := ctx.Login.Client() + comment, _, err := client.Issues.EditIssueComment(requestCtx, ctx.Owner, ctx.Repo, id, gitea.EditIssueCommentOption{ + Body: body, + }) + if err != nil { + return err + } + + print.Comment(comment) + return nil +} diff --git a/cmd/comments/list.go b/cmd/comments/list.go new file mode 100644 index 00000000..4fe9d97d --- /dev/null +++ b/cmd/comments/list.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdCommentsList lists comments on an issue or pull request. +var CmdCommentsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List comments on an issue or pull request", + Description: "List comments on an issue or pull request. Comment IDs returned here are the IDs accepted by 'tea comments edit' and 'tea comments delete'.", + ArgsUsage: "", + Action: RunCommentsList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunCommentsList lists comments on the given issue/PR. +func RunCommentsList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("please specify issue / pr index") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + client := ctx.Login.Client() + comments, _, err := client.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.ListIssueCommentOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + return print.CommentsList(comments, ctx.Output) +} diff --git a/cmd/detail_json.go b/cmd/detail_json.go new file mode 100644 index 00000000..cde3c3fd --- /dev/null +++ b/cmd/detail_json.go @@ -0,0 +1,93 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "encoding/json" + "io" + "time" + + "gitea.dev/sdk" +) + +type detailLabelData struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +type detailCommentData struct { + ID int64 `json:"id"` + Author string `json:"author"` + Created time.Time `json:"created"` + Body string `json:"body"` +} + +type detailReviewData struct { + ID int64 `json:"id"` + Reviewer string `json:"reviewer"` + State gitea.ReviewStateType `json:"state"` + Body string `json:"body"` + Created time.Time `json:"created"` +} + +func buildDetailLabels(labels []*gitea.Label) []detailLabelData { + labelSlice := make([]detailLabelData, 0, len(labels)) + for _, label := range labels { + labelSlice = append(labelSlice, detailLabelData{ + Name: label.Name, + Color: label.Color, + Description: label.Description, + }) + } + return labelSlice +} + +func buildDetailAssignees(assignees []*gitea.User) []string { + assigneeSlice := make([]string, 0, len(assignees)) + for _, assignee := range assignees { + assigneeSlice = append(assigneeSlice, username(assignee)) + } + return assigneeSlice +} + +func buildDetailComments(comments []*gitea.Comment) []detailCommentData { + commentSlice := make([]detailCommentData, 0, len(comments)) + for _, comment := range comments { + commentSlice = append(commentSlice, detailCommentData{ + ID: comment.ID, + Author: username(comment.Poster), + Body: comment.Body, + Created: comment.Created, + }) + } + return commentSlice +} + +func buildDetailReviews(reviews []*gitea.PullReview) []detailReviewData { + reviewSlice := make([]detailReviewData, 0, len(reviews)) + for _, review := range reviews { + reviewSlice = append(reviewSlice, detailReviewData{ + ID: review.ID, + Reviewer: username(review.Reviewer), + State: review.State, + Body: review.Body, + Created: review.Submitted, + }) + } + return reviewSlice +} + +func username(user *gitea.User) string { + if user == nil { + return "ghost" + } + return user.UserName +} + +func writeIndentedJSON(w io.Writer, data any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", "\t") + return encoder.Encode(data) +} diff --git a/cmd/flags/body.go b/cmd/flags/body.go new file mode 100644 index 00000000..211e3d28 --- /dev/null +++ b/cmd/flags/body.go @@ -0,0 +1,73 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package flags + +import ( + "fmt" + "io" + "os" + + "golang.org/x/term" +) + +// stdinPiped reports whether stdin is not a terminal, e.g. when a description +// is piped from a file, command substitution, or a CI harness. +func stdinPiped() bool { + return !term.IsTerminal(int(os.Stdin.Fd())) +} + +// resolveCreateBody returns the issue/PR description for create commands. +// +// Precedence: +// 1. --description-file (read from the file, or stdin when the path is "-") +// 2. --description +// 3. piped stdin +func resolveCreateBody(description, descriptionFile string, descriptionFileSet, stdinPiped bool, stdin io.Reader) (string, error) { + if descriptionFileSet { + return readDescriptionSource(descriptionFile, stdin) + } + if description != "" { + return description, nil + } + if stdinPiped { + return readDescriptionStdin(stdin) + } + return "", nil +} + +// resolveEditBody returns the new issue/PR body when a description flag was +// provided, or nil when the caller should leave the body unchanged. +func resolveEditBody(description string, descriptionSet bool, descriptionFile string, descriptionFileSet bool, stdin io.Reader) (*string, error) { + if descriptionFileSet { + body, err := readDescriptionSource(descriptionFile, stdin) + if err != nil { + return nil, err + } + return &body, nil + } + if descriptionSet { + body := description + return &body, nil + } + return nil, nil +} + +func readDescriptionSource(source string, stdin io.Reader) (string, error) { + if source == "-" { + return readDescriptionStdin(stdin) + } + data, err := os.ReadFile(source) + if err != nil { + return "", fmt.Errorf("could not read description file %q: %w", source, err) + } + return string(data), nil +} + +func readDescriptionStdin(stdin io.Reader) (string, error) { + data, err := io.ReadAll(stdin) + if err != nil { + return "", fmt.Errorf("could not read description from stdin: %w", err) + } + return string(data), nil +} diff --git a/cmd/flags/body_test.go b/cmd/flags/body_test.go new file mode 100644 index 00000000..dc3651bb --- /dev/null +++ b/cmd/flags/body_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package flags + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveCreateBody(t *testing.T) { + file := filepath.Join(t.TempDir(), "body.md") + require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600)) + + tests := []struct { + name string + description string + descriptionFile string + descriptionFileSet bool + stdinPiped bool + stdin string + want string + }{ + { + name: "description flag", + description: "from -d", + want: "from -d", + }, + { + name: "description file", + descriptionFile: file, + descriptionFileSet: true, + want: "from file", + }, + { + name: "description file wins over description", + description: "from -d", + descriptionFile: file, + descriptionFileSet: true, + want: "from file", + }, + { + name: "dash reads stdin", + descriptionFile: "-", + descriptionFileSet: true, + stdin: "from stdin", + want: "from stdin", + }, + { + name: "description wins over piped stdin", + description: "from -d", + stdinPiped: true, + stdin: "from stdin", + want: "from -d", + }, + { + name: "piped stdin", + stdinPiped: true, + stdin: "from stdin", + want: "from stdin", + }, + { + name: "empty description falls back to piped stdin", + description: "", + stdinPiped: true, + stdin: "from stdin", + want: "from stdin", + }, + { + name: "empty when no source provided", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveCreateBody(tt.description, tt.descriptionFile, tt.descriptionFileSet, tt.stdinPiped, strings.NewReader(tt.stdin)) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestResolveEditBody(t *testing.T) { + file := filepath.Join(t.TempDir(), "body.md") + require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600)) + + tests := []struct { + name string + description string + descriptionSet bool + descriptionFile string + descriptionFileSet bool + stdin string + wantBody string + wantSet bool + }{ + { + name: "no description flag", + }, + { + name: "description flag", + description: "from -d", + descriptionSet: true, + wantBody: "from -d", + wantSet: true, + }, + { + name: "empty description clears body", + descriptionSet: true, + wantSet: true, + }, + { + name: "description file", + descriptionFile: file, + descriptionFileSet: true, + wantBody: "from file", + wantSet: true, + }, + { + name: "description file wins over description", + description: "from -d", + descriptionSet: true, + descriptionFile: file, + descriptionFileSet: true, + wantBody: "from file", + wantSet: true, + }, + { + name: "dash reads stdin", + descriptionFile: "-", + descriptionFileSet: true, + stdin: "from stdin", + wantBody: "from stdin", + wantSet: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveEditBody(tt.description, tt.descriptionSet, tt.descriptionFile, tt.descriptionFileSet, strings.NewReader(tt.stdin)) + require.NoError(t, err) + if !tt.wantSet { + assert.Nil(t, got) + return + } + require.NotNil(t, got) + assert.Equal(t, tt.wantBody, *got) + }) + } +} + +func TestResolveDescriptionSourceError(t *testing.T) { + _, err := resolveCreateBody("", filepath.Join(t.TempDir(), "missing.md"), true, false, strings.NewReader("")) + require.ErrorContains(t, err, "could not read description file") +} diff --git a/cmd/flags/csvflag.go b/cmd/flags/csvflag.go new file mode 100644 index 00000000..b3eb0972 --- /dev/null +++ b/cmd/flags/csvflag.go @@ -0,0 +1,53 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package flags + +import ( + "fmt" + "strings" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/modules/utils" +) + +// CsvFlag is a wrapper around cli.StringFlag, with an added GetValues() method +// to retrieve comma separated string values as a slice. +type CsvFlag struct { + cli.StringFlag + AvailableFields []string +} + +// NewCsvFlag creates a CsvFlag, while setting its usage string and default values +func NewCsvFlag(name, usage string, aliases, availableValues, defaults []string) *CsvFlag { + var availableDesc string + if len(availableValues) != 0 { + availableDesc = " Available values:" + } + return &CsvFlag{ + AvailableFields: availableValues, + StringFlag: cli.StringFlag{ + Name: name, + Aliases: aliases, + Value: strings.Join(defaults, ","), + Usage: fmt.Sprintf(`Comma-separated list of %s.%s + %s + `, usage, availableDesc, strings.Join(availableValues, ",")), + }, + } +} + +// GetValues returns the value of the flag, parsed as a commaseparated list +func (f CsvFlag) GetValues(cmd *cli.Command) ([]string, error) { + val := cmd.String(f.Name) + selection := strings.Split(val, ",") + if f.AvailableFields != nil && val != "" { + for _, field := range selection { + if !utils.Contains(f.AvailableFields, field) { + return nil, fmt.Errorf("invalid field '%s'", field) + } + } + } + return selection, nil +} diff --git a/cmd/flags/generic.go b/cmd/flags/generic.go new file mode 100644 index 00000000..56bb65d5 --- /dev/null +++ b/cmd/flags/generic.go @@ -0,0 +1,190 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package flags + +import ( + "errors" + "fmt" + + gitea "gitea.dev/sdk" + "github.com/urfave/cli/v3" +) + +// LoginFlag provides flag to specify tea login profile +var LoginFlag = cli.StringFlag{ + Name: "login", + Aliases: []string{"l"}, + Usage: "Use a different Gitea Login. Optional", +} + +// RepoFlag provides flag to specify repository +var RepoFlag = cli.StringFlag{ + Name: "repo", + Aliases: []string{"r"}, + Usage: "Override local repository path or gitea repository slug to interact with. Optional", +} + +// RemoteFlag provides flag to specify remote repository +var RemoteFlag = cli.StringFlag{ + Name: "remote", + Aliases: []string{"R"}, + Usage: "Discover Gitea login from remote. Optional", +} + +// OutputFlag provides flag to specify output type +var OutputFlag = cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Output format. (simple, table, csv, tsv, yaml, json)", +} + +var ( + // ErrPage indicates that the provided page value is invalid (less than -1 or equal to 0). + ErrPage = errors.New("page cannot be smaller than 1") + // ErrLimit indicates that the provided limit value is invalid (negative). + ErrLimit = errors.New("limit cannot be negative") +) + +const ( + defaultPageValue = 1 + defaultLimitValue = 30 +) + +// GetListOptions returns list options derived from the active command. +func GetListOptions(cmd *cli.Command) gitea.ListOptions { + page := cmd.Int("page") + if page == 0 { + page = defaultPageValue + } + + pageSize := cmd.Int("limit") + if pageSize == 0 { + pageSize = defaultLimitValue + } + + return gitea.ListOptions{ + Page: page, + PageSize: pageSize, + } +} + +// PaginationFlags provides all pagination related flags +var PaginationFlags = []cli.Flag{ + &PaginationPageFlag, + &PaginationLimitFlag, +} + +// PaginationPageFlag provides flag for pagination options +var PaginationPageFlag = cli.IntFlag{ + Name: "page", + Aliases: []string{"p"}, + Usage: "specify page", + Value: defaultPageValue, + Validator: func(i int) error { + if i < 1 && i != -1 { + return ErrPage + } + return nil + }, +} + +// PaginationLimitFlag provides flag for pagination options +var PaginationLimitFlag = cli.IntFlag{ + Name: "limit", + Aliases: []string{"lm"}, + Usage: "specify limit of items per page", + Value: defaultLimitValue, + Validator: func(i int) error { + if i < 0 { + return ErrLimit + } + return nil + }, +} + +// LoginOutputFlags defines login and output flags that should +// added to all subcommands and appended to the flags of the +// subcommand to work around issue and provide --login and --output: +// https://github.com/urfave/cli/issues/585 +var LoginOutputFlags = []cli.Flag{ + &LoginFlag, + &OutputFlag, +} + +// LoginRepoFlags defines login and repo flags that should +// be used for all subcommands and appended to the flags of +// the subcommand to work around issue and provide --login and --repo: +// https://github.com/urfave/cli/issues/585 +var LoginRepoFlags = []cli.Flag{ + &LoginFlag, + &RepoFlag, + &RemoteFlag, +} + +// AllDefaultFlags defines flags that should be available +// for all subcommands working with dedicated repositories +// to work around issue and provide --login, --repo and --output: +// https://github.com/urfave/cli/issues/585 +var AllDefaultFlags = append([]cli.Flag{ + &RepoFlag, + &RemoteFlag, +}, LoginOutputFlags...) + +// NotificationFlags defines flags that should be available on notifications. +var NotificationFlags = append([]cli.Flag{ + NotificationStateFlag, + &cli.BoolFlag{ + Name: "mine", + Aliases: []string{"m"}, + Usage: "Show notifications across all your repositories instead of the current repository only", + }, + &PaginationPageFlag, + &PaginationLimitFlag, +}, AllDefaultFlags...) + +// NotificationStateFlag is a csv flag applied to all notification subcommands as filter +var NotificationStateFlag = NewCsvFlag( + "states", + "notification states to filter by", + []string{"s"}, + []string{"pinned", "unread", "read"}, + []string{"unread", "pinned"}, +) + +// FieldsFlag generates a flag selecting printable fields. +// To retrieve the value, use f.GetValues() +func FieldsFlag(availableFields, defaultFields []string) *CsvFlag { + return NewCsvFlag("fields", "fields to print", []string{"f"}, availableFields, defaultFields) +} + +// ParseState parses a state string and returns the corresponding gitea.StateType +func ParseState(stateStr string) (gitea.StateType, error) { + switch stateStr { + case "all": + return gitea.StateAll, nil + case "", "open": + return gitea.StateOpen, nil + case "closed": + return gitea.StateClosed, nil + default: + return "", fmt.Errorf("unknown state '%s'", stateStr) + } +} + +// ParseIssueKind parses a kind string and returns the corresponding gitea.IssueType. +// If kindStr is empty, returns the provided defaultKind. +func ParseIssueKind(kindStr string, defaultKind gitea.IssueType) (gitea.IssueType, error) { + switch kindStr { + case "": + return defaultKind, nil + case "all": + return gitea.IssueTypeAll, nil + case "issue", "issues": + return gitea.IssueTypeIssue, nil + case "pull", "pulls", "pr": + return gitea.IssueTypePull, nil + default: + return "", fmt.Errorf("unknown kind '%s'", kindStr) + } +} diff --git a/cmd/flags/generic_test.go b/cmd/flags/generic_test.go new file mode 100644 index 00000000..f98cc8c8 --- /dev/null +++ b/cmd/flags/generic_test.go @@ -0,0 +1,152 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package flags + +import ( + "context" + "io" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestPaginationFlags(t *testing.T) { + var ( + defaultPage = PaginationPageFlag.Value + defaultLimit = PaginationLimitFlag.Value + ) + + cases := []struct { + name string + args []string + expectedPage int + expectedLimit int + }{ + { + name: "no flags", + args: []string{"test"}, + expectedPage: defaultPage, + expectedLimit: defaultLimit, + }, + { + name: "only paging", + args: []string{"test", "--page", "5"}, + expectedPage: 5, + expectedLimit: defaultLimit, + }, + { + name: "only limit", + args: []string{"test", "--limit", "10"}, + expectedPage: defaultPage, + expectedLimit: 10, + }, + { + name: "only limit", + args: []string{"test", "--limit", "10"}, + expectedPage: defaultPage, + expectedLimit: 10, + }, + { + name: "both flags", + args: []string{"test", "--page", "2", "--limit", "20"}, + expectedPage: 2, + expectedLimit: 20, + }, + { // TODO: Should no paging be applied as -1 or a separate flag? It's not obvious that page=-1 turns off paging and limit is ignored + name: "no paging", + args: []string{"test", "--limit", "20", "--page", "-1"}, + expectedPage: -1, + expectedLimit: 20, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := cli.Command{ + Name: "test-paging", + Action: func(_ context.Context, cmd *cli.Command) error { + assert.Equal(t, tc.expectedPage, cmd.Int("page")) + assert.Equal(t, tc.expectedLimit, cmd.Int("limit")) + return nil + }, + Flags: PaginationFlags, + } + err := cmd.Run(t.Context(), tc.args) + require.NoError(t, err) + }) + } +} + +func TestPaginationFailures(t *testing.T) { + cases := []struct { + name string + args []string + expectedError error + }{ + { + name: "negative limit", + args: []string{"test", "--limit", "-10"}, + expectedError: ErrLimit, + }, + { + name: "negative paging", + args: []string{"test", "--page", "-2"}, + expectedError: ErrPage, + }, + { + name: "zero paging", + args: []string{"test", "--page", "0"}, + expectedError: ErrPage, + }, + { + // urfave does not validate all flags in one pass + name: "negative paging and paging", + args: []string{"test", "--page", "-2", "--limit", "-10"}, + expectedError: ErrPage, + }, + } + + for _, tc := range cases { + cmd := cli.Command{ + Name: "test-paging", + Flags: PaginationFlags, + Writer: io.Discard, + ErrWriter: io.Discard, + } + t.Run(tc.name, func(t *testing.T) { + err := cmd.Run(t.Context(), tc.args) + require.ErrorContains(t, err, tc.expectedError.Error()) + // require.ErrorIs(t, err, tc.expectedError) + }) + } +} + +func TestGetListOptionsDoesNotLeakBetweenCommands(t *testing.T) { + var results []gitea.ListOptions + + run := func(args []string) { + t.Helper() + + cmd := cli.Command{ + Name: "test-paging", + Action: func(_ context.Context, cmd *cli.Command) error { + results = append(results, GetListOptions(cmd)) + return nil + }, + Flags: PaginationFlags, + } + + require.NoError(t, cmd.Run(t.Context(), args)) + } + + run([]string{"test", "--page", "5", "--limit", "10"}) + run([]string{"test"}) + + require.Len(t, results, 2) + assert.Equal(t, gitea.ListOptions{Page: 5, PageSize: 10}, results[0]) + assert.Equal(t, gitea.ListOptions{Page: defaultPageValue, PageSize: defaultLimitValue}, results[1]) +} diff --git a/cmd/flags/issue_pr.go b/cmd/flags/issue_pr.go new file mode 100644 index 00000000..3259c885 --- /dev/null +++ b/cmd/flags/issue_pr.go @@ -0,0 +1,279 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package flags + +import ( + stdctx "context" + "fmt" + "strings" + "time" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/task" + + "github.com/araddon/dateparse" + "github.com/urfave/cli/v3" +) + +// StateFlag provides flag to specify issue/pr state, defaulting to "open" +var StateFlag = cli.StringFlag{ + Name: "state", + Usage: "Filter by state (all|open|closed)", + DefaultText: "open", +} + +// MilestoneFilterFlag is a CSV flag used to filter issues by milestones +var MilestoneFilterFlag = NewCsvFlag( + "milestones", + "milestones to match issues against", + []string{"m"}, nil, nil) + +// LabelFilterFlag is a CSV flag used to filter issues by labels +var LabelFilterFlag = NewCsvFlag( + "labels", + "labels to match issues against", + []string{"L"}, nil, nil) + +// PRListingFlags defines flags that should be available on pr listing flags. +var PRListingFlags = append([]cli.Flag{ + &StateFlag, + &PaginationPageFlag, + &PaginationLimitFlag, +}, AllDefaultFlags...) + +// IssueListingFlags defines flags that should be available on issue listing flags. +var IssueListingFlags = append([]cli.Flag{ + &StateFlag, + &cli.StringFlag{ + Name: "kind", + Aliases: []string{"K"}, + Usage: "Whether to return `issues`, `pulls`, or `all` (you can use this to apply advanced search filters to PRs)", + DefaultText: "issues", + }, + &cli.StringFlag{ + Name: "keyword", + Aliases: []string{"k"}, + Usage: "Filter by search string", + }, + LabelFilterFlag, + MilestoneFilterFlag, + &cli.StringFlag{ + Name: "author", + Aliases: []string{"A"}, + }, + &cli.StringFlag{ + Name: "assignee", + Aliases: []string{"a"}, + }, + &cli.StringFlag{ + Name: "mentions", + Aliases: []string{"M"}, + }, + &cli.StringFlag{ + Name: "owner", + Aliases: []string{"org"}, + }, + &cli.StringFlag{ + Name: "from", + Aliases: []string{"F"}, + Usage: "Filter by activity after this date", + }, + &cli.StringFlag{ + Name: "until", + Aliases: []string{"u"}, + Usage: "Filter by activity before this date", + }, + &PaginationPageFlag, + &PaginationLimitFlag, +}, AllDefaultFlags...) + +// issuePRFlags defines shared flags between flags IssuePRCreateFlags and IssuePREditFlags +var issuePRFlags = append([]cli.Flag{ + &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"d"}, + }, + &cli.StringFlag{ + Name: "description-file", + Usage: "Read description from file ('-' for stdin)", + }, + &cli.StringFlag{ + Name: "referenced-version", + Aliases: []string{"v"}, + Usage: "commit-hash or tag name to assign", + }, + &cli.StringFlag{ + Name: "milestone", + Aliases: []string{"m"}, + Usage: "Milestone to assign", + }, + &cli.StringFlag{ + Name: "deadline", + Aliases: []string{"D"}, + Usage: "Deadline timestamp to assign", + }, +}, LoginRepoFlags...) + +// IssuePRCreateFlags defines flags for creation of issues and PRs +var IssuePRCreateFlags = append([]cli.Flag{ + &cli.StringFlag{ + Name: "assignees", + Aliases: []string{"a"}, + Usage: "Comma-separated list of usernames to assign", + }, + &cli.StringFlag{ + Name: "labels", + Aliases: []string{"L"}, + Usage: "Comma-separated list of labels to assign", + }, +}, issuePRFlags...) + +// GetIssuePRCreateFlags parses all IssuePREditFlags +func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) (*gitea.CreateIssueOption, error) { + body, err := resolveCreateBody( + ctx.String("description"), + ctx.String("description-file"), + ctx.IsSet("description-file"), + stdinPiped(), + ctx.Reader, + ) + if err != nil { + return nil, err + } + + opts := gitea.CreateIssueOption{ + Title: ctx.String("title"), + Body: body, + Assignees: strings.Split(ctx.String("assignees"), ","), + } + + date := ctx.String("deadline") + if date != "" { + t, err := dateparse.ParseAny(date) + if err != nil { + return nil, err + } + opts.Deadline = &t + } + + client := ctx.Login.Client() + + labelNames := strings.Split(ctx.String("labels"), ",") + if len(labelNames) != 0 { + if client == nil { + client = ctx.Login.Client() + } + if opts.Labels, err = task.ResolveLabelNames(requestCtx, client, ctx.Owner, ctx.Repo, labelNames); err != nil { + return nil, err + } + } + + if milestoneName := ctx.String("milestone"); len(milestoneName) != 0 { + if client == nil { + client = ctx.Login.Client() + } + ms, _, err := client.Repositories.GetMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, milestoneName) + if err != nil { + return nil, fmt.Errorf("milestone '%s' not found", milestoneName) + } + opts.Milestone = ms.ID + } + + return &opts, nil +} + +// IssuePREditFlags defines flags for editing properties of issues and PRs +var IssuePREditFlags = append([]cli.Flag{ + &cli.StringFlag{ + Name: "set-assignees", + Usage: "Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees", + }, + &cli.StringFlag{ + Name: "add-assignees", + Aliases: []string{"a"}, + Usage: "Comma-separated list of usernames to assign. Takes precedence over --remove-assignees", + }, + &cli.StringFlag{ + Name: "remove-assignees", + Usage: "Comma-separated list of usernames to remove", + }, + &cli.StringFlag{ + Name: "add-labels", + Aliases: []string{"L"}, + Usage: "Comma-separated list of labels to assign. Takes precedence over --remove-labels", + }, + &cli.StringFlag{ + Name: "remove-labels", + Usage: "Comma-separated list of labels to remove", + }, +}, issuePRFlags...) + +// GetIssuePREditFlags parses all IssuePREditFlags +func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error) { + opts := task.EditIssueOption{} + if ctx.IsSet("title") { + val := ctx.String("title") + opts.Title = &val + } + body, err := resolveEditBody( + ctx.String("description"), + ctx.IsSet("description"), + ctx.String("description-file"), + ctx.IsSet("description-file"), + ctx.Reader, + ) + if err != nil { + return nil, err + } + if body != nil { + opts.Body = body + } + if ctx.IsSet("referenced-version") { + val := ctx.String("referenced-version") + opts.Ref = &val + } + if ctx.IsSet("milestone") { + val := ctx.String("milestone") + opts.Milestone = &val + } + if ctx.IsSet("deadline") { + date := ctx.String("deadline") + if date == "" { + opts.Deadline = &time.Time{} + } else { + t, err := dateparse.ParseAny(date) + if err != nil { + return nil, err + } + opts.Deadline = &t + } + } + if ctx.IsSet("set-assignees") { + val := ctx.String("set-assignees") + opts.SetAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",") + } + if ctx.IsSet("add-assignees") { + val := ctx.String("add-assignees") + opts.AddAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",") + } + if ctx.IsSet("remove-assignees") { + val := ctx.String("remove-assignees") + opts.RemoveAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",") + } + if ctx.IsSet("add-labels") { + val := ctx.String("add-labels") + opts.AddLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",") + } + if ctx.IsSet("remove-labels") { + val := ctx.String("remove-labels") + opts.RemoveLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",") + } + return &opts, nil +} diff --git a/cmd/issues.go b/cmd/issues.go new file mode 100644 index 00000000..7f3f4bea --- /dev/null +++ b/cmd/issues.go @@ -0,0 +1,174 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "fmt" + "time" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/cmd/issues" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +type labelData = detailLabelData + +type issueData struct { + ID int64 `json:"id"` + Index int64 `json:"index"` + Title string `json:"title"` + State gitea.StateType `json:"state"` + Created time.Time `json:"created"` + Labels []labelData `json:"labels"` + User string `json:"user"` + Body string `json:"body"` + Assignees []string `json:"assignees"` + URL string `json:"url"` + ClosedAt *time.Time `json:"closedAt"` + Comments []commentData `json:"comments"` +} + +type issueDetailClient interface { + GetIssue(ctx stdctx.Context, owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) + ListIssueReactions(ctx stdctx.Context, owner, repo string, index int64, opt gitea.ListIssueReactionsOptions) ([]*gitea.Reaction, *gitea.Response, error) +} + +type issueCommentClient interface { + ListIssueComments(ctx stdctx.Context, owner, repo string, index int64, opt gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error) +} + +type commentData = detailCommentData + +// CmdIssues represents to login a gitea server. +var CmdIssues = cli.Command{ + Name: "issues", + Aliases: []string{"issue", "i"}, + Category: catEntities, + Usage: "List, create and update issues", + Description: `Lists issues when called without argument. If issue index is provided, will show it in detail.`, + ArgsUsage: "[]", + Action: runIssues, + Commands: []*cli.Command{ + &issues.CmdIssuesList, + &issues.CmdIssuesCreate, + &issues.CmdIssuesEdit, + &issues.CmdIssuesReopen, + &issues.CmdIssuesClose, + }, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "comments", + Usage: "Whether to display comments (will prompt if not provided & run interactively)", + }, + }, issues.CmdIssuesList.Flags...), +} + +func runIssues(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 1 { + return runIssueDetail(ctx, cmd, cmd.Args().First()) + } + return issues.RunIssuesList(ctx, cmd) +} + +func runIssueDetail(requestCtx stdctx.Context, cmd *cli.Command, index string) error { + ctx, idx, err := resolveIssueDetailContext(cmd, index) + if err != nil { + return err + } + + return runIssueDetailWithClient(requestCtx, ctx, idx, ctx.Login.Client().Issues) +} + +func resolveIssueDetailContext(cmd *cli.Command, index string) (*context.TeaContext, int64, error) { + ctx, err := context.InitCommand(cmd) + if err != nil { + return nil, 0, err + } + if ctx.IsSet("owner") { + ctx.Owner = ctx.String("owner") + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return nil, 0, err + } + + idx, err := utils.ArgToIndex(index) + if err != nil { + return nil, 0, err + } + + return ctx, idx, nil +} + +func runIssueDetailWithClient(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64, client issueDetailClient) error { + issue, _, err := client.GetIssue(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return err + } + reactions, _, err := client.ListIssueReactions(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.ListIssueReactionsOptions{}) + if err != nil { + return err + } + + if ctx.IsSet("output") { + switch ctx.String("output") { + case "json": + return runIssueDetailAsJSON(requestCtx, ctx, issue) + } + } + + print.IssueDetails(issue, reactions) + + if issue.Comments > 0 { + err = interact.ShowCommentsMaybeInteractive(requestCtx, ctx, idx, issue.Comments) + if err != nil { + return fmt.Errorf("error loading comments: %v", err) + } + } + + return nil +} + +func runIssueDetailAsJSON(requestCtx stdctx.Context, ctx *context.TeaContext, issue *gitea.Issue) error { + return runIssueDetailAsJSONWithClient(requestCtx, ctx, issue, ctx.Login.Client().Issues) +} + +func runIssueDetailAsJSONWithClient(requestCtx stdctx.Context, ctx *context.TeaContext, issue *gitea.Issue, c issueCommentClient) error { + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)} + comments := []*gitea.Comment{} + + if ctx.Bool("comments") { + var err error + comments, _, err = c.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, issue.Index, opts) + if err != nil { + return err + } + } + + return writeIndentedJSON(ctx.Writer, buildIssueData(issue, comments)) +} + +func buildIssueData(issue *gitea.Issue, comments []*gitea.Comment) issueData { + return issueData{ + ID: issue.ID, + Index: issue.Index, + Title: issue.Title, + State: issue.State, + Created: issue.Created, + User: username(issue.Poster), + Body: issue.Body, + Labels: buildDetailLabels(issue.Labels), + Assignees: buildDetailAssignees(issue.Assignees), + URL: issue.HTMLURL, + ClosedAt: issue.Closed, + Comments: buildDetailComments(comments), + } +} diff --git a/cmd/issues/close.go b/cmd/issues/close.go new file mode 100644 index 00000000..3b24d668 --- /dev/null +++ b/cmd/issues/close.go @@ -0,0 +1,64 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" + "github.com/urfave/cli/v3" +) + +// CmdIssuesClose represents a sub command of issues to close an issue +var CmdIssuesClose = cli.Command{ + Name: "close", + Usage: "Change state of one ore more issues to 'closed'", + Description: `Change state of one ore more issues to 'closed'`, + ArgsUsage: " [...]", + Action: func(ctx stdctx.Context, cmd *cli.Command) error { + s := gitea.StateClosed + return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s}) + }, + Flags: flags.AllDefaultFlags, +} + +// editIssueState abstracts the arg parsing to edit the given issue +func editIssueState(requestCtx stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + if ctx.Args().Len() == 0 { + return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) + } + + indices, err := utils.ArgsToIndices(ctx.Args().Slice()) + if err != nil { + return err + } + + client := ctx.Login.Client() + for _, index := range indices { + issue, _, err := client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, index, opts) + if err != nil { + return err + } + + if len(indices) > 1 { + fmt.Println(issue.HTMLURL) + } else { + print.IssueDetails(issue, nil) + } + } + return nil +} diff --git a/cmd/issues/create.go b/cmd/issues/create.go new file mode 100644 index 00000000..9cfd72bd --- /dev/null +++ b/cmd/issues/create.go @@ -0,0 +1,95 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + stdctx "context" + "encoding/json" + "fmt" + "io" + + gitea "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/task" +) + +// CmdIssuesCreate represents a sub command of issues to create issue +var CmdIssuesCreate = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create an issue on repository", + Description: `Create an issue on repository`, + ArgsUsage: " ", // command does not accept arguments + Action: runIssuesCreate, + Flags: flags.IssuePRCreateFlags, +} + +func runIssuesCreate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.IsInteractiveMode() { + err := interact.CreateIssue(requestCtx, ctx.Login, ctx.Owner, ctx.Repo) + if err != nil && !interact.IsQuitting(err) { + return err + } + return nil + } + + opts, err := flags.GetIssuePRCreateFlags(requestCtx, ctx) + if err != nil { + return err + } + + issue, err := task.CreateIssue(requestCtx, ctx.Login, + ctx.Owner, + ctx.Repo, + *opts, + ) + if err != nil { + return err + } + + if ctx.IsSet("output") { + switch ctx.String("output") { + case "json": + return writeCreatedIssueAsJSON(ctx.Writer, issue) + } + } + + print.IssueDetails(issue, nil) + + fmt.Println(issue.HTMLURL) + + return nil +} + +// createdIssueJSON is the machine-readable representation of a freshly +// created issue, mirroring the create-PR equivalent in cmd/pulls/create.go +// (createdPullJSON). +type createdIssueJSON struct { + Index int64 `json:"index"` + Title string `json:"title"` + URL string `json:"url"` + State gitea.StateType `json:"state"` +} + +func writeCreatedIssueAsJSON(w io.Writer, issue *gitea.Issue) error { + return json.NewEncoder(w).Encode(createdIssueJSON{ + Index: issue.Index, + Title: issue.Title, + URL: issue.HTMLURL, + State: issue.State, + }) +} diff --git a/cmd/issues/create_test.go b/cmd/issues/create_test.go new file mode 100644 index 00000000..274b312a --- /dev/null +++ b/cmd/issues/create_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "bytes" + "encoding/json" + "testing" + + gitea "gitea.dev/sdk" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteCreatedIssueAsJSON(t *testing.T) { + issue := &gitea.Issue{ + Index: 42, + Title: "test title", + HTMLURL: "https://gitea.example.com/owner/repo/issues/42", + State: gitea.StateOpen, + } + + var buf bytes.Buffer + require.NoError(t, writeCreatedIssueAsJSON(&buf, issue)) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + + assert.Equal(t, float64(42), got["index"]) + assert.Equal(t, "test title", got["title"]) + assert.Equal(t, "https://gitea.example.com/owner/repo/issues/42", got["url"]) + assert.Equal(t, "open", got["state"]) + + // exactly the lean field set, nothing extra + assert.Len(t, got, 4) + + // machine-readable output must not contain terminal escape sequences + assert.NotContains(t, buf.String(), "\x1b") +} diff --git a/cmd/issues/edit.go b/cmd/issues/edit.go new file mode 100644 index 00000000..3635afa3 --- /dev/null +++ b/cmd/issues/edit.go @@ -0,0 +1,81 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "fmt" + + stdctx "context" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/utils" +) + +// CmdIssuesEdit is the subcommand of issues to edit issues +var CmdIssuesEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit one or more issues", + Description: `Edit one or more issues. To unset a property again, +use an empty string (eg. --milestone "").`, + ArgsUsage: " [...]", + Action: runIssuesEdit, + Flags: flags.IssuePREditFlags, +} + +func runIssuesEdit(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if !cmd.Args().Present() { + return fmt.Errorf("must specify at least one issue index") + } + + opts, err := flags.GetIssuePREditFlags(ctx) + if err != nil { + return err + } + + indices, err := utils.ArgsToIndices(ctx.Args().Slice()) + if err != nil { + return err + } + + client := ctx.Login.Client() + for _, opts.Index = range indices { + if ctx.IsInteractiveMode() { + var err error + opts, err = interact.EditIssue(requestCtx, *ctx, opts.Index) + if err != nil { + if interact.IsQuitting(err) { + return nil // user quit + } + return err + } + } + + issue, err := task.EditIssue(requestCtx, ctx, client, *opts) + if err != nil { + return err + } + if ctx.Args().Len() > 1 { + fmt.Println(issue.HTMLURL) + } else { + print.IssueDetails(issue, nil) + } + } + + return nil +} diff --git a/cmd/issues/list.go b/cmd/issues/list.go new file mode 100644 index 00000000..184c2fc6 --- /dev/null +++ b/cmd/issues/list.go @@ -0,0 +1,119 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + stdctx "context" + "errors" + "time" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/araddon/dateparse" + "github.com/urfave/cli/v3" +) + +var issueFieldsFlag = flags.FieldsFlag(print.IssueFields, []string{ + "index", "title", "state", "author", "milestone", "labels", "owner", "repo", +}) + +// CmdIssuesList represents a sub command of issues to list issues +var CmdIssuesList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List issues of the repository", + Description: `List issues of the repository`, + ArgsUsage: " ", // command does not accept arguments + Action: RunIssuesList, + Flags: append([]cli.Flag{issueFieldsFlag}, flags.IssueListingFlags...), +} + +// RunIssuesList list issues +func RunIssuesList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err + } + + kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeIssue) + if err != nil { + return err + } + + var from, until time.Time + if ctx.IsSet("from") { + from, err = dateparse.ParseLocal(ctx.String("from")) + if err != nil { + return err + } + } + if ctx.IsSet("until") { + until, err = dateparse.ParseLocal(ctx.String("until")) + if err != nil { + return err + } + } + owner := ctx.Owner + if ctx.IsSet("owner") { + owner = ctx.String("owner") + } + + // ignore error, as we don't do any input validation on these flags + labels, _ := flags.LabelFilterFlag.GetValues(cmd) + milestones, _ := flags.MilestoneFilterFlag.GetValues(cmd) + var issues []*gitea.Issue + if ctx.Repo != "" { + issues, _, err = ctx.Login.Client().Issues.ListRepoIssues(requestCtx, owner, ctx.Repo, gitea.ListIssueOption{ + ListOptions: flags.GetListOptions(cmd), + State: state, + Type: kind, + KeyWord: ctx.String("keyword"), + CreatedBy: ctx.String("author"), + AssignedBy: ctx.String("assignee"), + MentionedBy: ctx.String("mentions"), + Labels: labels, + Milestones: milestones, + Since: from, + Before: until, + }) + if err != nil { + return err + } + } else { + if ctx.IsSet("assignee") { + return errors.New("--assignee requires --repo (global issue search does not support assignee filter)") + } + issues, _, err = ctx.Login.Client().Issues.ListIssues(requestCtx, gitea.ListIssueOption{ + ListOptions: flags.GetListOptions(cmd), + State: state, + Type: kind, + KeyWord: ctx.String("keyword"), + CreatedBy: ctx.String("author"), + MentionedBy: ctx.String("mentions"), + Labels: labels, + Milestones: milestones, + Since: from, + Before: until, + Owner: owner, + }) + if err != nil { + return err + } + } + + fields, err := issueFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + + return print.IssuesPullsList(issues, ctx.Output, fields) +} diff --git a/cmd/issues/list_test.go b/cmd/issues/list_test.go new file mode 100644 index 00000000..2e265b39 --- /dev/null +++ b/cmd/issues/list_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + stdctx "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "gitea.dev/tea/modules/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "golang.org/x/crypto/ssh" +) + +func TestRunIssuesListWithSSHPubkeyLoginDoesNotDeadlock(t *testing.T) { + t.Parallel() + + sshKeyPath, fingerprint := writeTestSSHKey(t) + + var versionRequests atomic.Int32 + var issueRequests atomic.Int32 + var signedVersionRequests atomic.Int32 + var signedIssueRequests atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + versionRequests.Add(1) + if r.Header.Get("Signature") != "" { + signedVersionRequests.Add(1) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.26.4"}`)) + case "/api/v1/repos/gitea/tea/issues": + issueRequests.Add(1) + if r.Header.Get("Signature") != "" { + signedIssueRequests.Add(1) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "ssh-login", + URL: server.URL, + SSHKey: sshKeyPath, + SSHKeyFingerprint: fingerprint, + VersionCheck: true, + Default: true, + }}, + }) + + cmd := cli.Command{ + Name: CmdIssuesList.Name, + Flags: CmdIssuesList.Flags, + } + require.NoError(t, cmd.Set("login", "ssh-login")) + require.NoError(t, cmd.Set("repo", "gitea/tea")) + require.NoError(t, cmd.Set("output", "json")) + + done := make(chan error, 1) + go func() { + done <- RunIssuesList(stdctx.Background(), &cmd) + }() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("RunIssuesList deadlocked while bootstrapping the server version for HTTPSign authentication") + } + + assert.EqualValues(t, 1, versionRequests.Load()) + assert.EqualValues(t, 0, signedVersionRequests.Load()) + assert.EqualValues(t, 1, issueRequests.Load()) + assert.EqualValues(t, 1, signedIssueRequests.Load()) +} + +func writeTestSSHKey(t *testing.T) (string, string) { + t.Helper() + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey) + require.NoError(t, err) + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}) + sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519") + require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600)) + + signer, err := ssh.NewSignerFromKey(privateKey) + require.NoError(t, err) + + return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey()) +} diff --git a/cmd/issues/reopen.go b/cmd/issues/reopen.go new file mode 100644 index 00000000..30ba85fd --- /dev/null +++ b/cmd/issues/reopen.go @@ -0,0 +1,27 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "github.com/urfave/cli/v3" +) + +// CmdIssuesReopen represents a sub command of issues to open an issue +var CmdIssuesReopen = cli.Command{ + Name: "reopen", + Aliases: []string{"open"}, + Usage: "Change state of one or more issues to 'open'", + Description: `Change state of one or more issues to 'open'`, + ArgsUsage: " [...]", + Action: func(ctx context.Context, cmd *cli.Command) error { + s := gitea.StateOpen + return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s}) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/issues_test.go b/cmd/issues_test.go new file mode 100644 index 00000000..5a11acb8 --- /dev/null +++ b/cmd/issues_test.go @@ -0,0 +1,360 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + stdctx "context" + "encoding/json" + "fmt" + "testing" + "time" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/context" +) + +const ( + testOwner = "testOwner" + testRepo = "testRepo" +) + +type fakeIssueCommentClient struct { + owner string + repo string + index int64 + comments []*gitea.Comment +} + +func (f *fakeIssueCommentClient) ListIssueComments(_ stdctx.Context, owner, repo string, index int64, _ gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.index = index + return f.comments, nil, nil +} + +type fakeIssueDetailClient struct { + owner string + repo string + index int64 + issue *gitea.Issue + reactions []*gitea.Reaction +} + +func (f *fakeIssueDetailClient) GetIssue(_ stdctx.Context, owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.index = index + return f.issue, nil, nil +} + +func (f *fakeIssueDetailClient) ListIssueReactions(_ stdctx.Context, owner, repo string, index int64, _ gitea.ListIssueReactionsOptions) ([]*gitea.Reaction, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.index = index + return f.reactions, nil, nil +} + +func toCommentPointers(comments []gitea.Comment) []*gitea.Comment { + result := make([]*gitea.Comment, 0, len(comments)) + for i := range comments { + comment := comments[i] + result = append(result, &comment) + } + return result +} + +func createTestIssue(comments int, isClosed bool) gitea.Issue { + issue := gitea.Issue{ + ID: 42, + Index: 1, + Title: "Test issue", + State: gitea.StateOpen, + Body: "This is a test", + Created: time.Date(2025, 31, 10, 23, 59, 59, 999999999, time.UTC), + Updated: time.Date(2025, 1, 11, 0, 0, 0, 0, time.UTC), + Labels: []*gitea.Label{ + { + Name: "example/Label1", + Color: "very red", + Description: "This is an example label", + }, + { + Name: "example/Label2", + Color: "hardly red", + Description: "This is another example label", + }, + }, + Comments: comments, + Poster: &gitea.User{ + UserName: "testUser", + }, + Assignees: []*gitea.User{ + {UserName: "testUser"}, + {UserName: "testUser3"}, + }, + HTMLURL: "", + Closed: nil, // 2025-11-10T21:20:19Z + } + + if isClosed { + closed := time.Date(2025, 11, 10, 21, 20, 19, 0, time.UTC) + issue.Closed = &closed + } + + if isClosed { + issue.State = gitea.StateClosed + } else { + issue.State = gitea.StateOpen + } + + return issue +} + +func createTestIssueComments(comments int) []gitea.Comment { + baseID := 900 + var result []gitea.Comment + + for commentID := 0; commentID < comments; commentID++ { + result = append(result, gitea.Comment{ + ID: int64(baseID + commentID), + Poster: &gitea.User{ + UserName: "Freddy", + }, + Body: fmt.Sprintf("This is a test comment #%v", commentID), + Created: time.Date(2025, 11, 3, 12, 0, 0, 0, time.UTC). + Add(time.Duration(commentID) * time.Hour), + }) + } + + return result +} + +func TestRunIssueDetailAsJSON(t *testing.T) { + type TestCase struct { + name string + issue gitea.Issue + comments []gitea.Comment + flagComments bool + } + + cmd := cli.Command{ + Name: "t", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "comments", + Value: false, + }, + &cli.StringFlag{ + Name: "output", + Value: "json", + }, + }, + } + + testContext := context.TeaContext{ + Owner: testOwner, + Repo: testRepo, + Login: &config.Login{ + Name: "testLogin", + URL: "http://127.0.0.1:8081", + }, + Command: &cmd, + } + + testCases := []TestCase{ + { + name: "Simple issue with no comments, no comments requested", + issue: createTestIssue(0, true), + comments: []gitea.Comment{}, + flagComments: false, + }, + { + name: "Simple issue with no comments, comments requested", + issue: createTestIssue(0, true), + comments: []gitea.Comment{}, + flagComments: true, + }, + { + name: "Simple issue with comments, no comments requested", + issue: createTestIssue(2, true), + comments: createTestIssueComments(2), + flagComments: false, + }, + { + name: "Simple issue with comments, comments requested", + issue: createTestIssue(2, true), + comments: createTestIssueComments(2), + flagComments: true, + }, + { + name: "Simple issue with comments, comments requested, not closed", + issue: createTestIssue(2, false), + comments: createTestIssueComments(2), + flagComments: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + client := &fakeIssueCommentClient{ + comments: toCommentPointers(testCase.comments), + } + + testContext.Login.URL = "https://gitea.example.com" + testCase.issue.HTMLURL = fmt.Sprintf("%s/%s/%s/issues/%d/", testContext.Login.URL, testOwner, testRepo, testCase.issue.Index) + + var outBuffer bytes.Buffer + testContext.Writer = &outBuffer + var errBuffer bytes.Buffer + testContext.ErrWriter = &errBuffer + + if testCase.flagComments { + require.NoError(t, testContext.Set("comments", "true")) + } else { + require.NoError(t, testContext.Set("comments", "false")) + } + + err := runIssueDetailAsJSONWithClient(t.Context(), &testContext, &testCase.issue, client) + + require.NoError(t, err, "Failed to run issue detail as JSON") + if testCase.flagComments { + assert.Equal(t, testOwner, client.owner) + assert.Equal(t, testRepo, client.repo) + assert.Equal(t, testCase.issue.Index, client.index) + } + + out := outBuffer.String() + + require.NotEmpty(t, out, "Unexpected empty output from runIssueDetailAsJSON") + + // setting expectations + + var expectedLabels []labelData + expectedLabels = []labelData{} + for _, l := range testCase.issue.Labels { + expectedLabels = append(expectedLabels, labelData{ + Name: l.Name, + Color: l.Color, + Description: l.Description, + }) + } + + var expectedAssignees []string + expectedAssignees = []string{} + for _, a := range testCase.issue.Assignees { + expectedAssignees = append(expectedAssignees, a.UserName) + } + + var expectedClosedAt *time.Time + if testCase.issue.Closed != nil { + expectedClosedAt = testCase.issue.Closed + } + + var expectedComments []commentData + expectedComments = []commentData{} + if testCase.flagComments { + for _, c := range testCase.comments { + expectedComments = append(expectedComments, commentData{ + ID: c.ID, + Author: c.Poster.UserName, + Body: c.Body, + Created: c.Created, + }) + } + } + + expected := issueData{ + ID: testCase.issue.ID, + Index: testCase.issue.Index, + Title: testCase.issue.Title, + State: testCase.issue.State, + Created: testCase.issue.Created, + User: testCase.issue.Poster.UserName, + Body: testCase.issue.Body, + URL: testCase.issue.HTMLURL, + ClosedAt: expectedClosedAt, + Labels: expectedLabels, + Assignees: expectedAssignees, + Comments: expectedComments, + } + + // validating reality + var actual issueData + dec := json.NewDecoder(bytes.NewReader(outBuffer.Bytes())) + dec.DisallowUnknownFields() + err = dec.Decode(&actual) + require.NoError(t, err, "Failed to unmarshal output into struct") + + assert.Equal(t, expected, actual, "Expected structs differ from expected one") + }) + } +} + +func TestRunIssueDetailUsesOwnerFlag(t *testing.T) { + issueIndex := int64(12) + expectedOwner := "overrideOwner" + expectedRepo := "overrideRepo" + issue := &gitea.Issue{ + ID: 99, + Index: issueIndex, + Title: "Owner override test", + State: gitea.StateOpen, + Created: time.Date(2025, 11, 1, 10, 0, 0, 0, time.UTC), + Poster: &gitea.User{ + UserName: "tester", + }, + HTMLURL: "https://example.test/issues/12", + } + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: "https://gitea.example.com", + Token: "token", + User: "loginUser", + Default: true, + }}, + }) + + cmd := cli.Command{ + Name: "issues", + Flags: []cli.Flag{ + &flags.LoginFlag, + &flags.RepoFlag, + &flags.RemoteFlag, + &flags.OutputFlag, + &cli.StringFlag{Name: "owner"}, + &cli.BoolFlag{Name: "comments"}, + }, + } + var outBuffer bytes.Buffer + var errBuffer bytes.Buffer + cmd.Writer = &outBuffer + cmd.ErrWriter = &errBuffer + require.NoError(t, cmd.Set("login", "testLogin")) + require.NoError(t, cmd.Set("repo", expectedRepo)) + require.NoError(t, cmd.Set("owner", expectedOwner)) + require.NoError(t, cmd.Set("comments", "false")) + + teaCtx, idx, err := resolveIssueDetailContext(&cmd, fmt.Sprintf("%d", issueIndex)) + require.NoError(t, err) + + client := &fakeIssueDetailClient{ + issue: issue, + reactions: []*gitea.Reaction{}, + } + + err = runIssueDetailWithClient(t.Context(), teaCtx, idx, client) + require.NoError(t, err, "Expected runIssueDetail to succeed") + assert.Equal(t, expectedOwner, client.owner) + assert.Equal(t, expectedRepo, client.repo) + assert.Equal(t, issueIndex, client.index) +} diff --git a/cmd/labels.go b/cmd/labels.go new file mode 100644 index 00000000..b676de71 --- /dev/null +++ b/cmd/labels.go @@ -0,0 +1,42 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/labels" +) + +// CmdLabels represents to operate repositories' labels. +var CmdLabels = cli.Command{ + Name: "labels", + Aliases: []string{"label"}, + Category: catEntities, + Usage: "Manage issue labels", + Description: `Manage issue labels`, + ArgsUsage: " ", // command does not accept arguments + Action: runLabels, + Commands: []*cli.Command{ + &labels.CmdLabelsList, + &labels.CmdLabelCreate, + &labels.CmdLabelUpdate, + &labels.CmdLabelDelete, + }, + Flags: labels.CmdLabelsList.Flags, +} + +func runLabels(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 1 { + return runLabelsDetails(cmd) + } + return labels.RunLabelsList(ctx, cmd) +} + +func runLabelsDetails(cmd *cli.Command) error { + return fmt.Errorf("not yet implemented") +} diff --git a/cmd/labels/create.go b/cmd/labels/create.go new file mode 100644 index 00000000..aaea9a5c --- /dev/null +++ b/cmd/labels/create.go @@ -0,0 +1,116 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package labels + +import ( + "bufio" + stdctx "context" + "log" + "os" + "strings" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "github.com/urfave/cli/v3" +) + +// CmdLabelCreate represents a sub command of labels to create label. +var CmdLabelCreate = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create a label", + Description: `Create a label`, + ArgsUsage: " ", // command does not accept arguments + Action: runLabelCreate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "label name", + }, + &cli.StringFlag{ + Name: "color", + Usage: "label color value", + }, + &cli.StringFlag{ + Name: "description", + Usage: "label description", + }, + &cli.StringFlag{ + Name: "file", + Usage: "indicate a label file", + }, + }, flags.AllDefaultFlags...), +} + +func runLabelCreate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + labelFile := ctx.String("file") + if len(labelFile) == 0 { + _, _, err := ctx.Login.Client().Repositories.CreateLabel(requestCtx, ctx.Owner, ctx.Repo, gitea.CreateLabelOption{ + Name: ctx.String("name"), + Color: ctx.String("color"), + Description: ctx.String("description"), + }) + return err + } + + f, err := os.Open(labelFile) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + i := 1 + for scanner.Scan() { + line := scanner.Text() + color, name, description := splitLabelLine(line) + if color == "" || name == "" { + log.Printf("Line %d ignored because lack of enough fields: %s\n", i, line) + } else { + _, _, err = ctx.Login.Client().Repositories.CreateLabel(requestCtx, ctx.Owner, ctx.Repo, gitea.CreateLabelOption{ + Name: name, + Color: color, + Description: description, + }) + if err != nil { + return err + } + } + + i++ + } + + return nil +} + +func splitLabelLine(line string) (string, string, string) { + fields := strings.SplitN(line, ";", 2) + var color, name, description string + if len(fields) < 1 { + return "", "", "" + } else if len(fields) >= 2 { + description = strings.TrimSpace(fields[1]) + } + fields = strings.Fields(fields[0]) + if len(fields) <= 0 { + return "", "", "" + } + color = fields[0] + if len(fields) == 2 { + name = fields[1] + } else if len(fields) > 2 { + name = strings.Join(fields[1:], " ") + } + return color, name, description +} diff --git a/cmd/labels/create_test.go b/cmd/labels/create_test.go new file mode 100644 index 00000000..f192cea7 --- /dev/null +++ b/cmd/labels/create_test.go @@ -0,0 +1,51 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package labels + +import ( + "bufio" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseLabelLine(t *testing.T) { + const labels = `#ededed in progress +#fbca04 kind/breaking ; breaking label +#fc2929 kind/bug +#c5def5 kind/deployment ; deployment label +#000000 in progress ; in progress label +` + + scanner := bufio.NewScanner(strings.NewReader(labels)) + i := 1 + for scanner.Scan() { + line := scanner.Text() + color, name, description := splitLabelLine(line) + + switch i { + case 1: + assert.EqualValues(t, "#ededed", color) + assert.EqualValues(t, "in progress", name) + case 2: + assert.EqualValues(t, "#fbca04", color) + assert.EqualValues(t, "kind/breaking", name) + assert.EqualValues(t, "breaking label", description) + case 3: + assert.EqualValues(t, "#fc2929", color) + assert.EqualValues(t, "kind/bug", name) + case 4: + assert.EqualValues(t, "#c5def5", color) + assert.EqualValues(t, "kind/deployment", name) + assert.EqualValues(t, "deployment label", description) + case 5: + assert.EqualValues(t, "#000000", color) + assert.EqualValues(t, "in progress", name) + assert.EqualValues(t, "in progress label", description) + } + + i++ + } +} diff --git a/cmd/labels/delete.go b/cmd/labels/delete.go new file mode 100644 index 00000000..b7b2a7ad --- /dev/null +++ b/cmd/labels/delete.go @@ -0,0 +1,58 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package labels + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdLabelDelete represents a sub command of labels to delete label. +var CmdLabelDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete a label", + Description: `Delete a label`, + ArgsUsage: " ", // command does not accept arguments + Action: runLabelDelete, + Flags: append([]cli.Flag{ + &cli.Int64Flag{ + Name: "id", + Usage: "label id", + Required: true, + }, + }, flags.AllDefaultFlags...), +} + +func runLabelDelete(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + labelID := ctx.Int64("id") + client := ctx.Login.Client() + + // Verify the label exists first + label, _, err := client.Repositories.GetRepoLabel(requestCtx, ctx.Owner, ctx.Repo, labelID) + if err != nil { + return fmt.Errorf("failed to get label %d: %w", labelID, err) + } + + _, err = client.Repositories.DeleteLabel(requestCtx, ctx.Owner, ctx.Repo, labelID) + if err != nil { + return fmt.Errorf("failed to delete label '%s' (id: %d): %w", label.Name, labelID, err) + } + + fmt.Printf("Label '%s' (id: %d) deleted successfully\n", label.Name, labelID) + return nil +} diff --git a/cmd/labels/list.go b/cmd/labels/list.go new file mode 100644 index 00000000..793b3241 --- /dev/null +++ b/cmd/labels/list.go @@ -0,0 +1,107 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package labels + +import ( + stdctx "context" + "log" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/task" + "github.com/urfave/cli/v3" +) + +// CmdLabelsList represents a sub command of labels to list labels +var CmdLabelsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List labels", + Description: "List labels", + ArgsUsage: " ", // command does not accept arguments + Action: RunLabelsList, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "save", + Aliases: []string{"s"}, + Usage: "Save all the labels as a file", + }, + &cli.StringFlag{ + Name: "org", + Usage: "List organization labels", + }, + &cli.BoolFlag{ + Name: "exclude-org", + Usage: "Exclude organization labels from the list", + }, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunLabelsList list labels. +func RunLabelsList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if cmd.String("org") == "" && cmd.String("repo") == "" { + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + } + + if cmd.String("org") != "" && cmd.String("repo") != "" { + log.Printf("Warning: Both --org and --repo flags are set. Ignoring --org and using --repo value as owner.\n") + } + + client := ctx.Login.Client() + labels, err := collectLabels(requestCtx, client, ctx, cmd) + if err != nil { + return err + } + + if ctx.IsSet("save") { + return task.LabelsExport(labels, ctx.String("save")) + } + + return print.LabelsList(labels, ctx.Output) +} + +// CollectLabels collects labels based on the provided command flags and context. +func collectLabels(requestCtx stdctx.Context, client *gitea.Client, ctx *context.TeaContext, cmd *cli.Command) ([]*gitea.Label, error) { + var labels []*gitea.Label + var err error + owner := ctx.Owner + + // If --org is set but --repo is not, list organization labels only and skip repository labels. + if cmd.String("org") != "" && cmd.String("repo") == "" { + owner = ctx.Org + } else { + labels, _, err = client.Repositories.ListRepoLabels(requestCtx, owner, ctx.Repo, gitea.ListLabelsOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + log.Printf("Failed to list repository labels: %v", err) + } + } + + // If --exclude is not set and the owner is an organization, also list organization labels and append them to the list. + if !ctx.IsSet("exclude-org") { + if _, _, err = client.Organizations.GetOrg(requestCtx, owner); err == nil { + orgLabels, _, err := client.Organizations.ListOrgLabels(requestCtx, owner, gitea.ListOrgLabelsOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return nil, err + } + labels = append(labels, orgLabels...) + } + } + + return labels, nil +} diff --git a/cmd/labels/update.go b/cmd/labels/update.go new file mode 100644 index 00000000..640d1acd --- /dev/null +++ b/cmd/labels/update.go @@ -0,0 +1,79 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package labels + +import ( + stdctx "context" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "github.com/urfave/cli/v3" +) + +// CmdLabelUpdate represents a sub command of labels to update label. +var CmdLabelUpdate = cli.Command{ + Name: "update", + Usage: "Update a label", + Description: `Update a label`, + ArgsUsage: " ", // command does not accept arguments + Action: runLabelUpdate, + Flags: append([]cli.Flag{ + &cli.Int64Flag{ + Name: "id", + Usage: "label id", + }, + &cli.StringFlag{ + Name: "name", + Usage: "label name", + }, + &cli.StringFlag{ + Name: "color", + Usage: "label color value", + }, + &cli.StringFlag{ + Name: "description", + Usage: "label description", + }, + }, flags.AllDefaultFlags...), +} + +func runLabelUpdate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + id := ctx.Int64("id") + var pName, pColor, pDescription *string + name := ctx.String("name") + if name != "" { + pName = &name + } + + color := ctx.String("color") + if color != "" { + pColor = &color + } + + description := ctx.String("description") + if description != "" { + pDescription = &description + } + + _, _, err = ctx.Login.Client().Repositories.EditLabel(requestCtx, ctx.Owner, ctx.Repo, id, gitea.EditLabelOption{ + Name: pName, + Color: pColor, + Description: pDescription, + }) + if err != nil { + return err + } + + return nil +} diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 00000000..0a2de65d --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,57 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + + "gitea.dev/tea/cmd/login" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdLogin represents to login a gitea server. +var CmdLogin = cli.Command{ + Name: "logins", + Aliases: []string{"login"}, + Category: catSetup, + Usage: "Log in to a Gitea server", + Description: `Log in to a Gitea server`, + ArgsUsage: "[]", + Action: runLogins, + Commands: []*cli.Command{ + &login.CmdLoginList, + &login.CmdLoginAdd, + &login.CmdLoginEdit, + &login.CmdLoginDelete, + &login.CmdLoginSetDefault, + &login.CmdLoginHelper, + &login.CmdLoginOAuthRefresh, + &login.CmdLoginStatus, + }, +} + +func runLogins(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 1 { + return runLoginDetail(cmd.Args().First()) + } + return login.RunLoginList(ctx, cmd) +} + +func runLoginDetail(name string) error { + l, err := config.GetLoginByName(name) + if err != nil { + return err + } + if l == nil { + fmt.Printf("Login '%s' do not exist\n\n", name) + return nil + } + + print.LoginDetails(l) + return nil +} diff --git a/cmd/login/add.go b/cmd/login/add.go new file mode 100644 index 00000000..bcc71659 --- /dev/null +++ b/cmd/login/add.go @@ -0,0 +1,171 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "context" + "fmt" + + "gitea.dev/tea/modules/auth" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/task" + + "github.com/urfave/cli/v3" +) + +// CmdLoginAdd represents to login a gitea server. +var CmdLoginAdd = cli.Command{ + Name: "add", + Usage: "Add a Gitea login", + Description: `Add a Gitea login, without args it will create one interactively. + +By default tea only stores the token for its own API use. Pass --git-credentials +to also register tea as a git credential helper for the login's URL, so that +'git push' and 'git clone' over HTTPS authenticate silently using the stored +token. Equivalent to running 'tea login helper setup' afterwards.`, + ArgsUsage: " ", // command does not accept arguments + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "Login name", + }, + &cli.StringFlag{ + Name: "url", + Aliases: []string{"u"}, + Value: "https://gitea.com", + Sources: cli.EnvVars("GITEA_SERVER_URL"), + Usage: "Server URL", + }, + &cli.BoolFlag{ + Name: "no-version-check", + Aliases: []string{"nv"}, + Usage: "Do not check version of Gitea instance", + }, + &cli.StringFlag{ + Name: "token", + Aliases: []string{"t"}, + Value: "", + Sources: cli.EnvVars("GITEA_SERVER_TOKEN"), + Usage: "Access token. Can be obtained from Settings > Applications", + }, + &cli.StringFlag{ + Name: "user", + Value: "", + Sources: cli.EnvVars("GITEA_SERVER_USER"), + Usage: "User for basic auth (will create token)", + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"pwd"}, + Value: "", + Sources: cli.EnvVars("GITEA_SERVER_PASSWORD"), + Usage: "Password for basic auth (will create token)", + }, + &cli.StringFlag{ + Name: "otp", + Sources: cli.EnvVars("GITEA_SERVER_OTP"), + Usage: "OTP token for auth, if necessary", + }, + &cli.StringFlag{ + Name: "scopes", + Sources: cli.EnvVars("GITEA_SCOPES"), + Usage: "Token scopes to add when creating a new token, separated by a comma", + }, + &cli.StringFlag{ + Name: "ssh-key", + Aliases: []string{"s"}, + Usage: "Path to a SSH key/certificate to use, overrides auto-discovery", + }, + &cli.BoolFlag{ + Name: "insecure", + Aliases: []string{"i"}, + Usage: "Disable TLS verification", + }, + &cli.StringFlag{ + Name: "ssh-agent-principal", + Aliases: []string{"c"}, + Usage: "Use SSH certificate with specified principal to login (needs a running ssh-agent with certificate loaded)", + }, + &cli.StringFlag{ + Name: "ssh-agent-key", + Aliases: []string{"a"}, + Usage: "Use SSH public key or SSH fingerprint to login (needs a running ssh-agent with ssh key loaded)", + }, + &cli.BoolFlag{ + Name: "git-credentials", + Aliases: []string{"helper", "j"}, + Usage: "Register tea as a git credential helper for this login's URL, so 'git push' and 'git clone' over HTTPS authenticate silently using the stored token", + }, + &cli.BoolFlag{ + Name: "oauth", + Aliases: []string{"o"}, + Usage: "Use interactive OAuth2 flow for authentication", + }, + &cli.StringFlag{ + Name: "client-id", + Usage: "OAuth client ID (for use with --oauth)", + }, + &cli.StringFlag{ + Name: "redirect-url", + Usage: "OAuth redirect URL (for use with --oauth)", + }, + }, + Action: runLoginAdd, +} + +func runLoginAdd(requestCtx context.Context, cmd *cli.Command) error { + // if no args create login interactive + if cmd.NumFlags() == 0 { + if err := interact.CreateLogin(requestCtx); err != nil && !interact.IsQuitting(err) { + return fmt.Errorf("error adding login: %w", err) + } + return nil + } + + // if OAuth flag is provided, use OAuth2 PKCE flow + if cmd.Bool("oauth") { + opts := auth.OAuthOptions{ + Name: cmd.String("name"), + URL: cmd.String("url"), + Insecure: cmd.Bool("insecure"), + } + + // Only set clientID if provided + if cmd.String("client-id") != "" { + opts.ClientID = cmd.String("client-id") + } + + // Only set redirect URL if provided + if cmd.String("redirect-url") != "" { + opts.RedirectURL = cmd.String("redirect-url") + } + + return auth.OAuthLoginWithFullOptions(requestCtx, opts) + } + + sshAgent := false + if cmd.String("ssh-agent-key") != "" || cmd.String("ssh-agent-principal") != "" { + sshAgent = true + } + + // else use args to add login + return task.CreateLogin( + requestCtx, + cmd.String("name"), + cmd.String("token"), + cmd.String("user"), + cmd.String("password"), + cmd.String("otp"), + cmd.String("scopes"), + cmd.String("ssh-key"), + cmd.String("url"), + cmd.String("ssh-agent-principal"), + cmd.String("ssh-agent-key"), + cmd.Bool("insecure"), + sshAgent, + !cmd.Bool("no-version-check"), + cmd.Bool("git-credentials"), + ) +} diff --git a/cmd/login/default.go b/cmd/login/default.go new file mode 100644 index 00000000..76c0c1e8 --- /dev/null +++ b/cmd/login/default.go @@ -0,0 +1,38 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + + "github.com/urfave/cli/v3" +) + +// CmdLoginSetDefault represents to login a gitea server. +var CmdLoginSetDefault = cli.Command{ + Name: "default", + Usage: "Get or Set Default Login", + Description: `Get or Set Default Login`, + ArgsUsage: "", + Action: runLoginSetDefault, + Flags: []cli.Flag{&flags.OutputFlag}, +} + +func runLoginSetDefault(requestCtx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + l, err := config.GetDefaultLogin() + if err != nil { + return err + } + fmt.Printf("Default Login: %s\n", l.Name) + return nil + } + + name := cmd.Args().First() + return config.SetDefaultLogin(name) +} diff --git a/cmd/login/delete.go b/cmd/login/delete.go new file mode 100644 index 00000000..835c7247 --- /dev/null +++ b/cmd/login/delete.go @@ -0,0 +1,43 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "context" + "fmt" + + "gitea.dev/tea/modules/config" + + "github.com/urfave/cli/v3" +) + +// CmdLoginDelete is a command to delete a login +var CmdLoginDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Remove a Gitea login", + Description: `Remove a Gitea login`, + ArgsUsage: "", + Action: RunLoginDelete, +} + +// RunLoginDelete runs the action of a login delete command +func RunLoginDelete(requestCtx context.Context, cmd *cli.Command) error { + logins, err := config.GetLogins() + if err != nil { + return err + } + + var name string + + if len(cmd.Args().First()) != 0 { + name = cmd.Args().First() + } else if len(logins) == 1 { + name = logins[0].Name + } else { + return fmt.Errorf("please specify a login name") + } + + return config.DeleteLogin(name) +} diff --git a/cmd/login/edit.go b/cmd/login/edit.go new file mode 100644 index 00000000..03683255 --- /dev/null +++ b/cmd/login/edit.go @@ -0,0 +1,45 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "context" + "fmt" + "os" + "os/exec" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/utils" + + "github.com/skratchdot/open-golang/open" + "github.com/urfave/cli/v3" +) + +// CmdLoginEdit represents to login a gitea server. +var CmdLoginEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit Gitea logins", + Description: `Edit Gitea logins`, + ArgsUsage: " ", // command does not accept arguments + Action: runLoginEdit, + Flags: []cli.Flag{&flags.OutputFlag}, +} + +func runLoginEdit(requestCtx context.Context, _ *cli.Command) error { + ymlPath := config.GetConfigPath() + if e, ok := os.LookupEnv("EDITOR"); ok && e != "" { + cmd := exec.Command(e, ymlPath) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + if exist, _ := utils.FileExist(ymlPath); !exist { + fmt.Printf("Config file does not exist, please run login add first\n") + return nil + } + return open.Start(ymlPath) +} diff --git a/cmd/login/helper.go b/cmd/login/helper.go new file mode 100644 index 00000000..f252a24a --- /dev/null +++ b/cmd/login/helper.go @@ -0,0 +1,155 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "bufio" + "context" + "fmt" + "net/url" + "os" + "strings" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/task" +) + +// CmdLoginHelper represents to login a gitea helper. +var CmdLoginHelper = cli.Command{ + Name: "helper", + Aliases: []string{"git-credential"}, + Usage: "Act as a git credential helper for stored Gitea logins", + Description: `Speaks git's credential helper protocol so that HTTPS push and clone +operations against your configured Gitea instances authenticate silently +using the tokens tea already stores. + +Typical use is automatic: 'tea login add --git-credentials' (or 'tea login +helper setup' for existing logins) registers '!tea login helper' as a +credential helper in ~/.gitconfig. Git then invokes the 'get' subcommand +when it needs credentials for a configured host.`, + Commands: []*cli.Command{ + { + Name: "store", + Aliases: []string{"erase"}, + Usage: "No-op (git credential protocol store/erase)", + Description: "No-op invoked by git on credential store/erase; tea persists credentials only in its own config", + Action: func(requestCtx context.Context, _ *cli.Command) error { + return nil + }, + }, + { + Name: "setup", + Usage: "Register tea as a git credential helper for every configured login", + Description: "Register tea as a git credential helper in ~/.gitconfig for every configured login", + Action: func(requestCtx context.Context, _ *cli.Command) error { + logins, err := config.GetLogins() + if err != nil { + return err + } + for _, login := range logins { + added, err := task.SetupHelper(login) + if err != nil { + return err + } else if added { + fmt.Printf("Added \"%s\"\n", login.Name) + } else { + fmt.Printf("\"%s\" has already been added!\n", login.Name) + } + } + return nil + }, + }, + { + Name: "get", + Usage: "Return the stored token for a URL (git credential protocol)", + Description: "Return the stored token for a given URL in git's credential helper protocol (called by git, reads request from stdin)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "login", + Aliases: []string{"l"}, + Usage: "Use a specific login", + }, + }, + Action: func(requestCtx context.Context, cmd *cli.Command) error { + wants := map[string]string{} + s := bufio.NewScanner(os.Stdin) + for s.Scan() { + line := s.Text() + if line == "" { + break + } + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + continue + } + key, value := parts[0], parts[1] + if key == "url" { + u, err := url.Parse(value) + if err != nil { + return err + } + wants["protocol"] = u.Scheme + wants["host"] = u.Host + wants["path"] = u.Path + wants["username"] = u.User.Username() + wants["password"], _ = u.User.Password() + } else { + wants[key] = value + } + } + + if len(wants["host"]) == 0 { + return fmt.Errorf("hostname is required") + } else if len(wants["protocol"]) == 0 { + wants["protocol"] = "http" + } + + // Use --login flag if provided, otherwise fall back to host lookup + var userConfig *config.Login + if loginName := cmd.String("login"); loginName != "" { + var lookupErr error + userConfig, lookupErr = config.GetLoginByName(loginName) + if lookupErr != nil { + return lookupErr + } + if userConfig == nil { + return fmt.Errorf("login '%s' not found", loginName) + } + } else { + var lookupErr error + userConfig, lookupErr = config.GetLoginByHost(wants["host"]) + if lookupErr != nil { + return lookupErr + } + if userConfig == nil { + return fmt.Errorf("no login found for host '%s'", wants["host"]) + } + } + + if len(userConfig.GetAccessToken()) == 0 { + return fmt.Errorf("user not set") + } + + host, err := url.Parse(userConfig.URL) + if err != nil { + return err + } + + // Refresh token if expired or near expiry (updates userConfig in place) + if err = userConfig.RefreshOAuthTokenIfNeeded(); err != nil { + return err + } + + _, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.GetAccessToken()) + if err != nil { + return err + } + + return nil + }, + }, + }, +} diff --git a/cmd/login/list.go b/cmd/login/list.go new file mode 100644 index 00000000..d17ccc3b --- /dev/null +++ b/cmd/login/list.go @@ -0,0 +1,34 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "context" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdLoginList represents to login a gitea server. +var CmdLoginList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List Gitea logins", + Description: `List Gitea logins`, + ArgsUsage: " ", // command does not accept arguments + Action: RunLoginList, + Flags: []cli.Flag{&flags.OutputFlag}, +} + +// RunLoginList list all logins +func RunLoginList(requestCtx context.Context, cmd *cli.Command) error { + logins, err := config.GetLogins() + if err != nil { + return err + } + return print.LoginsList(logins, cmd.String("output")) +} diff --git a/cmd/login/oauth_refresh.go b/cmd/login/oauth_refresh.go new file mode 100644 index 00000000..969e47f2 --- /dev/null +++ b/cmd/login/oauth_refresh.go @@ -0,0 +1,71 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "context" + "fmt" + + "gitea.dev/tea/modules/auth" + "gitea.dev/tea/modules/config" + + "github.com/urfave/cli/v3" +) + +// CmdLoginOAuthRefresh represents a command to refresh an OAuth token +var CmdLoginOAuthRefresh = cli.Command{ + Name: "oauth-refresh", + Usage: "Refresh an OAuth token", + Description: "Manually refresh an expired OAuth token. If the refresh token is also expired, opens a browser for re-authentication.", + ArgsUsage: "[]", + Action: runLoginOAuthRefresh, +} + +func runLoginOAuthRefresh(requestCtx context.Context, cmd *cli.Command) error { + var loginName string + + // Get login name from args or use default + if cmd.Args().Len() > 0 { + loginName = cmd.Args().First() + } else { + // Get default login + login, err := config.GetDefaultLogin() + if err != nil { + return fmt.Errorf("no login specified and no default login found: %s", err) + } + loginName = login.Name + } + + // Get the login from config + login, err := config.GetLoginByName(loginName) + if err != nil { + return err + } + if login == nil { + return fmt.Errorf("login '%s' not found", loginName) + } + + // Check if the login has a refresh token + if login.GetRefreshToken() == "" { + return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName) + } + + // Try to refresh the token + err = auth.RefreshAccessToken(login) + if err == nil { + fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName) + return nil + } + + // Refresh failed - fall back to browser-based re-authentication + fmt.Printf("Token refresh failed: %s\n", err) + fmt.Println("Opening browser for re-authentication...") + + if err := auth.ReauthenticateLogin(requestCtx, login); err != nil { + return fmt.Errorf("re-authentication failed: %s", err) + } + + fmt.Printf("Successfully re-authenticated %s\n", loginName) + return nil +} diff --git a/cmd/login/status.go b/cmd/login/status.go new file mode 100644 index 00000000..9cb852ba --- /dev/null +++ b/cmd/login/status.go @@ -0,0 +1,59 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/task" + + "github.com/urfave/cli/v3" +) + +// CmdLoginStatus represents a command to show authentication status for logins. +var CmdLoginStatus = cli.Command{ + Name: "status", + Usage: "Show authentication status for Gitea logins", + Description: `Verify the stored token for one or all Gitea logins and report its validity.`, + ArgsUsage: "[]", + Action: RunLoginStatus, + Flags: []cli.Flag{&flags.OutputFlag}, +} + +// RunLoginStatus verifies one login, or every configured login when no name is +// provided, and prints a short authentication report. +func RunLoginStatus(requestCtx context.Context, cmd *cli.Command) error { + var logins []config.Login + + switch cmd.Args().Len() { + case 0: + var err error + logins, err = config.GetLogins() + if err != nil { + return err + } + case 1: + login, err := config.GetLoginByName(cmd.Args().First()) + if err != nil { + return err + } + if login == nil { + return fmt.Errorf("login '%s' not found", cmd.Args().First()) + } + logins = []config.Login{*login} + default: + return fmt.Errorf("too many arguments") + } + + statuses := make([]print.LoginStatus, 0, len(logins)) + for i := range logins { + statuses = append(statuses, task.CheckLoginStatus(requestCtx, &logins[i])) + } + + return print.LoginStatuses(statuses, cmd.String("output")) +} diff --git a/cmd/logout.go b/cmd/logout.go new file mode 100644 index 00000000..c20f3b79 --- /dev/null +++ b/cmd/logout.go @@ -0,0 +1,20 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "gitea.dev/tea/cmd/login" + + "github.com/urfave/cli/v3" +) + +// CmdLogout represents to logout a gitea server. +var CmdLogout = cli.Command{ + Name: "logout", + Category: catSetup, + Usage: "Log out from a Gitea server", + Description: `Log out from a Gitea server`, + ArgsUsage: "", + Action: login.RunLoginDelete, +} diff --git a/cmd/man.go b/cmd/man.go new file mode 100644 index 00000000..5a72bcb3 --- /dev/null +++ b/cmd/man.go @@ -0,0 +1,64 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + + docs "github.com/urfave/cli-docs/v3" + "github.com/urfave/cli/v3" +) + +// DocRenderFlags are the flags for documentation generation, used by `./docs/docs.go` and the `generate-man-page` sub command +var DocRenderFlags = []cli.Flag{ + &cli.StringFlag{ + Name: "out", + Usage: "Path to output docs to, otherwise prints to stdout", + Aliases: []string{"o"}, + }, +} + +// CmdGenerateManPage is the sub command to generate the `tea` man page +var CmdGenerateManPage = cli.Command{ + Name: "man", + Usage: "Generate man page", + Hidden: true, + Flags: DocRenderFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + return RenderDocs(cmd, cmd.Root(), func(cmd *cli.Command) (string, error) { + return docs.ToManWithSection(cmd, 1) + }) + }, +} + +// RenderDocs renders the documentation for `target` using the supplied `render` function +func RenderDocs(cmd, target *cli.Command, render func(*cli.Command) (string, error)) error { + out, err := render(target) + if err != nil { + return err + } + outPath := cmd.String("out") + if outPath == "" { + fmt.Print(out) + return nil + } + + if err = os.MkdirAll(filepath.Dir(outPath), os.ModePerm); err != nil { + return err + } + + fi, err := os.Create(outPath) + if err != nil { + return err + } + defer fi.Close() + if _, err = fi.WriteString(out); err != nil { + return err + } + + return nil +} diff --git a/cmd/milestones.go b/cmd/milestones.go new file mode 100644 index 00000000..4250c52e --- /dev/null +++ b/cmd/milestones.go @@ -0,0 +1,60 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/milestones" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" +) + +// CmdMilestones represents to operate repositories milestones. +var CmdMilestones = cli.Command{ + Name: "milestones", + Aliases: []string{"milestone", "ms"}, + Category: catEntities, + Usage: "List and create milestones", + Description: `List and create milestones`, + ArgsUsage: "[]", + Action: runMilestones, + Commands: []*cli.Command{ + &milestones.CmdMilestonesList, + &milestones.CmdMilestonesCreate, + &milestones.CmdMilestonesClose, + &milestones.CmdMilestonesDelete, + &milestones.CmdMilestonesReopen, + &milestones.CmdMilestonesIssues, + }, + Flags: milestones.CmdMilestonesList.Flags, +} + +func runMilestones(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 1 { + return runMilestoneDetail(ctx, cmd, cmd.Args().First()) + } + return milestones.RunMilestonesList(ctx, cmd) +} + +func runMilestoneDetail(requestCtx stdctx.Context, cmd *cli.Command, name string) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + milestone, _, err := client.Repositories.GetMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, name) + if err != nil { + return err + } + + print.MilestoneDetails(milestone) + return nil +} diff --git a/cmd/milestones/close.go b/cmd/milestones/close.go new file mode 100644 index 00000000..bea8af13 --- /dev/null +++ b/cmd/milestones/close.go @@ -0,0 +1,33 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package milestones + +import ( + "context" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" +) + +// CmdMilestonesClose represents a sub command of milestones to close an milestone +var CmdMilestonesClose = cli.Command{ + Name: "close", + Usage: "Change state of one or more milestones to 'closed'", + Description: `Change state of one or more milestones to 'closed'`, + ArgsUsage: " [...]", + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Bool("force") { + return deleteMilestone(ctx, cmd) + } + return editMilestoneStatus(ctx, cmd, true) + }, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "delete milestone", + }, + }, flags.AllDefaultFlags...), +} diff --git a/cmd/milestones/create.go b/cmd/milestones/create.go new file mode 100644 index 00000000..1c8c1dfc --- /dev/null +++ b/cmd/milestones/create.go @@ -0,0 +1,89 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package milestones + +import ( + "time" + + stdctx "context" + + gitea "gitea.dev/sdk" + "github.com/araddon/dateparse" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/task" +) + +// CmdMilestonesCreate represents a sub command of milestones to create milestone +var CmdMilestonesCreate = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create an milestone on repository", + Description: `Create an milestone on repository`, + ArgsUsage: " ", // command does not accept arguments + Action: runMilestonesCreate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + Usage: "milestone title to create", + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"d"}, + Usage: "milestone description to create", + }, + &cli.StringFlag{ + Name: "deadline", + Aliases: []string{"expires", "x"}, + Usage: "set milestone deadline (default is no due date)", + }, + &cli.StringFlag{ + Name: "state", + Usage: "set milestone state (default is open)", + DefaultText: "open", + }, + }, flags.AllDefaultFlags...), +} + +func runMilestonesCreate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + date := ctx.String("deadline") + deadline := &time.Time{} + if date != "" { + t, err := dateparse.ParseAny(date) + if err != nil { + return err + } + deadline = &t + } + + state := gitea.StateOpen + if ctx.String("state") == "closed" { + state = gitea.StateClosed + } + + if ctx.IsInteractiveMode() { + if err := interact.CreateMilestone(requestCtx, ctx.Login, ctx.Owner, ctx.Repo); err != nil && !interact.IsQuitting(err) { + return err + } + return nil + } + + return task.CreateMilestone(requestCtx, ctx.Login, + ctx.Owner, + ctx.Repo, + ctx.String("title"), + ctx.String("description"), + deadline, + state, + ) +} diff --git a/cmd/milestones/delete.go b/cmd/milestones/delete.go new file mode 100644 index 00000000..d4ef1646 --- /dev/null +++ b/cmd/milestones/delete.go @@ -0,0 +1,38 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package milestones + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdMilestonesDelete represents a sub command of milestones to delete an milestone +var CmdMilestonesDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "delete a milestone", + Description: "delete a milestone", + ArgsUsage: "", + Action: deleteMilestone, + Flags: flags.AllDefaultFlags, +} + +func deleteMilestone(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + _, err = client.Repositories.DeleteMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, ctx.Args().First()) + return err +} diff --git a/cmd/milestones/issues.go b/cmd/milestones/issues.go new file mode 100644 index 00000000..2f7c18b7 --- /dev/null +++ b/cmd/milestones/issues.go @@ -0,0 +1,198 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package milestones + +import ( + "fmt" + + stdctx "context" + + gitea "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" +) + +var msIssuesFieldsFlag = flags.FieldsFlag(print.IssueFields, []string{ + "index", "kind", "title", "state", "updated", "labels", +}) + +// CmdMilestonesIssues represents a sub command of milestones to manage issue/pull of an milestone +var CmdMilestonesIssues = cli.Command{ + Name: "issues", + Aliases: []string{"i"}, + Usage: "manage issue/pull of an milestone", + Description: "manage issue/pull of an milestone", + ArgsUsage: "", + Action: runMilestoneIssueList, + Commands: []*cli.Command{ + &CmdMilestoneAddIssue, + &CmdMilestoneRemoveIssue, + }, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "state", + Usage: "Filter by issue state (all|open|closed)", + DefaultText: "open", + }, + &cli.StringFlag{ + Name: "kind", + Usage: "Filter by kind (issue|pull)", + }, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + msIssuesFieldsFlag, + }, flags.AllDefaultFlags...), +} + +// CmdMilestoneAddIssue represents a sub command of milestone issues to add an issue/pull to an milestone +var CmdMilestoneAddIssue = cli.Command{ + Name: "add", + Aliases: []string{"a"}, + Usage: "Add an issue/pull to an milestone", + Description: "Add an issue/pull to an milestone", + ArgsUsage: " ", + Action: runMilestoneIssueAdd, + Flags: flags.AllDefaultFlags, +} + +// CmdMilestoneRemoveIssue represents a sub command of milestones to remove an issue/pull from an milestone +var CmdMilestoneRemoveIssue = cli.Command{ + Name: "remove", + Aliases: []string{"r"}, + Usage: "Remove an issue/pull to an milestone", + Description: "Remove an issue/pull to an milestone", + ArgsUsage: " ", + Action: runMilestoneIssueRemove, + Flags: flags.AllDefaultFlags, +} + +func runMilestoneIssueList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err + } + + kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeAll) + if err != nil { + return err + } + + if ctx.Args().Len() != 1 { + return fmt.Errorf("milestone name is required") + } + + milestone := ctx.Args().First() + // make sure milestone exist + _, _, err = client.Repositories.GetMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, milestone) + if err != nil { + return err + } + + issues, _, err := client.Issues.ListRepoIssues(requestCtx, ctx.Owner, ctx.Repo, gitea.ListIssueOption{ + ListOptions: flags.GetListOptions(cmd), + Milestones: []string{milestone}, + Type: kind, + State: state, + }) + if err != nil { + return err + } + + fields, err := msIssuesFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + return print.IssuesPullsList(issues, ctx.Output, fields) +} + +func runMilestoneIssueAdd(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + if ctx.Args().Len() != 2 { + return fmt.Errorf("need two arguments") + } + + mileName := ctx.Args().Get(0) + issueIndex := ctx.Args().Get(1) + idx, err := utils.ArgToIndex(issueIndex) + if err != nil { + return err + } + + // make sure milestone exist + mile, _, err := client.Repositories.GetMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, mileName) + if err != nil { + return fmt.Errorf("failed to get milestone '%s': %w", mileName, err) + } + + _, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{ + Milestone: &mile.ID, + }) + if err != nil { + return fmt.Errorf("failed to add issue #%d to milestone '%s': %w", idx, mileName, err) + } + return nil +} + +func runMilestoneIssueRemove(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + if ctx.Args().Len() != 2 { + return fmt.Errorf("need two arguments") + } + + mileName := ctx.Args().Get(0) + issueIndex := ctx.Args().Get(1) + idx, err := utils.ArgToIndex(issueIndex) + if err != nil { + return fmt.Errorf("invalid issue index '%s': %w", issueIndex, err) + } + + issue, _, err := client.Issues.GetIssue(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return fmt.Errorf("failed to get issue #%d: %w", idx, err) + } + + if issue.Milestone == nil { + return fmt.Errorf("issue #%d is not assigned to a milestone", idx) + } + + if issue.Milestone.Title != mileName { + return fmt.Errorf("issue #%d is assigned to milestone '%s', not '%s'", idx, issue.Milestone.Title, mileName) + } + + zero := int64(0) + _, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{ + Milestone: &zero, + }) + if err != nil { + return fmt.Errorf("failed to remove issue #%d from milestone '%s': %w", idx, mileName, err) + } + return nil +} diff --git a/cmd/milestones/list.go b/cmd/milestones/list.go new file mode 100644 index 00000000..7db0998f --- /dev/null +++ b/cmd/milestones/list.go @@ -0,0 +1,74 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package milestones + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +var fieldsFlag = flags.FieldsFlag(print.MilestoneFields, []string{ + "title", "items", "duedate", +}) + +// CmdMilestonesList represents a sub command of milestones to list milestones +var CmdMilestonesList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List milestones of the repository", + Description: `List milestones of the repository`, + ArgsUsage: " ", // command does not accept arguments + Action: RunMilestonesList, + Flags: append([]cli.Flag{ + fieldsFlag, + &cli.StringFlag{ + Name: "state", + Usage: "Filter by milestone state (all|open|closed)", + DefaultText: "open", + }, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunMilestonesList list milestones +func RunMilestonesList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + fields, err := fieldsFlag.GetValues(cmd) + if err != nil { + return err + } + + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err + } + if state == gitea.StateAll && !cmd.IsSet("fields") { + fields = append(fields, "state") + } + + client := ctx.Login.Client() + milestones, _, err := client.Repositories.ListMilestones(requestCtx, ctx.Owner, ctx.Repo, gitea.ListMilestoneOption{ + ListOptions: flags.GetListOptions(cmd), + State: state, + }) + if err != nil { + return err + } + + return print.MilestonesList(milestones, ctx.Output, fields) +} diff --git a/cmd/milestones/reopen.go b/cmd/milestones/reopen.go new file mode 100644 index 00000000..f79ac966 --- /dev/null +++ b/cmd/milestones/reopen.go @@ -0,0 +1,73 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package milestones + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdMilestonesReopen represents a sub command of milestones to open an milestone +var CmdMilestonesReopen = cli.Command{ + Name: "reopen", + Aliases: []string{"open"}, + Usage: "Change state of one or more milestones to 'open'", + Description: `Change state of one or more milestones to 'open'`, + ArgsUsage: " [ ...]", + Action: func(ctx stdctx.Context, cmd *cli.Command) error { + return editMilestoneStatus(ctx, cmd, false) + }, + Flags: flags.AllDefaultFlags, +} + +func editMilestoneStatus(requestCtx stdctx.Context, cmd *cli.Command, close bool) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + if ctx.Args().Len() == 0 { + return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) + } + + state := gitea.StateOpen + if close { + state = gitea.StateClosed + } + + client := ctx.Login.Client() + repoURL := "" + if ctx.Args().Len() > 1 { + repoURL, err = ctx.GetRemoteRepoHTMLURL() + if err != nil { + return err + } + } + for _, ms := range ctx.Args().Slice() { + opts := gitea.EditMilestoneOption{ + State: &state, + Title: ms, + } + milestone, _, err := client.Repositories.EditMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, ms, opts) + if err != nil { + return err + } + + if ctx.Args().Len() > 1 { + fmt.Printf("%s/milestone/%d\n", repoURL, milestone.ID) + } else { + print.MilestoneDetails(milestone) + } + } + return nil +} diff --git a/cmd/notifications.go b/cmd/notifications.go new file mode 100644 index 00000000..1dd182c4 --- /dev/null +++ b/cmd/notifications.go @@ -0,0 +1,28 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "gitea.dev/tea/cmd/notifications" + + "github.com/urfave/cli/v3" +) + +// CmdNotifications is the main command to operate with notifications +var CmdNotifications = cli.Command{ + Name: "notifications", + Aliases: []string{"notification", "n"}, + Category: catHelpers, + Usage: "Show notifications", + Description: "Show notifications, by default based on the current repo if available", + Action: notifications.RunNotificationsList, + Commands: []*cli.Command{ + ¬ifications.CmdNotificationsList, + ¬ifications.CmdNotificationsMarkRead, + ¬ifications.CmdNotificationsMarkUnread, + ¬ifications.CmdNotificationsMarkPinned, + ¬ifications.CmdNotificationsUnpin, + }, + Flags: notifications.CmdNotificationsList.Flags, +} diff --git a/cmd/notifications/list.go b/cmd/notifications/list.go new file mode 100644 index 00000000..6ebea505 --- /dev/null +++ b/cmd/notifications/list.go @@ -0,0 +1,110 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notifications + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +var notifyFieldsFlag = flags.FieldsFlag(print.NotificationFields, []string{ + "id", "status", "index", "type", "state", "title", +}) + +var notifyTypeFlag = flags.NewCsvFlag("types", "subject types to filter by", []string{"t"}, + []string{"issue", "pull", "repository", "commit"}, nil) + +// CmdNotificationsList represents a sub command of notifications to list notifications +var CmdNotificationsList = cli.Command{ + Name: "ls", + Aliases: []string{"list"}, + Usage: "List notifications", + Description: `List notifications`, + ArgsUsage: " ", // command does not accept arguments + Action: RunNotificationsList, + Flags: append([]cli.Flag{ + notifyFieldsFlag, + notifyTypeFlag, + }, flags.NotificationFlags...), +} + +// RunNotificationsList list notifications +func RunNotificationsList(ctx stdctx.Context, cmd *cli.Command) error { + var states []gitea.NotifyStatus + statesStr, err := flags.NotificationStateFlag.GetValues(cmd) + if err != nil { + return err + } + for _, s := range statesStr { + states = append(states, gitea.NotifyStatus(s)) + } + + var types []gitea.NotifySubjectType + typesStr, err := notifyTypeFlag.GetValues(cmd) + if err != nil { + return err + } + for _, t := range typesStr { + types = append(types, gitea.NotifySubjectType(t)) + } + + return listNotifications(ctx, cmd, states, types) +} + +// listNotifications will get the notifications based on status and subject type +func listNotifications(requestCtx stdctx.Context, cmd *cli.Command, status []gitea.NotifyStatus, subjects []gitea.NotifySubjectType) error { + var news []*gitea.NotificationThread + var err error + + all := cmd.Bool("mine") + ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: all}) + if err != nil { + return err + } + client := ctx.Login.Client() + + // This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733) + listOpts := flags.GetListOptions(cmd) + if listOpts.Page == 0 { + listOpts.Page = 1 + } + + fields, err := notifyFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + + if all { + // add repository to the default fields + if !cmd.IsSet("fields") { + fields = append(fields, "repository") + } + + news, _, err = client.Notifications.List(requestCtx, gitea.ListNotificationOptions{ + ListOptions: listOpts, + Status: status, + SubjectTypes: subjects, + }) + } else { + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + news, _, err = client.Notifications.ListByRepo(requestCtx, ctx.Owner, ctx.Repo, gitea.ListNotificationOptions{ + ListOptions: listOpts, + Status: status, + SubjectTypes: subjects, + }) + } + if err != nil { + return err + } + + return print.NotificationsList(news, ctx.Output, fields) +} diff --git a/cmd/notifications/list_test.go b/cmd/notifications/list_test.go new file mode 100644 index 00000000..197e6fba --- /dev/null +++ b/cmd/notifications/list_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notifications + +import ( + stdctx "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "gitea.dev/tea/modules/config" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestRunNotificationsListMineDoesNotProbeGitRepository(t *testing.T) { + gitPath := filepath.Join(t.TempDir(), "git") + gitScript := "#!/bin/sh\necho 'git should not be called' >&2\nexit 1\n" + if runtime.GOOS == "windows" { + gitPath += ".bat" + gitScript = "@echo git should not be called 1>&2\r\nexit /b 1\r\n" + } + require.NoError(t, os.WriteFile(gitPath, []byte(gitScript), 0o755)) + t.Setenv("PATH", filepath.Dir(gitPath)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/notifications", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "default", + URL: server.URL, + Token: "token", + User: "user", + Default: true, + }}, + }) + + cmd := cli.Command{ + Name: CmdNotificationsList.Name, + Flags: CmdNotificationsList.Flags, + } + require.NoError(t, cmd.Set("mine", "true")) + require.NoError(t, cmd.Set("output", "json")) + + require.NoError(t, RunNotificationsList(stdctx.Background(), &cmd)) +} diff --git a/cmd/notifications/mark_as.go b/cmd/notifications/mark_as.go new file mode 100644 index 00000000..82cab66e --- /dev/null +++ b/cmd/notifications/mark_as.go @@ -0,0 +1,158 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notifications + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" +) + +// CmdNotificationsMarkRead represents a sub command of notifications to list read notifications +var CmdNotificationsMarkRead = cli.Command{ + Name: "read", + Aliases: []string{"r"}, + Usage: "Mark all filtered or a specific notification as read", + Description: "Mark all filtered or a specific notification as read", + ArgsUsage: "[all | ]", + Flags: flags.NotificationFlags, + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")}) + if err != nil { + return err + } + filter, err := flags.NotificationStateFlag.GetValues(cmd) + if err != nil { + return err + } + if !ctx.IsSet(flags.NotificationStateFlag.Name) { + filter = []string{string(gitea.NotifyStatusUnread)} + } + return markNotificationAs(requestCtx, ctx, filter, gitea.NotifyStatusRead) + }, +} + +// CmdNotificationsMarkUnread will mark notifications as unread. +var CmdNotificationsMarkUnread = cli.Command{ + Name: "unread", + Aliases: []string{"u"}, + Usage: "Mark all filtered or a specific notification as unread", + Description: "Mark all filtered or a specific notification as unread", + ArgsUsage: "[all | ]", + Flags: flags.NotificationFlags, + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")}) + if err != nil { + return err + } + filter, err := flags.NotificationStateFlag.GetValues(cmd) + if err != nil { + return err + } + if !ctx.IsSet(flags.NotificationStateFlag.Name) { + filter = []string{string(gitea.NotifyStatusRead)} + } + return markNotificationAs(requestCtx, ctx, filter, gitea.NotifyStatusUnread) + }, +} + +// CmdNotificationsMarkPinned will mark notifications as unread. +var CmdNotificationsMarkPinned = cli.Command{ + Name: "pin", + Aliases: []string{"p"}, + Usage: "Mark all filtered or a specific notification as pinned", + Description: "Mark all filtered or a specific notification as pinned", + ArgsUsage: "[all | ]", + Flags: flags.NotificationFlags, + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")}) + if err != nil { + return err + } + filter, err := flags.NotificationStateFlag.GetValues(cmd) + if err != nil { + return err + } + if !ctx.IsSet(flags.NotificationStateFlag.Name) { + filter = []string{string(gitea.NotifyStatusUnread)} + } + return markNotificationAs(requestCtx, ctx, filter, gitea.NotifyStatusPinned) + }, +} + +// CmdNotificationsUnpin will mark pinned notifications as unread. +var CmdNotificationsUnpin = cli.Command{ + Name: "unpin", + Usage: "Unpin all pinned or a specific notification", + Description: "Marks all pinned or a specific notification as read", + ArgsUsage: "[all | ]", + Flags: flags.NotificationFlags, + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")}) + if err != nil { + return err + } + filter := []string{string(gitea.NotifyStatusPinned)} + // NOTE: we implicitly mark it as read, to match web UI semantics. marking as unread might be more useful? + return markNotificationAs(requestCtx, ctx, filter, gitea.NotifyStatusRead) + }, +} + +func markNotificationAs(ctx stdctx.Context, cmd *context.TeaContext, filterStates []string, targetState gitea.NotifyStatus) (err error) { + client := cmd.Login.Client() + subject := cmd.Args().First() + allRepos := cmd.Bool("mine") + + states := []gitea.NotifyStatus{} + for _, s := range filterStates { + states = append(states, gitea.NotifyStatus(s)) + } + + switch subject { + case "", "all": + opts := gitea.MarkNotificationOptions{Status: states, ToStatus: targetState} + + if allRepos { + _, _, err = client.Notifications.MarkRead(ctx, opts) + } else { + if err := cmd.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + _, _, err = client.Notifications.MarkReadByRepo(ctx, cmd.Owner, cmd.Repo, opts) + } + + // TODO: print all affected notification subject URLs + // (not supported by API currently, https://github.com/go-gitea/gitea/issues/16797) + + default: + id, err := utils.ArgToIndex(subject) + if err != nil { + return err + } + _, _, err = client.Notifications.MarkReadByID(ctx, id, targetState) + if err != nil { + return err + } + + n, _, err := client.Notifications.GetByID(ctx, id) + if err != nil { + return err + } + // Use LatestCommentHTMLURL if available, otherwise fall back to HTMLURL + if n.Subject.LatestCommentHTMLURL != "" { + fmt.Println(n.Subject.LatestCommentHTMLURL) + } else { + fmt.Println(n.Subject.HTMLURL) + } + return nil + } + + return err +} diff --git a/cmd/open.go b/cmd/open.go new file mode 100644 index 00000000..c69f94bf --- /dev/null +++ b/cmd/open.go @@ -0,0 +1,88 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "path" + "strings" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + local_git "gitea.dev/tea/modules/git" + + "github.com/skratchdot/open-golang/open" + "github.com/urfave/cli/v3" +) + +// CmdOpen represents a sub command of issues to open issue on the web browser +var CmdOpen = cli.Command{ + Name: "open", + Aliases: []string{"o"}, + Category: catHelpers, + Usage: "Open something of the repository in web browser", + Description: `Open something of the repository in web browser`, + Action: runOpen, + Flags: append([]cli.Flag{}, flags.LoginRepoFlags...), +} + +func runOpen(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + var suffix string + number := ctx.Args().Get(0) + switch { + case strings.EqualFold(number, "issues"): + suffix = "issues" + case strings.EqualFold(number, "pulls"): + suffix = "pulls" + case strings.EqualFold(number, "releases"): + suffix = "releases" + case strings.EqualFold(number, "commits"): + repo, err := local_git.RepoForWorkdir() + if err != nil { + return err + } + b, err := repo.Head() + if err != nil { + return err + } + name := b.Name() + switch { + case name.IsBranch(): + suffix = "commits/branch/" + name.Short() + case name.IsTag(): + suffix = "commits/tag/" + name.Short() + } + case strings.EqualFold(number, "branches"): + suffix = "branches" + case strings.EqualFold(number, "wiki"): + suffix = "wiki" + case strings.EqualFold(number, "activity"): + suffix = "activity" + case strings.EqualFold(number, "settings"): + suffix = "settings" + case strings.EqualFold(number, "labels"): + suffix = "labels" + case strings.EqualFold(number, "milestones"): + suffix = "milestones" + case number != "": + suffix = "issues/" + number + default: + suffix = number + } + + repoURL, err := ctx.GetRemoteRepoHTMLURL() + if err != nil { + return err + } + + return open.Run(path.Join(repoURL, suffix)) +} diff --git a/cmd/organizations.go b/cmd/organizations.go new file mode 100644 index 00000000..86315528 --- /dev/null +++ b/cmd/organizations.go @@ -0,0 +1,48 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/organizations" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdOrgs represents handle organization +var CmdOrgs = cli.Command{ + Name: "organizations", + Aliases: []string{"organization", "org"}, + Category: catEntities, + Usage: "List, create, delete organizations", + Description: "Show organization details", + ArgsUsage: "[]", + Action: runOrganizations, + Commands: []*cli.Command{ + &organizations.CmdOrganizationList, + &organizations.CmdOrganizationCreate, + &organizations.CmdOrganizationDelete, + }, + Flags: organizations.CmdOrganizationList.Flags, +} + +func runOrganizations(ctx stdctx.Context, cmd *cli.Command) error { + teaCtx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if teaCtx.Args().Len() == 1 { + org, _, err := teaCtx.Login.Client().Organizations.GetOrg(ctx, teaCtx.Args().First()) + if err != nil { + return err + } + + print.OrganizationDetails(org) + return nil + } + return organizations.RunOrganizationList(ctx, cmd) +} diff --git a/cmd/organizations/create.go b/cmd/organizations/create.go new file mode 100644 index 00000000..ff1cfce9 --- /dev/null +++ b/cmd/organizations/create.go @@ -0,0 +1,94 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organizations + +import ( + stdctx "context" + "fmt" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdOrganizationCreate represents a sub command of organizations to delete a given user organization +var CmdOrganizationCreate = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create an organization", + Description: "Create an organization", + Action: RunOrganizationCreate, + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "full-name", + Aliases: []string{"n"}, + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"d"}, + }, + &cli.StringFlag{ + Name: "website", + Aliases: []string{"w"}, + }, + &cli.StringFlag{ + Name: "location", + Aliases: []string{"L"}, + }, + &cli.StringFlag{ + Name: "visibility", + Aliases: []string{"v"}, + }, + &cli.BoolFlag{ + Name: "repo-admins-can-change-team-access", + }, + &flags.LoginFlag, + }, +} + +// RunOrganizationCreate sets up a new organization +func RunOrganizationCreate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + if ctx.Args().Len() < 1 { + return fmt.Errorf("organization name is required") + } + + var visibility gitea.VisibleType + switch ctx.String("visibility") { + case "", "public": + visibility = gitea.VisibleTypePublic + case "private": + visibility = gitea.VisibleTypePrivate + case "limited": + visibility = gitea.VisibleTypeLimited + default: + return fmt.Errorf("unknown visibility '%s'", ctx.String("visibility")) + } + + org, _, err := ctx.Login.Client().Organizations.CreateOrg(requestCtx, gitea.CreateOrgOption{ + Name: ctx.Args().First(), + FullName: ctx.String("full-name"), + Description: ctx.String("description"), + Website: ctx.String("website"), + Location: ctx.String("location"), + RepoAdminChangeTeamAccess: ctx.Bool("repo-admins-can-change-team-access"), + Visibility: visibility, + }) + if err != nil { + return err + } + + print.OrganizationDetails(org) + + return err +} diff --git a/cmd/organizations/delete.go b/cmd/organizations/delete.go new file mode 100644 index 00000000..c70740a6 --- /dev/null +++ b/cmd/organizations/delete.go @@ -0,0 +1,49 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organizations + +import ( + stdctx "context" + "fmt" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" +) + +// CmdOrganizationDelete represents a sub command of organizations to delete a given user organization +var CmdOrganizationDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete users Organizations", + Description: "Delete users organizations", + ArgsUsage: "", + Action: RunOrganizationDelete, + Flags: []cli.Flag{ + &flags.LoginFlag, + &flags.RemoteFlag, + }, +} + +// RunOrganizationDelete delete user organization +func RunOrganizationDelete(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + client := ctx.Login.Client() + + if ctx.Args().Len() < 1 { + return fmt.Errorf("organization name is required") + } + + response, err := client.Organizations.DeleteOrg(requestCtx, ctx.Args().First()) + if response != nil && response.StatusCode == 404 { + return fmt.Errorf("organization not found: %s", ctx.Args().First()) + } + + return err +} diff --git a/cmd/organizations/list.go b/cmd/organizations/list.go new file mode 100644 index 00000000..e1951aef --- /dev/null +++ b/cmd/organizations/list.go @@ -0,0 +1,47 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organizations + +import ( + stdctx "context" + + "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" +) + +// CmdOrganizationList represents a sub command of organizations to list users organizations +var CmdOrganizationList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List Organizations", + Description: "List users organizations", + ArgsUsage: " ", // command does not accept arguments + Action: RunOrganizationList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunOrganizationList list user organizations +func RunOrganizationList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := ctx.Login.Client() + + userOrganizations, _, err := client.Organizations.ListUserOrgs(requestCtx, ctx.Login.User, gitea.ListOrgsOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + return print.OrganizationsList(userOrganizations, ctx.Output) +} diff --git a/cmd/pulls.go b/cmd/pulls.go new file mode 100644 index 00000000..4f732c05 --- /dev/null +++ b/cmd/pulls.go @@ -0,0 +1,198 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "fmt" + "time" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/cmd/pulls" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +type pullLabelData = detailLabelData + +type pullReviewData = detailReviewData + +type pullCommentData = detailCommentData + +type pullData struct { + ID int64 `json:"id"` + Index int64 `json:"index"` + Title string `json:"title"` + State gitea.StateType `json:"state"` + Created *time.Time `json:"created"` + Updated *time.Time `json:"updated"` + Labels []pullLabelData `json:"labels"` + User string `json:"user"` + Body string `json:"body"` + Assignees []string `json:"assignees"` + URL string `json:"url"` + Base string `json:"base"` + Head string `json:"head"` + HeadSha string `json:"headSha"` + DiffURL string `json:"diffUrl"` + Mergeable bool `json:"mergeable"` + HasMerged bool `json:"hasMerged"` + MergedAt *time.Time `json:"mergedAt"` + MergedBy string `json:"mergedBy,omitempty"` + ClosedAt *time.Time `json:"closedAt"` + Reviews []pullReviewData `json:"reviews"` + Comments []pullCommentData `json:"comments"` +} + +// CmdPulls is the main command to operate on PRs +var CmdPulls = cli.Command{ + Name: "pulls", + Aliases: []string{"pull", "pr"}, + Category: catEntities, + Usage: "Manage and checkout pull requests", + Description: `Lists PRs when called without argument. If PR index is provided, will show it in detail.`, + ArgsUsage: "[]", + Action: runPulls, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "comments", + Usage: "Whether to display comments (will prompt if not provided & run interactively)", + }, + }, pulls.CmdPullsList.Flags...), + Commands: []*cli.Command{ + &pulls.CmdPullsList, + &pulls.CmdPullsCheckout, + &pulls.CmdPullsClean, + &pulls.CmdPullsCreate, + &pulls.CmdPullsClose, + &pulls.CmdPullsReopen, + &pulls.CmdPullsEdit, + &pulls.CmdPullsReview, + &pulls.CmdPullsApprove, + &pulls.CmdPullsReject, + &pulls.CmdPullsMerge, + &pulls.CmdPullsReply, + &pulls.CmdPullsReviewComments, + &pulls.CmdPullsResolve, + &pulls.CmdPullsUnresolve, + }, +} + +func runPulls(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 1 { + return runPullDetail(ctx, cmd, cmd.Args().First()) + } + return pulls.RunPullsList(ctx, cmd) +} + +func runPullDetail(requestCtx stdctx.Context, cmd *cli.Command, index string) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + idx, err := utils.ArgToIndex(index) + if err != nil { + return err + } + + client := ctx.Login.Client() + pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return err + } + + var reviews []*gitea.PullReview + for page := 1; ; { + page_reviews, resp, err := client.PullRequests.ListPullReviews(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + fmt.Printf("error while loading reviews: %v\n", err) + break + } + reviews = append(reviews, page_reviews...) + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage + } + + if ctx.IsSet("output") { + switch ctx.String("output") { + case "json": + return runPullDetailAsJSON(requestCtx, ctx, pr, reviews) + } + } + + ci, _, err := client.Repositories.GetCombinedStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha) + if err != nil { + fmt.Printf("error while loading CI: %v\n", err) + } + + print.PullDetails(pr, reviews, ci) + + if pr.Comments > 0 { + err = interact.ShowCommentsMaybeInteractive(requestCtx, ctx, idx, pr.Comments) + if err != nil { + fmt.Printf("error loading comments: %v\n", err) + } + } + + return nil +} + +func runPullDetailAsJSON(requestCtx stdctx.Context, ctx *context.TeaContext, pr *gitea.PullRequest, reviews []*gitea.PullReview) error { + c := ctx.Login.Client() + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)} + + mergedBy := "" + if pr.MergedBy != nil { + mergedBy = pr.MergedBy.UserName + } + + pullSlice := pullData{ + ID: pr.ID, + Index: pr.Index, + Title: pr.Title, + State: pr.State, + Created: pr.Created, + Updated: pr.Updated, + User: username(pr.Poster), + Body: pr.Body, + Labels: buildDetailLabels(pr.Labels), + Assignees: buildDetailAssignees(pr.Assignees), + URL: pr.HTMLURL, + Base: pr.Base.Ref, + Head: pr.Head.Ref, + HeadSha: pr.Head.Sha, + DiffURL: pr.DiffURL, + Mergeable: pr.Mergeable, + HasMerged: pr.HasMerged, + MergedAt: pr.Merged, + MergedBy: mergedBy, + ClosedAt: pr.Closed, + Reviews: buildDetailReviews(reviews), + Comments: make([]pullCommentData, 0), + } + + if ctx.Bool("comments") { + comments, _, err := c.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, pr.Index, opts) + if err != nil { + return err + } + + pullSlice.Comments = buildDetailComments(comments) + } + + return writeIndentedJSON(ctx.Writer, pullSlice) +} diff --git a/cmd/pulls/approve.go b/cmd/pulls/approve.go new file mode 100644 index 00000000..84d5b259 --- /dev/null +++ b/cmd/pulls/approve.go @@ -0,0 +1,31 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" +) + +// CmdPullsApprove approves a PR +var CmdPullsApprove = cli.Command{ + Name: "approve", + Aliases: []string{"lgtm", "a"}, + Usage: "Approve a pull request", + Description: "Approve a pull request", + ArgsUsage: " []", + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + return runPullReview(requestCtx, ctx, gitea.ReviewStateApproved, false) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/pulls/checkout.go b/cmd/pulls/checkout.go new file mode 100644 index 00000000..90c74c3d --- /dev/null +++ b/cmd/pulls/checkout.go @@ -0,0 +1,59 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdPullsCheckout is a command to locally checkout the given PR +var CmdPullsCheckout = cli.Command{ + Name: "checkout", + Aliases: []string{"co"}, + Usage: "Locally check out the given PR", + Description: `Locally check out the given PR`, + Action: runPullsCheckout, + ArgsUsage: "", + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "branch", + Aliases: []string{"b"}, + Usage: "Create a local branch if it doesn't exist yet", + }, + }, flags.AllDefaultFlags...), +} + +func runPullsCheckout(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{ + LocalRepo: true, + RemoteRepo: true, + }); err != nil { + return err + } + if ctx.Args().Len() != 1 { + return fmt.Errorf("pull request index is required") + } + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + if err := task.PullCheckout(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, ctx.Bool("branch"), idx, interact.PromptPassword); err != nil && !interact.IsQuitting(err) { + return err + } + return nil +} diff --git a/cmd/pulls/clean.go b/cmd/pulls/clean.go new file mode 100644 index 00000000..5bee1b66 --- /dev/null +++ b/cmd/pulls/clean.go @@ -0,0 +1,56 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + "fmt" + + stdctx "context" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/utils" +) + +// CmdPullsClean removes the remote and local feature branches, if a PR is merged. +var CmdPullsClean = cli.Command{ + Name: "clean", + Usage: "Deletes local & remote feature-branches for a closed pull request", + Description: `Deletes local & remote feature-branches for a closed pull request`, + ArgsUsage: "", + Action: runPullsClean, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "ignore-sha", + Usage: "Find the local branch by name instead of commit hash (less precise)", + }, + }, flags.AllDefaultFlags...), +} + +func runPullsClean(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{LocalRepo: true}); err != nil { + return err + } + if ctx.Args().Len() != 1 { + return fmt.Errorf("pull request index is required") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + if err := task.PullClean(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, ctx.Bool("ignore-sha"), interact.PromptPassword); err != nil && !interact.IsQuitting(err) { + return err + } + return nil +} diff --git a/cmd/pulls/close.go b/cmd/pulls/close.go new file mode 100644 index 00000000..766f564c --- /dev/null +++ b/cmd/pulls/close.go @@ -0,0 +1,26 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + "context" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "github.com/urfave/cli/v3" +) + +// CmdPullsClose closes a given open pull request +var CmdPullsClose = cli.Command{ + Name: "close", + Usage: "Change state of one or more pull requests to 'closed'", + Description: `Change state of one or more pull requests to 'closed'`, + ArgsUsage: " [...]", + Action: func(ctx context.Context, cmd *cli.Command) error { + s := gitea.StateClosed + return editPullState(ctx, cmd, gitea.EditPullRequestOption{State: &s}) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go new file mode 100644 index 00000000..6740c974 --- /dev/null +++ b/cmd/pulls/create.go @@ -0,0 +1,166 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "encoding/json" + "fmt" + "io" + + gitea "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/utils" +) + +// CmdPullsCreate creates a pull request +var CmdPullsCreate = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create a pull-request", + Description: "Create a pull-request in the current repo", + Action: runPullsCreate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "head", + Usage: "Branch name of the PR source (default is current one). To specify a different head repo, use :", + }, + &cli.StringFlag{ + Name: "base", + Aliases: []string{"b"}, + Usage: "Branch name of the PR target (default is repos default branch)", + }, + &cli.BoolFlag{ + Name: "allow-maintainer-edits", + Aliases: []string{"edits"}, + Usage: "Enable maintainers to push to the base branch of created pull", + Value: true, + }, + &cli.BoolFlag{ + Name: "agit", + Usage: "Create an agit flow pull request", + }, + &cli.StringFlag{ + Name: "topic", + Usage: "Topic name for agit flow pull request", + }, + &cli.BoolFlag{ + Name: "draft", + Usage: "Create as a draft (prepends \"WIP: \" to the title; Gitea treats WIP-prefixed PRs as drafts)", + }, + }, flags.IssuePRCreateFlags...), +} + +func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + // Interactive mode and head-branch defaulting both need a local repo. + // When --head is given explicitly the user can target a cross-fork PR + // from outside a working tree (e.g. with --repo /); + // task.CreatePull only consults ctx.LocalRepo when head is empty. + needsLocalRepo := ctx.IsInteractiveMode() || len(ctx.String("head")) == 0 + requirement := context.CtxRequirement{RemoteRepo: true} + if needsLocalRepo { + requirement.LocalRepo = true + } + if err := ctx.Ensure(requirement); err != nil { + return err + } + + // no args -> interactive mode + if ctx.IsInteractiveMode() { + if err := interact.CreatePull(requestCtx, ctx); err != nil && !interact.IsQuitting(err) { + return err + } + return nil + } + + // agit flow creates the PR via git push and returns no PR object, so + // --output cannot be honored there; fail fast before any API calls + if ctx.Bool("agit") && ctx.IsSet("output") { + return fmt.Errorf("--output cannot be combined with --agit: the PR is created via git push, so no pull request object is available to print") + } + + // else use args to create PR + opts, err := flags.GetIssuePRCreateFlags(requestCtx, ctx) + if err != nil { + return err + } + + if ctx.Bool("draft") { + opts.Title = utils.AddDraftPrefix(opts.Title) + } + + if ctx.Bool("agit") { + return task.CreateAgitFlowPull( + requestCtx, + ctx, + ctx.String("remote"), + ctx.String("head"), + ctx.String("base"), + ctx.String("topic"), + opts, + interact.PromptPassword, + ) + } + + var allowMaintainerEdits *bool + if ctx.IsSet("allow-maintainer-edits") { + allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits")) + } + + pr, err := task.CreatePull( + requestCtx, + ctx, + ctx.String("base"), + ctx.String("head"), + allowMaintainerEdits, + opts, + ) + if err != nil { + return err + } + + if ctx.IsSet("output") { + switch ctx.String("output") { + case "json": + return writeCreatedPullAsJSON(ctx.Writer, pr) + } + } + + print.PullDetails(pr, nil, nil) + + return nil +} + +// createdPullJSON is the machine-readable representation of a freshly +// created pull request. A new PR has no reviews, comments or CI yet, so +// this is intentionally leaner than the detail view's pullData (cmd/pulls.go). +type createdPullJSON struct { + Index int64 `json:"index"` + Title string `json:"title"` + URL string `json:"url"` + State gitea.StateType `json:"state"` + Base string `json:"base"` + Head string `json:"head"` +} + +func writeCreatedPullAsJSON(w io.Writer, pr *gitea.PullRequest) error { + return json.NewEncoder(w).Encode(createdPullJSON{ + Index: pr.Index, + Title: pr.Title, + URL: pr.HTMLURL, + State: pr.State, + Base: pr.Base.Ref, + Head: pr.Head.Ref, + }) +} diff --git a/cmd/pulls/create_app_test.go b/cmd/pulls/create_app_test.go new file mode 100644 index 00000000..c22c1f51 --- /dev/null +++ b/cmd/pulls/create_app_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls_test + +import ( + "context" + "testing" + + "gitea.dev/tea/cmd" + "gitea.dev/tea/modules/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPullsCreateAgitOutputRejected verifies that --output (parsed via the +// urfave/cli v3 ancestor-flag cascade, since create itself does not declare +// it) is rejected for the agit flow before any API call or git push happens. +func TestPullsCreateAgitOutputRejected(t *testing.T) { + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: "https://gitea.example.com", + Token: "test-token", + User: "testUser", + Default: true, + }}, + }) + t.Cleanup(func() { + config.SetConfigForTesting(config.LocalConfig{}) + }) + + app := cmd.App() + args := []string{ + "tea", "pulls", "create", + "--agit", + "--output", "json", + "--head", "topic-branch", + "--title", "test", + "--login", "testLogin", + "--repo", "user/repo", + } + + err := app.Run(context.Background(), args) + require.Error(t, err) + assert.Contains(t, err.Error(), "--output cannot be combined with --agit") +} diff --git a/cmd/pulls/create_test.go b/cmd/pulls/create_test.go new file mode 100644 index 00000000..93f9774d --- /dev/null +++ b/cmd/pulls/create_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + "bytes" + "encoding/json" + "testing" + + gitea "gitea.dev/sdk" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteCreatedPullAsJSON(t *testing.T) { + pr := &gitea.PullRequest{ + Index: 33, + Title: "test title", + HTMLURL: "https://gitea.example.com/owner/repo/pulls/33", + State: gitea.StateOpen, + Base: &gitea.PRBranchInfo{Ref: "main"}, + Head: &gitea.PRBranchInfo{Ref: "feature"}, + } + + var buf bytes.Buffer + require.NoError(t, writeCreatedPullAsJSON(&buf, pr)) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + + assert.Equal(t, float64(33), got["index"]) + assert.Equal(t, "test title", got["title"]) + assert.Equal(t, "https://gitea.example.com/owner/repo/pulls/33", got["url"]) + assert.Equal(t, "open", got["state"]) + assert.Equal(t, "main", got["base"]) + assert.Equal(t, "feature", got["head"]) + + // exactly the lean field set, nothing extra + assert.Len(t, got, 6) + + // machine-readable output must not contain terminal escape sequences + assert.NotContains(t, buf.String(), "\x1b") +} diff --git a/cmd/pulls/edit.go b/cmd/pulls/edit.go new file mode 100644 index 00000000..64604e7c --- /dev/null +++ b/cmd/pulls/edit.go @@ -0,0 +1,168 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "fmt" + "strings" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/utils" + "github.com/urfave/cli/v3" +) + +// applyDraftFlag mutates opts.Title according to --draft / --ready. +// If a flag is set but --title isn't, it fetches the current title from the server. +// Returns an error if both --draft and --ready are set, or on a server error. +func applyDraftFlag(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client, idx int64, opts *task.EditIssueOption) error { + draft := ctx.Bool("draft") + ready := ctx.Bool("ready") + if !draft && !ready { + return nil + } + if draft && ready { + return fmt.Errorf("--draft and --ready are mutually exclusive") + } + + var current string + if opts.Title != nil { + current = *opts.Title + } else { + pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return fmt.Errorf("could not fetch pull request #%d: %s", idx, err) + } + current = pr.Title + } + + var next string + if draft { + next = utils.AddDraftPrefix(current) + } else { + next = utils.StripDraftPrefix(current) + } + if next != current || opts.Title != nil { + opts.Title = &next + } + return nil +} + +// CmdPullsEdit is the subcommand of pulls to edit pull requests +var CmdPullsEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit one or more pull requests", + Description: `Edit one or more pull requests. To unset a property again, +use an empty string (eg. --milestone "").`, + ArgsUsage: " [...]", + Action: runPullsEdit, + Flags: append(flags.IssuePREditFlags, + &cli.StringFlag{ + Name: "add-reviewers", + Aliases: []string{"r"}, + Usage: "Comma-separated list of usernames to request review from", + }, + &cli.StringFlag{ + Name: "remove-reviewers", + Usage: "Comma-separated list of usernames to remove from reviewers", + }, + &cli.BoolFlag{ + Name: "draft", + Usage: "Mark as draft by prepending \"WIP: \" to the title (idempotent)", + }, + &cli.BoolFlag{ + Name: "ready", + Usage: "Mark as ready for review by stripping any leading \"WIP: \" or \"[WIP]\" prefix", + }, + ), +} + +func runPullsEdit(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if !cmd.Args().Present() { + return fmt.Errorf("must specify at least one pull request index") + } + + opts, err := flags.GetIssuePREditFlags(ctx) + if err != nil { + return err + } + + if cmd.IsSet("add-reviewers") { + opts.AddReviewers = strings.Split(cmd.String("add-reviewers"), ",") + } + if cmd.IsSet("remove-reviewers") { + opts.RemoveReviewers = strings.Split(cmd.String("remove-reviewers"), ",") + } + + indices, err := utils.ArgsToIndices(ctx.Args().Slice()) + if err != nil { + return err + } + + client := ctx.Login.Client() + for _, opts.Index = range indices { + if err := applyDraftFlag(requestCtx, ctx, client, opts.Index, opts); err != nil { + return err + } + pr, err := task.EditPull(requestCtx, ctx, client, *opts) + if err != nil { + return err + } + if ctx.Args().Len() > 1 { + fmt.Println(pr.HTMLURL) + } else { + print.PullDetails(pr, nil, nil) + } + } + + return nil +} + +// editPullState abstracts the arg parsing to edit the given pull request +func editPullState(requestCtx stdctx.Context, cmd *cli.Command, opts gitea.EditPullRequestOption) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + if ctx.Args().Len() == 0 { + return fmt.Errorf("pull request index is required") + } + + indices, err := utils.ArgsToIndices(ctx.Args().Slice()) + if err != nil { + return err + } + + client := ctx.Login.Client() + for _, index := range indices { + pr, _, err := client.PullRequests.EditPullRequest(requestCtx, ctx.Owner, ctx.Repo, index, opts) + if err != nil { + return err + } + + if len(indices) > 1 { + fmt.Println(pr.HTMLURL) + } else { + print.PullDetails(pr, nil, nil) + } + } + return nil +} diff --git a/cmd/pulls/list.go b/cmd/pulls/list.go new file mode 100644 index 00000000..f772e0f8 --- /dev/null +++ b/cmd/pulls/list.go @@ -0,0 +1,80 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "fmt" + "slices" + + "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" +) + +var pullFieldsFlag = flags.FieldsFlag(print.PullFields, []string{ + "index", "title", "state", "author", "milestone", "updated", "labels", +}) + +// CmdPullsList represents a sub command of issues to list pulls +var CmdPullsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List pull requests of the repository", + Description: `List pull requests of the repository`, + ArgsUsage: " ", // command does not accept arguments + Action: RunPullsList, + Flags: append([]cli.Flag{pullFieldsFlag}, flags.PRListingFlags...), +} + +// RunPullsList return list of pulls +func RunPullsList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err + } + + client := ctx.Login.Client() + prs, _, err := client.PullRequests.ListRepoPullRequests(requestCtx, ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ + ListOptions: flags.GetListOptions(cmd), + State: state, + }) + if err != nil { + return err + } + + fields, err := pullFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + + var ciStatuses map[int64]*gitea.CombinedStatus + if slices.Contains(fields, "ci") { + ciStatuses = map[int64]*gitea.CombinedStatus{} + for _, pr := range prs { + if pr.Head == nil || pr.Head.Sha == "" { + continue + } + ci, _, err := client.Repositories.GetCombinedStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha) + if err != nil { + fmt.Printf("error fetching CI status for PR #%d: %v\n", pr.Index, err) + continue + } + ciStatuses[pr.Index] = ci + } + } + + return print.PullsList(prs, ctx.Output, fields, ciStatuses) +} diff --git a/cmd/pulls/merge.go b/cmd/pulls/merge.go new file mode 100644 index 00000000..a08d0606 --- /dev/null +++ b/cmd/pulls/merge.go @@ -0,0 +1,72 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/utils" +) + +// CmdPullsMerge merges a PR +var CmdPullsMerge = cli.Command{ + Name: "merge", + Aliases: []string{"m"}, + Usage: "Merge a pull request", + Description: "Merge a pull request", + ArgsUsage: "", + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "style", + Aliases: []string{"s"}, + Usage: "Kind of merge to perform: merge, rebase, squash, rebase-merge", + Value: "merge", + }, + &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + Usage: "Merge commit title", + }, + &cli.StringFlag{ + Name: "message", + Aliases: []string{"m"}, + Usage: "Merge commit message", + }, + }, flags.AllDefaultFlags...), + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() != 1 { + // If no PR index is provided, try interactive mode + if err := interact.MergePull(requestCtx, ctx); err != nil && !interact.IsQuitting(err) { + return err + } + return nil + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + return task.PullMerge(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, gitea.MergePullRequestOption{ + Style: gitea.MergeStyle(ctx.String("style")), + Title: ctx.String("title"), + Message: ctx.String("message"), + }) + }, +} diff --git a/cmd/pulls/reject.go b/cmd/pulls/reject.go new file mode 100644 index 00000000..1b44ca81 --- /dev/null +++ b/cmd/pulls/reject.go @@ -0,0 +1,30 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" +) + +// CmdPullsReject requests changes to a PR +var CmdPullsReject = cli.Command{ + Name: "reject", + Usage: "Request changes to a pull request", + Description: "Request changes to a pull request", + ArgsUsage: " ", + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + return runPullReview(requestCtx, ctx, gitea.ReviewStateRequestChanges, true) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/pulls/reopen.go b/cmd/pulls/reopen.go new file mode 100644 index 00000000..68790e29 --- /dev/null +++ b/cmd/pulls/reopen.go @@ -0,0 +1,27 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + "context" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "github.com/urfave/cli/v3" +) + +// CmdPullsReopen reopens a given closed pull request +var CmdPullsReopen = cli.Command{ + Name: "reopen", + Aliases: []string{"open"}, + Usage: "Change state of one or more pull requests to 'open'", + Description: `Change state of one or more pull requests to 'open'`, + ArgsUsage: " [...]", + Action: func(ctx context.Context, cmd *cli.Command) error { + s := gitea.StateOpen + return editPullState(ctx, cmd, gitea.EditPullRequestOption{State: &s}) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/pulls/reply.go b/cmd/pulls/reply.go new file mode 100644 index 00000000..f66dcf86 --- /dev/null +++ b/cmd/pulls/reply.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdPullsReply replies to a review comment on a pull request. +var CmdPullsReply = cli.Command{ + Name: "reply", + Usage: "Reply to a pull request review comment", + Description: "Reply to a pull request review comment", + ArgsUsage: " []", + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + return runPullReviewReply(requestCtx, ctx) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/pulls/reply_test.go b/cmd/pulls/reply_test.go new file mode 100644 index 00000000..9dcf72b7 --- /dev/null +++ b/cmd/pulls/reply_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + "context" + "testing" + + "gitea.dev/tea/modules/config" + + "github.com/stretchr/testify/assert" +) + +func TestReply(t *testing.T) { + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: "https://gitea.example.com", + Token: "test-token", + User: "testUser", + Default: true, + }}, + }) + t.Cleanup(func() { + config.SetConfigForTesting(config.LocalConfig{}) + }) + + tests := []struct { + name string + args []string + wantErr bool + errContains string + }{ + { + name: "no arguments", + args: []string{}, + wantErr: true, + errContains: "pull request index and comment ID are required", + }, + { + name: "missing comment id", + args: []string{"1"}, + wantErr: true, + errContains: "pull request index and comment ID are required", + }, + { + name: "pull index and comment id", + args: []string{"1", "2"}, + wantErr: true, + errContains: "no reply content provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := CmdPullsReply + args := append([]string{"reply"}, tt.args...) + args = append(args, "--login", "testLogin", "--repo", "user/repo") + err := cmd.Run(context.Background(), args) + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + }) + } +} diff --git a/cmd/pulls/resolve.go b/cmd/pulls/resolve.go new file mode 100644 index 00000000..a6e3bb73 --- /dev/null +++ b/cmd/pulls/resolve.go @@ -0,0 +1,30 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/task" + + "github.com/urfave/cli/v3" +) + +// CmdPullsResolve resolves a review comment on a pull request +var CmdPullsResolve = cli.Command{ + Name: "resolve", + Usage: "Resolve a review comment on a pull request", + Description: "Resolve a review comment on a pull request", + ArgsUsage: "", + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + return runResolveComment(requestCtx, ctx, task.ResolvePullReviewComment) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/pulls/review.go b/cmd/pulls/review.go new file mode 100644 index 00000000..c9b5b063 --- /dev/null +++ b/cmd/pulls/review.go @@ -0,0 +1,59 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "fmt" + "os" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdPullsReview starts an interactive review session +var CmdPullsReview = cli.Command{ + Name: "review", + Usage: "Interactively review a pull request", + Description: "Interactively review a pull request", + ArgsUsage: "", + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if !ctx.Args().Present() { + return fmt.Errorf("must specify at least one PR index") + } + + // This command is intentionally interactive. Fail early in CI / non-TTY + // contexts rather than hanging on prompts. + if os.Getenv("CI") != "" || !print.IsInteractive() || interact.IsStdinPiped() { + return fmt.Errorf("pull review requires an interactive terminal") + } + + for _, arg := range ctx.Args().Slice() { + idx, err := utils.ArgToIndex(arg) + if err != nil { + return err + } + + if err := interact.ReviewPull(requestCtx, ctx, idx); err != nil && !interact.IsQuitting(err) { + return err + } + } + + return nil + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/pulls/review_comments.go b/cmd/pulls/review_comments.go new file mode 100644 index 00000000..7bc0c39f --- /dev/null +++ b/cmd/pulls/review_comments.go @@ -0,0 +1,63 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +var reviewCommentFieldsFlag = flags.FieldsFlag(print.PullReviewCommentFields, []string{ + "id", "path", "line", "body", "reviewer", "resolver", +}) + +// CmdPullsReviewComments lists review comments on a pull request +var CmdPullsReviewComments = cli.Command{ + Name: "review-comments", + Aliases: []string{"rc"}, + Usage: "List review comments on a pull request", + Description: "List review comments on a pull request", + ArgsUsage: "", + Action: runPullsReviewComments, + Flags: append([]cli.Flag{reviewCommentFieldsFlag}, flags.AllDefaultFlags...), +} + +func runPullsReviewComments(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() < 1 { + return fmt.Errorf("pull request index is required") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + comments, err := task.ListPullReviewComments(requestCtx, ctx, idx) + if err != nil { + return err + } + + fields, err := reviewCommentFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + + return print.PullReviewCommentsList(comments, ctx.Output, fields) +} diff --git a/cmd/pulls/review_helpers.go b/cmd/pulls/review_helpers.go new file mode 100644 index 00000000..82db65e8 --- /dev/null +++ b/cmd/pulls/review_helpers.go @@ -0,0 +1,128 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "errors" + "fmt" + "io" + "strings" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/theme" + "gitea.dev/tea/modules/utils" + + "charm.land/huh/v2" +) + +// runPullReview handles the common logic for approving/rejecting pull requests +func runPullReview(requestCtx stdctx.Context, ctx *context.TeaContext, state gitea.ReviewStateType, requireComment bool) error { + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + minArgs := 1 + if requireComment { + minArgs = 2 + } + + if ctx.Args().Len() < minArgs { + if requireComment { + return fmt.Errorf("pull request index and comment are required") + } + return fmt.Errorf("pull request index is required") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + comment := strings.Join(ctx.Args().Tail(), " ") + + return task.CreatePullReview(requestCtx, ctx, idx, state, comment, nil) +} + +// runResolveComment handles the common logic for resolving/unresolving review comments +func runResolveComment(requestCtx stdctx.Context, ctx *context.TeaContext, action func(stdctx.Context, *context.TeaContext, int64) error) error { + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() < 1 { + return fmt.Errorf("comment ID is required") + } + + commentID, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + return action(requestCtx, ctx, commentID) +} + +// runPullReviewReply handles replying to a specific review comment on a pull request. +func runPullReviewReply(requestCtx stdctx.Context, ctx *context.TeaContext) error { + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() < 2 { + return fmt.Errorf("pull request index and comment ID are required") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + commentID, err := utils.ArgToIndex(ctx.Args().Get(1)) + if err != nil { + return err + } + + body, err := getCommentBody(ctx, ctx.Args().Slice()[2:], "Reply(markdown):", "reply") + if err != nil { + return err + } + + return task.ReplyToPullReviewComment(requestCtx, ctx, idx, commentID, body) +} + +func getCommentBody(ctx *context.TeaContext, extraArgs []string, promptTitle, noun string) (string, error) { + body := strings.Join(extraArgs, " ") + if interact.IsStdinPiped() { + bodyStdin, err := io.ReadAll(ctx.Reader) + if err != nil { + return "", err + } + if len(bodyStdin) != 0 { + body = strings.Join([]string{body, string(bodyStdin)}, "\n\n") + } + } else if len(body) == 0 { + if err := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title(promptTitle). + ExternalEditor(config.GetPreferences().Editor). + EditorExtension("md"). + Value(&body), + ), + ).WithTheme(theme.GetTheme()).Run(); err != nil { + return "", err + } + } + + if len(strings.TrimSpace(body)) == 0 { + return "", errors.New("no " + noun + " content provided") + } + + return body, nil +} diff --git a/cmd/pulls/review_test.go b/cmd/pulls/review_test.go new file mode 100644 index 00000000..83d65eeb --- /dev/null +++ b/cmd/pulls/review_test.go @@ -0,0 +1,61 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReview(t *testing.T) { + if os.Getenv("GITEA_TEA_TEST_URL") == "" { + t.Skip("GITEA_TEA_TEST_URL is not set, skipping test") + } + + tests := []struct { + name string + args []string + wantErr bool + errContains string + }{ + { + name: "no arguments", + args: []string{}, + wantErr: true, + errContains: "must specify at least one PR index", + }, + { + name: "one argument", + args: []string{"1"}, + wantErr: false, + }, + { + name: "two arguments", + args: []string{"1", "2"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := CmdPullsReview + args := append(tt.args, "--repo", "user/repo") + err := cmd.Run(t.Context(), args) + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + // Don't assert no error, because we expect an error about the missing + // remote. Just assert that the error is not the one we're looking for. + if err != nil { + assert.NotContains(t, err.Error(), "must specify at least one PR index") + } + }) + } +} diff --git a/cmd/pulls/unresolve.go b/cmd/pulls/unresolve.go new file mode 100644 index 00000000..55f72e1b --- /dev/null +++ b/cmd/pulls/unresolve.go @@ -0,0 +1,30 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/task" + + "github.com/urfave/cli/v3" +) + +// CmdPullsUnresolve unresolves a review comment on a pull request +var CmdPullsUnresolve = cli.Command{ + Name: "unresolve", + Usage: "Unresolve a review comment on a pull request", + Description: "Unresolve a review comment on a pull request", + ArgsUsage: "", + Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + return runResolveComment(requestCtx, ctx, task.UnresolvePullReviewComment) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/releases.go b/cmd/releases.go new file mode 100644 index 00000000..6c2989f6 --- /dev/null +++ b/cmd/releases.go @@ -0,0 +1,31 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/cmd/releases" + + "github.com/urfave/cli/v3" +) + +// CmdReleases represents to login a gitea server. +// ToDo: ReleaseDetails +var CmdReleases = cli.Command{ + Name: "releases", + Aliases: []string{"release", "r"}, + Category: catEntities, + Usage: "Manage releases", + Description: "Manage releases", + ArgsUsage: " ", // command does not accept arguments + Action: releases.RunReleasesList, + Commands: []*cli.Command{ + &releases.CmdReleaseList, + &releases.CmdReleaseCreate, + &releases.CmdReleaseDelete, + &releases.CmdReleaseEdit, + &CmdReleaseAttachments, + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/releases/create.go b/cmd/releases/create.go new file mode 100644 index 00000000..74f7ebc3 --- /dev/null +++ b/cmd/releases/create.go @@ -0,0 +1,130 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package releases + +import ( + stdctx "context" + "fmt" + "net/http" + "os" + "path/filepath" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "github.com/urfave/cli/v3" +) + +// CmdReleaseCreate represents a sub command of Release to create release +var CmdReleaseCreate = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create a release", + Description: `Create a release for a new or existing git tag`, + ArgsUsage: "[]", + Action: runReleaseCreate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "tag", + Usage: "Tag name. If the tag does not exist yet, it will be created by Gitea", + }, + &cli.StringFlag{ + Name: "target", + Usage: "Target branch name or commit hash. Defaults to the default branch of the repo", + }, + &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + Usage: "Release title", + }, + &cli.StringFlag{ + Name: "note", + Aliases: []string{"n"}, + Usage: "Release notes", + }, + &cli.StringFlag{ + Name: "note-file", + Aliases: []string{"f"}, + Usage: "Release notes file name. If set, --note is ignored.", + }, + &cli.BoolFlag{ + Name: "draft", + Aliases: []string{"d"}, + Usage: "Is a draft", + }, + &cli.BoolFlag{ + Name: "prerelease", + Aliases: []string{"p"}, + Usage: "Is a pre-release", + }, + &cli.StringSliceFlag{ + Name: "asset", + Aliases: []string{"a"}, + Usage: "Path to file attachment. Can be specified multiple times", + }, + }, flags.AllDefaultFlags...), +} + +func runReleaseCreate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + tag := ctx.String("tag") + if cmd.Args().Present() { + if len(tag) != 0 { + return fmt.Errorf("ambiguous arguments: provide tagname via --tag or argument, but not both") + } + tag = cmd.Args().First() + } + + notestring := ctx.String("note") + notefile := ctx.String("note-file") + if notefile != "" { + notebytes, err := os.ReadFile(notefile) + if err != nil { + return fmt.Errorf("unable to read the note file") + } + notestring = string(notebytes) + } + + release, resp, err := ctx.Login.Client().Releases.CreateRelease(requestCtx, ctx.Owner, ctx.Repo, gitea.CreateReleaseOption{ + TagName: tag, + Target: ctx.String("target"), + Title: ctx.String("title"), + Note: notestring, + IsDraft: ctx.Bool("draft"), + IsPrerelease: ctx.Bool("prerelease"), + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusConflict { + return fmt.Errorf("there is already a release for this tag") + } + return err + } + + for _, asset := range ctx.StringSlice("asset") { + var file *os.File + + if file, err = os.Open(asset); err != nil { + return err + } + + filePath := filepath.Base(asset) + + if _, _, err = ctx.Login.Client().Releases.CreateReleaseAttachment(requestCtx, ctx.Owner, ctx.Repo, release.ID, file, filePath); err != nil { + file.Close() + return err + } + + file.Close() + } + + return nil +} diff --git a/cmd/releases/delete.go b/cmd/releases/delete.go new file mode 100644 index 00000000..5deb41c5 --- /dev/null +++ b/cmd/releases/delete.go @@ -0,0 +1,74 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package releases + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdReleaseDelete represents a sub command of Release to delete a release +var CmdReleaseDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete one or more releases", + Description: `Delete one or more releases`, + ArgsUsage: " [...]", + Action: runReleaseDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "Confirm deletion (required)", + }, + &cli.BoolFlag{ + Name: "delete-tag", + Usage: "Also delete the git tag for this release", + }, + }, flags.AllDefaultFlags...), +} + +func runReleaseDelete(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + if !ctx.Args().Present() { + fmt.Println("Release tag needed to edit") + return nil + } + + if !ctx.Bool("confirm") { + fmt.Println("Are you sure? Please confirm with -y or --confirm.") + return nil + } + + for _, tag := range ctx.Args().Slice() { + release, err := GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) + if err != nil { + return err + } + _, err = client.Releases.DeleteRelease(requestCtx, ctx.Owner, ctx.Repo, release.ID) + if err != nil { + return err + } + + if ctx.Bool("delete-tag") { + _, err = client.Repositories.DeleteTag(requestCtx, ctx.Owner, ctx.Repo, tag) + return err + } + } + + return nil +} diff --git a/cmd/releases/edit.go b/cmd/releases/edit.go new file mode 100644 index 00000000..76fb3b49 --- /dev/null +++ b/cmd/releases/edit.go @@ -0,0 +1,103 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package releases + +import ( + "fmt" + "strings" + + stdctx "context" + + gitea "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" +) + +// CmdReleaseEdit represents a sub command of Release to edit releases +var CmdReleaseEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit one or more releases", + Description: `Edit one or more releases`, + ArgsUsage: " [...]", + Action: runReleaseEdit, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "tag", + Usage: "Change Tag", + }, + &cli.StringFlag{ + Name: "target", + Usage: "Change Target", + }, + &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + Usage: "Change Title", + }, + &cli.StringFlag{ + Name: "note", + Aliases: []string{"n"}, + Usage: "Change Notes", + }, + &cli.StringFlag{ + Name: "draft", + Aliases: []string{"d"}, + Usage: "Mark as Draft [True/false]", + DefaultText: "true", + }, + &cli.StringFlag{ + Name: "prerelease", + Aliases: []string{"p"}, + Usage: "Mark as Pre-Release [True/false]", + DefaultText: "true", + }, + }, flags.AllDefaultFlags...), +} + +func runReleaseEdit(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + var isDraft, isPre *bool + if ctx.IsSet("draft") { + isDraft = gitea.OptionalBool(strings.ToLower(ctx.String("draft"))[:1] == "t") + } + if ctx.IsSet("prerelease") { + isPre = gitea.OptionalBool(strings.ToLower(ctx.String("prerelease"))[:1] == "t") + } + + if !ctx.Args().Present() { + fmt.Println("Release tag needed to edit") + return nil + } + + for _, tag := range ctx.Args().Slice() { + release, err := GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) + if err != nil { + return err + } + + _, _, err = client.Releases.EditRelease(requestCtx, ctx.Owner, ctx.Repo, release.ID, gitea.EditReleaseOption{ + TagName: ctx.String("tag"), + Target: ctx.String("target"), + Title: ctx.String("title"), + Note: ctx.String("note"), + IsDraft: isDraft, + IsPrerelease: isPre, + }) + if err != nil { + return err + } + } + return nil +} diff --git a/cmd/releases/list.go b/cmd/releases/list.go new file mode 100644 index 00000000..4fa950d0 --- /dev/null +++ b/cmd/releases/list.go @@ -0,0 +1,49 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package releases + +import ( + stdctx "context" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdReleaseList represents a sub command of Release to list releases +var CmdReleaseList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List Releases", + Description: "List Releases", + ArgsUsage: " ", // command does not accept arguments + Action: RunReleasesList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunReleasesList list releases +func RunReleasesList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + releases, _, err := ctx.Login.Client().Releases.ListReleases(requestCtx, ctx.Owner, ctx.Repo, gitea.ListReleasesOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + return print.ReleasesList(releases, ctx.Output) +} diff --git a/cmd/releases/utils.go b/cmd/releases/utils.go new file mode 100644 index 00000000..6230ee46 --- /dev/null +++ b/cmd/releases/utils.go @@ -0,0 +1,36 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package releases + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" +) + +// GetReleaseByTag finds a release by its tag name. +func GetReleaseByTag(ctx stdctx.Context, owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { + for page := 1; ; { + rl, resp, err := client.Releases.ListReleases(ctx, owner, repo, gitea.ListReleasesOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, err + } + if page == 1 && len(rl) == 0 { + return nil, fmt.Errorf("repo does not have any release") + } + for _, r := range rl { + if r.TagName == tag { + return r, nil + } + } + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage + } + return nil, fmt.Errorf("release tag does not exist") +} diff --git a/cmd/repos.go b/cmd/repos.go new file mode 100644 index 00000000..47b54e94 --- /dev/null +++ b/cmd/repos.go @@ -0,0 +1,65 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/repos" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" + "github.com/urfave/cli/v3" +) + +// CmdRepos represents the command to manage repositories. +var CmdRepos = cli.Command{ + Name: "repos", + Aliases: []string{"repo"}, + Category: catEntities, + Usage: "Manage repositories", + Description: "Manage repositories", + ArgsUsage: "[/]", + Action: runRepos, + Commands: []*cli.Command{ + &repos.CmdReposList, + &repos.CmdReposSearch, + &repos.CmdRepoCreate, + &repos.CmdRepoCreateFromTemplate, + &repos.CmdRepoFork, + &repos.CmdRepoMigrate, + &repos.CmdRepoRm, + &repos.CmdRepoEdit, + }, + Flags: repos.CmdReposListFlags, +} + +func runRepos(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 1 { + return runRepoDetail(ctx, cmd, cmd.Args().First()) + } + return repos.RunReposList(ctx, cmd) +} + +func runRepoDetail(requestCtx stdctx.Context, cmd *cli.Command, path string) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := ctx.Login.Client() + repoOwner, repoName := utils.GetOwnerAndRepo(path, ctx.Owner) + repo, _, err := client.Repositories.GetRepo(requestCtx, repoOwner, repoName) + if err != nil { + return err + } + topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repoOwner, repoName, gitea.ListRepoTopicsOptions{}) + if err != nil { + return err + } + + print.RepoDetails(repo, topics) + return nil +} diff --git a/cmd/repos/create.go b/cmd/repos/create.go new file mode 100644 index 00000000..ef7b324d --- /dev/null +++ b/cmd/repos/create.go @@ -0,0 +1,160 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + stdctx "context" + "fmt" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdRepoCreate represents a sub command of repos to create one +var CmdRepoCreate = cli.Command{ + Name: "create", + Aliases: []string{"c"}, + Usage: "Create a repository", + Description: "Create a repository", + ArgsUsage: " ", // command does not accept arguments + Action: runRepoCreate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{""}, + Required: true, + Usage: "name of new repo", + }, + &cli.StringFlag{ + Name: "owner", + Aliases: []string{"O"}, + Required: false, + Usage: "name of repo owner", + }, + &cli.BoolFlag{ + Name: "private", + Required: false, + Value: false, + Usage: "make repo private", + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"desc"}, + Required: false, + Usage: "add description to repo", + }, + &cli.BoolFlag{ + Name: "init", + Required: false, + Value: false, + Usage: "initialize repo", + }, + &cli.StringFlag{ + Name: "labels", + Required: false, + Usage: "name of label set to add", + }, + &cli.StringFlag{ + Name: "gitignores", + Aliases: []string{"git"}, + Required: false, + Usage: "list of gitignore templates (need --init)", + }, + &cli.StringFlag{ + Name: "license", + Required: false, + Usage: "add license (need --init)", + }, + &cli.StringFlag{ + Name: "readme", + Required: false, + Usage: "use readme template (need --init)", + }, + &cli.StringFlag{ + Name: "branch", + Required: false, + Usage: "use custom default branch (need --init)", + }, + &cli.BoolFlag{ + Name: "template", + Usage: "make repo a template repo", + }, + &cli.StringFlag{ + Name: "trustmodel", + Usage: "select trust model (committer,collaborator,collaborator+committer)", + }, + &cli.StringFlag{ + Name: "object-format", + Required: false, + Usage: "select git object format (sha1,sha256)", + Validator: func(v string) error { + if v != "sha1" && v != "sha256" { + return fmt.Errorf("invalid object format '%s', must be either 'sha1' or 'sha256'", v) + } + return nil + }, + }, + }, flags.LoginOutputFlags...), +} + +func runRepoCreate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := ctx.Login.Client() + var ( + repo *gitea.Repository + trustmodel gitea.TrustModel + ) + + if ctx.IsSet("trustmodel") { + switch ctx.String("trustmodel") { + case "committer": + trustmodel = gitea.TrustModelCommitter + case "collaborator": + trustmodel = gitea.TrustModelCollaborator + case "collaborator+committer": + trustmodel = gitea.TrustModelCollaboratorCommitter + default: + return fmt.Errorf("unknown trustmodel type '%s'", ctx.String("trustmodel")) + } + } + + opts := gitea.CreateRepoOption{ + Name: ctx.String("name"), + Description: ctx.String("description"), + Private: ctx.Bool("private"), + AutoInit: ctx.Bool("init"), + IssueLabels: ctx.String("labels"), + Gitignores: ctx.String("gitignores"), + License: ctx.String("license"), + Readme: ctx.String("readme"), + DefaultBranch: ctx.String("branch"), + Template: ctx.Bool("template"), + TrustModel: trustmodel, + ObjectFormatName: ctx.String("object-format"), + } + if len(ctx.String("owner")) != 0 { + repo, _, err = client.Repositories.CreateOrgRepo(requestCtx, ctx.String("owner"), opts) + } else { + repo, _, err = client.Repositories.CreateRepo(requestCtx, opts) + } + if err != nil { + return err + } + + topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) + if err != nil { + return err + } + print.RepoDetails(repo, topics) + + fmt.Printf("%s\n", repo.HTMLURL) + return nil +} diff --git a/cmd/repos/create_from_template.go b/cmd/repos/create_from_template.go new file mode 100644 index 00000000..8bbc8912 --- /dev/null +++ b/cmd/repos/create_from_template.go @@ -0,0 +1,125 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + "fmt" + + stdctx "context" + + "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" +) + +// CmdRepoCreateFromTemplate represents a sub command of repos to generate one from a template repo +var CmdRepoCreateFromTemplate = cli.Command{ + Name: "create-from-template", + Aliases: []string{"ct"}, + Usage: "Create a repository based on an existing template", + Description: "Create a repository based on an existing template", + Action: runRepoCreateFromTemplate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "template", + Aliases: []string{"t"}, + Required: true, + Usage: "source template to copy from", + }, + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Required: true, + Usage: "name of new repo", + }, + &cli.StringFlag{ + Name: "owner", + Aliases: []string{"O"}, + Usage: "name of repo owner", + }, + &cli.BoolFlag{ + Name: "private", + Usage: "make new repo private", + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"desc"}, + Usage: "add custom description to repo", + }, + &cli.BoolFlag{ + Name: "content", + Value: true, + Usage: "copy git content from template", + }, + &cli.BoolFlag{ + Name: "githooks", + Value: true, + Usage: "copy git hooks from template", + }, + &cli.BoolFlag{ + Name: "avatar", + Value: true, + Usage: "copy repo avatar from template", + }, + &cli.BoolFlag{ + Name: "labels", + Value: true, + Usage: "copy repo labels from template", + }, + &cli.BoolFlag{ + Name: "topics", + Value: true, + Usage: "copy topics from template", + }, + &cli.BoolFlag{ + Name: "webhooks", + Usage: "copy webhooks from template", + }, + }, flags.LoginOutputFlags...), +} + +func runRepoCreateFromTemplate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := ctx.Login.Client() + + templateOwner, templateRepo := utils.GetOwnerAndRepo(ctx.String("template"), ctx.Login.User) + owner := ctx.Login.User + if ctx.IsSet("owner") { + owner = ctx.String("owner") + } + + opts := gitea.CreateRepoFromTemplateOption{ + Name: ctx.String("name"), + Owner: owner, + Description: ctx.String("description"), + Private: ctx.Bool("private"), + GitContent: ctx.Bool("content"), + GitHooks: ctx.Bool("githooks"), + Avatar: ctx.Bool("avatar"), + Labels: ctx.Bool("labels"), + Topics: ctx.Bool("topics"), + Webhooks: ctx.Bool("webhooks"), + } + + repo, _, err := client.Repositories.CreateRepoFromTemplate(requestCtx, templateOwner, templateRepo, opts) + if err != nil { + return err + } + + topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) + if err != nil { + return err + } + print.RepoDetails(repo, topics) + + fmt.Printf("%s\n", repo.HTMLURL) + return nil +} diff --git a/cmd/repos/delete.go b/cmd/repos/delete.go new file mode 100644 index 00000000..46e2871d --- /dev/null +++ b/cmd/repos/delete.go @@ -0,0 +1,89 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "charm.land/huh/v2" + "github.com/urfave/cli/v3" +) + +// CmdRepoRm represents a sub command of repos to delete an existing repo +var CmdRepoRm = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete an existing repository", + Description: "Removes a repository from your Gitea instance", + ArgsUsage: " ", // command does not accept arguments + Action: runRepoDelete, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{""}, + Required: true, + Usage: "name of the repo", + }, + &cli.StringFlag{ + Name: "owner", + Aliases: []string{"O"}, + Required: false, + Usage: "owner of the repo", + }, + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Required: false, + Value: false, + Usage: "Force the deletion and don't ask for confirmation", + }, + }, flags.LoginOutputFlags...), +} + +func runRepoDelete(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + client := ctx.Login.Client() + + var owner string + if ctx.IsSet("owner") { + owner = ctx.String("owner") + } else { + owner = ctx.Login.User + } + + repoName := ctx.String("name") + + repoSlug := fmt.Sprintf("%s/%s", owner, repoName) + + if !ctx.Bool("force") { + var enteredRepoSlug string + if err := huh.NewInput(). + Title(fmt.Sprintf("Confirm the deletion of the repository '%s' by typing its name: ", repoSlug)). + Validate(huh.ValidateNotEmpty()). + Value(&enteredRepoSlug). + Run(); err != nil { + return err + } + + if enteredRepoSlug != repoSlug { + return fmt.Errorf("entered wrong repository name '%s', expected '%s'", enteredRepoSlug, repoSlug) + } + } + + _, err = client.Repositories.DeleteRepo(requestCtx, owner, repoName) + if err != nil { + return err + } + + fmt.Printf("Successfully deleted %s/%s\n", owner, repoName) + return nil +} diff --git a/cmd/repos/edit.go b/cmd/repos/edit.go new file mode 100644 index 00000000..635ad2f2 --- /dev/null +++ b/cmd/repos/edit.go @@ -0,0 +1,111 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + stdctx "context" + "strings" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdRepoEdit represents a sub command of repos to edit one +var CmdRepoEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit repository properties", + Description: "Edit repository properties", + ArgsUsage: " ", // command does not accept arguments + Action: runRepoEdit, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "New name of the repository", + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"desc"}, + Usage: "New description of the repository", + }, + &cli.StringFlag{ + Name: "website", + Usage: "New website URL of the repository", + }, + &cli.StringFlag{ + Name: "private", + Usage: "Set private [true/false]", + DefaultText: "true", + }, + &cli.StringFlag{ + Name: "template", + Usage: "Set template [true/false]", + DefaultText: "true", + }, + &cli.StringFlag{ + Name: "archived", + Usage: "Set archived [true/false]", + DefaultText: "true", + }, + &cli.StringFlag{ + Name: "default-branch", + Usage: "Set default branch", + }, + }, flags.AllDefaultFlags...), +} + +func runRepoEdit(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + opts := gitea.EditRepoOption{} + + if ctx.IsSet("name") { + val := ctx.String("name") + opts.Name = &val + } + if ctx.IsSet("description") { + val := ctx.String("description") + opts.Description = &val + } + if ctx.IsSet("website") { + val := ctx.String("website") + opts.Website = &val + } + if ctx.IsSet("default-branch") { + val := ctx.String("default-branch") + opts.DefaultBranch = &val + } + if ctx.IsSet("private") { + opts.Private = gitea.OptionalBool(strings.ToLower(ctx.String("private"))[:1] == "t") + } + if ctx.IsSet("template") { + opts.Template = gitea.OptionalBool(strings.ToLower(ctx.String("template"))[:1] == "t") + } + if ctx.IsSet("archived") { + opts.Archived = gitea.OptionalBool(strings.ToLower(ctx.String("archived"))[:1] == "t") + } + + repo, _, err := client.Repositories.EditRepo(requestCtx, ctx.Owner, ctx.Repo, opts) + if err != nil { + return err + } + + topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) + if err != nil { + return err + } + print.RepoDetails(repo, topics) + return nil +} diff --git a/cmd/repos/flags.go b/cmd/repos/flags.go new file mode 100644 index 00000000..180a4c0b --- /dev/null +++ b/cmd/repos/flags.go @@ -0,0 +1,36 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + "fmt" + + "gitea.dev/sdk" + "github.com/urfave/cli/v3" +) + +var typeFilterFlag = cli.StringFlag{ + Name: "type", + Aliases: []string{"T"}, + Required: false, + Usage: "Filter by type: fork, mirror, source", +} + +func getTypeFilter(cmd *cli.Command) (filter gitea.RepoType, err error) { + t := cmd.String("type") + filter = gitea.RepoTypeNone + switch t { + case "": + filter = gitea.RepoTypeNone + case "fork": + filter = gitea.RepoTypeFork + case "mirror": + filter = gitea.RepoTypeMirror + case "source": + filter = gitea.RepoTypeSource + default: + err = fmt.Errorf("invalid repo type '%s'. valid: fork, mirror, source", t) + } + return +} diff --git a/cmd/repos/fork.go b/cmd/repos/fork.go new file mode 100644 index 00000000..9cbc4a72 --- /dev/null +++ b/cmd/repos/fork.go @@ -0,0 +1,64 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + stdctx "context" + "fmt" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdRepoFork represents a sub command of repos to fork an existing repo +var CmdRepoFork = cli.Command{ + Name: "fork", + Aliases: []string{"f"}, + Usage: "Fork an existing repository", + Description: "Create a repository from an existing repo", + ArgsUsage: " ", // command does not accept arguments + Action: runRepoFork, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "owner", + Aliases: []string{"O"}, + Usage: "name of fork's owner, defaults to current user", + }, + }, flags.LoginRepoFlags...), +} + +func runRepoFork(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := ctx.Login.Client() + + opts := gitea.CreateForkOption{} + if ctx.IsSet("owner") { + owner := ctx.String("owner") + opts.Organization = &owner + } + + repo, _, err := client.Repositories.CreateFork(requestCtx, ctx.Owner, ctx.Repo, opts) + if err != nil { + return err + } + + topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) + if err != nil { + return err + } + print.RepoDetails(repo, topics) + + fmt.Printf("%s\n", repo.HTMLURL) + return nil +} diff --git a/cmd/repos/list.go b/cmd/repos/list.go new file mode 100644 index 00000000..0f4a33c7 --- /dev/null +++ b/cmd/repos/list.go @@ -0,0 +1,187 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + stdctx "context" + "fmt" + "net/http" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +var repoFieldsFlag = flags.FieldsFlag(print.RepoFields, []string{ + "owner", "name", "type", "ssh", +}) + +// CmdReposListFlags contains all flags needed for repo listing +var CmdReposListFlags = append([]cli.Flag{ + &cli.BoolFlag{ + Name: "watched", + Aliases: []string{"w"}, + Required: false, + Usage: "List your watched repos instead", + }, + &cli.BoolFlag{ + Name: "starred", + Aliases: []string{"s"}, + Required: false, + Usage: "List your starred repos instead", + }, + &cli.StringFlag{ + Name: "owner", + Aliases: []string{"O"}, + Required: false, + Usage: "List repos of a specific owner (org or user)", + }, + repoFieldsFlag, + &typeFilterFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, +}, flags.LoginOutputFlags...) + +// CmdReposList represents a sub command of repos to list them +var CmdReposList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List repositories you have access to", + Description: "List repositories you have access to", + Action: RunReposList, + Flags: CmdReposListFlags, +} + +// RunReposList list repositories +func RunReposList(requestCtx stdctx.Context, cmd *cli.Command) error { + teaCmd, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := teaCmd.Login.Client() + + typeFilter, err := getTypeFilter(cmd) + if err != nil { + return err + } + + var rps []*gitea.Repository + if owner := teaCmd.String("owner"); owner != "" { + var err error + _, resp, orgErr := client.Organizations.GetOrg(requestCtx, owner) + if orgErr != nil { + if resp == nil || resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("could not find owner: %w", orgErr) + } + // not an org, treat as user + rps, _, err = client.Repositories.ListUserRepos(requestCtx, owner, gitea.ListReposOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + } else { + rps, _, err = client.Repositories.ListOrgRepos(requestCtx, owner, gitea.ListOrgReposOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + } + if err != nil { + return err + } + } else if teaCmd.Bool("starred") { + user, _, err := client.Users.GetMyUserInfo(requestCtx) + if err != nil { + return err + } + rps, _, err = client.Repositories.SearchRepos(requestCtx, gitea.SearchRepoOptions{ + ListOptions: flags.GetListOptions(cmd), + StarredByUserID: user.ID, + }) + if err != nil { + return err + } + } else if teaCmd.Bool("watched") { + // GetMyWatchedRepos doesn't expose server-side pagination, + // so we implement client-side pagination as a workaround + allRepos, _, err := client.Repositories.GetMyWatchedRepos(requestCtx) + if err != nil { + return err + } + rps = paginateRepos(allRepos, flags.GetListOptions(cmd)) + } else { + var err error + rps, _, err = client.Repositories.ListMyRepos(requestCtx, gitea.ListReposOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + } + + reposFiltered := rps + if typeFilter != gitea.RepoTypeNone { + reposFiltered = filterReposByType(rps, typeFilter) + } + + fields, err := repoFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + + return print.ReposList(reposFiltered, teaCmd.Output, fields) +} + +func filterReposByType(repos []*gitea.Repository, t gitea.RepoType) []*gitea.Repository { + var filtered []*gitea.Repository + for _, r := range repos { + switch t { + case gitea.RepoTypeFork: + if !r.Fork { + continue + } + case gitea.RepoTypeMirror: + if !r.Mirror { + continue + } + case gitea.RepoTypeSource: + if r.Fork || r.Mirror { + continue + } + } + + filtered = append(filtered, r) + } + return filtered +} + +// paginateRepos implements client-side pagination for repositories. +// This is a workaround for API endpoints that don't support server-side pagination. +func paginateRepos(repos []*gitea.Repository, opts gitea.ListOptions) []*gitea.Repository { + if len(repos) == 0 { + return repos + } + + pageSize := opts.PageSize + if pageSize <= 0 { + pageSize = flags.PaginationLimitFlag.Value + } + + page := opts.Page + if page < 1 { + page = 1 + } + + start := (page - 1) * pageSize + end := start + pageSize + + if start >= len(repos) { + return []*gitea.Repository{} + } + + if end > len(repos) { + end = len(repos) + } + + return repos[start:end] +} diff --git a/cmd/repos/migrate.go b/cmd/repos/migrate.go new file mode 100644 index 00000000..a241d0b8 --- /dev/null +++ b/cmd/repos/migrate.go @@ -0,0 +1,175 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + "fmt" + + stdctx "context" + + "gitea.dev/sdk" + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" +) + +// CmdRepoMigrate represents a sub command of repos to migrate one +var CmdRepoMigrate = cli.Command{ + Name: "migrate", + Aliases: []string{"m"}, + Usage: "Migrate a repository", + Description: "Migrate a repository and or mirror it.", + ArgsUsage: " ", // command does not accept arguments + Action: runRepoMigrate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "Name of the repository", + Required: true, + }, + &cli.StringFlag{ + Name: "owner", + Usage: "Owner of the repository", + Required: true, + }, + &cli.StringFlag{ + Name: "clone-url", + Usage: "Clone URL of the repository", + Required: true, + }, + &cli.StringFlag{ + Name: "service", + Usage: string("Service to migrate from. Supported services are: " + gitea.GitServicePlain + + ", " + gitea.GitServiceGitea + ", " + gitea.GitServiceGitlab + ", " + gitea.GitServiceGogs), + Required: true, + }, + &cli.BoolFlag{ + Name: "mirror", + Usage: "Mirror the repository", + }, + &cli.BoolFlag{ + Name: "private", + Usage: "Make the repository private", + }, + &cli.BoolFlag{ + Name: "template", + Usage: "Make the repository a template", + }, + &cli.BoolFlag{ + Name: "wiki", + Usage: "Copy the wiki", + }, + &cli.BoolFlag{ + Name: "issues", + Usage: "Copy the issues", + }, + &cli.BoolFlag{ + Name: "labels", + Usage: "Copy the lables", + }, + &cli.BoolFlag{ + Name: "pull-requests", + Usage: "Copy the pull requests", + }, + &cli.BoolFlag{ + Name: "releases", + Usage: "Copy the releases", + }, + &cli.BoolFlag{ + Name: "milestones", + Usage: "Copy the milestones", + }, + &cli.StringFlag{ + Name: "mirror-interval", + Usage: "Interval to mirror the repository.", + }, + &cli.BoolFlag{ + Name: "lfs", + Usage: "Copy the LFS objects", + }, + &cli.StringFlag{ + Name: "lfs-endpoint", + Usage: "LFS endpoint to use", + }, + &cli.StringFlag{ + Name: "auth-user", + Usage: "Username to use for authentication.", + }, + &cli.StringFlag{ + Name: "auth-password", + Usage: "Password to use for authentication.", + }, + &cli.StringFlag{ + Name: "auth-token", + Usage: "Token to use for authentication.", + }, + }, flags.LoginOutputFlags...), +} + +func runRepoMigrate(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := ctx.Login.Client() + var ( + repo *gitea.Repository + service gitea.GitServiceType + ) + + if ctx.IsSet("service") { + switch ctx.String("service") { + case "git": + service = gitea.GitServicePlain + case "gitea": + service = gitea.GitServiceGitea + case "gitlab": + service = gitea.GitServiceGitlab + case "gogs": + service = gitea.GitServiceGogs + case "github": + service = gitea.GitServiceGithub + default: + return fmt.Errorf("unknown git service type '%s'", ctx.String("service")) + } + } + + opts := gitea.MigrateRepoOption{ + RepoName: ctx.String("name"), + RepoOwner: ctx.String("owner"), + CloneAddr: ctx.String("clone-url"), + Service: service, + AuthUsername: ctx.String("auth-user"), + AuthPassword: ctx.String("auth-password"), + AuthToken: ctx.String("auth-token"), + Mirror: ctx.Bool("mirror"), + Private: ctx.Bool("private"), + Description: ctx.String("description"), + Wiki: ctx.Bool("wiki"), + Milestones: ctx.Bool("milestones"), + Labels: ctx.Bool("labels"), + Issues: ctx.Bool("issues"), + PullRequests: ctx.Bool("pull-requests"), + Releases: ctx.Bool("releases"), + MirrorInterval: ctx.String("mirror-interval"), + LFS: ctx.Bool("lfs"), + LFSEndpoint: ctx.String("lfs-endpoint"), + } + + repo, _, err = client.Repositories.MigrateRepo(requestCtx, opts) + if err != nil { + return err + } + + topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) + if err != nil { + return err + } + print.RepoDetails(repo, topics) + + fmt.Printf("%s\n", repo.HTMLURL) + return nil +} diff --git a/cmd/repos/search.go b/cmd/repos/search.go new file mode 100644 index 00000000..4efde401 --- /dev/null +++ b/cmd/repos/search.go @@ -0,0 +1,134 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + stdctx "context" + "fmt" + "net/http" + "strings" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "github.com/urfave/cli/v3" +) + +// CmdReposSearch represents a sub command of repos to find them +var CmdReposSearch = cli.Command{ + Name: "search", + Aliases: []string{"s"}, + Usage: "Find any repo on an Gitea instance", + Description: "Find any repo on an Gitea instance", + ArgsUsage: "[]", + Action: runReposSearch, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + // TODO: it might be nice to search for topics as an ADDITIONAL filter. + // for that, we'd probably need to make multiple queries and UNION the results. + Name: "topic", + Aliases: []string{"t"}, + Required: false, + Usage: "Search for term in repo topics instead of name", + }, + &typeFilterFlag, + &cli.StringFlag{ + Name: "owner", + Aliases: []string{"O"}, + Required: false, + Usage: "Filter by owner", + }, + &cli.StringFlag{ + Name: "private", + Required: false, + Usage: "Filter private repos (true|false)", + }, + &cli.StringFlag{ + Name: "archived", + Required: false, + Usage: "Filter archived repos (true|false)", + }, + repoFieldsFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.LoginOutputFlags...), +} + +func runReposSearch(requestCtx stdctx.Context, cmd *cli.Command) error { + teaCmd, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := teaCmd.Login.Client() + + var ownerID int64 + if teaCmd.IsSet("owner") { + // test if owner is an organization + org, resp, err := client.Organizations.GetOrg(requestCtx, teaCmd.String("owner")) + if err != nil { + if resp == nil || resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("could not find owner: %w", err) + } + + // if owner is no org, its a user + user, _, err := client.Users.GetUserInfo(requestCtx, teaCmd.String("owner")) + if err != nil { + return err + } + ownerID = user.ID + } else { + ownerID = org.ID + } + } + + var isArchived *bool + if teaCmd.IsSet("archived") { + archived := strings.ToLower(teaCmd.String("archived"))[:1] == "t" + isArchived = &archived + } + + var isPrivate *bool + if teaCmd.IsSet("private") { + private := strings.ToLower(teaCmd.String("private"))[:1] == "t" + isPrivate = &private + } + + mode, err := getTypeFilter(cmd) + if err != nil { + return err + } + + var keyword string + if teaCmd.Args().Present() { + keyword = strings.Join(teaCmd.Args().Slice(), " ") + } + + user, _, err := client.Users.GetMyUserInfo(requestCtx) + if err != nil { + return err + } + + rps, _, err := client.Repositories.SearchRepos(requestCtx, gitea.SearchRepoOptions{ + ListOptions: flags.GetListOptions(cmd), + OwnerID: ownerID, + IsPrivate: isPrivate, + IsArchived: isArchived, + Type: mode, + Keyword: keyword, + KeywordInDescription: true, + KeywordIsTopic: teaCmd.Bool("topic"), + PrioritizedByOwnerID: user.ID, + }) + if err != nil { + return err + } + + fields, err := repoFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + return print.ReposList(rps, teaCmd.Output, fields) +} diff --git a/cmd/sshkeys.go b/cmd/sshkeys.go new file mode 100644 index 00000000..f232c170 --- /dev/null +++ b/cmd/sshkeys.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/sshkeys" +) + +// CmdSSHKeys represents the ssh-keys command group +var CmdSSHKeys = cli.Command{ + Name: "ssh-keys", + Aliases: []string{"ssh-key"}, + Category: catSetup, + Usage: "Manage SSH public keys", + Description: "List, add, or delete SSH public keys on the current user's account", + ArgsUsage: " ", + Action: runSSHKeys, + Commands: []*cli.Command{ + &sshkeys.CmdSSHKeyList, + &sshkeys.CmdSSHKeyAdd, + &sshkeys.CmdSSHKeyDelete, + }, + Flags: sshkeys.CmdSSHKeyList.Flags, +} + +func runSSHKeys(ctx stdctx.Context, cmd *cli.Command) error { + return sshkeys.RunSSHKeyList(ctx, cmd) +} diff --git a/cmd/sshkeys/add.go b/cmd/sshkeys/add.go new file mode 100644 index 00000000..1a930b5c --- /dev/null +++ b/cmd/sshkeys/add.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sshkeys + +import ( + stdctx "context" + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdSSHKeyAdd represents a sub command of ssh-keys to add an SSH public key +var CmdSSHKeyAdd = cli.Command{ + Name: "add", + Usage: "Add an SSH public key", + Description: "Add an SSH public key to the current user's profile", + ArgsUsage: "", + Action: RunSSHKeyAdd, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + Usage: "Title for the key (defaults to the filename without extension)", + }, + }, flags.LoginOutputFlags...), +} + +// RunSSHKeyAdd reads a public key file and registers it with the Gitea instance +func RunSSHKeyAdd(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + if ctx.Args().Len() < 1 { + return fmt.Errorf("key file path is required") + } + + keyFile := ctx.Args().First() + keyBytes, err := os.ReadFile(keyFile) + if err != nil { + return fmt.Errorf("could not read key file '%s': %w", keyFile, err) + } + + keyContent := strings.TrimSpace(string(keyBytes)) + if keyContent == "" { + return fmt.Errorf("key file '%s' is empty", keyFile) + } + + title := ctx.String("title") + if title == "" { + base := filepath.Base(keyFile) + title = strings.TrimSuffix(base, filepath.Ext(base)) + } + + key, _, err := ctx.Login.Client().Users.CreatePublicKey(requestCtx, gitea.CreateKeyOption{ + Title: title, + Key: keyContent, + }) + if err != nil { + return err + } + + fmt.Printf("Key '%s' (id: %d) added successfully.\n", key.Title, key.ID) + return nil +} diff --git a/cmd/sshkeys/add_test.go b/cmd/sshkeys/add_test.go new file mode 100644 index 00000000..40c8147a --- /dev/null +++ b/cmd/sshkeys/add_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sshkeys + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKeyTitleFromFilename(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"id_ed25519.pub", "id_ed25519"}, + {"id_rsa.pub", "id_rsa"}, + {"/home/user/.ssh/id_ed25519.pub", "id_ed25519"}, + {"mykey", "mykey"}, // no extension + {"my.key.pub", "my.key"}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + base := filepath.Base(tc.input) + title := strings.TrimSuffix(base, filepath.Ext(base)) + assert.Equal(t, tc.expected, title) + }) + } +} diff --git a/cmd/sshkeys/delete.go b/cmd/sshkeys/delete.go new file mode 100644 index 00000000..d9df0d93 --- /dev/null +++ b/cmd/sshkeys/delete.go @@ -0,0 +1,76 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sshkeys + +import ( + stdctx "context" + "fmt" + "strconv" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdSSHKeyDelete represents a sub command of ssh-keys to delete an SSH key by ID +var CmdSSHKeyDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete an SSH key", + Description: "Delete an SSH key from the current user's profile by its numeric ID", + ArgsUsage: "", + Action: RunSSHKeyDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "Confirm deletion (required)", + }, + }, flags.LoginOutputFlags...), +} + +// RunSSHKeyDelete removes an SSH key by its numeric ID +func RunSSHKeyDelete(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + if ctx.Args().Len() < 1 { + return fmt.Errorf("key ID is required") + } + + keyID, err := strconv.ParseInt(ctx.Args().First(), 10, 64) + if err != nil { + return fmt.Errorf("invalid key ID '%s': must be a number", ctx.Args().First()) + } + + client := ctx.Login.Client() + + key, resp, err := client.Users.GetPublicKey(requestCtx, keyID) + if err != nil { + if resp != nil && resp.StatusCode == 404 { + return fmt.Errorf("SSH key with ID %d not found", keyID) + } + return err + } + + if !ctx.Bool("confirm") { + fmt.Printf("Are you sure you want to delete SSH key '%s' (id: %d)? [y/N] ", key.Title, keyID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Deletion canceled.") + return nil + } + } + + if _, err = client.Users.DeletePublicKey(requestCtx, keyID); err != nil { + return err + } + + fmt.Printf("SSH key '%s' deleted successfully\n", key.Title) + return nil +} diff --git a/cmd/sshkeys/list.go b/cmd/sshkeys/list.go new file mode 100644 index 00000000..269b876a --- /dev/null +++ b/cmd/sshkeys/list.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sshkeys + +import ( + stdctx "context" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdSSHKeyList represents a sub command of ssh-keys to list the current user's SSH keys +var CmdSSHKeyList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List SSH keys", + Description: "List the SSH keys registered for the current user", + ArgsUsage: " ", // command does not accept arguments + Action: RunSSHKeyList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.LoginOutputFlags...), +} + +// RunSSHKeyList lists SSH keys for the current user +func RunSSHKeyList(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := ctx.Login.Client() + + keys, _, err := client.Users.ListMyPublicKeys(requestCtx, gitea.ListPublicKeysOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + return print.SSHKeysList(keys, ctx.Output) +} diff --git a/cmd/times.go b/cmd/times.go new file mode 100644 index 00000000..fd1a761e --- /dev/null +++ b/cmd/times.go @@ -0,0 +1,30 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "github.com/urfave/cli/v3" + + "gitea.dev/tea/cmd/times" +) + +// CmdTrackedTimes represents the command to operate repositories' times. +var CmdTrackedTimes = cli.Command{ + Name: "times", + Aliases: []string{"time", "t"}, + Category: catEntities, + Usage: "Operate on tracked times of a repository's issues & pulls", + Description: `Operate on tracked times of a repository's issues & pulls. + Depending on your permissions on the repository, only your own tracked + times might be listed.`, + ArgsUsage: "[username | #issue]", + Action: times.RunTimesList, + Commands: []*cli.Command{ + ×.CmdTrackedTimesAdd, + ×.CmdTrackedTimesDelete, + ×.CmdTrackedTimesReset, + ×.CmdTrackedTimesList, + }, + Flags: times.CmdTrackedTimesList.Flags, +} diff --git a/cmd/times/add.go b/cmd/times/add.go new file mode 100644 index 00000000..26f7781e --- /dev/null +++ b/cmd/times/add.go @@ -0,0 +1,61 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package times + +import ( + stdctx "context" + "fmt" + "strings" + "time" + + "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + "github.com/urfave/cli/v3" +) + +// CmdTrackedTimesAdd represents a sub command of times to add time to an issue +var CmdTrackedTimesAdd = cli.Command{ + Name: "add", + Aliases: []string{"a"}, + Usage: "Track spent time on an issue", + UsageText: "tea times add ", + Description: `Track spent time on an issue + Example: + tea times add 1 1h25m + `, + Action: runTrackedTimesAdd, + Flags: flags.LoginRepoFlags, +} + +func runTrackedTimesAdd(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() < 2 { + return fmt.Errorf("no issue or duration specified.\nUsage:\t%s", ctx.Command.UsageText) + } + + issue, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + duration, err := time.ParseDuration(strings.Join(ctx.Args().Tail(), "")) + if err != nil { + return err + } + + _, _, err = ctx.Login.Client().Issues.AddTime(requestCtx, ctx.Owner, ctx.Repo, issue, gitea.AddTimeOption{ + Time: int64(duration.Seconds()), + }) + return err +} diff --git a/cmd/times/delete.go b/cmd/times/delete.go new file mode 100644 index 00000000..58a2e2c0 --- /dev/null +++ b/cmd/times/delete.go @@ -0,0 +1,54 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package times + +import ( + stdctx "context" + "fmt" + "strconv" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdTrackedTimesDelete is a sub command of CmdTrackedTimes, and removes time from an issue +var CmdTrackedTimesDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete a single tracked time on an issue", + UsageText: "tea times delete