diff --git a/server/src/external/github.rs b/server/src/external/github.rs index 560f30471..19410f2e4 100644 --- a/server/src/external/github.rs +++ b/server/src/external/github.rs @@ -142,6 +142,140 @@ impl GitHub { } } } + + /// Find an open PR with a specific label + #[tracing::instrument] + pub async fn find_pr_with_label(self, label: &str) -> anyhow::Result> { + let Some(octocrab) = self.octocrab else { + anyhow::bail!("GitHub client not initialized"); + }; + + // Search through all pages of open PRs to find one with the label + let mut page = octocrab + .pulls("TUM-Dev", "NavigaTUM") + .list() + .state(octocrab::params::State::Open) + .per_page(100) + .send() + .await?; + + loop { + for pr in &page.items { + if let Some(labels) = &pr.labels { + for pr_label in labels { + if pr_label.name == label { + return Ok(Some((pr.number, pr.head.ref_field.clone()))); + } + } + } + } + + // Check if there's a next page + match octocrab.get_page(&page.next).await? { + Some(next_page) => page = next_page, + None => break, + } + } + + Ok(None) + } + + /// Update PR labels + #[tracing::instrument] + pub async fn update_pr_labels(self, pr_number: u64, labels: Vec) -> anyhow::Result<()> { + let Some(octocrab) = self.octocrab else { + anyhow::bail!("GitHub client not initialized"); + }; + + octocrab + .issues("TUM-Dev", "NavigaTUM") + .update(pr_number) + .labels(&labels) + .send() + .await?; + + Ok(()) + } + + /// Update PR title + #[tracing::instrument] + pub async fn update_pr_title(self, pr_number: u64, title: &str) -> anyhow::Result<()> { + let Some(octocrab) = self.octocrab else { + anyhow::bail!("GitHub client not initialized"); + }; + + octocrab + .issues("TUM-Dev", "NavigaTUM") + .update(pr_number) + .title(title) + .send() + .await?; + + Ok(()) + } + + /// Get the number of commits in a PR + #[tracing::instrument] + pub async fn get_pr_commit_count(self, pr_number: u64) -> anyhow::Result { + let Some(octocrab) = self.octocrab else { + anyhow::bail!("GitHub client not initialized"); + }; + + // Fetch the first page of commits (up to 100 per page) + let mut page = octocrab + .pulls("TUM-Dev", "NavigaTUM") + .pr_commits(pr_number) + .per_page(100) + .send() + .await?; + + // Count commits from the first page + let mut total_commits = page.items.len(); + + // Follow pagination links to count commits from all subsequent pages + while let Some(next_page) = octocrab.get_page(&page.next).await? { + total_commits += next_page.items.len(); + page = next_page; + } + + Ok(total_commits) + } + + /// Get PR description (body) + #[tracing::instrument] + pub async fn get_pr_description(self, pr_number: u64) -> anyhow::Result { + let Some(octocrab) = self.octocrab else { + anyhow::bail!("GitHub client not initialized"); + }; + + let pr = octocrab + .pulls("TUM-Dev", "NavigaTUM") + .get(pr_number) + .await?; + + Ok(pr.body.unwrap_or_default()) + } + + /// Update PR description (body) + #[tracing::instrument(skip(description))] + pub async fn update_pr_description( + self, + pr_number: u64, + description: &str, + ) -> anyhow::Result<()> { + let Some(octocrab) = self.octocrab else { + anyhow::bail!("GitHub client not initialized"); + }; + + octocrab + .issues("TUM-Dev", "NavigaTUM") + .update(pr_number) + .body(description) + .send() + .await?; + + Ok(()) + } } #[cfg(test)] diff --git a/server/src/routes/feedback/batch_processor/mod.rs b/server/src/routes/feedback/batch_processor/mod.rs new file mode 100644 index 000000000..4ec8771bc --- /dev/null +++ b/server/src/routes/feedback/batch_processor/mod.rs @@ -0,0 +1,95 @@ +use tracing::{error, info}; + +use crate::external::github::GitHub; + +pub const BATCH_LABEL: &str = "batch-in-progress"; + +/// Find the current open batch PR (if any) +pub async fn find_open_batch_pr() -> anyhow::Result> { + let github = GitHub::default(); + + match github.find_pr_with_label(BATCH_LABEL).await { + Ok(Some((pr_number, branch))) => { + info!(%pr_number, %branch, "Found open batch PR"); + Ok(Some((pr_number, branch))) + } + Ok(None) => { + info!("No open batch PR found"); + Ok(None) + } + Err(e) => { + error!(error=?e, "Error finding batch PR"); + Err(e) + } + } +} + +/// Update batch PR metadata (title with edit count, labels, and description) +pub async fn update_batch_pr_metadata( + pr_number: u64, + edit_request: &super::proposed_edits::EditRequest, + new_edit_description: &str, +) -> anyhow::Result<()> { + // Update labels based on edit type + let mut labels = vec![BATCH_LABEL.to_string(), "webform".to_string()]; + + // Check edit types using the extract_labels method + let edit_labels = edit_request.extract_labels(); + for label in edit_labels { + if label != "webform" && !labels.contains(&label) { + labels.push(label); + } + } + + let github = GitHub::default(); + match github.update_pr_labels(pr_number, labels).await { + Ok(_) => info!("Updated labels for batch PR #{}", pr_number), + Err(e) => error!( + error=?e, %pr_number, "Failed to update labels for batch PR" + ), + } + + // Get commits to count edits + let github = GitHub::default(); + let edit_count = match github.get_pr_commit_count(pr_number).await { + Ok(count) => count, + Err(e) => { + error!(error=?e, %pr_number, "Failed to get commit count for PR"); + return Err(e); + } + }; + + // Update PR title with edit count + let github = GitHub::default(); + let title = format!("chore(data): batch coordinate edits ({edit_count} edits)"); + match github.update_pr_title(pr_number, &title).await { + Ok(_) => info!(%pr_number, "Updated title for batch PR"), + Err(e) => error!(error=?e, %pr_number, "Failed to update title for batch PR"), + } + + // Append to PR description + let github = GitHub::default(); + let current_description = github + .get_pr_description(pr_number) + .await + .unwrap_or_default(); + + // Append the new edit's description + let updated_description = if current_description.is_empty() { + format!("## Batched Coordinate Edits\n\n### Edit #{edit_count}\n{new_edit_description}") + } else { + format!("{current_description}\n\n---\n\n### Edit #{edit_count}\n{new_edit_description}") + }; + + let github = GitHub::default(); + match github + .update_pr_description(pr_number, &updated_description) + .await + { + Ok(_) => info!(%pr_number, "Updated description for batch PR"), + Err(e) => error!(error=?e, %pr_number, + "Failed to update description for batch PR"), + } + + Ok(()) +} diff --git a/server/src/routes/feedback/mod.rs b/server/src/routes/feedback/mod.rs index 4cb41fba6..a0ef8b32b 100644 --- a/server/src/routes/feedback/mod.rs +++ b/server/src/routes/feedback/mod.rs @@ -1,3 +1,4 @@ +pub mod batch_processor; pub mod post_feedback; pub mod proposed_edits; pub mod tokens; diff --git a/server/src/routes/feedback/proposed_edits/mod.rs b/server/src/routes/feedback/proposed_edits/mod.rs index 85cadc1c5..067a0f865 100644 --- a/server/src/routes/feedback/proposed_edits/mod.rs +++ b/server/src/routes/feedback/proposed_edits/mod.rs @@ -4,7 +4,7 @@ use std::path::Path; use actix_web::web::{Data, Json}; use actix_web::{HttpResponse, post}; use serde::Deserialize; -use tracing::error; +use tracing::{error, info}; #[expect( unused_imports, reason = "has to be imported as otherwise utoipa generates incorrect code" @@ -79,7 +79,7 @@ impl EditRequest { .collect() } - fn extract_labels(&self) -> Vec { + pub(super) fn extract_labels(&self) -> Vec { let mut labels = vec!["webform".to_string()]; if self @@ -95,6 +95,7 @@ impl EditRequest { } labels } + fn extract_subject(&self) -> String { use itertools::Itertools; let coordinate_edits = self.edits_for(|edit| edit.coordinate); @@ -178,22 +179,60 @@ pub async fn propose_edits( }; let branch_name = format!("usergenerated/request-{}", rand::random::()); + + // Try to find an open batch PR and use it + let batch_pr = super::batch_processor::find_open_batch_pr() + .await + .ok() + .flatten(); + + let (branch_to_use, pr_number_opt) = match batch_pr { + Some((pr_number, batch_branch)) => { + info!(%pr_number, "Adding edit to existing batch PR"); + (batch_branch, Some(pr_number)) + } + None => (branch_name, None), + }; + match req_data - .apply_changes_and_generate_description(&branch_name) + .apply_changes_and_generate_description(&branch_to_use) .await { Ok(description) => { - GitHub::default() - .open_pr( - branch_name, - &format!( - "chore(data): {subject}", - subject = req_data.extract_subject() - ), + if let Some(pr_number) = pr_number_opt { + // Update metadata for batch PR (including appending description) + if let Err(e) = super::batch_processor::update_batch_pr_metadata( + pr_number, + &req_data, &description, - req_data.extract_labels(), ) .await + { + error!(error = ?e, "Failed to update batch PR metadata"); + } + + let pr_url = format!("https://github.com/TUM-Dev/NavigaTUM/pull/{pr_number}"); + HttpResponse::Created() + .content_type("text/plain") + .body(pr_url) + } else { + // Create new batch PR with batch-in-progress label + let mut labels = req_data.extract_labels(); + labels.push(super::batch_processor::BATCH_LABEL.to_string()); + + // Use extract_subject for first PR to provide helpful context + let subject = req_data.extract_subject(); + let title = format!("chore(data): {subject}"); + + GitHub::default() + .open_pr( + branch_to_use, + &title, + &format!("## Batched Coordinate Edits\n\n### Edit #1\n{description}"), + labels, + ) + .await + } } Err(error) => { error!(?error, "could not apply changes");