浏览代码

Implement assignment handler

Currently anyone can assign anyone (and de-assign anyone). Only one
assignee is supported at a time.
Mark Rousskov 5 年之前
父节点
当前提交
08fcbf45fc
共有 4 个文件被更改,包括 208 次插入37 次删除
  1. 94 4
      src/github.rs
  2. 1 1
      src/handlers.rs
  3. 63 32
      src/handlers/assign.rs
  4. 50 0
      src/interactions.rs

+ 94 - 4
src/github.rs

@@ -1,6 +1,7 @@
 use failure::{Error, ResultExt};
 use reqwest::header::{AUTHORIZATION, USER_AGENT};
 use reqwest::{Client, Error as HttpError, RequestBuilder, Response, StatusCode};
+use std::fmt;
 use std::io::Read;
 
 #[derive(Debug, serde::Deserialize)]
@@ -52,6 +53,7 @@ impl Label {
 #[derive(Debug, serde::Deserialize)]
 pub struct Issue {
     pub number: u64,
+    pub body: String,
     title: String,
     user: User,
     labels: Vec<Label>,
@@ -68,7 +70,73 @@ pub struct Comment {
     pub user: User,
 }
 
+#[derive(Debug)]
+pub enum AssignmentError {
+    InvalidAssignee,
+    Http(HttpError),
+}
+
+impl fmt::Display for AssignmentError {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            AssignmentError::InvalidAssignee => write!(f, "invalid assignee"),
+            AssignmentError::Http(e) => write!(f, "cannot assign: {}", e),
+        }
+    }
+}
+
+impl std::error::Error for AssignmentError {}
+
+impl From<HttpError> for AssignmentError {
+    fn from(h: HttpError) -> AssignmentError {
+        AssignmentError::Http(h)
+    }
+}
+
 impl Issue {
+    pub fn get_comment(&self, client: &GithubClient, id: usize) -> Result<Comment, Error> {
+        let comment_url = format!("{}/issues/comments/{}", self.repository_url, id);
+        let comment = client
+            .get(&comment_url)
+            .send_req()
+            .context("failed to get comment")?
+            .json()?;
+        Ok(comment)
+    }
+
+    pub fn edit_body(&self, client: &GithubClient, body: &str) -> Result<(), Error> {
+        let edit_url = format!("{}/issues/{}", self.repository_url, self.number);
+        #[derive(serde::Serialize)]
+        struct ChangedIssue<'a> {
+            body: &'a str,
+        }
+        client
+            .patch(&edit_url)
+            .json(&ChangedIssue { body })
+            .send_req()
+            .context("failed to edit issue body")?;
+        Ok(())
+    }
+
+    pub fn edit_comment(
+        &self,
+        client: &GithubClient,
+        id: usize,
+        new_body: &str,
+    ) -> Result<(), Error> {
+        let comment_url = format!("{}/issues/comments/{}", self.repository_url, id);
+        #[derive(serde::Serialize)]
+        struct NewComment<'a> {
+            body: &'a str,
+        }
+        client
+            .patch(&comment_url)
+            .json(&NewComment { body: new_body })
+            .send_req()
+            .context("failed to edit comment")?;
+        Ok(())
+    }
+
     pub fn post_comment(&self, client: &GithubClient, body: &str) -> Result<(), Error> {
         #[derive(serde::Serialize)]
         struct PostComment<'a> {
@@ -112,7 +180,7 @@ impl Issue {
         &self.labels
     }
 
-    pub fn add_assignee(&self, client: &GithubClient, user: &str) -> Result<(), Error> {
+    pub fn set_assignee(&self, client: &GithubClient, user: &str) -> Result<(), AssignmentError> {
         let url = format!(
             "{repo_url}/issues/{number}/assignees",
             repo_url = self.repository_url,
@@ -130,21 +198,33 @@ impl Issue {
                 if resp.status() == reqwest::StatusCode::NO_CONTENT {
                     // all okay
                 } else if resp.status() == reqwest::StatusCode::NOT_FOUND {
-                    failure::bail!("invalid assignee {:?}", user);
+                    return Err(AssignmentError::InvalidAssignee);
                 }
             }
-            Err(e) => failure::bail!("unable to check assignee validity: {:?}", e),
+            Err(e) => return Err(AssignmentError::Http(e)),
         }
 
         #[derive(serde::Serialize)]
         struct AssigneeReq<'a> {
             assignees: &'a [&'a str],
         }
+        client
+            .delete(&url)
+            .json(&AssigneeReq {
+                assignees: &self
+                    .assignees
+                    .iter()
+                    .map(|u| u.login.as_str())
+                    .collect::<Vec<_>>()[..],
+            })
+            .send_req()
+            .map_err(AssignmentError::Http)?;
+
         client
             .post(&url)
             .json(&AssigneeReq { assignees: &[user] })
             .send_req()
-            .context("failed to add assignee")?;
+            .map_err(AssignmentError::Http)?;
 
         Ok(())
     }
