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