Skip to main content

gitee_cli_rs/api/
pulls.rs

1use super::client::Client;
2use crate::api::{resolve_latest_comment, StateChange};
3use crate::error::{GiteeError, Result};
4use crate::models::{
5    FileDiff, Label, MergeMethod, PrComment, PrCommentKind, PrCommit, PrState, PullRequest,
6    UserAssignee,
7};
8use crate::repo::Repo;
9use std::collections::HashSet;
10
11pub struct Pulls<'a> {
12    client: &'a Client,
13    repo: &'a Repo,
14}
15
16#[derive(Default)]
17pub struct PrFilter<'a> {
18    pub state: Option<&'a str>,
19    pub author: Option<&'a str>,
20    /// 评审者 (reviewer) login — server-side filter per the v5 swagger.
21    pub assignee: Option<&'a str>,
22    /// 测试者 (tester) login — server-side filter per the v5 swagger.
23    pub tester: Option<&'a str>,
24    pub limit: usize,
25}
26
27#[derive(Default)]
28pub struct CreatePr<'a> {
29    pub title: &'a str,
30    pub head: &'a str,
31    pub base: &'a str,
32    pub body: Option<&'a str>,
33    /// Comma-joined by the handler (v5 takes one `labels` string).
34    pub labels: Option<&'a str>,
35    pub assignees: Option<&'a str>,
36    pub testers: Option<&'a str>,
37    pub milestone_number: Option<i64>,
38    /// Linked issue ident; paired with close_related_issue=true so the issue
39    /// closes on merge (swagger: `issue` is the string ident;
40    /// `close_related_issue` is boolean).
41    pub issue: Option<&'a str>,
42    pub close_related_issue: bool,
43    /// Mark the new PR as a draft (`draft=true` form field). Default false —
44    /// omitted from the form when false (server default).
45    pub draft: bool,
46}
47
48/// Fields for `pr edit`. All optional; only `Some` fields are sent, so unset
49/// values are never blanked. `labels`/`assignees`/`testers` arrive pre-joined
50/// (comma-separated) by the handler. Param names per the v5 swagger, except
51/// `assignees`/`testers` which the PATCH swagger omits — names follow the PR
52/// create endpoint; live round-trip is unverified (no mutations in tests).
53#[derive(Default)]
54pub struct EditPr<'a> {
55    pub title: Option<&'a str>,
56    pub body: Option<&'a str>,
57    pub labels: Option<&'a str>,
58    pub assignees: Option<&'a str>,
59    pub testers: Option<&'a str>,
60    pub milestone_number: Option<i64>,
61}
62
63/// Optional form fields for `Pulls::comment` line/diff comments (`commit_id`,
64/// `path`, `position`). Omitted fields are not sent.
65#[derive(Default)]
66pub struct PrCommentPositional<'a> {
67    pub path: Option<&'a str>,
68    pub position: Option<i64>,
69    pub commit_id: Option<&'a str>,
70}
71
72/// Filters for `Pulls::list_comments`. `kind` is the CLI vocabulary; ops maps
73/// it to Gitee's `comment_type` query (`diff_comment` | `pr_comment`).
74#[derive(Default)]
75pub struct PrCommentFilter {
76    pub kind: Option<PrCommentKind>,
77    pub limit: usize,
78}
79
80impl Pulls<'_> {
81    pub(crate) fn new<'a>(client: &'a Client, repo: &'a Repo) -> Pulls<'a> {
82        Pulls { client, repo }
83    }
84
85    pub fn list(&self, filter: &PrFilter<'_>) -> Result<Vec<PullRequest>> {
86        let o = self.repo.owner.as_str();
87        let r = self.repo.name.as_str();
88        let mut q: Vec<(&str, String)> = Vec::new();
89        if let Some(s) = filter.state {
90            q.push(("state", s.to_string()));
91        }
92        if let Some(a) = filter.author {
93            q.push(("author", a.to_string()));
94        }
95        if let Some(a) = filter.assignee {
96            q.push(("assignee", a.to_string()));
97        }
98        if let Some(t) = filter.tester {
99            q.push(("tester", t.to_string()));
100        }
101        let qref = Client::str_refs(&q);
102        let path = format!("/repos/{o}/{r}/pulls");
103        self.client.get_paged(&path, &qref, filter.limit)
104    }
105
106    pub fn get(&self, number: i64) -> Result<PullRequest> {
107        let o = self.repo.owner.as_str();
108        let r = self.repo.name.as_str();
109        self.client
110            .get(&format!("/repos/{o}/{r}/pulls/{number}"), &[])
111    }
112
113    /// Whether a pull request has been merged. Maps Gitee's check endpoint:
114    /// 204 ⇒ merged, 404 ⇒ not merged.
115    pub fn is_merged(&self, number: i64) -> Result<bool> {
116        let o = self.repo.owner.as_str();
117        let r = self.repo.name.as_str();
118        let path = format!("/repos/{o}/{r}/pulls/{number}/merge");
119        match self.client.get_ok(&path) {
120            Ok(()) => Ok(true),
121            Err(GiteeError::NotFound(_)) => Ok(false),
122            Err(e) => Err(e),
123        }
124    }
125
126    pub fn files(&self, number: i64) -> Result<Vec<FileDiff>> {
127        let o = self.repo.owner.as_str();
128        let r = self.repo.name.as_str();
129        self.client
130            .get(&format!("/repos/{o}/{r}/pulls/{number}/files"), &[])
131    }
132
133    /// Commits in a pull request. Not paginated on the server (hard cap 250);
134    /// `--limit` is applied client-side after the GET.
135    pub fn commits(&self, number: i64, limit: usize) -> Result<Vec<PrCommit>> {
136        let o = self.repo.owner.as_str();
137        let r = self.repo.name.as_str();
138        let all: Vec<PrCommit> = self
139            .client
140            .get(&format!("/repos/{o}/{r}/pulls/{number}/commits"), &[])?;
141        Ok(all.into_iter().take(limit).collect())
142    }
143
144    pub fn create(&self, req: &CreatePr<'_>) -> Result<PullRequest> {
145        let o = self.repo.owner.as_str();
146        let r = self.repo.name.as_str();
147        let mut f: Vec<(&str, String)> = vec![
148            ("title", req.title.to_string()),
149            ("head", req.head.to_string()),
150            ("base", req.base.to_string()),
151        ];
152        if let Some(b) = req.body {
153            f.push(("body", b.to_string()));
154        }
155        if let Some(v) = req.labels {
156            f.push(("labels", v.to_string()));
157        }
158        if let Some(v) = req.assignees {
159            f.push(("assignees", v.to_string()));
160        }
161        if let Some(v) = req.testers {
162            f.push(("testers", v.to_string()));
163        }
164        if let Some(n) = req.milestone_number {
165            f.push(("milestone_number", n.to_string()));
166        }
167        if let Some(i) = req.issue {
168            f.push(("issue", i.to_string()));
169        }
170        if req.close_related_issue {
171            f.push(("close_related_issue", "true".to_string()));
172        }
173        if req.draft {
174            f.push(("draft", Client::bool_str(true).to_string()));
175        }
176        let form = Client::str_refs(&f);
177        self.client.post(&format!("/repos/{o}/{r}/pulls"), &form)
178    }
179
180    pub fn merge(&self, number: i64, method: MergeMethod, close_related_issue: bool) -> Result<()> {
181        let o = self.repo.owner.as_str();
182        let r = self.repo.name.as_str();
183        let close = Client::bool_str(close_related_issue);
184        let f: Vec<(&str, String)> = vec![
185            ("merge_method", method.as_str().to_string()),
186            ("close_related_issue", close.to_string()),
187        ];
188        let form = Client::str_refs(&f);
189        self.client
190            .put_ok(&format!("/repos/{o}/{r}/pulls/{number}/merge"), &form)
191    }
192
193    /// Idempotent merge: if the PR is already merged, return `Already` without
194    /// calling the merge endpoint. Otherwise merge and return `Changed`.
195    pub fn merge_idempotent(
196        &self,
197        number: i64,
198        method: MergeMethod,
199        close_related_issue: bool,
200    ) -> Result<StateChange<()>> {
201        let cur: PullRequest = self.get(number)?;
202        if cur.state == PrState::Merged {
203            return Ok(StateChange::Already(()));
204        }
205        self.merge(number, method, close_related_issue)?;
206        Ok(StateChange::Changed(()))
207    }
208
209    pub fn comment(
210        &self,
211        number: i64,
212        body: &str,
213        positional: &PrCommentPositional<'_>,
214    ) -> Result<PrComment> {
215        let o = self.repo.owner.as_str();
216        let r = self.repo.name.as_str();
217        let mut f: Vec<(&str, String)> = vec![("body", body.to_string())];
218        if let Some(p) = positional.path {
219            f.push(("path", p.to_string()));
220        }
221        if let Some(pos) = positional.position {
222            f.push(("position", pos.to_string()));
223        }
224        if let Some(c) = positional.commit_id {
225            f.push(("commit_id", c.to_string()));
226        }
227        let form = Client::str_refs(&f);
228        self.client
229            .post(&format!("/repos/{o}/{r}/pulls/{number}/comments"), &form)
230    }
231
232    /// List comments on a pull request. Optional `kind` maps to Gitee's
233    /// `comment_type` query (`diff_comment` | `pr_comment`).
234    pub fn list_comments(
235        &self,
236        number: i64,
237        filter: &PrCommentFilter,
238    ) -> Result<Vec<PrComment>> {
239        let o = self.repo.owner.as_str();
240        let r = self.repo.name.as_str();
241        let mut q: Vec<(&str, String)> = Vec::new();
242        if let Some(k) = filter.kind {
243            q.push(("comment_type", k.as_api_str().to_string()));
244        }
245        let qref = Client::str_refs(&q);
246        self.client.get_paged(
247            &format!("/repos/{o}/{r}/pulls/{number}/comments"),
248            &qref,
249            filter.limit,
250        )
251    }
252
253    /// Resolve `--last`: the comment by `login` with the latest `created_at`.
254    /// Paginates fully (independent of list `--limit`). Nothing found ⇒ Usage.
255    pub fn latest_comment(&self, number: i64, login: &str) -> Result<PrComment> {
256        let comments = self.list_comments(
257            number,
258            &PrCommentFilter {
259                kind: None,
260                limit: usize::MAX,
261            },
262        )?;
263        resolve_latest_comment(&comments, login)
264            .cloned()
265            .ok_or_else(|| {
266                GiteeError::Usage(format!(
267                    "no comment by '{login}' on pull request {number}"
268                ))
269            })
270    }
271
272    /// PATCH a pull-request comment by integer `id`.
273    pub fn update_comment(&self, id: i64, body: &str) -> Result<PrComment> {
274        let o = self.repo.owner.as_str();
275        let r = self.repo.name.as_str();
276        let f: Vec<(&str, String)> = vec![("body", body.to_string())];
277        let form = Client::str_refs(&f);
278        self.client
279            .patch(&format!("/repos/{o}/{r}/pulls/comments/{id}"), &form)
280    }
281
282    /// `--last` edit: resolve `login`'s most-recent comment on the PR, then PATCH.
283    pub fn update_latest_comment(
284        &self,
285        number: i64,
286        login: &str,
287        body: &str,
288    ) -> Result<PrComment> {
289        let comment = self.latest_comment(number, login)?;
290        self.update_comment(comment.id, body)
291    }
292
293    /// DELETE a pull-request comment by integer `id`. Already gone (404) ⇒ `Already`
294    /// (idempotent success, silent at the cmd layer).
295    pub fn delete_comment(&self, id: i64) -> Result<StateChange<()>> {
296        let o = self.repo.owner.as_str();
297        let r = self.repo.name.as_str();
298        match self
299            .client
300            .delete_ok(&format!("/repos/{o}/{r}/pulls/comments/{id}"))
301        {
302            Ok(()) => Ok(StateChange::Changed(())),
303            Err(GiteeError::NotFound(_)) => Ok(StateChange::Already(())),
304            Err(e) => Err(e),
305        }
306    }
307
308    /// `--last` delete: resolve `login`'s most-recent comment on the PR, then DELETE.
309    pub fn delete_latest_comment(
310        &self,
311        number: i64,
312        login: &str,
313    ) -> Result<StateChange<()>> {
314        let comment = self.latest_comment(number, login)?;
315        self.delete_comment(comment.id)
316    }
317
318    /// Gitee quirk: POST /review returns an empty body on success; `force` is sent only when true.
319    pub fn approve(&self, number: i64, force: bool) -> Result<()> {
320        let o = self.repo.owner.as_str();
321        let r = self.repo.name.as_str();
322        let mut f: Vec<(&str, String)> = Vec::new();
323        if force {
324            f.push(("force", "true".to_string()));
325        }
326        let form = Client::str_refs(&f);
327        self.client
328            .post_ok(&format!("/repos/{o}/{r}/pulls/{number}/review"), &form)
329    }
330
331    /// Gitee quirk: POST /test returns an empty body on success; force sent only when true.
332    pub fn test(&self, number: i64, force: bool) -> Result<()> {
333        let o = self.repo.owner.as_str();
334        let r = self.repo.name.as_str();
335        let mut f: Vec<(&str, String)> = Vec::new();
336        if force {
337            f.push(("force", "true".to_string()));
338        }
339        let form = Client::str_refs(&f);
340        self.client
341            .post_ok(&format!("/repos/{o}/{r}/pulls/{number}/test"), &form)
342    }
343
344    /// Gitee accepts form-encoded PATCH on pull requests.
345    pub fn set_state(&self, number: i64, state: PrState) -> Result<PullRequest> {
346        let o = self.repo.owner.as_str();
347        let r = self.repo.name.as_str();
348        let f: Vec<(&str, String)> = vec![("state", state.as_str().to_string())];
349        let form = Client::str_refs(&f);
350        self.client
351            .patch(&format!("/repos/{o}/{r}/pulls/{number}"), &form)
352    }
353
354    /// Idempotent state change: GET first; if already in `target`, return
355    /// `Already` without PATCHing. Otherwise PATCH and return `Changed`.
356    pub fn set_state_idempotent(
357        &self,
358        number: i64,
359        target: PrState,
360    ) -> Result<StateChange<PullRequest>> {
361        let cur: PullRequest = self.get(number)?;
362        if cur.state == target {
363            return Ok(StateChange::Already(cur));
364        }
365        let pr = self.set_state(number, target)?;
366        Ok(StateChange::Changed(pr))
367    }
368
369    /// Set the draft flag via form-encoded PATCH (`draft` boolean).
370    pub fn set_draft(&self, number: i64, draft: bool) -> Result<PullRequest> {
371        let o = self.repo.owner.as_str();
372        let r = self.repo.name.as_str();
373        let f: Vec<(&str, String)> = vec![("draft", Client::bool_str(draft).to_string())];
374        let form = Client::str_refs(&f);
375        self.client
376            .patch(&format!("/repos/{o}/{r}/pulls/{number}"), &form)
377    }
378
379    /// Idempotent draft toggle: GET first; skip PATCH when already at target.
380    /// Missing `draft` on the current PR is treated as `false`.
381    pub fn set_draft_idempotent(
382        &self,
383        number: i64,
384        draft: bool,
385    ) -> Result<StateChange<PullRequest>> {
386        let cur: PullRequest = self.get(number)?;
387        if cur.draft.unwrap_or(false) == draft {
388            return Ok(StateChange::Already(cur));
389        }
390        let pr = self.set_draft(number, draft)?;
391        Ok(StateChange::Changed(pr))
392    }
393
394    /// PATCH metadata. Only `Some` fields become form entries.
395    pub fn edit(&self, number: i64, req: &EditPr<'_>) -> Result<PullRequest> {
396        let o = self.repo.owner.as_str();
397        let r = self.repo.name.as_str();
398        let mut f: Vec<(&str, String)> = Vec::new();
399        if let Some(v) = req.title {
400            f.push(("title", v.to_string()));
401        }
402        if let Some(v) = req.body {
403            f.push(("body", v.to_string()));
404        }
405        if let Some(v) = req.labels {
406            f.push(("labels", v.to_string()));
407        }
408        if let Some(v) = req.assignees {
409            f.push(("assignees", v.to_string()));
410        }
411        if let Some(v) = req.testers {
412            f.push(("testers", v.to_string()));
413        }
414        if let Some(n) = req.milestone_number {
415            f.push(("milestone_number", n.to_string()));
416        }
417        let form = Client::str_refs(&f);
418        self.client
419            .patch(&format!("/repos/{o}/{r}/pulls/{number}"), &form)
420    }
421
422    /// Labels currently attached to a PR. Uses `get_paged` (endpoint is paginated).
423    pub fn list_labels(&self, number: i64) -> Result<Vec<Label>> {
424        let o = self.repo.owner.as_str();
425        let r = self.repo.name.as_str();
426        self.client.get_paged(
427            &format!("/repos/{o}/{r}/pulls/{number}/labels"),
428            &[],
429            usize::MAX,
430        )
431    }
432
433    /// Reviewers (审查人) on a PR — read from `assignees[]` on GET PR.
434    pub fn list_assignees(&self, number: i64) -> Result<Vec<UserAssignee>> {
435        Ok(self.get(number)?.assignees.unwrap_or_default())
436    }
437
438    /// Testers (测试人) on a PR — read from `testers[]` on GET PR.
439    pub fn list_testers(&self, number: i64) -> Result<Vec<UserAssignee>> {
440        Ok(self.get(number)?.testers.unwrap_or_default())
441    }
442
443    /// Add reviewers without replacing the rest. GETs current membership first;
444    /// POSTs only logins that are missing (form `assignees=comma-separated`).
445    /// Already-present ⇒ `Already` (no POST).
446    pub fn add_assignees_idempotent(
447        &self,
448        number: i64,
449        logins: &[&str],
450    ) -> Result<StateChange<PullRequest>> {
451        self.add_members_idempotent(number, "assignees", logins, |pr| {
452            pr.assignees.as_deref().unwrap_or(&[])
453        })
454    }
455
456    /// Remove only the named reviewers. GETs current membership first; DELETEs
457    /// present logins via query `assignees=…`. Absent names ⇒ no-op.
458    pub fn remove_assignees_idempotent(
459        &self,
460        number: i64,
461        logins: &[&str],
462    ) -> Result<StateChange<()>> {
463        self.remove_members_idempotent(number, "assignees", logins, |pr| {
464            pr.assignees.as_deref().unwrap_or(&[])
465        })
466    }
467
468    /// Add testers without replacing the rest. Mirror of `add_assignees_idempotent`
469    /// on `/testers` with form `testers=comma-separated`.
470    pub fn add_testers_idempotent(
471        &self,
472        number: i64,
473        logins: &[&str],
474    ) -> Result<StateChange<PullRequest>> {
475        self.add_members_idempotent(number, "testers", logins, |pr| {
476            pr.testers.as_deref().unwrap_or(&[])
477        })
478    }
479
480    /// Remove only the named testers via DELETE query `testers=…`.
481    pub fn remove_testers_idempotent(
482        &self,
483        number: i64,
484        logins: &[&str],
485    ) -> Result<StateChange<()>> {
486        self.remove_members_idempotent(number, "testers", logins, |pr| {
487            pr.testers.as_deref().unwrap_or(&[])
488        })
489    }
490
491    fn add_members_idempotent(
492        &self,
493        number: i64,
494        field: &str,
495        logins: &[&str],
496        current_of: impl FnOnce(&PullRequest) -> &[UserAssignee],
497    ) -> Result<StateChange<PullRequest>> {
498        let o = self.repo.owner.as_str();
499        let r = self.repo.name.as_str();
500        let pr = self.get(number)?;
501        let present: HashSet<String> = current_of(&pr).iter().map(|u| u.login.clone()).collect();
502        let mut seen = HashSet::new();
503        let missing: Vec<&str> = logins
504            .iter()
505            .copied()
506            .filter(|n| !present.contains(*n) && seen.insert(*n))
507            .collect();
508        if missing.is_empty() {
509            return Ok(StateChange::Already(pr));
510        }
511        let joined = missing.join(",");
512        let form = [(field, joined.as_str())];
513        let updated: PullRequest = self
514            .client
515            .post(&format!("/repos/{o}/{r}/pulls/{number}/{field}"), &form)?;
516        Ok(StateChange::Changed(updated))
517    }
518
519    fn remove_members_idempotent(
520        &self,
521        number: i64,
522        field: &str,
523        logins: &[&str],
524        current_of: impl FnOnce(&PullRequest) -> &[UserAssignee],
525    ) -> Result<StateChange<()>> {
526        let o = self.repo.owner.as_str();
527        let r = self.repo.name.as_str();
528        let pr = self.get(number)?;
529        let present: HashSet<String> = current_of(&pr).iter().map(|u| u.login.clone()).collect();
530        let mut seen = HashSet::new();
531        let to_remove: Vec<&str> = logins
532            .iter()
533            .copied()
534            .filter(|n| present.contains(*n) && seen.insert(*n))
535            .collect();
536        if to_remove.is_empty() {
537            return Ok(StateChange::Already(()));
538        }
539        let joined = to_remove.join(",");
540        let query = [(field, joined.as_str())];
541        match self
542            .client
543            .delete_ok_query(&format!("/repos/{o}/{r}/pulls/{number}/{field}"), &query)
544        {
545            Ok(()) => Ok(StateChange::Changed(())),
546            Err(GiteeError::NotFound(_)) => Ok(StateChange::Already(())),
547            Err(e) => Err(e),
548        }
549    }
550
551    /// Add labels without replacing the rest. GETs current membership first;
552    /// POSTs only names that are missing. Already-present ⇒ `Already` (no POST).
553    pub fn add_labels_idempotent(
554        &self,
555        number: i64,
556        names: &[&str],
557    ) -> Result<StateChange<Vec<Label>>> {
558        let o = self.repo.owner.as_str();
559        let r = self.repo.name.as_str();
560        let path = format!("/repos/{o}/{r}/pulls/{number}/labels");
561        let current = self.list_labels(number)?;
562        let present: HashSet<String> = current.iter().map(|l| l.name.clone()).collect();
563        let mut seen = HashSet::new();
564        let missing: Vec<&str> = names
565            .iter()
566            .copied()
567            .filter(|n| !present.contains(*n) && seen.insert(*n))
568            .collect();
569        if missing.is_empty() {
570            return Ok(StateChange::Already(current));
571        }
572        let body = serde_json::Value::Array(
573            missing
574                .iter()
575                .map(|n| serde_json::Value::String((*n).to_string()))
576                .collect(),
577        );
578        let labels: Vec<Label> = self.client.post_json(&path, &body)?;
579        Ok(StateChange::Changed(labels))
580    }
581
582    /// Remove only the named labels. GETs current membership first; DELETEs
583    /// only names that are present. Absent names and DELETE 404 ⇒ no-op.
584    pub fn remove_labels_idempotent(
585        &self,
586        number: i64,
587        names: &[&str],
588    ) -> Result<StateChange<()>> {
589        let o = self.repo.owner.as_str();
590        let r = self.repo.name.as_str();
591        let current = self.list_labels(number)?;
592        let present: HashSet<String> = current.iter().map(|l| l.name.clone()).collect();
593        let mut seen = HashSet::new();
594        let to_remove: Vec<&str> = names
595            .iter()
596            .copied()
597            .filter(|n| present.contains(*n) && seen.insert(*n))
598            .collect();
599        if to_remove.is_empty() {
600            return Ok(StateChange::Already(()));
601        }
602        let mut changed = false;
603        for name in to_remove {
604            match self
605                .client
606                .delete_ok(&format!("/repos/{o}/{r}/pulls/{number}/labels/{name}"))
607            {
608                Ok(()) => changed = true,
609                Err(GiteeError::NotFound(_)) => {}
610                Err(e) => return Err(e),
611            }
612        }
613        if changed {
614            Ok(StateChange::Changed(()))
615        } else {
616            Ok(StateChange::Already(()))
617        }
618    }
619
620    /// GET the PR first; if `body` already contains `tag`, returns `Ok(false)` without PATCH.
621    /// Otherwise PATCH form `body` with appended `Linked: {tag}` and returns `Ok(true)`.
622    pub fn link(&self, number: i64, tag: &str) -> Result<bool> {
623        let o = self.repo.owner.as_str();
624        let r = self.repo.name.as_str();
625        let pr: PullRequest = self
626            .client
627            .get(&format!("/repos/{o}/{r}/pulls/{number}"), &[])?;
628        let cur = pr.body.clone().unwrap_or_default();
629        if cur.contains(tag) {
630            return Ok(false);
631        }
632        let new = format!("{cur}\n\nLinked: {tag}");
633        let f: Vec<(&str, String)> = vec![("body", new)];
634        let form = Client::str_refs(&f);
635        let _: PullRequest = self
636            .client
637            .patch(&format!("/repos/{o}/{r}/pulls/{number}"), &form)?;
638        Ok(true)
639    }
640}