Skip to main content

git_bot_feedback/client/gitea/
mod.rs

1use std::{env, fs::OpenOptions, io::Write, path::PathBuf};
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    /// Uses `GITEA_WORKSPACE`, or `GITHUB_WORKSPACE` if that cannot be read.
81    /// The error names `GITEA_WORKSPACE` when neither can be read.
82    fn workspace_root(&self) -> Result<Option<PathBuf>, ClientError> {
83        env::var("GITEA_WORKSPACE")
84            .or_else(|e| env::var("GITHUB_WORKSPACE").map_err(|_| e))
85            .map(|v| Some(PathBuf::from(v)))
86            .map_err(|e| ClientError::env_var("GITEA_WORKSPACE or GITHUB_WORKSPACE", e))
87    }
88
89    fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError> {
90        self.client = Client::builder()
91            .default_headers(Self::make_headers()?)
92            .user_agent(user_agent)
93            .build()?;
94        Ok(())
95    }
96
97    /// Does not support push events, only PR events.
98    async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError> {
99        let comments_url = match &self.pull_request {
100            Some(pr_info) => {
101                if pr_info.locked {
102                    return Ok(()); // cannot comment on locked PRs
103                }
104                env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
105                self.api_url.join(
106                    format!("repos/{}/issues/{}/comments", self.repo, pr_info.number).as_str(),
107                )?
108            }
109            None => {
110                // This feature is supported in non-PR events on other git servers.
111                // But Gitea only supports comments on PRs or issues.
112                // Leave a informative log entry to highlight this and return early.
113                log::info!(
114                    "Gitea support for posting thread comments is limited to pull requests only."
115                );
116                return Ok(());
117            }
118        };
119        self.update_comment(comments_url, options).await
120    }
121
122    async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
123        if let Some(pr_info) = self.pull_request.as_ref() {
124            // Guard checks for unsuitable PR states
125            if (!options.allow_draft && pr_info.draft)
126                || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
127                || pr_info.locked
128            {
129                return Ok(());
130            }
131
132            // Fetch existing reviews from this bot
133            let existing_reviews = self
134                .get_existing_review_comments(pr_info.number as i64, &options.marker)
135                .await?;
136
137            if existing_reviews.is_empty() {
138                return Ok(());
139            }
140
141            let mut outdated_comment_ids = Vec::new();
142            let mut outdated_review_ids = Vec::new();
143            let mut reused_comments = std::collections::HashSet::new();
144
145            // Check each existing review for reused comments
146            for review in &existing_reviews {
147                let mut keep_review = false;
148
149                for existing_comment in &review.comments {
150                    let mut keep_comment = false;
151
152                    // Try to match against proposed comments
153                    for proposed_comment in options.comments.iter() {
154                        if Self::match_review_comment(
155                            existing_comment,
156                            proposed_comment,
157                            &options.marker,
158                        ) {
159                            log::info!(
160                                "Using existing review comment: path='{}', line_end={}",
161                                existing_comment.path,
162                                existing_comment.new_position
163                            );
164                            reused_comments.insert(proposed_comment.clone());
165                            keep_comment = true;
166                            keep_review = true;
167                            break;
168                        }
169                    }
170
171                    // If comment doesn't match any proposed comment, mark for deletion
172                    if !keep_comment {
173                        outdated_comment_ids.push(existing_comment.id);
174                    }
175                }
176
177                // If no comments in this review were kept, mark review for deletion
178                if !keep_review {
179                    outdated_review_ids.push(review.id);
180                }
181            }
182
183            // Remove reused comments from proposed comments
184            options.comments.retain(|c| !reused_comments.contains(c));
185
186            // Delete outdated comments and reviews
187            if !outdated_comment_ids.is_empty() || !outdated_review_ids.is_empty() {
188                self.delete_outdated_review_comments(
189                    pr_info.number as i64,
190                    outdated_comment_ids,
191                    outdated_review_ids,
192                    options.delete_review_comments,
193                )
194                .await?;
195            }
196        }
197        Ok(())
198    }
199
200    async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
201        if let Some(pr_info) = self.pull_request.as_ref() {
202            if (!options.allow_draft && pr_info.draft)
203                || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
204                || pr_info.locked
205            {
206                return Ok(());
207            }
208            env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
209            let url = self
210                .api_url
211                .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
212            let payload = FullReview {
213                event: match options.action {
214                    ReviewAction::Comment => String::from("COMMENT"),
215                    ReviewAction::Approve => String::from("APPROVED"),
216                    ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
217                },
218                body: if options.summary.starts_with(&options.marker) {
219                    options.summary.clone()
220                } else {
221                    format!("{}{}", options.marker, options.summary)
222                },
223                comments: options
224                    .comments
225                    .iter()
226                    .map(ReviewDiffComment::from)
227                    .map(|mut r| {
228                        if !r.body.starts_with(&options.marker) {
229                            r.body = format!("{}{}", options.marker, r.body);
230                        }
231                        r
232                    })
233                    .collect(),
234                commit_id: self.sha.clone(),
235            };
236            let request = self.make_api_request(
237                &self.client,
238                url,
239                Method::POST,
240                Some(
241                    serde_json::to_string(&payload)
242                        .map_err(|e| ClientError::json("serialize PR review payload", e))?,
243                ),
244                None,
245            )?;
246            let response = self
247                .send_api_request(&self.client, request, &self.rate_limit_headers)
248                .await;
249            match response {
250                Ok(response) => {
251                    self.log_response(response, "Failed to post PR review")
252                        .await;
253                }
254                Err(e) => {
255                    return Err(e.add_request_context("post PR review"));
256                }
257            }
258        }
259        Ok(())
260    }
261
262    fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
263        if vars.is_empty() {
264            // Should probably be an error. This check is only here to prevent needlessly
265            // fetching the env var GITEA_OUTPUT value and opening the referenced file.
266            return Ok(());
267        }
268        if let Ok(gh_out) = env::var("GITEA_OUTPUT") {
269            return match OpenOptions::new().append(true).open(gh_out) {
270                Ok(mut gh_out_file) => {
271                    for out_var in vars {
272                        out_var.validate()?;
273                        writeln!(&mut gh_out_file, "{}={}\n", out_var.name, out_var.value)
274                            .map_err(|e| ClientError::io("write to GITEA_OUTPUT file", e))?;
275                    }
276                    Ok(())
277                }
278                Err(e) => Err(ClientError::io("write to GITEA_OUTPUT file", e)),
279            };
280        }
281        Ok(())
282    }
283
284    fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
285        if let Ok(gh_out) = env::var("GITEA_STEP_SUMMARY") {
286            // step summary MD file can be overwritten/removed in CI runners
287            return match OpenOptions::new().append(true).open(gh_out) {
288                Ok(mut gh_out_file) => {
289                    writeln!(&mut gh_out_file, "\n{comment}\n")
290                        .map_err(|e| ClientError::io("write to GITEA_STEP_SUMMARY file", e))?;
291                    if let Some(step_summary_url) = step_summary_url() {
292                        log::info!("View step summary at {step_summary_url}");
293                    }
294                    Ok(())
295                }
296                Err(e) => Err(ClientError::io("open GITEA_STEP_SUMMARY file", e)),
297            };
298        }
299        Ok(())
300    }
301
302    #[cfg(feature = "file-changes")]
303    #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
304    async fn get_list_of_changed_files(
305        &self,
306        file_filter: &FileFilter,
307        lines_changed_only: &LinesChangedOnly,
308        _base_diff: Option<String>,
309        _ignore_index: bool,
310    ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
311        let url_path = match &self.pull_request {
312            Some(pr_info) => format!("repos/{}/pulls/{}.diff", self.repo, pr_info.number),
313            None => format!("repos/{}/commits/{}.diff", self.repo, self.sha),
314        };
315        let url = self.api_url.join(&url_path)?;
316        let mut headers = HeaderMap::new();
317        headers.insert("Accept", HeaderValue::from_str("text/plain")?);
318        let request = self.make_api_request(&self.client, url, Method::GET, None, Some(headers))?;
319        let response = self
320            .send_api_request(&self.client, request, &self.rate_limit_headers)
321            .await?;
322        if let Err(e) = response.error_for_status_ref() {
323            if let Ok(body) = response.text().await {
324                log::error!("Failed to get list of changed files: {e:?}\n{body}");
325            }
326            return Err(ClientError::Request(e).add_request_context("get list of changed files"));
327        }
328        let body = (response.text()).await?.to_string();
329        parse_diff(&body, file_filter, lines_changed_only).map_err(ClientError::DiffError)
330    }
331
332    fn client_kind(&self) -> String {
333        "gitea".to_string()
334    }
335}