Skip to main content

gitee_cli_rs/api/
issues.rs

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
8/// When a state write hits Gitee's opaque enterprise/project 404, surface a
9/// clearer hint than a bare path. Non-state edits keep the original error.
10fn 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    /// Creator login — server-side filter per the v5 swagger.
45    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/// Fields for `issue edit`. All optional; only `Some` fields are sent. `labels`
60/// arrives comma-joined from the handler. Param names per the v5 swagger
61/// (milestone takes the milestone number as integer).
62#[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    /// Lifecycle state write: `open` / `progressing` / `closed` only.
71    ///
72    /// Gitee v5 rejects `rejected` with HTTP 400
73    /// (`state does not have a valid value`). `closed` always maps to the
74    /// Chinese board state 已完成 — there is no close-reason field for 拒绝.
75    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    /// Gitee quirk: issue creation is POST /repos/{owner}/issues with `repo` as a form field
109    /// (no repo segment in the path).
110    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    /// Gitee quirk: state changes are PATCH /repos/{owner}/issues/{number} with a JSON body
136    /// `{repo, title, state}`. The current title must be echoed back or Gitee blanks it.
137    ///
138    /// Writable `state` values observed on the live v5 API: `open`,
139    /// `progressing`, `closed`. Sending `rejected` yields 400. Closing always
140    /// becomes issue_state 已完成 (not 拒绝 / wontfix).
141    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    /// Idempotent state change: GETs the current issue first; if it is already
158    /// in `target`, returns `StateChange::Already(issue)` without a PATCH.
159    /// Otherwise applies the PATCH and returns `StateChange::Changed(issue)`.
160    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    /// PATCH metadata. Same JSON quirk as set_state: `repo` and the current
186    /// `title` must always be echoed; only `Some` fields are added.
187    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    /// List comments on an issue. `limit` caps how many are returned (paging
231    /// via `get_paged`).
232    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    /// Resolve `--last`: the comment by `login` with the latest `created_at`.
243    /// Paginates fully (independent of list `--limit`). Nothing found ⇒ Usage.
244    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    /// PATCH an issue comment by integer `id`.
256    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    /// `--last` edit: resolve `login`'s most-recent comment on the issue, then PATCH.
266    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    /// DELETE an issue comment by integer `id`. Already gone (404) ⇒ `Already`
277    /// (idempotent success, silent at the cmd layer).
278    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    /// `--last` delete: resolve `login`'s most-recent comment on the issue, then DELETE.
292    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    /// Labels currently attached to an issue. Gitee's issue-labels GET is not
302    /// paginated (unlike PR labels).
303    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    /// Add labels without replacing the rest. GETs current membership first;
311    /// POSTs only names that are missing. Already-present ⇒ `Already` (no POST).
312    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    /// Remove only the named labels. GETs current membership first; DELETEs
342    /// only names that are present. Absent names and DELETE 404 ⇒ no-op.
343    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    /// GET the issue first; if `body` already contains `tag`, returns `Ok(false)` without PATCH.
380    /// Otherwise PATCH JSON `{repo, title, body}` with appended `Linked: {tag}` and returns `Ok(true)`.
381    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}