@@ -246,6 +326,16 @@ impl GithubClient {
         self.client.get(url).configure(self)
     }
 
+    fn patch(&self, url: &str) -> RequestBuilder {
+        log::trace!("patch {:?}", url);
+        self.client.patch(url).configure(self)
+    }
+
+    fn delete(&self, url: &str) -> RequestBuilder {
+        log::trace!("delete {:?}", url);
+        self.client.delete(url).configure(self)
+    }
+
     fn post(&self, url: &str) -> RequestBuilder {
         log::trace!("post {:?}", url);
         self.client.post(url).configure(self)

+ 1 - 1
src/handlers.rs

@@ -25,7 +25,7 @@ macro_rules! handlers {
 }
 
 handlers! {
-    //assign = assign::AssignmentHandler,
+    assign = assign::AssignmentHandler,
     relabel = relabel::RelabelHandler,
     //tracking_issue = tracking_issue::TrackingIssueHandler,
 }

+ 63 - 32
src/handlers/assign.rs

@@ -12,56 +12,87 @@
 //! Assign users with `@rustbot assign @gh-user` or `@rustbot claim` (self-claim).
 
 use crate::{
-    github::GithubClient,
-    registry::{Event, Handler},
+    config::AssignConfig,
+    github::{self, Event},
+    handlers::{Context, Handler},
+    interactions::EditIssueBody,
 };
-use failure::Error;
-use lazy_static::lazy_static;
-use regex::Regex;
+use failure::{Error, ResultExt};
+use parser::command::assign::AssignCommand;
+use parser::command::{Command, Input};
 
-pub struct AssignmentHandler {
-    pub client: GithubClient,
-}
+pub(super) struct AssignmentHandler;
 
 impl Handler for AssignmentHandler {
-    fn handle_event(&self, event: &Event) -> Result<(), Error> {
+    type Input = AssignCommand;
+    type Config = AssignConfig;
+
+    fn parse_input(&self, ctx: &Context, event: &Event) -> Result<Option<Self::Input>, Error> {
         #[allow(irrefutable_let_patterns)]
         let event = if let Event::IssueComment(e) = event {
             e
         } else {
             // not interested in other events
-            return Ok(());
+            return Ok(None);
         };
 
-        lazy_static! {
-            static ref RE_ASSIGN: Regex = Regex::new(r"/assign @(\S+)").unwrap();
-            static ref RE_CLAIM: Regex = Regex::new(r"/claim").unwrap();
+        let mut input = Input::new(&event.comment.body, &ctx.username);
+        match input.parse_command() {
+            Command::Assign(Ok(command)) => Ok(Some(command)),
+            Command::Assign(Err(err)) => {
+                failure::bail!(
+                    "Parsing assign command in [comment]({}) failed: {}",
+                    event.comment.html_url,
+                    err
+                );
+            }
+            _ => Ok(None),
         }
+    }
 
-        if RE_CLAIM.is_match(&event.comment.body) {
-            log::trace!(
-                "comment {:?} matched claim regex, assigning {:?}",
-                event.comment.body,
-                event.comment.user.login
-            );
-            event
-                .issue
-                .add_assignee(&self.client, &event.comment.user.login)?;
+    fn handle_input(
+        &self,
+        ctx: &Context,
+        _config: &AssignConfig,
+        event: &Event,
+        cmd: AssignCommand,
+    ) -> Result<(), Error> {
+        #[allow(irrefutable_let_patterns)]
+        let event = if let Event::IssueComment(e) = event {
+            e
         } else {
-            if let Some(capture) = RE_ASSIGN.captures(&event.comment.body) {
-                log::trace!(
-                    "comment {:?} matched assignment regex, assigning {:?}",
-                    event.comment.body,
-                    &capture[1]
+            // not interested in other events
+            return Ok(());
+        };
+
+        let to_assign = match cmd {
+            AssignCommand::Own => event.comment.user.login.clone(),
+            AssignCommand::User { username } => username.clone(),
+        };
+
+        let e = EditIssueBody::new(&event.issue, "ASSIGN", String::new());
+        e.apply(&ctx.github)?;
+
+        match event.issue.set_assignee(&ctx.github, &to_assign) {
+            Ok(()) => return Ok(()), // we are done
+            Err(github::AssignmentError::InvalidAssignee) => {
+                event
+                    .issue
+                    .set_assignee(&ctx.github, &ctx.username)
+                    .context("self-assignment failed")?;
+                let e = EditIssueBody::new(
+                    &event.issue,
+                    "ASSIGN",
+                    format!(
+                        "This issue has been assigned to @{} via [this comment]({}).",
+                        to_assign, event.comment.html_url
+                    ),
                 );
-                event.issue.add_assignee(&self.client, &capture[1])?;
+                e.apply(&ctx.github)?;
             }
+            Err(e) => return Err(e.into()),
         }
 
-        // TODO: Enqueue a check-in in two weeks.
-        // TODO: Post a comment documenting the biweekly check-in? Maybe just give them two weeks
-        //       without any commentary from us.
-
         Ok(())
     }
 }

+ 50 - 0
src/interactions.rs

@@ -29,3 +29,53 @@ impl<'a> ErrorComment<'a> {
         self.issue.post_comment(client, &body)
     }
 }
+
+pub struct EditIssueBody<'a> {
+    issue: &'a Issue,
+    id: &'static str,
+    text: String,
+}
+
+static START_BOT: &str = "<!-- TRIAGEBOT_START -->\n\n----\n";
+static END_BOT: &str = "<!-- TRIAGEBOT_END -->";
+
+impl<'a> EditIssueBody<'a> {
+    pub fn new(issue: &'a Issue, id: &'static str, text: String) -> EditIssueBody<'a> {
+        EditIssueBody { issue, id, text }
+    }
+
+    pub fn apply(&self, client: &GithubClient) -> Result<(), Error> {
+        let mut current_body = self.issue.body.clone();
+        let start_section = format!("<!-- TRIAGEBOT_{}_START -->\n", self.id);
+        let end_section = format!("\n<!-- TRIAGEBOT_{}_END -->\n", self.id);
+
+        let bot_section = format!("{}{}{}", start_section, self.text, end_section);
+
+        let all_new = format!("\n\n{}{}{}", START_BOT, bot_section, END_BOT);
+        if current_body.contains(START_BOT) {
+            if current_body.contains(&start_section) {
+                let start_idx = current_body.find(&start_section).unwrap();
+                let end_idx = current_body.find(&end_section).unwrap();
+                let mut new = current_body.replace(
+                    &current_body[start_idx..(end_idx + end_section.len())],
+                    &bot_section,
+                );
+                if new.contains(&all_new) && self.text.is_empty() {
+                    let start_idx = new.find(&all_new).unwrap();
+                    let end_idx = start_idx + all_new.len();
+                    new = new.replace(&new[start_idx..end_idx], "");
+                }
+                self.issue.edit_body(&client, &new)?;
+            } else {
+                let end_idx = current_body.find(&END_BOT).unwrap();
+                current_body.insert_str(end_idx, &bot_section);
+                self.issue.edit_body(&client, &current_body)?;
+            }
+        } else {
+            let new_body = format!("{}{}", current_body, all_new);
+
+            self.issue.edit_body(&client, &new_body)?;
+        }
+        Ok(())
+    }
+}