diff --git a/src/api.rs b/src/api.rs index cc83cb32ba..b2dc6fcb75 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1572,6 +1572,59 @@ impl Api { Ok(rv) } + /// List all issues associated with an organization and a project + pub fn list_organization_project_issues( + &self, + org: &str, + project: &str, + max_pages: usize, + query: Option, + ) -> ApiResult> { + let mut rv = vec![]; + let mut cursor = "".to_string(); + let mut requests_no = 0; + + let url = if let Some(query) = query { + format!( + "/projects/{}/{}/issues/?query={}&", + PathArg(org), + PathArg(project), + QueryArg(&query), + ) + } else { + format!("/projects/{}/{}/issues/?", PathArg(org), PathArg(project),) + }; + + loop { + requests_no += 1; + + let resp = self.get(&format!("{}cursor={}", url, QueryArg(&cursor)))?; + + if resp.status() == 404 || (resp.status() == 400 && !cursor.is_empty()) { + if rv.is_empty() { + return Err(ApiErrorKind::OrganizationNotFound.into()); + } else { + break; + } + } + + let pagination = resp.pagination(); + rv.extend(resp.convert::>()?.into_iter()); + + if requests_no == max_pages { + break; + } + + if let Some(next) = pagination.into_next_cursor() { + cursor = next; + } else { + break; + } + } + + Ok(rv) + } + /// List all repos associated with an organization pub fn list_organization_repos(&self, org: &str) -> ApiResult> { let mut rv = vec![]; @@ -2356,6 +2409,17 @@ struct MissingChecksumsResponse { missing: HashSet, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Issue { + pub id: String, + pub short_id: String, + pub title: String, + pub last_seen: String, + pub status: String, + pub level: String, +} + /// Change information for issue bulk updates. #[derive(Serialize, Default)] pub struct IssueChanges { diff --git a/src/commands/issues/list.rs b/src/commands/issues/list.rs new file mode 100644 index 0000000000..16cb638e56 --- /dev/null +++ b/src/commands/issues/list.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use clap::{Arg, ArgMatches, Command}; + +use crate::api::Api; +use crate::config::Config; +use crate::utils::formatting::Table; + +pub fn make_command(command: Command) -> Command { + command + .about("List all issues in your organization.") + .arg( + Arg::new("max_rows") + .long("max-rows") + .value_name("MAX_ROWS") + .value_parser(clap::value_parser!(usize)) + .help("Maximum number of rows to print."), + ) + .arg( + Arg::new("pages") + .long("pages") + .value_name("PAGES") + .default_value("5") + .value_parser(clap::value_parser!(usize)) + .help("Maximum number of pages to fetch (100 issues/page)."), + ) + .arg( + Arg::new("query") + .long("query") + .value_name("QUERY") + .default_value("") + .help("Query to pass at the request. An example is \"is:unresolved\""), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + let config = Config::current(); + let org = config.get_org(matches)?; + let project = config.get_project(matches)?; + let pages = *matches.get_one("pages").unwrap(); + let query = matches.get_one::("query").cloned(); + let api = Api::current(); + + let issues = api.list_organization_project_issues(&org, &project, pages, query)?; + + let mut table = Table::new(); + table + .title_row() + .add("Issue ID") + .add("Short ID") + .add("Title") + .add("Last seen") + .add("Status") + .add("Level"); + + let max_rows = std::cmp::min( + issues.len(), + *matches.get_one("max_rows").unwrap_or(&std::usize::MAX), + ); + + if let Some(issues) = issues.get(..max_rows) { + for issue in issues { + let row = table.add_row(); + row.add(&issue.id) + .add(&issue.short_id) + .add(&issue.title) + .add(&issue.last_seen) + .add(&issue.status) + .add(&issue.level); + } + } + + if table.is_empty() { + println!("No issues found"); + } else { + table.print(); + } + + Ok(()) +} diff --git a/src/commands/issues/mod.rs b/src/commands/issues/mod.rs index 11191b0a9c..0fb7a22c39 100644 --- a/src/commands/issues/mod.rs +++ b/src/commands/issues/mod.rs @@ -3,12 +3,14 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use crate::utils::args::ArgExt; +pub mod list; pub mod mute; pub mod resolve; pub mod unresolve; macro_rules! each_subcommand { ($mac:ident) => { + $mac!(list); $mac!(mute); $mac!(resolve); $mac!(unresolve); diff --git a/src/utils/sourcemaps.rs b/src/utils/sourcemaps.rs index b205436022..feed673959 100644 --- a/src/utils/sourcemaps.rs +++ b/src/utils/sourcemaps.rs @@ -684,7 +684,7 @@ impl SourceMapProcessor { .filter_map(|artifact| Digest::from_str(&artifact.sha1).ok()) .collect(); - for mut source in self.sources.values_mut() { + for source in self.sources.values_mut() { if let Ok(checksum) = source.checksum() { if already_uploaded_checksums.contains(&checksum) { source.already_uploaded = true; diff --git a/tests/integration/_cases/issues/issues-display-with-query.trycmd b/tests/integration/_cases/issues/issues-display-with-query.trycmd new file mode 100644 index 0000000000..ab799d6a2f --- /dev/null +++ b/tests/integration/_cases/issues/issues-display-with-query.trycmd @@ -0,0 +1,10 @@ +``` +$ sentry-cli issues list --query is:resolved +? success ++------------+-----------+-----------+-----------------------------+----------+-------+ +| Issue ID | Short ID | Title | Last seen | Status | Level | ++------------+-----------+-----------+-----------------------------+----------+-------+ +| 4242424242 | SEN-CLI-H | N+1 Query | 2023-07-18T00:10:01.222387Z | resolved | info | ++------------+-----------+-----------+-----------------------------+----------+-------+ + +``` diff --git a/tests/integration/_cases/issues/issues-display.trycmd b/tests/integration/_cases/issues/issues-display.trycmd new file mode 100644 index 0000000000..dd97f11b2d --- /dev/null +++ b/tests/integration/_cases/issues/issues-display.trycmd @@ -0,0 +1,12 @@ +``` +$ sentry-cli issues list +? success ++------------+-----------+---------------------------------------------------------+-----------------------------+------------+-------+ +| Issue ID | Short ID | Title | Last seen | Status | Level | ++------------+-----------+---------------------------------------------------------+-----------------------------+------------+-------+ +| 4242424243 | SEN-CLI-L | ProgrammingError: column users_user.role does not exist | 2023-07-18T00:12:01.222387Z | unresolved | error | +| 4242424242 | SEN-CLI-H | N+1 Query | 2023-07-18T00:10:01.222387Z | resolved | info | +| 4242424241 | SEN-CLI-1 | NameError: name 'jobs' is not defined | 2023-07-18T00:00:01.222387Z | ignored | error | ++------------+-----------+---------------------------------------------------------+-----------------------------+------------+-------+ + +``` diff --git a/tests/integration/_cases/issues/issues-help.trycmd b/tests/integration/_cases/issues/issues-help.trycmd new file mode 100644 index 0000000000..6fa1e23016 --- /dev/null +++ b/tests/integration/_cases/issues/issues-help.trycmd @@ -0,0 +1,33 @@ + +``` +$ sentry-cli issues --help +? success +Manage issues in Sentry. + +Usage: sentry-cli[EXE] issues [OPTIONS] + +Commands: + list List all issues in your organization. + mute Bulk mute all selected issues. + resolve Bulk resolve all selected issues. + unresolve Bulk unresolve all selected issues. + help Print this message or the help of the given subcommand(s) + +Options: + -o, --org The organization slug + --header Custom headers that should be attached to all requests + in key:value format. + -p, --project The project slug. + --auth-token Use the given Sentry auth token. + -s, --status Select all issues matching a given status. [possible values: + resolved, muted, unresolved] + -a, --all Select all issues (this might be limited). + -i, --id Select the issue with the given ID. + --log-level Set the log output verbosity. [possible values: trace, debug, info, + warn, error] + --quiet Do not print any output while preserving correct exit code. This + flag is currently implemented only for selected subcommands. + [aliases: silent] + -h, --help Print help + +``` diff --git a/tests/integration/_cases/issues/issues-list-empty.trycmd b/tests/integration/_cases/issues/issues-list-empty.trycmd new file mode 100644 index 0000000000..24879bb394 --- /dev/null +++ b/tests/integration/_cases/issues/issues-list-empty.trycmd @@ -0,0 +1,6 @@ +``` +$ sentry-cli issues list +? success +No issues found + +``` diff --git a/tests/integration/_cases/issues/issues-list-help.trycmd b/tests/integration/_cases/issues/issues-list-help.trycmd new file mode 100644 index 0000000000..3603d7e036 --- /dev/null +++ b/tests/integration/_cases/issues/issues-list-help.trycmd @@ -0,0 +1,29 @@ +``` +$ sentry-cli issues list --help +? success +List all issues in your organization. + +Usage: sentry-cli[EXE] issues list [OPTIONS] + +Options: + --max-rows Maximum number of rows to print. + -o, --org The organization slug + --header Custom headers that should be attached to all requests + in key:value format. + -p, --project The project slug. + --pages Maximum number of pages to fetch (100 issues/page). [default: 5] + --auth-token Use the given Sentry auth token. + --query Query to pass at the request. An example is "is:unresolved" + [default: ] + -s, --status Select all issues matching a given status. [possible values: + resolved, muted, unresolved] + -a, --all Select all issues (this might be limited). + -i, --id Select the issue with the given ID. + --log-level Set the log output verbosity. [possible values: trace, debug, info, + warn, error] + --quiet Do not print any output while preserving correct exit code. This + flag is currently implemented only for selected subcommands. + [aliases: silent] + -h, --help Print help + +``` diff --git a/tests/integration/_responses/issues/get-issues.json b/tests/integration/_responses/issues/get-issues.json new file mode 100644 index 0000000000..597c4100a6 --- /dev/null +++ b/tests/integration/_responses/issues/get-issues.json @@ -0,0 +1,26 @@ +[ + { + "id": "4242424243", + "shortId": "SEN-CLI-L", + "title": "ProgrammingError: column users_user.role does not exist", + "lastSeen": "2023-07-18T00:12:01.222387Z", + "status": "unresolved", + "level": "error" + }, + { + "id": "4242424242", + "shortId": "SEN-CLI-H", + "title": "N+1 Query", + "lastSeen": "2023-07-18T00:10:01.222387Z", + "status": "resolved", + "level": "info" + }, + { + "id": "4242424241", + "shortId": "SEN-CLI-1", + "title": "NameError: name 'jobs' is not defined", + "lastSeen": "2023-07-18T00:00:01.222387Z", + "status": "ignored", + "level": "error" + } +] diff --git a/tests/integration/_responses/issues/get-resolved-issues.json b/tests/integration/_responses/issues/get-resolved-issues.json new file mode 100644 index 0000000000..5f371713ae --- /dev/null +++ b/tests/integration/_responses/issues/get-resolved-issues.json @@ -0,0 +1,10 @@ +[ + { + "id": "4242424242", + "shortId": "SEN-CLI-H", + "title": "N+1 Query", + "lastSeen": "2023-07-18T00:10:01.222387Z", + "status": "resolved", + "level": "info" + } +] diff --git a/tests/integration/issues/list.rs b/tests/integration/issues/list.rs new file mode 100644 index 0000000000..5a444144be --- /dev/null +++ b/tests/integration/issues/list.rs @@ -0,0 +1,45 @@ +use crate::integration::{mock_endpoint, register_test, EndpointOptions}; + +#[test] +fn command_issues_list_help() { + register_test("issues/issues-list-help.trycmd"); +} + +#[test] +fn doesnt_fail_with_empty_response() { + let _server = mock_endpoint( + EndpointOptions::new( + "GET", + "/api/0/projects/wat-org/wat-project/issues/?query=&cursor=", + 200, + ) + .with_response_body("[]"), + ); + register_test("issues/issues-list-empty.trycmd"); +} + +#[test] +fn display_issues() { + let _server = mock_endpoint( + EndpointOptions::new( + "GET", + "/api/0/projects/wat-org/wat-project/issues/?query=&cursor=", + 200, + ) + .with_response_file("issues/get-issues.json"), + ); + register_test("issues/issues-display.trycmd"); +} + +#[test] +fn display_resolved_issues() { + let _server = mock_endpoint( + EndpointOptions::new( + "GET", + "/api/0/projects/wat-org/wat-project/issues/?query=is:resolved&cursor=", + 200, + ) + .with_response_file("issues/get-resolved-issues.json"), + ); + register_test("issues/issues-display-with-query.trycmd"); +} diff --git a/tests/integration/issues/mod.rs b/tests/integration/issues/mod.rs new file mode 100644 index 0000000000..60d7c94dd6 --- /dev/null +++ b/tests/integration/issues/mod.rs @@ -0,0 +1,8 @@ +use crate::integration::register_test; + +mod list; + +#[test] +fn command_issues_help() { + register_test("issues/issues-help.trycmd"); +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 5df0c40b5f..1b342a831d 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -4,6 +4,7 @@ mod deploys; mod events; mod help; mod info; +mod issues; mod login; mod monitors; mod org_tokens;