1use super::client::Client;
2use crate::api::{resolve_latest_comment, StateChange};
3use crate::error::{GiteeError, Result};
4use crate::models::{Comment, Issue, IssueState, Label};
5use crate::repo::Repo;
6use std::collections::HashSet;
7
8fn map_issue_state_err(err: GiteeError, changing_state: bool, owner: &str, number: &str) -> GiteeError {
11 if !changing_state {
12 return err;
13 }
14 let enterprise = matches!(
15 &err,
16 GiteeError::Api { message, .. } if message.to_lowercase().contains("project or enterprise")
17 );
18 if !enterprise {
19 return err;
20 }
21 GiteeError::Api {
22 status: 404,
23 message: format!(
24 "could not change issue {number} state (HTTP 404). Tried PATCH \
25 /repos/{owner}/issues/{number} with JSON {{repo, title, state}}. \
26 If this is an enterprise/project board issue, that personal/org \
27 endpoint may not apply — check --repo/--remote resolve to the \
28 right repository and that your token can access it. For raw \
29 `gitee api`, use that owner path with a JSON body (not form \
30 fields on /repos/{{owner}}/{{repo}}/issues/{{number}})."
31 ),
32 }
33}
34
35pub struct Issues<'a> {
36 client: &'a Client,
37 repo: &'a Repo,
38}
39
40#[derive(Default)]
41pub struct IssueFilter<'a> {
42 pub state: Option<&'a str>,
43 pub assignee: Option<&'a str>,
44 pub creator: Option<&'a str>,
46 pub limit: usize,
47}
48
49#[derive(Default)]
50pub struct CreateIssue<'a> {
51 pub title: &'a str,
52 pub body: Option<&'a str>,
53 pub assignee: Option<&'a str>,
54 pub labels: Option<&'a str>,
55 pub milestone_number: Option<i64>,
56 pub security_hole: bool,
57}
58
59#[derive(Default)]
63pub struct EditIssue<'a> {
64 pub title: Option<&'a str>,
65 pub body: Option<&'a str>,
66 pub assignee: Option<&'a str>,
67 pub labels: Option<&'a str>,
68 pub milestone_number: Option<i64>,
69 pub security_hole: Option<bool>,
70 pub state: Option<IssueState>,
76}
77
78impl Issues<'_> {
79 pub(crate) fn new<'a>(client: &'a Client, repo: &'a Repo) -> Issues<'a> {
80 Issues { client, repo }
81 }
82
83 pub fn list(&self, filter: &IssueFilter<'_>) -> Result<Vec<Issue>> {
84 let o = self.repo.owner.as_str();
85 let r = self.repo.name.as_str();
86 let mut q: Vec<(&str, String)> = Vec::new();
87 if let Some(s) = filter.state {
88 q.push(("state", s.to_string()));
89 }
90 if let Some(a) = filter.assignee {
91 q.push(("assignee", a.to_string()));
92 }
93 if let Some(c) = filter.creator {
94 q.push(("creator", c.to_string()));
95 }
96 let qref = Client::str_refs(&q);
97 let path = format!("/repos/{o}/{r}/issues");
98 self.client.get_paged(&path, &qref, filter.limit)
99 }
100
101 pub fn get(&self, number: &str) -> Result<Issue> {
102 let o = self.repo.owner.as_str();
103 let r = self.repo.name.as_str();
104 self.client
105 .get(&format!("/repos/{o}/{r}/issues/{number}"), &[])
106 }
107
108 pub fn create(&self, req: &CreateIssue<'_>) -> Result<Issue> {
111 let o = self.repo.owner.as_str();
112 let mut f: Vec<(&str, String)> = vec![
113 ("repo", self.repo.name.clone()),
114 ("title", req.title.to_string()),
115 ];
116 if let Some(b) = req.body {
117 f.push(("body", b.to_string()));
118 }
119 if let Some(a) = req.assignee {
120 f.push(("assignee", a.to_string()));
121 }
122 if let Some(l) = req.labels {
123 f.push(("labels", l.to_string()));
124 }
125 if let Some(n) = req.milestone_number {
126 f.push(("milestone", n.to_string()));
127 }
128 if req.security_hole {
129 f.push(("security_hole", "true".to_string()));
130 }
131 let form = Client::str_refs(&f);
132 self.client.post(&format!("/repos/{o}/issues"), &form)
133 }
134
135 pub fn set_state(&self, number: &str, state: IssueState) -> Result<Issue> {
142 let o = self.repo.owner.as_str();
143 let name = &self.repo.name;
144 let cur: Issue = self
145 .client
146 .get(&format!("/repos/{o}/{name}/issues/{number}"), &[])?;
147 let body = serde_json::json!({
148 "repo": self.repo.name,
149 "title": cur.title,
150 "state": state.as_str(),
151 });
152 self.client
153 .patch_json(&format!("/repos/{o}/issues/{number}"), &body)
154 .map_err(|e| map_issue_state_err(e, true, o, number))
155 }
156
157 pub fn set_state_idempotent(
161 &self,
162 number: &str,
163 target: IssueState,
164 ) -> Result<StateChange<Issue>> {
165 let o = self.repo.owner.as_str();
166 let name = &self.repo.name;
167 let cur: Issue = self
168 .client
169 .get(&format!("/repos/{o}/{name}/issues/{number}"), &[])?;
170 if cur.state == target {
171 return Ok(StateChange::Already(cur));
172 }
173 let body = serde_json::json!({
174 "repo": self.repo.name,
175 "title": cur.title,
176 "state": target.as_str(),
177 });
178 let issue: Issue = self
179 .client
180 .patch_json(&format!("/repos/{o}/issues/{number}"), &body)
181 .map_err(|e| map_issue_state_err(e, true, o, number))?;
182 Ok(StateChange::Changed(issue))
183 }
184
185 pub fn edit(&self, number: &str, req: &EditIssue<'_>) -> Result<Issue> {
188 let o = self.repo.owner.as_str();
189 let name = &self.repo.name;
190 let cur: Issue = self
191 .client
192 .get(&format!("/repos/{o}/{name}/issues/{number}"), &[])?;
193 let mut body = serde_json::json!({
194 "repo": self.repo.name,
195 "title": req.title.unwrap_or(&cur.title),
196 });
197 let map = body.as_object_mut().expect("json object");
198 if let Some(v) = req.body {
199 map.insert("body".into(), v.into());
200 }
201 if let Some(v) = req.assignee {
202 map.insert("assignee".into(), v.into());
203 }
204 if let Some(v) = req.labels {
205 map.insert("labels".into(), v.into());
206 }
207 if let Some(n) = req.milestone_number {
208 map.insert("milestone".into(), n.into());
209 }
210 if let Some(b) = req.security_hole {
211 map.insert("security_hole".into(), b.into());
212 }
213 if let Some(s) = req.state {
214 map.insert("state".into(), s.as_str().into());
215 }
216 self.client
217 .patch_json(&format!("/repos/{o}/issues/{number}"), &body)
218 .map_err(|e| map_issue_state_err(e, req.state.is_some(), o, number))
219 }
220
221 pub fn comment(&self, number: &str, body: &str) -> Result<Comment> {
222 let o = self.repo.owner.as_str();
223 let r = self.repo.name.as_str();
224 let f: Vec<(&str, String)> = vec![("body", body.to_string())];
225 let form = Client::str_refs(&f);
226 self.client
227 .post(&format!("/repos/{o}/{r}/issues/{number}/comments"), &form)
228 }
229
230 pub fn list_comments(&self, number: &str, limit: usize) -> Result<Vec<Comment>> {
233 let o = self.repo.owner.as_str();
234 let r = self.repo.name.as_str();
235 self.client.get_paged(
236 &format!("/repos/{o}/{r}/issues/{number}/comments"),
237 &[],
238 limit,
239 )
240 }
241
242 pub fn latest_comment(&self, number: &str, login: &str) -> Result<Comment> {
245 let comments = self.list_comments(number, usize::MAX)?;
246 resolve_latest_comment(&comments, login)
247 .cloned()
248 .ok_or_else(|| {
249 GiteeError::Usage(format!(
250 "no comment by '{login}' on issue {number}"
251 ))
252 })
253 }
254
255 pub fn update_comment(&self, id: i64, body: &str) -> Result<Comment> {
257 let o = self.repo.owner.as_str();
258 let r = self.repo.name.as_str();
259 let f: Vec<(&str, String)> = vec![("body", body.to_string())];
260 let form = Client::str_refs(&f);
261 self.client
262 .patch(&format!("/repos/{o}/{r}/issues/comments/{id}"), &form)
263 }
264
265 pub fn update_latest_comment(
267 &self,
268 number: &str,
269 login: &str,
270 body: &str,
271 ) -> Result<Comment> {
272 let comment = self.latest_comment(number, login)?;
273 self.update_comment(comment.id, body)
274 }
275
276 pub fn delete_comment(&self, id: i64) -> Result<StateChange<()>> {
279 let o = self.repo.owner.as_str();
280 let r = self.repo.name.as_str();
281 match self
282 .client
283 .delete_ok(&format!("/repos/{o}/{r}/issues/comments/{id}"))
284 {
285 Ok(()) => Ok(StateChange::Changed(())),
286 Err(GiteeError::NotFound(_)) => Ok(StateChange::Already(())),
287 Err(e) => Err(e),
288 }
289 }
290
291 pub fn delete_latest_comment(
293 &self,
294 number: &str,
295 login: &str,
296 ) -> Result<StateChange<()>> {
297 let comment = self.latest_comment(number, login)?;
298 self.delete_comment(comment.id)
299 }
300
301 pub fn list_labels(&self, number: &str) -> Result<Vec<Label>> {
304 let o = self.repo.owner.as_str();
305 let r = self.repo.name.as_str();
306 self.client
307 .get(&format!("/repos/{o}/{r}/issues/{number}/labels"), &[])
308 }
309
310 pub fn add_labels_idempotent(
313 &self,
314 number: &str,
315 names: &[&str],
316 ) -> Result<StateChange<Vec<Label>>> {
317 let o = self.repo.owner.as_str();
318 let r = self.repo.name.as_str();
319 let path = format!("/repos/{o}/{r}/issues/{number}/labels");
320 let current = self.list_labels(number)?;
321 let present: HashSet<String> = current.iter().map(|l| l.name.clone()).collect();
322 let mut seen = HashSet::new();
323 let missing: Vec<&str> = names
324 .iter()
325 .copied()
326 .filter(|n| !present.contains(*n) && seen.insert(*n))
327 .collect();
328 if missing.is_empty() {
329 return Ok(StateChange::Already(current));
330 }
331 let body = serde_json::Value::Array(
332 missing
333 .iter()
334 .map(|n| serde_json::Value::String((*n).to_string()))
335 .collect(),
336 );
337 let labels: Vec<Label> = self.client.post_json(&path, &body)?;
338 Ok(StateChange::Changed(labels))
339 }
340
341 pub fn remove_labels_idempotent(
344 &self,
345 number: &str,
346 names: &[&str],
347 ) -> Result<StateChange<()>> {
348 let o = self.repo.owner.as_str();
349 let r = self.repo.name.as_str();
350 let current = self.list_labels(number)?;
351 let present: HashSet<String> = current.iter().map(|l| l.name.clone()).collect();
352 let mut seen = HashSet::new();
353 let to_remove: Vec<&str> = names
354 .iter()
355 .copied()
356 .filter(|n| present.contains(*n) && seen.insert(*n))
357 .collect();
358 if to_remove.is_empty() {
359 return Ok(StateChange::Already(()));
360 }
361 let mut changed = false;
362 for name in to_remove {
363 match self
364 .client
365 .delete_ok(&format!("/repos/{o}/{r}/issues/{number}/labels/{name}"))
366 {
367 Ok(()) => changed = true,
368 Err(GiteeError::NotFound(_)) => {}
369 Err(e) => return Err(e),
370 }
371 }
372 if changed {
373 Ok(StateChange::Changed(()))
374 } else {
375 Ok(StateChange::Already(()))
376 }
377 }
378
379 pub fn link(&self, number: &str, tag: &str) -> Result<bool> {
382 let o = self.repo.owner.as_str();
383 let r = self.repo.name.as_str();
384 let cur: Issue = self
385 .client
386 .get(&format!("/repos/{o}/{r}/issues/{number}"), &[])?;
387 let old = cur.body.clone().unwrap_or_default();
388 if old.contains(tag) {
389 return Ok(false);
390 }
391 let new = format!("{old}\n\nLinked: {tag}");
392 let body = serde_json::json!({
393 "repo": self.repo.name,
394 "title": cur.title,
395 "body": new,
396 });
397 let _: Issue = self
398 .client
399 .patch_json(&format!("/repos/{o}/issues/{number}"), &body)?;
400 Ok(true)
401 }
402}