1use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
4use serde::Deserialize;
5
6#[derive(Debug, Deserialize)]
8pub struct Issue {
9 pub iid: u64,
10 pub title: String,
11 pub description: Option<String>,
12 pub state: String,
13 pub web_url: String,
14}
15
16pub struct Client {
18 http: reqwest::blocking::Client,
19 base_url: String,
20 project_path: String,
21}
22
23impl Client {
24 pub fn from_project_url(
29 url: &str,
30 ) -> Result<Self, Box<dyn std::error::Error>> {
31 let token = std::env::var("GITLAB_TOKEN").ok().or_else(|| {
32 crate::config::load()
33 .ok()
34 .and_then(|c| c.gitlab.map(|g| g.access_token))
35 });
36 let token = token.ok_or(
37 "GitLab token not found; set GITLAB_TOKEN \
38 or run 'hs-relmon config'",
39 )?;
40 let (base_url, project_path) = parse_project_url(url)?;
41 Self::new(&base_url, &project_path, &token)
42 }
43
44 pub fn new(
46 base_url: &str,
47 project_path: &str,
48 token: &str,
49 ) -> Result<Self, Box<dyn std::error::Error>> {
50 let mut headers = HeaderMap::new();
51 headers.insert(
52 HeaderName::from_static("private-token"),
53 HeaderValue::from_str(token)?,
54 );
55 let http = reqwest::blocking::Client::builder()
56 .user_agent("hs-relmon/0.2.0")
57 .default_headers(headers)
58 .build()?;
59 Ok(Self {
60 http,
61 base_url: base_url.trim_end_matches('/').to_string(),
62 project_path: project_path.to_string(),
63 })
64 }
65
66 pub fn create_issue(
68 &self,
69 title: &str,
70 description: Option<&str>,
71 labels: Option<&str>,
72 ) -> Result<Issue, Box<dyn std::error::Error>> {
73 let mut body = serde_json::json!({"title": title});
74 if let Some(desc) = description {
75 body["description"] = desc.into();
76 }
77 if let Some(labels) = labels {
78 body["labels"] = labels.into();
79 }
80
81 let resp = self.http.post(&self.issues_url()).json(&body).send()?;
82 check_response(resp)
83 }
84
85 pub fn list_issues(
87 &self,
88 label: &str,
89 state: &str,
90 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
91 let resp = self
92 .http
93 .get(&self.issues_url())
94 .query(&[("labels", label), ("state", state)])
95 .send()?;
96 if !resp.status().is_success() {
97 let status = resp.status();
98 let text = resp.text()?;
99 return Err(
100 format!("GitLab API error {status}: {text}").into(),
101 );
102 }
103 Ok(resp.json()?)
104 }
105
106 pub fn edit_issue(
108 &self,
109 iid: u64,
110 updates: &IssueUpdate,
111 ) -> Result<Issue, Box<dyn std::error::Error>> {
112 let body = serde_json::to_value(updates)?;
113 let resp = self
114 .http
115 .put(&format!("{}/{iid}", self.issues_url()))
116 .json(&body)
117 .send()?;
118 check_response(resp)
119 }
120
121 fn issues_url(&self) -> String {
122 let encoded = self.project_path.replace('/', "%2F");
123 format!(
124 "{}/api/v4/projects/{}/issues",
125 self.base_url, encoded
126 )
127 }
128}
129
130#[derive(Debug, Default, serde::Serialize)]
132pub struct IssueUpdate {
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub title: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub description: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub add_labels: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub remove_labels: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub state_event: Option<String>,
143}
144
145fn check_response(
146 resp: reqwest::blocking::Response,
147) -> Result<Issue, Box<dyn std::error::Error>> {
148 if !resp.status().is_success() {
149 let status = resp.status();
150 let text = resp.text()?;
151 return Err(format!("GitLab API error {status}: {text}").into());
152 }
153 Ok(resp.json()?)
154}
155
156pub fn validate_token(
158 base_url: &str,
159 token: &str,
160) -> Result<bool, Box<dyn std::error::Error>> {
161 let mut headers = HeaderMap::new();
162 headers.insert(
163 HeaderName::from_static("private-token"),
164 HeaderValue::from_str(token)?,
165 );
166 let client = reqwest::blocking::Client::builder()
167 .user_agent("hs-relmon/0.2.0")
168 .default_headers(headers)
169 .build()?;
170 let url = format!(
171 "{}/api/v4/user",
172 base_url.trim_end_matches('/')
173 );
174 let resp = client.get(&url).send()?;
175 Ok(resp.status().is_success())
176}
177
178pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
183 let url = url.trim_end_matches('/');
184 let rest = url
185 .strip_prefix("https://")
186 .or_else(|| url.strip_prefix("http://"))
187 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
188
189 let slash = rest
190 .find('/')
191 .ok_or_else(|| format!("no project path in URL: {url}"))?;
192
193 let host = &rest[..slash];
194 let path = &rest[slash + 1..];
195
196 if path.is_empty() {
197 return Err(format!("no project path in URL: {url}"));
198 }
199
200 let scheme = if url.starts_with("https://") {
201 "https"
202 } else {
203 "http"
204 };
205 Ok((format!("{scheme}://{host}"), path.to_string()))
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn test_parse_project_url() {
214 let (base, path) = parse_project_url(
215 "https://gitlab.com/CentOS/Hyperscale/rpms/perf",
216 )
217 .unwrap();
218 assert_eq!(base, "https://gitlab.com");
219 assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
220 }
221
222 #[test]
223 fn test_parse_project_url_trailing_slash() {
224 let (base, path) =
225 parse_project_url("https://gitlab.com/group/project/")
226 .unwrap();
227 assert_eq!(base, "https://gitlab.com");
228 assert_eq!(path, "group/project");
229 }
230
231 #[test]
232 fn test_parse_project_url_http() {
233 let (base, path) = parse_project_url(
234 "http://gitlab.example.com/group/project",
235 )
236 .unwrap();
237 assert_eq!(base, "http://gitlab.example.com");
238 assert_eq!(path, "group/project");
239 }
240
241 #[test]
242 fn test_parse_project_url_no_scheme() {
243 assert!(
244 parse_project_url("gitlab.com/group/project").is_err()
245 );
246 }
247
248 #[test]
249 fn test_parse_project_url_no_path() {
250 assert!(parse_project_url("https://gitlab.com/").is_err());
251 assert!(parse_project_url("https://gitlab.com").is_err());
252 }
253
254 #[test]
255 fn test_issues_url() {
256 let client = Client::new(
257 "https://gitlab.com",
258 "CentOS/Hyperscale/rpms/perf",
259 "fake-token",
260 )
261 .unwrap();
262 assert_eq!(
263 client.issues_url(),
264 "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
265 );
266 }
267
268 #[test]
269 fn test_issue_update_serialization() {
270 let update = IssueUpdate {
271 title: Some("new title".into()),
272 add_labels: Some("bug".into()),
273 ..Default::default()
274 };
275 let json = serde_json::to_value(&update).unwrap();
276 assert_eq!(json["title"], "new title");
277 assert_eq!(json["add_labels"], "bug");
278 assert!(json.get("description").is_none());
280 assert!(json.get("state_event").is_none());
281 }
282
283 #[test]
284 fn test_issue_deserialize() {
285 let json = r#"{
286 "iid": 42,
287 "title": "Test issue",
288 "description": "Some description",
289 "state": "opened",
290 "web_url": "https://gitlab.com/group/project/-/issues/42"
291 }"#;
292 let issue: Issue = serde_json::from_str(json).unwrap();
293 assert_eq!(issue.iid, 42);
294 assert_eq!(issue.title, "Test issue");
295 assert_eq!(issue.description.as_deref(), Some("Some description"));
296 assert_eq!(issue.state, "opened");
297 }
298
299 #[test]
300 fn test_issue_deserialize_null_description() {
301 let json = r#"{
302 "iid": 1,
303 "title": "t",
304 "description": null,
305 "state": "opened",
306 "web_url": "u"
307 }"#;
308 let issue: Issue = serde_json::from_str(json).unwrap();
309 assert!(issue.description.is_none());
310 }
311}