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: if options.summary.starts_with(&options.marker) {
210                    options.summary.clone()
211                } else {
212                    format!("{}{}", options.marker, options.summary)
213                },
214                comments: options
215                    .comments
216                    .iter()
217                    .map(ReviewDiffComment::from)
218                    .map(|mut r| {
219                        if !r.body.starts_with(&options.marker) {
220                            r.body = format!("{}{}", options.marker, r.body);
221                        }
222                        r
223                    })
224                    .collect(),
225                commit_id: self.sha.clone(),
226            };
227            let request = self.make_api_request(
228                &self.client,
229                url,
230                Method::POST,
231                Some(
232                    serde_json::to_string(&payload)
233                        .map_err(|e| ClientError::json("serialize PR review payload", e))?,
234                ),
235                None,
236            )?;
237            let response = self
238                .send_api_request(&self.client, request, &self.rate_limit_headers)
239                .await;
240            match response {
241                Ok(response) => {
242                    self.log_response(response, "Failed to post PR review")
243                        .await;
244                }
245                Err(e) => {
246                    return Err(e.add_request_context("post PR review"));
247                }
248            }
249        }
250        Ok(())
251    }
252
253    fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
254        if vars.is_empty() {
255            // Should probably be an error. This check is only here to prevent needlessly
256            // fetching the env var GITEA_OUTPUT value and opening the referenced file.
257            return Ok(());
258        }
259        if let Ok(gh_out) = env::var("GITEA_OUTPUT") {
260            return match OpenOptions::new().append(true).open(gh_out) {
261                Ok(mut gh_out_file) => {
262                    for out_var in vars {
263                        out_var.validate()?;
264                        writeln!(&mut gh_out_file, "{}={}\n", out_var.name, out_var.value)
265                            .map_err(|e| ClientError::io("write to GITEA_OUTPUT file", e))?;
266                    }
267                    Ok(())
268                }
269                Err(e) => Err(ClientError::io("write to GITEA_OUTPUT file", e)),
270            };
271        }
272        Ok(())
273    }
274
275    fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
276        if let Ok(gh_out) = env::var("GITEA_STEP_SUMMARY") {
277            // step summary MD file can be overwritten/removed in CI runners
278            return match OpenOptions::new().append(true).open(gh_out) {
279                Ok(mut gh_out_file) => {
280                    writeln!(&mut gh_out_file, "\n{comment}\n")
281                        .map_err(|e| ClientError::io("write to GITEA_STEP_SUMMARY file", e))?;
282                    if let Some(step_summary_url) = step_summary_url() {
283                        log::info!("View step summary at {step_summary_url}");
284                    }
285                    Ok(())
286                }
287                Err(e) => Err(ClientError::io("open GITEA_STEP_SUMMARY file", e)),
288            };
289        }
290        Ok(())
291    }
292
293    #[cfg(feature = "file-changes")]
294    #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
295    async fn get_list_of_changed_files(
296        &self,
297        file_filter: &FileFilter,
298        lines_changed_only: &LinesChangedOnly,
299        _base_diff: Option<String>,
300        _ignore_index: bool,
301    ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
302        let url_path = match &self.pull_request {
303            Some(pr_info) => format!("repos/{}/pulls/{}.diff", self.repo, pr_info.number),
304            None => format!("repos/{}/commits/{}.diff", self.repo, self.sha),
305        };
306        let url = self.api_url.join(&url_path)?;
307        let mut headers = HeaderMap::new();
308        headers.insert("Accept", HeaderValue::from_str("text/plain")?);
309        let request = self.make_api_request(&self.client, url, Method::GET, None, Some(headers))?;
310        let response = self
311            .send_api_request(&self.client, request, &self.rate_limit_headers)
312            .await?;
313        if let Err(e) = response.error_for_status_ref() {
314            if let Ok(body) = response.text().await {
315                log::error!("Failed to get list of changed files: {e:?}\n{body}");
316            }
317            return Err(ClientError::Request(e).add_request_context("get list of changed files"));
318        }
319        let body = (response.text()).await?.to_string();
320        parse_diff(&body, file_filter, lines_changed_only).map_err(ClientError::DiffError)
321    }
322
323    fn client_kind(&self) -> String {
324        "gitea".to_string()
325    }
326}