Skip to main content

git_bot_feedback/client/gitea/
mod.rs

1use std::{env, fs::OpenOptions, io::Write};
2
3use async_trait::async_trait;
4use reqwest::{Client, Method};
5use url::Url;
6
7use super::{ClientError, RestApiClient, RestApiRateLimitHeaders, common::PullRequestInfo};
8use crate::{
9    OutputVariable, ReviewAction, ReviewOptions, ThreadCommentOptions,
10    client::common::PullRequestState,
11};
12mod serde_structs;
13use serde_structs::{FullReview, ReviewDiffComment};
14mod specific_api;
15
16#[cfg(feature = "file-changes")]
17use crate::{FileDiffLines, FileFilter, LinesChangedOnly, parse_diff};
18#[cfg(feature = "file-changes")]
19use reqwest::header::{HeaderMap, HeaderValue};
20#[cfg(feature = "file-changes")]
21use std::collections::HashMap;
22
23/// A structure to work with Gitea REST API.
24pub struct GiteaApiClient {
25    /// The HTTP request client to be used for all REST API calls.
26    client: Client,
27
28    /// The CI run's event payload from the webhook that triggered the workflow.
29    pull_request: Option<PullRequestInfo>,
30
31    /// The name of the event that was triggered when running cpp_linter.
32    pub event_name: String,
33
34    /// The value of the `GITHUB_API_URL` environment variable.
35    api_url: Url,
36
37    /// The value of the `GITHUB_REPOSITORY` environment variable.
38    repo: String,
39
40    /// The value of the `GITHUB_SHA` environment variable.
41    sha: String,
42
43    /// The value of the `ACTIONS_STEP_DEBUG` environment variable.
44    pub debug_enabled: bool,
45
46    /// The response header names that describe the rate limit status.
47    rate_limit_headers: RestApiRateLimitHeaders,
48}
49
50fn step_summary_url() -> Option<String> {
51    let server_url = env::var("GITEA_SERVER_URL")
52        .or_else(|_| env::var("GITHUB_SERVER_URL"))
53        .ok()?;
54    let repo = env::var("GITEA_REPOSITORY")
55        .or_else(|_| env::var("GITHUB_REPOSITORY"))
56        .ok()?;
57    let run_id = env::var("GITEA_RUN_ID")
58        .or_else(|_| env::var("GITHUB_RUN_ID"))
59        .ok()?;
60    Some(format!(
61        "{}/{repo}/actions/runs/{run_id}",
62        server_url.trim_end_matches('/')
63    ))
64}
65
66#[async_trait]
67impl RestApiClient for GiteaApiClient {
68    fn start_log_group(&self, name: &str) {
69        log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
70    }
71
72    fn end_log_group(&self, _name: &str) {
73        log::info!(target: "CI_LOG_GROUPING", "::endgroup::");
74    }
75
76    fn is_pr_event(&self) -> bool {
77        self.pull_request.is_some()
78    }
79
80    fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError> {
81        self.client = Client::builder()
82            .default_headers(Self::make_headers()?)
83            .user_agent(user_agent)
84            .build()?;
85        Ok(())
86    }
87
88    /// Does not support push events, only PR events.
89    async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError> {
90        let comments_url = match &self.pull_request {
91            Some(pr_info) => {
92                if pr_info.locked {
93                    return Ok(()); // cannot comment on locked PRs
94                }
95                env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
96                self.api_url.join(
97                    format!("repos/{}/issues/{}/comments", self.repo, pr_info.number).as_str(),
98                )?
99            }
100            None => {
101                // This feature is supported in non-PR events on other git servers.
102                // But Gitea only supports comments on PRs or issues.
103                // Leave a informative log entry to highlight this and return early.
104                log::info!(
105                    "Gitea support for posting thread comments is limited to pull requests only."
106                );
107                return Ok(());
108            }
109        };
110        self.update_comment(comments_url, options).await
111    }
112
113    async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
114        if let Some(pr_info) = self.pull_request.as_ref() {
115            // Guard checks for unsuitable PR states
116            if (!options.allow_draft && pr_info.draft)
117                || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
118                || pr_info.locked
119            {
120                return Ok(());
121            }
122
123            // Fetch existing reviews from this bot
124            let existing_reviews = self
125                .get_existing_review_comments(pr_info.number as i64, &options.marker)
126                .await?;
127
128            if existing_reviews.is_empty() {
129                return Ok(());
130            }
131
132            let mut outdated_comment_ids = Vec::new();
133            let mut outdated_review_ids = Vec::new();
134            let mut reused_comments = std::collections::HashSet::new();
135
136            // Check each existing review for reused comments
137            for review in &existing_reviews {
138                let mut keep_review = false;
139
140                for existing_comment in &review.comments {
141                    let mut keep_comment = false;
142
143                    // Try to match against proposed comments
144                    for proposed_comment in options.comments.iter() {
145                        if Self::match_review_comment(
146                            existing_comment,
147                            proposed_comment,
148                            &options.marker,
149                        ) {
150                            log::info!(
151                                "Using existing review comment: path='{}', line_end={}",
152                                existing_comment.path,
153                                existing_comment.new_position
154                            );
155                            reused_comments.insert(proposed_comment.clone());
156                            keep_comment = true;
157                            keep_review = true;
158                            break;
159                        }
160                    }
161
162                    // If comment doesn't match any proposed comment, mark for deletion
163                    if !keep_comment {
164                        outdated_comment_ids.push(existing_comment.id);
165                    }
166                }
167
168                // If no comments in this review were kept, mark review for deletion
169                if !keep_review {
170                    outdated_review_ids.push(review.id);
171                }
172            }
173
174            // Remove reused comments from proposed comments
175            options.comments.retain(|c| !reused_comments.contains(c));
176
177            // Delete outdated comments and reviews
178            if !outdated_comment_ids.is_empty() || !outdated_review_ids.is_empty() {
179                self.delete_outdated_review_comments(
180                    pr_info.number as i64,
181                    outdated_comment_ids,
182                    outdated_review_ids,
183                    options.delete_review_comments,
184                )
185                .await?;
186            }
187        }
188        Ok(())
189    }
190
191    async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
192        if let Some(pr_info) = self.pull_request.as_ref() {
193            if (!options.allow_draft && pr_info.draft)
194                || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
195                || pr_info.locked
196            {
197                return Ok(());
198            }
199            env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
200            let url = self
201                .api_url
202                .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
203            let payload = FullReview {
204                event: match options.action {
205                    ReviewAction::Comment => String::from("COMMENT"),
206                    ReviewAction::Approve => String::from("APPROVED"),
207                    ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
208                },
209                body: format!("{}{}", options.marker, options.summary),
210                comments: options
211                    .comments
212                    .iter()
213                    .map(ReviewDiffComment::from)
214                    .map(|mut r| {
215                        if !r.body.starts_with(&options.marker) {
216                            r.body = format!("{}{}", options.marker, r.body);
217                        }
218                        r
219                    })
220                    .collect(),
221                commit_id: self.sha.clone(),
222            };
223            let request = self.make_api_request(
224                &self.client,
225                url,
226                Method::POST,
227                Some(
228                    serde_json::to_string(&payload)
229                        .map_err(|e| ClientError::json("serialize PR review payload", e))?,
230                ),
231                None,
232            )?;
233            let response = self
234                .send_api_request(&self.client, request, &self.rate_limit_headers)
235                .await;
236            match response {
237                Ok(response) => {
238                    self.log_response(response, "Failed to post PR review")
239                        .await;
240                }
241                Err(e) => {
242                    return Err(e.add_request_context("post PR review"));
243                }
244            }
245        }
246        Ok(())
247    }
248
249    fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
250        if vars.is_empty() {
251            // Should probably be an error. This check is only here to prevent needlessly
252            // fetching the env var GITEA_OUTPUT value and opening the referenced file.
253            return Ok(());
254        }
255        if let Ok(gh_out) = env::var("GITEA_OUTPUT") {
256            return match OpenOptions::new().append(true).open(gh_out) {
257                Ok(mut gh_out_file) => {
258                    for out_var in vars {
259                        out_var.validate()?;
260                        writeln!(&mut gh_out_file, "{}={}\n", out_var.name, out_var.value)
261                            .map_err(|e| ClientError::io("write to GITEA_OUTPUT file", e))?;
262                    }
263                    Ok(())
264                }
265                Err(e) => Err(ClientError::io("write to GITEA_OUTPUT file", e)),
266            };
267        }
268        Ok(())
269    }
270
271    fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
272        if let Ok(gh_out) = env::var("GITEA_STEP_SUMMARY") {
273            // step summary MD file can be overwritten/removed in CI runners
274            return match OpenOptions::new().append(true).open(gh_out) {
275                Ok(mut gh_out_file) => {
276                    writeln!(&mut gh_out_file, "\n{comment}\n")
277                        .map_err(|e| ClientError::io("write to GITEA_STEP_SUMMARY file", e))?;
278                    if let Some(step_summary_url) = step_summary_url() {
279                        log::info!("View step summary at {step_summary_url}");
280                    }
281                    Ok(())
282                }
283                Err(e) => Err(ClientError::io("open GITEA_STEP_SUMMARY file", e)),
284            };
285        }
286        Ok(())
287    }
288
289    #[cfg(feature = "file-changes")]
290    #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
291    async fn get_list_of_changed_files(
292        &self,
293        file_filter: &FileFilter,
294        lines_changed_only: &LinesChangedOnly,
295        _base_diff: Option<String>,
296        _ignore_index: bool,
297    ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
298        let url_path = match &self.pull_request {
299            Some(pr_info) => format!("repos/{}/pulls/{}.diff", self.repo, pr_info.number),
300            None => format!("repos/{}/commits/{}.diff", self.repo, self.sha),
301        };
302        let url = self.api_url.join(&url_path)?;
303        let mut headers = HeaderMap::new();
304        headers.insert("Accept", HeaderValue::from_str("text/plain")?);
305        let request = self.make_api_request(&self.client, url, Method::GET, None, Some(headers))?;
306        let response = self
307            .send_api_request(&self.client, request, &self.rate_limit_headers)
308            .await?;
309        if let Err(e) = response.error_for_status_ref() {
310            if let Ok(body) = response.text().await {
311                log::error!("Failed to get list of changed files: {e:?}\n{body}");
312            }
313            return Err(ClientError::Request(e).add_request_context("get list of changed files"));
314        }
315        let body = (response.text()).await?.to_string();
316        parse_diff(&body, file_filter, lines_changed_only).map_err(ClientError::DiffError)
317    }
318
319    fn client_kind(&self) -> String {
320        "gitea".to_string()
321    }
322}