Selaa lähdekoodia

Automatically rename Zulip topics for MCPs when the GitHub issue is renamed (#711)

Camelid 4 vuotta sitten
vanhempi
commit
b69d666822
3 muutettua tiedostoa jossa 146 lisäystä ja 29 poistoa
  1. 31 11
      src/github.rs
  2. 71 18
      src/handlers/major_change.rs
  3. 44 0
      src/zulip.rs

+ 31 - 11
src/github.rs

@@ -260,6 +260,30 @@ pub struct Issue {
     repository: OnceCell<IssueRepository>,
 }
 
+/// Contains only the parts of `Issue` that are needed for turning the issue title into a Zulip
+/// topic.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ZulipGitHubReference {
+    pub number: u64,
+    pub title: String,
+    pub repository: IssueRepository,
+}
+
+impl ZulipGitHubReference {
+    pub fn zulip_topic_reference(&self) -> String {
+        let repo = &self.repository;
+        if repo.organization == "rust-lang" {
+            if repo.repository == "rust" {
+                format!("#{}", self.number)
+            } else {
+                format!("{}#{}", repo.repository, self.number)
+            }
+        } else {
+            format!("{}/{}#{}", repo.organization, repo.repository, self.number)
+        }
+    }
+}
+
 #[derive(Debug, serde::Deserialize)]
 pub struct Comment {
     #[serde(deserialize_with = "opt_string")]
@@ -305,7 +329,7 @@ impl fmt::Display for AssignmentError {
 
 impl std::error::Error for AssignmentError {}
 
-#[derive(Debug)]
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct IssueRepository {
     pub organization: String,
     pub repository: String,
@@ -327,16 +351,11 @@ impl IssueRepository {
 }
 
 impl Issue {
-    pub fn zulip_topic_reference(&self) -> String {
-        let repo = self.repository();
-        if repo.organization == "rust-lang" {
-            if repo.repository == "rust" {
-                format!("#{}", self.number)
-            } else {
-                format!("{}#{}", repo.repository, self.number)
-            }
-        } else {
-            format!("{}/{}#{}", repo.organization, repo.repository, self.number)
+    pub fn to_zulip_github_reference(&self) -> ZulipGitHubReference {
+        ZulipGitHubReference {
+            number: self.number,
+            title: self.title.clone(),
+            repository: self.repository.get().unwrap().clone(),
         }
     }
 
@@ -630,6 +649,7 @@ pub struct ChangeInner {
 
 #[derive(Debug, serde::Deserialize)]
 pub struct Changes {
+    pub title: ChangeInner,
     pub body: ChangeInner,
 }
 

+ 71 - 18
src/handlers/major_change.rs

@@ -1,16 +1,17 @@
 use crate::{
     config::MajorChangeConfig,
-    github::{Event, Issue, IssuesAction, IssuesEvent, Label},
+    github::{Event, Issue, IssuesAction, IssuesEvent, Label, ZulipGitHubReference},
     handlers::Context,
     interactions::ErrorComment,
 };
 use anyhow::Context as _;
 use parser::command::second::SecondCommand;
 
-#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+#[derive(Clone, PartialEq, Eq, Debug)]
 pub enum Invocation {
     NewProposal,
     AcceptedProposal,
+    Rename { prev_issue: ZulipGitHubReference },
 }
 
 pub(super) fn parse_input(
@@ -18,6 +19,19 @@ pub(super) fn parse_input(
     event: &IssuesEvent,
     _config: Option<&MajorChangeConfig>,
 ) -> Result<Option<Invocation>, String> {
+    if event.action == IssuesAction::Edited {
+        if let Some(changes) = &event.changes {
+            let prev_issue = ZulipGitHubReference {
+                number: event.issue.number,
+                title: changes.title.from.clone(),
+                repository: event.issue.repository().clone(),
+            };
+            return Ok(Some(Invocation::Rename { prev_issue }));
+        } else {
+            return Err(format!("no changes property in edited event"));
+        }
+    }
+
     // If we were labeled with accepted, then issue that event
     if event.action == IssuesAction::Labeled
         && event
@@ -32,7 +46,7 @@ pub(super) fn parse_input(
     // "Opened" and "Labeled" events.
     //
     // We want to treat reopened issues as new proposals but if the
-    // issues is freshly opened, we only want to trigger once;
+    // issue is freshly opened, we only want to trigger once;
     // currently we do so on the label event.
     if (event.action == IssuesAction::Reopened
         && event
@@ -85,6 +99,40 @@ pub(super) async fn handle_input(
             "This proposal has been accepted: [#{}]({}).",
             event.issue.number, event.issue.html_url,
         ),
+        Invocation::Rename { prev_issue } => {
+            let issue = &event.issue;
+
+            let prev_topic = zulip_topic_from_issue(&prev_issue);
+            let partial_issue = issue.to_zulip_github_reference();
+            let new_topic = zulip_topic_from_issue(&partial_issue);
+
+            let zulip_send_req = crate::zulip::MessageApiRequest {
+                recipient: crate::zulip::Recipient::Stream {
+                    id: config.zulip_stream,
+                    topic: &prev_topic,
+                },
+                content: "The associated GitHub issue has been renamed. Renaming this Zulip topic.",
+            };
+            let zulip_send_res = zulip_send_req
+                .send(&ctx.github.raw())
+                .await
+                .context("zulip post failed")?;
+
+            let zulip_send_res: crate::zulip::MessageApiResponse = zulip_send_res.json().await?;
+
+            let zulip_update_req = crate::zulip::UpdateMessageApiRequest {
+                message_id: zulip_send_res.message_id,
+                topic: Some(&new_topic),
+                propagate_mode: None,
+                content: None,
+            };
+            zulip_update_req
+                .send(&ctx.github.raw())
+                .await
+                .context("zulip message update failed")?;
+
+            return Ok(());
+        }
     };
     handle(
         ctx,
@@ -157,21 +205,8 @@ async fn handle(
     labels.push(Label { name: label_to_add });
     let github_req = issue.set_labels(&ctx.github, labels);
 
-    // Concatenate the issue title and the topic reference, truncating such that
-    // the overall length does not exceed 60 characters (a Zulip limitation).
-    let topic_ref = issue.zulip_topic_reference();
-    // Skip chars until the last characters that can be written:
-    // Maximum 60, minus the reference, minus the elipsis and the space
-    let mut chars = issue
-        .title
-        .char_indices()
-        .skip(60 - topic_ref.chars().count() - 2);
-    let zulip_topic = match chars.next() {
-        Some((len, _)) if chars.next().is_some() => {
-            format!("{}… {}", &issue.title[..len], topic_ref)
-        }
-        _ => format!("{} {}", issue.title, topic_ref),
-    };
+    let partial_issue = issue.to_zulip_github_reference();
+    let zulip_topic = zulip_topic_from_issue(&partial_issue);
 
     let zulip_req = crate::zulip::MessageApiRequest {
         recipient: crate::zulip::Recipient::Stream {
@@ -207,3 +242,21 @@ async fn handle(
     gh_res.context("label setting failed")?;
     Ok(())
 }
+
+fn zulip_topic_from_issue(issue: &ZulipGitHubReference) -> String {
+    // Concatenate the issue title and the topic reference, truncating such that
+    // the overall length does not exceed 60 characters (a Zulip limitation).
+    let topic_ref = issue.zulip_topic_reference();
+    // Skip chars until the last characters that can be written:
+    // Maximum 60, minus the reference, minus the elipsis and the space
+    let mut chars = issue
+        .title
+        .char_indices()
+        .skip(60 - topic_ref.chars().count() - 2);
+    match chars.next() {
+        Some((len, _)) if chars.next().is_some() => {
+            format!("{}… {}", &issue.title[..len], topic_ref)
+        }
+        _ => format!("{} {}", issue.title, topic_ref),
+    }
+}

+ 44 - 0
src/zulip.rs

@@ -469,6 +469,50 @@ impl<'a> MessageApiRequest<'a> {
     }
 }
 
+#[derive(serde::Deserialize)]
+pub struct MessageApiResponse {
+    #[serde(rename = "id")]
+    pub message_id: u64,
+}
+
+#[derive(Debug)]
+pub struct UpdateMessageApiRequest<'a> {
+    pub message_id: u64,
+    pub topic: Option<&'a str>,
+    pub propagate_mode: Option<&'a str>,
+    pub content: Option<&'a str>,
+}
+
+impl<'a> UpdateMessageApiRequest<'a> {
+    pub async fn send(&self, client: &reqwest::Client) -> anyhow::Result<reqwest::Response> {
+        let bot_api_token = env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN");
+
+        #[derive(serde::Serialize)]
+        struct SerializedApi<'a> {
+            #[serde(skip_serializing_if = "Option::is_none")]
+            pub topic: Option<&'a str>,
+            #[serde(skip_serializing_if = "Option::is_none")]
+            pub propagate_mode: Option<&'a str>,
+            #[serde(skip_serializing_if = "Option::is_none")]
+            pub content: Option<&'a str>,
+        }
+
+        Ok(client
+            .patch(&format!(
+                "https://rust-lang.zulipchat.com/api/v1/messages/{}",
+                self.message_id
+            ))
+            .basic_auth(BOT_EMAIL, Some(&bot_api_token))
+            .form(&SerializedApi {
+                topic: self.topic,
+                propagate_mode: self.propagate_mode,
+                content: self.content,
+            })
+            .send()
+            .await?)
+    }
+}
+
 async fn acknowledge(gh_id: i64, mut words: impl Iterator<Item = &str>) -> anyhow::Result<String> {
     let filter = match words.next() {
         Some(filter) => {