Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- Fixed a bug that prevented project IDs from being used with the `sentry-cli releases new` command for users with self-hosted Sentry instances on versions older than 25.12.1 ([#3068](https://github.com/getsentry/sentry-cli/issues/3068)).

## 3.0.3

### Fixes
Expand Down
2 changes: 2 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod data_types;
mod encoding;
mod errors;
mod pagination;
mod serialization;

use std::borrow::Cow;
use std::cell::RefCell;
Expand Down Expand Up @@ -1530,6 +1531,7 @@ pub struct AuthInfo {
#[derive(Debug, Serialize, Default)]
pub struct NewRelease {
pub version: String,
#[serde(serialize_with = "serialization::serialize_id_slug_list")]
pub projects: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
Expand Down
128 changes: 128 additions & 0 deletions src/api/serialization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! This module contains some custom serialization logic for the API.
use std::sync::LazyLock;

use regex::Regex;
use serde::ser::SerializeSeq as _;
use serde::{Serialize, Serializer};

/// A container for either a numeric ID or an alphanumeric slug.
///
/// IDs are serialized as integers, while slugs are serialized as strings.
#[derive(Serialize)]
#[serde(untagged)]
enum IdSlug<'s> {
Id(i64),
Slug(&'s str),
}

/// Serializes a sequence of strings, which may contain either numeric IDs or alphanumeric slugs.
///
/// We check each element in the sequence. If the element only contains digits and can be parsed as a 64-bit signed integer,
/// we consider the value to be an ID. Otherwise, we consider the value to be a slug.
///
/// IDs are serialized as integers, while slugs are serialized as strings.
pub fn serialize_id_slug_list<I, S>(list: I, serializer: S) -> Result<S::Ok, S::Error>
where
I: IntoIterator,
I::Item: AsRef<str>,
S: Serializer,
{
let mut seq = serializer.serialize_seq(None)?;
for item in list {
let item = item.as_ref();
let id_slug = IdSlug::from(&item);
seq.serialize_element(&id_slug)?;
}
seq.end()
}

impl<'a, S> From<&'a S> for IdSlug<'a>
where
S: AsRef<str>,
{
/// Convert from a string reference to an IdSlug.
///
/// If the string contains only digits and can be parsed as a 64-bit signed integer,
/// we consider the value to be an ID. Otherwise, we consider the value to be a slug.
fn from(value: &'a S) -> Self {
/// Project ID regex
///
/// Project IDs always contain only digits.
static PROJECT_ID_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\d+$").expect("regex is valid"));

let value = value.as_ref();

PROJECT_ID_REGEX
.is_match(value)
.then(|| value.parse().ok().map(IdSlug::Id))
.flatten()
.unwrap_or(IdSlug::Slug(value))
}
}

#[cfg(test)]
mod tests {
use super::*;

/// A test struct which serializes with serialize_id_slug_list
#[derive(Serialize)]
struct IdSlugListSerializerTest<const N: usize> {
#[serde(serialize_with = "serialize_id_slug_list")]
value: [&'static str; N],
}

#[test]
fn test_serialize_id_slug_list_empty() {
let to_serialize = IdSlugListSerializerTest { value: [] };

let serialized = serde_json::to_string(&to_serialize).unwrap();
let expected = serde_json::json!({ "value": [] }).to_string();

assert_eq!(serialized, expected)
}

#[test]
fn test_serialize_id_slug_list_single_id() {
let to_serialize = IdSlugListSerializerTest { value: ["123"] };

let serialized = serde_json::to_string(&to_serialize).unwrap();
let expected = serde_json::json!({ "value": [123] }).to_string();

assert_eq!(serialized, expected)
}

#[test]
fn test_serialize_id_slug_list_single_slug() {
let to_serialize = IdSlugListSerializerTest { value: ["abc"] };

let serialized = serde_json::to_string(&to_serialize).unwrap();
let expected = serde_json::json!({ "value": ["abc"] }).to_string();

assert_eq!(serialized, expected)
}

#[test]
fn test_serialize_id_slug_list_multiple_ids_and_slugs() {
let to_serialize = IdSlugListSerializerTest {
value: ["123", "abc", "456", "whatever"],
};

let serialized = serde_json::to_string(&to_serialize).unwrap();
let expected = serde_json::json!({ "value": [123, "abc", 456, "whatever"] }).to_string();

assert_eq!(serialized, expected)
}

/// Slugs of "-0" are possible. This test ensures that we serialize "-0" as a slug,
/// rather than as an ID 0.
#[test]
fn test_serialize_id_slug_minus_zero_edge_case() {
let to_serialize = IdSlugListSerializerTest { value: ["-0"] };

let serialized = serde_json::to_string(&to_serialize).unwrap();
let expected = serde_json::json!({ "value": ["-0"] }).to_string();

assert_eq!(serialized, expected)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
```
$ sentry-cli releases new -p 123 -p my-project -p 456 test-release
? success
Created release test-release

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
```
$ sentry-cli releases new -p 123 -p 456 test-release
? success
Created release test-release

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
```
$ sentry-cli releases new -p 123 test-release
? success
Created release test-release

```
48 changes: 48 additions & 0 deletions tests/integration/releases/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,51 @@ fn creates_release_which_is_instantly_finalized() {
.register_trycmd_test("releases/releases-new-finalize.trycmd")
.with_default_token();
}

#[test]
fn creates_release_with_numeric_project_id() {
TestManager::new()
.mock_endpoint(
MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/releases/")
.with_status(201)
.with_response_file("releases/get-release.json")
.with_matcher(Matcher::PartialJson(json!({
"version": "test-release",
"projects": [123],
}))),
)
.register_trycmd_test("releases/releases-new-numeric-project.trycmd")
.with_default_token();
}

#[test]
fn creates_release_with_multiple_numeric_project_ids() {
TestManager::new()
.mock_endpoint(
MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/releases/")
.with_status(201)
.with_response_file("releases/get-release.json")
.with_matcher(Matcher::PartialJson(json!({
"version": "test-release",
"projects": [123, 456],
}))),
)
.register_trycmd_test("releases/releases-new-multiple-numeric-projects.trycmd")
.with_default_token();
}

#[test]
fn creates_release_with_mixed_project_ids() {
TestManager::new()
.mock_endpoint(
MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/releases/")
.with_status(201)
.with_response_file("releases/get-release.json")
.with_matcher(Matcher::PartialJson(json!({
"version": "test-release",
"projects": [123, "my-project", 456],
}))),
)
.register_trycmd_test("releases/releases-new-mixed-projects.trycmd")
.with_default_token();
}