Skip to content

Commit f316566

Browse files
fix(releases): Serialize project IDs as integers (#3068)
### Description Serialize project IDs as integers when creating a new release. This restores compatibility with self-hosted Sentry versions which lack [this bug fix](https://linear.app/getsentry/review/fixapi-accept-project-ids-as-strings-in-organization-releases-endpoint-0c24c083a8ac) to properly handle project IDs serialized as strings. ### Issues - Resolves #3066 - Resolves [CLI-261](https://linear.app/getsentry/issue/CLI-261/send-project-ids-as-integers-for-release-endpoints) - See also getsentry/sentry#105038
1 parent 1662f2f commit f316566

File tree

7 files changed

+200
-0
lines changed

7 files changed

+200
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- 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)).
6+
37
## 3.0.3
48

59
### Fixes

src/api/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod data_types;
1010
mod encoding;
1111
mod errors;
1212
mod pagination;
13+
mod serialization;
1314

1415
use std::borrow::Cow;
1516
use std::cell::RefCell;
@@ -1530,6 +1531,7 @@ pub struct AuthInfo {
15301531
#[derive(Debug, Serialize, Default)]
15311532
pub struct NewRelease {
15321533
pub version: String,
1534+
#[serde(serialize_with = "serialization::serialize_id_slug_list")]
15331535
pub projects: Vec<String>,
15341536
#[serde(skip_serializing_if = "Option::is_none")]
15351537
pub url: Option<String>,

src/api/serialization.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//! This module contains some custom serialization logic for the API.
2+
use std::sync::LazyLock;
3+
4+
use regex::Regex;
5+
use serde::ser::SerializeSeq as _;
6+
use serde::{Serialize, Serializer};
7+
8+
/// A container for either a numeric ID or an alphanumeric slug.
9+
///
10+
/// IDs are serialized as integers, while slugs are serialized as strings.
11+
#[derive(Serialize)]
12+
#[serde(untagged)]
13+
enum IdSlug<'s> {
14+
Id(i64),
15+
Slug(&'s str),
16+
}
17+
18+
/// Serializes a sequence of strings, which may contain either numeric IDs or alphanumeric slugs.
19+
///
20+
/// We check each element in the sequence. If the element only contains digits and can be parsed as a 64-bit signed integer,
21+
/// we consider the value to be an ID. Otherwise, we consider the value to be a slug.
22+
///
23+
/// IDs are serialized as integers, while slugs are serialized as strings.
24+
pub fn serialize_id_slug_list<I, S>(list: I, serializer: S) -> Result<S::Ok, S::Error>
25+
where
26+
I: IntoIterator,
27+
I::Item: AsRef<str>,
28+
S: Serializer,
29+
{
30+
let mut seq = serializer.serialize_seq(None)?;
31+
for item in list {
32+
let item = item.as_ref();
33+
let id_slug = IdSlug::from(&item);
34+
seq.serialize_element(&id_slug)?;
35+
}
36+
seq.end()
37+
}
38+
39+
impl<'a, S> From<&'a S> for IdSlug<'a>
40+
where
41+
S: AsRef<str>,
42+
{
43+
/// Convert from a string reference to an IdSlug.
44+
///
45+
/// If the string contains only digits and can be parsed as a 64-bit signed integer,
46+
/// we consider the value to be an ID. Otherwise, we consider the value to be a slug.
47+
fn from(value: &'a S) -> Self {
48+
/// Project ID regex
49+
///
50+
/// Project IDs always contain only digits.
51+
static PROJECT_ID_REGEX: LazyLock<Regex> =
52+
LazyLock::new(|| Regex::new(r"^\d+$").expect("regex is valid"));
53+
54+
let value = value.as_ref();
55+
56+
PROJECT_ID_REGEX
57+
.is_match(value)
58+
.then(|| value.parse().ok().map(IdSlug::Id))
59+
.flatten()
60+
.unwrap_or(IdSlug::Slug(value))
61+
}
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use super::*;
67+
68+
/// A test struct which serializes with serialize_id_slug_list
69+
#[derive(Serialize)]
70+
struct IdSlugListSerializerTest<const N: usize> {
71+
#[serde(serialize_with = "serialize_id_slug_list")]
72+
value: [&'static str; N],
73+
}
74+
75+
#[test]
76+
fn test_serialize_id_slug_list_empty() {
77+
let to_serialize = IdSlugListSerializerTest { value: [] };
78+
79+
let serialized = serde_json::to_string(&to_serialize).unwrap();
80+
let expected = serde_json::json!({ "value": [] }).to_string();
81+
82+
assert_eq!(serialized, expected)
83+
}
84+
85+
#[test]
86+
fn test_serialize_id_slug_list_single_id() {
87+
let to_serialize = IdSlugListSerializerTest { value: ["123"] };
88+
89+
let serialized = serde_json::to_string(&to_serialize).unwrap();
90+
let expected = serde_json::json!({ "value": [123] }).to_string();
91+
92+
assert_eq!(serialized, expected)
93+
}
94+
95+
#[test]
96+
fn test_serialize_id_slug_list_single_slug() {
97+
let to_serialize = IdSlugListSerializerTest { value: ["abc"] };
98+
99+
let serialized = serde_json::to_string(&to_serialize).unwrap();
100+
let expected = serde_json::json!({ "value": ["abc"] }).to_string();
101+
102+
assert_eq!(serialized, expected)
103+
}
104+
105+
#[test]
106+
fn test_serialize_id_slug_list_multiple_ids_and_slugs() {
107+
let to_serialize = IdSlugListSerializerTest {
108+
value: ["123", "abc", "456", "whatever"],
109+
};
110+
111+
let serialized = serde_json::to_string(&to_serialize).unwrap();
112+
let expected = serde_json::json!({ "value": [123, "abc", 456, "whatever"] }).to_string();
113+
114+
assert_eq!(serialized, expected)
115+
}
116+
117+
/// Slugs of "-0" are possible. This test ensures that we serialize "-0" as a slug,
118+
/// rather than as an ID 0.
119+
#[test]
120+
fn test_serialize_id_slug_minus_zero_edge_case() {
121+
let to_serialize = IdSlugListSerializerTest { value: ["-0"] };
122+
123+
let serialized = serde_json::to_string(&to_serialize).unwrap();
124+
let expected = serde_json::json!({ "value": ["-0"] }).to_string();
125+
126+
assert_eq!(serialized, expected)
127+
}
128+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
```
2+
$ sentry-cli releases new -p 123 -p my-project -p 456 test-release
3+
? success
4+
Created release test-release
5+
6+
```
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
```
2+
$ sentry-cli releases new -p 123 -p 456 test-release
3+
? success
4+
Created release test-release
5+
6+
```
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
```
2+
$ sentry-cli releases new -p 123 test-release
3+
? success
4+
Created release test-release
5+
6+
```

tests/integration/releases/new.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,51 @@ fn creates_release_which_is_instantly_finalized() {
123123
.register_trycmd_test("releases/releases-new-finalize.trycmd")
124124
.with_default_token();
125125
}
126+
127+
#[test]
128+
fn creates_release_with_numeric_project_id() {
129+
TestManager::new()
130+
.mock_endpoint(
131+
MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/releases/")
132+
.with_status(201)
133+
.with_response_file("releases/get-release.json")
134+
.with_matcher(Matcher::PartialJson(json!({
135+
"version": "test-release",
136+
"projects": [123],
137+
}))),
138+
)
139+
.register_trycmd_test("releases/releases-new-numeric-project.trycmd")
140+
.with_default_token();
141+
}
142+
143+
#[test]
144+
fn creates_release_with_multiple_numeric_project_ids() {
145+
TestManager::new()
146+
.mock_endpoint(
147+
MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/releases/")
148+
.with_status(201)
149+
.with_response_file("releases/get-release.json")
150+
.with_matcher(Matcher::PartialJson(json!({
151+
"version": "test-release",
152+
"projects": [123, 456],
153+
}))),
154+
)
155+
.register_trycmd_test("releases/releases-new-multiple-numeric-projects.trycmd")
156+
.with_default_token();
157+
}
158+
159+
#[test]
160+
fn creates_release_with_mixed_project_ids() {
161+
TestManager::new()
162+
.mock_endpoint(
163+
MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/releases/")
164+
.with_status(201)
165+
.with_response_file("releases/get-release.json")
166+
.with_matcher(Matcher::PartialJson(json!({
167+
"version": "test-release",
168+
"projects": [123, "my-project", 456],
169+
}))),
170+
)
171+
.register_trycmd_test("releases/releases-new-mixed-projects.trycmd")
172+
.with_default_token();
173+
}

0 commit comments

Comments
 (0)