Bläddra i källkod

Revert "Cleanup and refactor handlers"

Mark Rousskov 4 år sedan
förälder
incheckning
4e7662d0ed

+ 1 - 1
parser/src/error.rs

@@ -5,7 +5,7 @@ use std::fmt;
 pub struct Error<'a> {
     pub input: &'a str,
     pub position: usize,
-    pub source: Box<dyn error::Error + Send>,
+    pub source: Box<dyn error::Error>,
 }
 
 impl<'a> PartialEq for Error<'a> {

+ 1 - 1
parser/src/token.rs

@@ -85,7 +85,7 @@ impl<'a> Tokenizer<'a> {
         }
     }
 
-    pub fn error<T: 'static + std::error::Error + Send>(&mut self, source: T) -> Error<'a> {
+    pub fn error<T: 'static + std::error::Error>(&mut self, source: T) -> Error<'a> {
         Error {
             input: self.input,
             position: self.cur_pos(),

+ 60 - 134
src/handlers.rs

@@ -1,9 +1,8 @@
-use crate::config::{self, Config, ConfigurationError};
-use crate::github::{Event, GithubClient, IssuesAction, IssuesEvent};
+use crate::config::{self, ConfigurationError};
+use crate::github::{Event, GithubClient};
+use futures::future::BoxFuture;
 use octocrab::Octocrab;
-use parser::command::{Command, Input};
 use std::fmt;
-use std::sync::Arc;
 use tokio_postgres::Client as DbClient;
 
 #[derive(Debug)]
@@ -23,161 +22,68 @@ impl fmt::Display for HandlerError {
     }
 }
 
-mod assign;
-mod autolabel;
-mod glacier;
-mod major_change;
-mod nominate;
 mod notification;
-mod notify_zulip;
-mod ping;
-mod prioritize;
-mod relabel;
 mod rustc_commits;
 
-// TODO: Return multiple handler errors ?
-pub async fn handle(ctx: &Context, event: &Event) -> Result<(), HandlerError> {
-    let config = match config::get(&ctx.github, event.repo_name()).await {
-        Ok(config) => config,
-        Err(e @ ConfigurationError::Missing) => {
-            return Err(HandlerError::Message(e.to_string()));
-        }
-        Err(e @ ConfigurationError::Toml(_)) => {
-            return Err(HandlerError::Message(e.to_string()));
-        }
-        Err(e @ ConfigurationError::Http(_)) => {
-            return Err(HandlerError::Other(e.into()));
-        }
-    };
-
-    if let Event::Issue(event) = event {
-        handle_issue(ctx, event, &config).await?;
-    }
-
-    if let Some(body) = event.comment_body() {
-        handle_command(ctx, event, &config, body).await?;
-    }
-
-    if let Err(e) = notification::handle(ctx, event).await {
-        log::error!(
-            "failed to process event {:?} with notification handler: {:?}",
-            event,
-            e
-        );
-    }
-
-    if let Err(e) = rustc_commits::handle(ctx, event).await {
-        log::error!(
-            "failed to process event {:?} with rustc_commits handler: {:?}",
-            event,
-            e
-        );
-    }
+macro_rules! handlers {
+    ($($name:ident = $handler:expr,)*) => {
+        $(mod $name;)*
 
-    Ok(())
-}
+        pub async fn handle(ctx: &Context, event: &Event) -> Result<(), HandlerError> {
+            let config = config::get(&ctx.github, event.repo_name()).await;
 
-macro_rules! issue_handlers {
-    ($($name:ident,)*) => {
-        async fn handle_issue(ctx: &Context, event: &IssuesEvent, config: &Arc<Config>) -> Result<(), HandlerError> {
             $(
-            if let Some(input) = $name::parse_input(
-                ctx, event, config.$name.as_ref(),
+            if let Some(input) = Handler::parse_input(
+                &$handler, ctx, event, config.as_ref().ok().and_then(|c| c.$name.as_ref()),
             ).map_err(HandlerError::Message)? {
+                let config = match &config {
+                    Ok(config) => config,
+                    Err(e @ ConfigurationError::Missing) => {
+                        return Err(HandlerError::Message(e.to_string()));
+                    }
+                    Err(e @ ConfigurationError::Toml(_)) => {
+                        return Err(HandlerError::Message(e.to_string()));
+                    }
+                    Err(e @ ConfigurationError::Http(_)) => {
+                        return Err(HandlerError::Other(e.clone().into()));
+                    }
+                };
                 if let Some(config) = &config.$name {
-                    $name::handle_input(ctx, config, event, input).await.map_err(HandlerError::Other)?;
+                    Handler::handle_input(&$handler, ctx, config, event, input).await.map_err(HandlerError::Other)?;
                 } else {
                     return Err(HandlerError::Message(format!(
                         "The feature `{}` is not enabled in this repository.\n\
-                        To enable it add its section in the `triagebot.toml` \
-                        in the root of the repository.",
+                         To enable it add its section in the `triagebot.toml` \
+                         in the root of the repository.",
                         stringify!($name)
                     )));
                 }
             })*
-            Ok(())
-        }
-    }
-}
-
-// Handle events that happend on issues
-//
-// This is for events that happends only on issues (e.g. label changes).
-// Each module in the list must contain the functions `parse_input` and `handle_input`.
-issue_handlers! {
-    autolabel,
-    major_change,
-    notify_zulip,
-}
 
-macro_rules! command_handlers {
-    ($($name:ident: $enum:ident,)*) => {
-        async fn handle_command(ctx: &Context, event: &Event, config: &Arc<Config>, body: &str) -> Result<(), HandlerError> {
-            if let Event::Issue(e) = event {
-                if !matches!(e.action, IssuesAction::Opened | IssuesAction::Edited) {
-                    // no change in issue's body for these events, so skip
-                    log::debug!("skipping event, issue was {:?}", e.action);
-                    return Ok(());
-                }
+            if let Err(e) = notification::handle(ctx, event).await {
+                log::error!("failed to process event {:?} with notification handler: {:?}", event, e);
             }
 
-            // TODO: parse multiple commands and diff them
-            let mut input = Input::new(&body, &ctx.username);
-            let command = input.parse_command();
-
-            if let Some(previous) = event.comment_from() {
-                let mut prev_input = Input::new(&previous, &ctx.username);
-                let prev_command = prev_input.parse_command();
-                if command == prev_command {
-                    log::info!("skipping unmodified command: {:?} -> {:?}", prev_command, command);
-                    return Ok(());
-                } else {
-                    log::debug!("executing modified command: {:?} -> {:?}", prev_command, command);
-                }
+            if let Err(e) = rustc_commits::handle(ctx, event).await {
+                log::error!("failed to process event {:?} with rustc_commits handler: {:?}", event, e);
             }
 
-            match command {
-                $(
-                Command::$enum(Ok(command)) => {
-                    if let Some(config) = &config.$name {
-                        $name::handle_command(ctx, config, event, command).await.map_err(HandlerError::Other)?;
-                    } else {
-                        return Err(HandlerError::Message(format!(
-                            "The feature `{}` is not enabled in this repository.\n\
-                            To enable it add its section in the `triagebot.toml` \
-                            in the root of the repository.",
-                            stringify!($name)
-                        )));
-                    }
-                }
-                Command::$enum(Err(err)) => {
-                    return Err(HandlerError::Message(format!(
-                        "Parsing {} command in [comment]({}) failed: {}",
-                        stringify!($name),
-                        event.html_url().expect("has html url"),
-                        err
-                    )));
-                })*
-                Command::None => {}
-            }
             Ok(())
         }
     }
 }
 
-// Handle commands in comments/issues body
-//
-// This is for handlers for commands parsed by the `parser` crate.
-// Each variant of `parser::command::Command` must be in this list,
-// preceded by the module containing the coresponding `handle_command` function
-command_handlers! {
-    assign: Assign,
-    glacier: Glacier,
-    nominate: Nominate,
-    ping: Ping,
-    prioritize: Prioritize,
-    relabel: Relabel,
-    major_change: Second,
+handlers! {
+    assign = assign::AssignmentHandler,
+    relabel = relabel::RelabelHandler,
+    ping = ping::PingHandler,
+    nominate = nominate::NominateHandler,
+    prioritize = prioritize::PrioritizeHandler,
+    major_change = major_change::MajorChangeHandler,
+    //tracking_issue = tracking_issue::TrackingIssueHandler,
+    glacier = glacier::GlacierHandler,
+    autolabel = autolabel::AutolabelHandler,
+    notify_zulip = notify_zulip::NotifyZulipHandler,
 }
 
 pub struct Context {
@@ -186,3 +92,23 @@ pub struct Context {
     pub username: String,
     pub octocrab: Octocrab,
 }
+
+pub trait Handler: Sync + Send {
+    type Input;
+    type Config;
+
+    fn parse_input(
+        &self,
+        ctx: &Context,
+        event: &Event,
+        config: Option<&Self::Config>,
+    ) -> Result<Option<Self::Input>, String>;
+
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        config: &'a Self::Config,
+        event: &'a Event,
+        input: Self::Input,
+    ) -> BoxFuture<'a, anyhow::Result<()>>;
+}

+ 81 - 10
src/handlers/assign.rs

@@ -14,23 +14,86 @@
 use crate::{
     config::AssignConfig,
     github::{self, Event, Selection},
-    handlers::Context,
+    handlers::{Context, Handler},
     interactions::EditIssueBody,
 };
 use anyhow::Context as _;
+use futures::future::{BoxFuture, FutureExt};
 use parser::command::assign::AssignCommand;
+use parser::command::{Command, Input};
+
+pub(super) struct AssignmentHandler;
 
 #[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
 struct AssignData {
     user: Option<String>,
 }
 
-pub(super) async fn handle_command(
-    ctx: &Context,
-    _config: &AssignConfig,
-    event: &Event,
-    cmd: AssignCommand,
-) -> anyhow::Result<()> {
+impl Handler for AssignmentHandler {
+    type Input = AssignCommand;
+    type Config = AssignConfig;
+
+    fn parse_input(
+        &self,
+        ctx: &Context,
+        event: &Event,
+        _: Option<&AssignConfig>,
+    ) -> Result<Option<Self::Input>, String> {
+        let body = if let Some(b) = event.comment_body() {
+            b
+        } else {
+            // not interested in other events
+            return Ok(None);
+        };
+
+        if let Event::Issue(e) = event {
+            if !matches!(e.action, github::IssuesAction::Opened | github::IssuesAction::Edited) {
+                log::debug!("skipping event, issue was {:?}", e.action);
+                // skip events other than opening or editing the issue to avoid retriggering commands in the
+                // issue body
+                return Ok(None);
+            }
+        }
+
+        let mut input = Input::new(&body, &ctx.username);
+        let command = input.parse_command();
+        
+        if let Some(previous) = event.comment_from() {
+            let mut prev_input = Input::new(&previous, &ctx.username);
+            let prev_command = prev_input.parse_command();
+            if command == prev_command {
+                log::info!("skipping unmodified command: {:?} -> {:?}", prev_command, command);
+                return Ok(None);
+            } else {
+                log::debug!("executing modified command: {:?} -> {:?}", prev_command, command);
+            }
+        }
+        
+        match command {
+            Command::Assign(Ok(command)) => Ok(Some(command)),
+            Command::Assign(Err(err)) => {
+                return Err(format!(
+                    "Parsing assign command in [comment]({}) failed: {}",
+                    event.html_url().expect("has html url"),
+                    err
+                ));
+            }
+            _ => Ok(None),
+        }
+    }
+
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        _config: &'a AssignConfig,
+        event: &'a Event,
+        cmd: AssignCommand,
+    ) -> BoxFuture<'a, anyhow::Result<()>> {
+        handle_input(ctx, event, cmd).boxed()
+    }
+}
+
+async fn handle_input(ctx: &Context, event: &Event, cmd: AssignCommand) -> anyhow::Result<()> {
     let is_team_member = if let Err(_) | Ok(false) = event.user().is_team_member(&ctx.github).await
     {
         false
@@ -60,7 +123,10 @@ pub(super) async fn handle_command(
             );
             return Ok(());
         }
-        if let Err(err) = issue.set_assignee(&ctx.github, &username).await {
+        if let Err(err) = issue
+            .set_assignee(&ctx.github, &username)
+            .await
+        {
             log::warn!(
                 "failed to set assignee of PR {} to {}: {:?}",
                 issue.global_id(),
@@ -87,7 +153,9 @@ pub(super) async fn handle_command(
             }) = e.current_data()
             {
                 if current == event.user().login || is_team_member {
-                    issue.remove_assignees(&ctx.github, Selection::All).await?;
+                    issue
+                        .remove_assignees(&ctx.github, Selection::All)
+                        .await?;
                     e.apply(&ctx.github, String::new(), AssignData { user: None })
                         .await?;
                     return Ok(());
@@ -124,7 +192,10 @@ pub(super) async fn handle_command(
 
     e.apply(&ctx.github, String::new(), &data).await?;
 
-    match issue.set_assignee(&ctx.github, &to_assign).await {
+    match issue
+        .set_assignee(&ctx.github, &to_assign)
+        .await
+    {
         Ok(()) => return Ok(()), // we are done
         Err(github::AssignmentError::InvalidAssignee) => {
             issue

+ 72 - 50
src/handlers/autolabel.rs

@@ -1,80 +1,102 @@
 use crate::{
     config::AutolabelConfig,
-    github::{IssuesAction, IssuesEvent, Label},
-    handlers::Context,
+    github::{self, Event, Label},
+    handlers::{Context, Handler},
 };
+use futures::future::{BoxFuture, FutureExt};
 pub(super) struct AutolabelInput {
-    labels: Vec<Label>,
+    labels: Vec<Label>
 }
 
-pub(super) fn parse_input(
-    _ctx: &Context,
-    event: &IssuesEvent,
-    config: Option<&AutolabelConfig>,
-) -> Result<Option<AutolabelInput>, String> {
-    if event.action == IssuesAction::Labeled {
-        if let Some(config) = config {
-            let mut autolabels = Vec::new();
-            let applied_label = &event.label.as_ref().expect("label").name;
+pub(super) struct AutolabelHandler;
 
-            'outer: for (label, config) in config.get_by_trigger(applied_label) {
-                let exclude_patterns: Vec<glob::Pattern> = config
-                    .exclude_labels
-                    .iter()
-                    .filter_map(|label| match glob::Pattern::new(label) {
-                        Ok(exclude_glob) => Some(exclude_glob),
-                        Err(error) => {
-                            log::error!("Invalid glob pattern: {}", error);
-                            None
-                        }
-                    })
-                    .collect();
+impl Handler for AutolabelHandler {
+    type Input = AutolabelInput;
+    type Config = AutolabelConfig;
+
+    fn parse_input(
+        &self,
+        _ctx: &Context,
+        event: &Event,
+        config: Option<&Self::Config>,
+    ) -> Result<Option<Self::Input>, String> {
+        if let Event::Issue(e) = event {
+            if e.action == github::IssuesAction::Labeled {
+                if let Some(config) = config {
+                    let mut autolabels = Vec::new();
+                    let applied_label = &e.label.as_ref().expect("label").name;
+
+                    'outer: for (label, config) in config.get_by_trigger(applied_label) {
+                        let exclude_patterns: Vec<glob::Pattern> = config
+                            .exclude_labels
+                            .iter()
+                            .filter_map(|label| {
+                                match glob::Pattern::new(label) {
+                                    Ok(exclude_glob) => {
+                                        Some(exclude_glob)
+                                    }
+                                    Err(error) => {
+                                        log::error!("Invalid glob pattern: {}", error);
+                                        None
+                                    }
+                                }
+                            })
+                            .collect();
 
-                for label in event.issue.labels() {
-                    for pat in &exclude_patterns {
-                        if pat.matches(&label.name) {
-                            // If we hit an excluded label, ignore this autolabel and check the next
-                            continue 'outer;
+                        for label in event.issue().unwrap().labels() {
+                            for pat in &exclude_patterns {
+                                if pat.matches(&label.name) {
+                                    // If we hit an excluded label, ignore this autolabel and check the next
+                                    continue 'outer;
+                                }
+                            }
                         }
+
+                        // If we reach here, no excluded labels were found, so we should apply the autolabel.
+                        autolabels.push(Label { name: label.to_owned() });
+                    }
+                    if !autolabels.is_empty() {
+                        return Ok(Some(AutolabelInput { labels: autolabels }));
                     }
                 }
-
-                // If we reach here, no excluded labels were found, so we should apply the autolabel.
-                autolabels.push(Label {
-                    name: label.to_owned(),
-                });
             }
-            if !autolabels.is_empty() {
-                return Ok(Some(AutolabelInput { labels: autolabels }));
+            if e.action == github::IssuesAction::Closed {
+                let labels = event.issue().unwrap().labels();
+                if let Some(x) = labels.iter().position(|x| x.name == "I-prioritize") {
+                    let mut labels_excluded = labels.to_vec();
+                    labels_excluded.remove(x);
+                    return Ok(Some(AutolabelInput { labels: labels_excluded }));
+                }
             }
         }
+        Ok(None)
     }
-    if event.action == IssuesAction::Closed {
-        let labels = event.issue.labels();
-        if let Some(x) = labels.iter().position(|x| x.name == "I-prioritize") {
-            let mut labels_excluded = labels.to_vec();
-            labels_excluded.remove(x);
-            return Ok(Some(AutolabelInput {
-                labels: labels_excluded,
-            }));
-        }
+
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        config: &'a Self::Config,
+        event: &'a Event,
+        input: Self::Input,
+    ) -> BoxFuture<'a, anyhow::Result<()>> {
+        handle_input(ctx, config, event, input).boxed()
     }
-    Ok(None)
 }
 
-pub(super) async fn handle_input(
+async fn handle_input(
     ctx: &Context,
     _config: &AutolabelConfig,
-    event: &IssuesEvent,
+    event: &Event,
     input: AutolabelInput,
 ) -> anyhow::Result<()> {
-    let mut labels = event.issue.labels().to_owned();
+    let issue = event.issue().unwrap();
+    let mut labels = issue.labels().to_owned();
     for label in input.labels {
         // Don't add the label if it's already there
         if !labels.contains(&label) {
             labels.push(label);
         }
     }
-    event.issue.set_labels(&ctx.github, labels).await?;
+    issue.set_labels(&ctx.github, labels).await?;
     Ok(())
 }

+ 77 - 45
src/handlers/glacier.rs

@@ -1,25 +1,76 @@
 //! Allows team members to directly create a glacier PR with the code provided.
 
-use crate::{config::GlacierConfig, github::Event, handlers::Context};
+use crate::{
+    config::GlacierConfig,
+    github::Event,
+    handlers::{Context, Handler},
+};
 
-use octocrab::models::Object;
-use octocrab::params::repos::Reference;
+use futures::future::{BoxFuture, FutureExt};
 use parser::command::glacier::GlacierCommand;
+use parser::command::{Command, Input};
+use octocrab::params::repos::Reference;
+use octocrab::models::Object;
+
+pub(super) struct GlacierHandler;
+
+impl Handler for GlacierHandler {
+    type Input = GlacierCommand;
+    type Config = GlacierConfig;
+
+    fn parse_input(
+        &self,
+        ctx: &Context,
+        event: &Event,
+        _: Option<&GlacierConfig>,
+    ) -> Result<Option<Self::Input>, String> {
+        let body = if let Some(b) = event.comment_body() {
+            b
+        } else {
+            // not interested in other events
+            return Ok(None);
+        };
 
-pub(super) async fn handle_command(
-    ctx: &Context,
-    _config: &GlacierConfig,
-    event: &Event,
-    cmd: GlacierCommand,
-) -> anyhow::Result<()> {
-    let is_team_member = event
-        .user()
-        .is_team_member(&ctx.github)
-        .await
-        .unwrap_or(false);
+        let mut input = Input::new(&body, &ctx.username);
+        let command = input.parse_command();
+        
+        if let Some(previous) = event.comment_from() {
+            let mut prev_input = Input::new(&previous, &ctx.username);
+            let prev_command = prev_input.parse_command();
+            if command == prev_command {
+                return Ok(None);
+            }
+        }
+        
+        match command {
+            Command::Glacier(Ok(command)) => Ok(Some(command)),
+            Command::Glacier(Err(err)) => {
+                return Err(format!(
+                    "Parsing glacier command in [comment]({}) failed: {}",
+                    event.html_url().expect("has html url"),
+                    err
+                ));
+            }
+            _ => Ok(None),
+        }
+    }
+
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        _config: &'a GlacierConfig,
+        event: &'a Event,
+        cmd: GlacierCommand,
+    ) -> BoxFuture<'a, anyhow::Result<()>> {
+        handle_input(ctx, event, cmd).boxed()
+    }
+}
+
+async fn handle_input(ctx: &Context, event: &Event, cmd: GlacierCommand) -> anyhow::Result<()> {
+    let is_team_member = event.user().is_team_member(&ctx.github).await.unwrap_or(false);
 
     if !is_team_member {
-        return Ok(());
+        return Ok(())
     };
 
     let response = ctx.github.raw().get(&cmd.source).send().await?;
@@ -33,42 +84,23 @@ pub(super) async fn handle_command(
     let fork = octocrab.repos("rustbot", "glacier");
     let base = octocrab.repos("rust-lang", "glacier");
 
-    let master = base
-        .get_ref(&Reference::Branch("master".to_string()))
-        .await?
-        .object;
-    let master = if let Object::Commit { sha, .. } = master {
+    let master = base.get_ref(&Reference::Branch("master".to_string())).await?.object;
+    let master = if let Object::Commit { sha, ..} = master {
         sha
     } else {
         log::error!("invalid commit sha - master {:?}", master);
         unreachable!()
     };
 
-    fork.create_ref(
-        &Reference::Branch(format!("triagebot-ice-{}", number)),
-        master,
-    )
-    .await?;
-    fork.create_file(
-        format!("ices/{}.rs", number),
-        format!("Add ICE reproduction for issue #{}.", number),
-        body,
-    )
-    .branch(format!("triagebot-ice-{}", number))
-    .send()
-    .await?;
-
-    octocrab
-        .pulls("rust-lang", "glacier")
-        .create(
-            format!("ICE - {}", number),
-            format!("rustbot:triagebot-ice-{}", number),
-            "master",
-        )
-        .body(format!(
-            "Automatically created by @{} in issue #{}",
-            user.login, number
-        ))
+    fork.create_ref(&Reference::Branch(format!("triagebot-ice-{}", number)), master).await?;
+    fork.create_file(format!("ices/{}.rs", number), format!("Add ICE reproduction for issue #{}.", number), body)
+        .branch(format!("triagebot-ice-{}", number))
+        .send()
+        .await?;
+
+    octocrab.pulls("rust-lang", "glacier")
+        .create(format!("ICE - {}", number), format!("rustbot:triagebot-ice-{}", number), "master")
+        .body(format!("Automatically created by @{} in issue #{}", user.login, number),)
         .send()
         .await?;
     Ok(())

+ 156 - 134
src/handlers/major_change.rs

@@ -1,160 +1,182 @@
 use crate::{
     config::MajorChangeConfig,
-    github::{Event, Issue, IssuesAction, IssuesEvent, Label},
-    handlers::Context,
+    github::{self, Event, IssuesAction},
+    handlers::{Context, Handler},
     interactions::ErrorComment,
 };
 use anyhow::Context as _;
+use futures::future::{BoxFuture, FutureExt};
 use parser::command::second::SecondCommand;
+use parser::command::{Command, Input};
 
-#[derive(Copy, Clone, PartialEq, Eq, Debug)]
-pub enum Invocation {
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub(super) enum Invocation {
+    Second,
     NewProposal,
     AcceptedProposal,
 }
 
-pub(super) fn parse_input(
-    _ctx: &Context,
-    event: &IssuesEvent,
-    _config: Option<&MajorChangeConfig>,
-) -> Result<Option<Invocation>, String> {
-    // If we were labeled with accepted, then issue that event
-    if event.action == IssuesAction::Labeled
-        && event
-            .label
-            .as_ref()
-            .map_or(false, |l| l.name == "major-change-accepted")
-    {
-        return Ok(Some(Invocation::AcceptedProposal));
+pub(super) struct MajorChangeHandler;
+
+impl Handler for MajorChangeHandler {
+    type Input = Invocation;
+    type Config = MajorChangeConfig;
+
+    fn parse_input(
+        &self,
+        ctx: &Context,
+        event: &Event,
+        _: Option<&Self::Config>,
+    ) -> Result<Option<Self::Input>, String> {
+        let body = if let Some(b) = event.comment_body() {
+            b
+        } else {
+            // not interested in other events
+            return Ok(None);
+        };
+
+        match event {
+            Event::Issue(e) => {
+                // If we were labeled with accepted, then issue that event
+                if e.action == IssuesAction::Labeled
+                    && e.label
+                        .as_ref()
+                        .map_or(false, |l| l.name == "major-change-accepted")
+                {
+                    return Ok(Some(Invocation::AcceptedProposal));
+                }
+
+                // Opening an issue with a label assigned triggers both
+                // "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;
+                // currently we do so on the label event.
+                if (e.action == IssuesAction::Reopened
+                    && e.issue.labels().iter().any(|l| l.name == "major-change"))
+                    || (e.action == IssuesAction::Labeled
+                        && e.label.as_ref().map_or(false, |l| l.name == "major-change"))
+                {
+                    return Ok(Some(Invocation::NewProposal));
+                }
+
+                // All other issue events are ignored
+                return Ok(None);
+            }
+            Event::IssueComment(_) => {}
+        }
+
+        let mut input = Input::new(&body, &ctx.username);
+        let command = input.parse_command();
+
+        if let Some(previous) = event.comment_from() {
+            let mut prev_input = Input::new(&previous, &ctx.username);
+            let prev_command = prev_input.parse_command();
+            if command == prev_command {
+                return Ok(None);
+            }
+        }
+
+        match command {
+            Command::Second(Ok(SecondCommand)) => Ok(Some(Invocation::Second)),
+            _ => Ok(None),
+        }
     }
 
-    // Opening an issue with a label assigned triggers both
-    // "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;
-    // currently we do so on the label event.
-    if (event.action == IssuesAction::Reopened
-        && event
-            .issue
-            .labels()
-            .iter()
-            .any(|l| l.name == "major-change"))
-        || (event.action == IssuesAction::Labeled
-            && event
-                .label
-                .as_ref()
-                .map_or(false, |l| l.name == "major-change"))
-    {
-        return Ok(Some(Invocation::NewProposal));
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        config: &'a Self::Config,
+        event: &'a Event,
+        cmd: Self::Input,
+    ) -> BoxFuture<'a, anyhow::Result<()>> {
+        handle_input(ctx, config, event, cmd).boxed()
     }
-
-    // All other issue events are ignored
-    return Ok(None);
-}
-
-pub(super) async fn handle_input(
-    ctx: &Context,
-    config: &MajorChangeConfig,
-    event: &IssuesEvent,
-    cmd: Invocation,
-) -> anyhow::Result<()> {
-    if !event
-        .issue
-        .labels()
-        .iter()
-        .any(|l| l.name == "major-change")
-    {
-        let cmnt = ErrorComment::new(
-            &event.issue,
-            "This is not a major change (it lacks the `major-change` label).",
-        );
-        cmnt.post(&ctx.github).await?;
-        return Ok(());
-    }
-    let zulip_msg = match cmd {
-        Invocation::NewProposal => format!(
-            "A new proposal has been announced: [#{}]({}). It will be \
-            announced at the next meeting to try and draw attention to it, \
-            but usually MCPs are not discussed during triage meetings. If \
-            you think this would benefit from discussion amongst the \
-            team, consider proposing a design meeting.",
-            event.issue.number, event.issue.html_url,
-        ),
-        Invocation::AcceptedProposal => format!(
-            "This proposal has been accepted: [#{}]({}).",
-            event.issue.number, event.issue.html_url,
-        ),
-    };
-    handle(
-        ctx,
-        config,
-        &event.issue,
-        zulip_msg,
-        config.meeting_label.clone(),
-        cmd == Invocation::NewProposal,
-    )
-    .await
 }
 
-pub(super) async fn handle_command(
+async fn handle_input(
     ctx: &Context,
     config: &MajorChangeConfig,
     event: &Event,
-    _cmd: SecondCommand,
+    cmd: Invocation,
 ) -> anyhow::Result<()> {
     let issue = event.issue().unwrap();
 
-    if !issue.labels().iter().any(|l| l.name == "major-change") {
-        let cmnt = ErrorComment::new(
-            &issue,
-            "This is not a major change (it lacks the `major-change` label).",
-        );
-        cmnt.post(&ctx.github).await?;
-        return Ok(());
-    }
-
-    let is_team_member = event
-        .user()
-        .is_team_member(&ctx.github)
-        .await
-        .ok()
-        .unwrap_or(false);
-
-    if !is_team_member {
-        let cmnt = ErrorComment::new(&issue, "Only team members can second issues.");
-        cmnt.post(&ctx.github).await?;
-        return Ok(());
-    }
-
-    let zulip_msg = format!(
-        "@*{}*: Proposal [#{}]({}) has been seconded, and will be approved in 10 days if no objections are raised.",
-        config.zulip_ping,
-        issue.number,
-        event.html_url().unwrap()
-    );
-
-    handle(
-        ctx,
-        config,
-        issue,
-        zulip_msg,
-        config.second_label.clone(),
-        false,
-    )
-    .await
-}
+    let (zulip_msg, label_to_add) = match cmd {
+        Invocation::Second => {
+            if !issue.labels().iter().any(|l| l.name == "major-change") {
+                let cmnt = ErrorComment::new(
+                    &issue,
+                    "This is not a major change (it lacks the `major-change` label).",
+                );
+                cmnt.post(&ctx.github).await?;
+                return Ok(());
+            }
+
+            let is_team_member =
+                if let Err(_) | Ok(false) = event.user().is_team_member(&ctx.github).await {
+                    false
+                } else {
+                    true
+                };
+
+            if !is_team_member {
+                let cmnt = ErrorComment::new(&issue, "Only team members can second issues.");
+                cmnt.post(&ctx.github).await?;
+                return Ok(());
+            }
+
+            (format!(
+                "@*{}*: Proposal [#{}]({}) has been seconded, and will be approved in 10 days if no objections are raised.",
+                config.zulip_ping,
+                issue.number,
+                event.html_url().unwrap()
+            ), config.second_label.clone())
+        }
+        Invocation::NewProposal => {
+            if !issue.labels().iter().any(|l| l.name == "major-change") {
+                let cmnt = ErrorComment::new(
+                    &issue,
+                    "This is not a major change (it lacks the `major-change` label).",
+                );
+                cmnt.post(&ctx.github).await?;
+                return Ok(());
+            }
+            (
+                format!(
+                    "A new proposal has been announced: [#{}]({}). It will be
+                announced at the next meeting to try and draw attention to it,
+                but usually MCPs are not discussed during triage meetings. If
+                you think this would benefit from discussion amongst the
+                team, consider proposing a design meeting.",
+                    issue.number,
+                    event.html_url().unwrap()
+                ),
+                config.meeting_label.clone(),
+            )
+        }
+        Invocation::AcceptedProposal => {
+            if !issue.labels().iter().any(|l| l.name == "major-change") {
+                let cmnt = ErrorComment::new(
+                    &issue,
+                    "This is not a major change (it lacks the `major-change` label).",
+                );
+                cmnt.post(&ctx.github).await?;
+                return Ok(());
+            }
+            (
+                format!(
+                    "This proposal has been accepted: [#{}]({}).",
+                    issue.number,
+                    event.html_url().unwrap()
+                ),
+                config.meeting_label.clone(),
+            )
+        }
+    };
 
-async fn handle(
-    ctx: &Context,
-    config: &MajorChangeConfig,
-    issue: &Issue,
-    zulip_msg: String,
-    label_to_add: String,
-    new_proposal: bool,
-) -> anyhow::Result<()> {
     let mut labels = issue.labels().to_owned();
-    labels.push(Label { name: label_to_add });
+    labels.push(github::Label { name: label_to_add });
     let github_req = issue.set_labels(&ctx.github, labels);
 
     let mut zulip_topic = format!(" {}", issue.zulip_topic_reference());
@@ -173,7 +195,7 @@ async fn handle(
         content: &zulip_msg,
     };
 
-    if new_proposal {
+    if cmd == Invocation::NewProposal {
         let topic_url = zulip_req.url();
         let comment = format!(
             "This issue is not meant to be used for technical discussion. \

+ 66 - 2
src/handlers/nominate.rs

@@ -3,12 +3,76 @@
 use crate::{
     config::NominateConfig,
     github::{self, Event},
-    handlers::Context,
+    handlers::{Context, Handler},
     interactions::ErrorComment,
 };
+use futures::future::{BoxFuture, FutureExt};
 use parser::command::nominate::{NominateCommand, Style};
+use parser::command::{Command, Input};
 
-pub(super) async fn handle_command(
+pub(super) struct NominateHandler;
+
+impl Handler for NominateHandler {
+    type Input = NominateCommand;
+    type Config = NominateConfig;
+
+    fn parse_input(
+        &self,
+        ctx: &Context,
+        event: &Event,
+        _: Option<&Self::Config>,
+    ) -> Result<Option<Self::Input>, String> {
+        let body = if let Some(b) = event.comment_body() {
+            b
+        } else {
+            // not interested in other events
+            return Ok(None);
+        };
+
+        if let Event::Issue(e) = event {
+            if !matches!(e.action, github::IssuesAction::Opened | github::IssuesAction::Edited) {
+                // skip events other than opening or editing the issue to avoid retriggering commands in the
+                // issue body
+                return Ok(None);
+            }
+        }
+
+        let mut input = Input::new(&body, &ctx.username);
+        let command = input.parse_command();
+        
+        if let Some(previous) = event.comment_from() {
+            let mut prev_input = Input::new(&previous, &ctx.username);
+            let prev_command = prev_input.parse_command();
+            if command == prev_command {
+                return Ok(None);
+            }
+        }
+        
+        match command {
+            Command::Nominate(Ok(command)) => Ok(Some(command)),
+            Command::Nominate(Err(err)) => {
+                return Err(format!(
+                    "Parsing nominate command in [comment]({}) failed: {}",
+                    event.html_url().expect("has html url"),
+                    err
+                ));
+            }
+            _ => Ok(None),
+        }
+    }
+
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        config: &'a Self::Config,
+        event: &'a Event,
+        input: Self::Input,
+    ) -> BoxFuture<'a, anyhow::Result<()>> {
+        handle_input(ctx, config, event, input).boxed()
+    }
+}
+
+async fn handle_input(
     ctx: &Context,
     config: &NominateConfig,
     event: &Event,

+ 59 - 38
src/handlers/notify_zulip.rs

@@ -1,8 +1,9 @@
 use crate::{
     config::NotifyZulipConfig,
-    github::{IssuesAction, IssuesEvent},
-    handlers::Context,
+    github::{self, Event},
+    handlers::{Context, Handler},
 };
+use futures::future::{BoxFuture, FutureExt};
 
 pub(super) struct NotifyZulipInput {
     notification_type: NotificationType,
@@ -13,53 +14,73 @@ pub(super) enum NotificationType {
     Unlabeled,
 }
 
-pub(super) fn parse_input(
-    _ctx: &Context,
-    event: &IssuesEvent,
-    config: Option<&NotifyZulipConfig>,
-) -> Result<Option<NotifyZulipInput>, String> {
-    if let IssuesAction::Labeled | IssuesAction::Unlabeled = event.action {
-        let applied_label = &event.label.as_ref().expect("label").name;
-        if let Some(config) = config.and_then(|c| c.labels.get(applied_label)) {
-            for label in &config.required_labels {
-                let pattern = match glob::Pattern::new(label) {
-                    Ok(pattern) => pattern,
-                    Err(err) => {
-                        log::error!("Invalid glob pattern: {}", err);
-                        continue;
+pub(super) struct NotifyZulipHandler;
+
+impl Handler for NotifyZulipHandler {
+    type Input = NotifyZulipInput;
+    type Config = NotifyZulipConfig;
+
+    fn parse_input(
+        &self,
+        _ctx: &Context,
+        event: &Event,
+        config: Option<&Self::Config>,
+    ) -> Result<Option<Self::Input>, String> {
+        if let Event::Issue(e) = event {
+            if let github::IssuesAction::Labeled | github::IssuesAction::Unlabeled = e.action {
+                let applied_label = &e.label.as_ref().expect("label").name;
+                if let Some(config) = config.and_then(|c| c.labels.get(applied_label)) {
+                    for label in &config.required_labels {
+                        let pattern = match glob::Pattern::new(label) {
+                            Ok(pattern) => pattern,
+                            Err(err) => {
+                                log::error!("Invalid glob pattern: {}", err);
+                                continue;
+                            }
+                        };
+                        if !e.issue.labels().iter().any(|l| pattern.matches(&l.name)) {
+                            // Issue misses a required label, ignore this event
+                            return Ok(None);
+                        }
                     }
-                };
-                if !event
-                    .issue
-                    .labels()
-                    .iter()
-                    .any(|l| pattern.matches(&l.name))
-                {
-                    // Issue misses a required label, ignore this event
-                    return Ok(None);
-                }
-            }
 
-            if event.action == IssuesAction::Labeled && config.message_on_add.is_some() {
-                return Ok(Some(NotifyZulipInput {
-                    notification_type: NotificationType::Labeled,
-                }));
-            } else if config.message_on_remove.is_some() {
-                return Ok(Some(NotifyZulipInput {
-                    notification_type: NotificationType::Unlabeled,
-                }));
+                    if e.action == github::IssuesAction::Labeled && config.message_on_add.is_some()
+                    {
+                        return Ok(Some(NotifyZulipInput {
+                            notification_type: NotificationType::Labeled,
+                        }));
+                    } else if config.message_on_remove.is_some() {
+                        return Ok(Some(NotifyZulipInput {
+                            notification_type: NotificationType::Unlabeled,
+                        }));
+                    }
+                }
             }
         }
+        Ok(None)
+    }
+
+    fn handle_input<'b>(
+        &self,
+        ctx: &'b Context,
+        config: &'b Self::Config,
+        event: &'b Event,
+        input: Self::Input,
+    ) -> BoxFuture<'b, anyhow::Result<()>> {
+        handle_input(ctx, config, event, input).boxed()
     }
-    Ok(None)
 }
 
-pub(super) async fn handle_input<'a>(
+async fn handle_input<'a>(
     ctx: &Context,
     config: &NotifyZulipConfig,
-    event: &IssuesEvent,
+    event: &Event,
     input: NotifyZulipInput,
 ) -> anyhow::Result<()> {
+    let event = match event {
+        Event::Issue(e) => e,
+        _ => unreachable!(),
+    };
     let config = config
         .labels
         .get(&event.label.as_ref().unwrap().name)

+ 70 - 6
src/handlers/ping.rs

@@ -7,16 +7,80 @@
 use crate::{
     config::PingConfig,
     github::{self, Event},
-    handlers::Context,
+    handlers::{Context, Handler},
     interactions::ErrorComment,
 };
+use futures::future::{BoxFuture, FutureExt};
 use parser::command::ping::PingCommand;
+use parser::command::{Command, Input};
 
-pub(super) async fn handle_command(
+pub(super) struct PingHandler;
+
+impl Handler for PingHandler {
+    type Input = PingCommand;
+    type Config = PingConfig;
+
+    fn parse_input(
+        &self,
+        ctx: &Context,
+        event: &Event,
+        _: Option<&Self::Config>,
+    ) -> Result<Option<Self::Input>, String> {
+        let body = if let Some(b) = event.comment_body() {
+            b
+        } else {
+            // not interested in other events
+            return Ok(None);
+        };
+
+        if let Event::Issue(e) = event {
+            if !matches!(e.action, github::IssuesAction::Opened | github::IssuesAction::Edited) {
+                // skip events other than opening or editing the issue to avoid retriggering commands in the
+                // issue body
+                return Ok(None);
+            }
+        }
+
+        let mut input = Input::new(&body, &ctx.username);
+        let command = input.parse_command();
+        
+        if let Some(previous) = event.comment_from() {
+            let mut prev_input = Input::new(&previous, &ctx.username);
+            let prev_command = prev_input.parse_command();
+            if command == prev_command {
+                return Ok(None);
+            }
+        }
+        
+        match command {
+            Command::Ping(Ok(command)) => Ok(Some(command)),
+            Command::Ping(Err(err)) => {
+                return Err(format!(
+                    "Parsing ping command in [comment]({}) failed: {}",
+                    event.html_url().expect("has html url"),
+                    err
+                ));
+            }
+            _ => Ok(None),
+        }
+    }
+
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        config: &'a PingConfig,
+        event: &'a Event,
+        input: PingCommand,
+    ) -> BoxFuture<'a, anyhow::Result<()>> {
+        handle_input(ctx, config, event, input.team).boxed()
+    }
+}
+
+async fn handle_input(
     ctx: &Context,
     config: &PingConfig,
     event: &Event,
-    team_name: PingCommand,
+    team_name: String,
 ) -> anyhow::Result<()> {
     let is_team_member = if let Err(_) | Ok(false) = event.user().is_team_member(&ctx.github).await
     {
@@ -34,7 +98,7 @@ pub(super) async fn handle_command(
         return Ok(());
     }
 
-    let (gh_team, config) = match config.get_by_name(&team_name.team) {
+    let (gh_team, config) = match config.get_by_name(&team_name) {
         Some(v) => v,
         None => {
             let cmnt = ErrorComment::new(
@@ -42,7 +106,7 @@ pub(super) async fn handle_command(
                 format!(
                     "This team (`{}`) cannot be pinged via this command;\
                  it may need to be added to `triagebot.toml` on the master branch.",
-                    team_name.team,
+                    team_name,
                 ),
             );
             cmnt.post(&ctx.github).await?;
@@ -57,7 +121,7 @@ pub(super) async fn handle_command(
                 &event.issue().unwrap(),
                 format!(
                     "This team (`{}`) does not exist in the team repository.",
-                    team_name.team,
+                    team_name,
                 ),
             );
             cmnt.post(&ctx.github).await?;

+ 60 - 5
src/handlers/prioritize.rs

@@ -1,11 +1,68 @@
 use crate::{
     config::PrioritizeConfig,
     github::{self, Event},
-    handlers::Context,
+    handlers::{Context, Handler},
 };
+use futures::future::{BoxFuture, FutureExt};
 use parser::command::prioritize::PrioritizeCommand;
+use parser::command::{Command, Input};
 
-pub(super) async fn handle_command(
+pub(super) struct PrioritizeHandler;
+
+impl Handler for PrioritizeHandler {
+    type Input = PrioritizeCommand;
+    type Config = PrioritizeConfig;
+
+    fn parse_input(
+        &self,
+        ctx: &Context,
+        event: &Event,
+        _config: Option<&Self::Config>,
+    ) -> Result<Option<Self::Input>, String> {
+        let body = if let Some(b) = event.comment_body() {
+            b
+        } else {
+            // not interested in other events
+            return Ok(None);
+        };
+        
+        if let Event::Issue(e) = event {
+            if !matches!(e.action, github::IssuesAction::Opened | github::IssuesAction::Edited) {
+                // skip events other than opening or editing the issue to avoid retriggering commands in the
+                // issue body
+                return Ok(None);
+            }
+        }
+
+        let mut input = Input::new(&body, &ctx.username);
+        let command = input.parse_command();
+        
+        if let Some(previous) = event.comment_from() {
+            let mut prev_input = Input::new(&previous, &ctx.username);
+            let prev_command = prev_input.parse_command();
+            if command == prev_command {
+                return Ok(None);
+            }
+        }
+        
+        match command {
+            Command::Prioritize(Ok(PrioritizeCommand)) => Ok(Some(PrioritizeCommand)),
+            _ => Ok(None),
+        }
+    }
+
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        config: &'a Self::Config,
+        event: &'a Event,
+        cmd: Self::Input,
+    ) -> BoxFuture<'a, anyhow::Result<()>> {
+        handle_input(ctx, config, event, cmd).boxed()
+    }
+}
+
+async fn handle_input(
     ctx: &Context,
     config: &PrioritizeConfig,
     event: &Event,
@@ -16,9 +73,7 @@ pub(super) async fn handle_command(
 
     // Don't add the label if it's already there
     if !labels.iter().any(|l| l.name == config.label) {
-        labels.push(github::Label {
-            name: config.label.to_owned(),
-        });
+        labels.push(github::Label { name: config.label.to_owned() });
     }
 
     issue.set_labels(&ctx.github, labels).await?;

+ 66 - 2
src/handlers/relabel.rs

@@ -11,12 +11,76 @@
 use crate::{
     config::RelabelConfig,
     github::{self, Event, GithubClient},
-    handlers::Context,
+    handlers::{Context, Handler},
     interactions::ErrorComment,
 };
+use futures::future::{BoxFuture, FutureExt};
 use parser::command::relabel::{LabelDelta, RelabelCommand};
+use parser::command::{Command, Input};
 
-pub(super) async fn handle_command(
+pub(super) struct RelabelHandler;
+
+impl Handler for RelabelHandler {
+    type Input = RelabelCommand;
+    type Config = RelabelConfig;
+
+    fn parse_input(
+        &self,
+        ctx: &Context,
+        event: &Event,
+        _: Option<&Self::Config>,
+    ) -> Result<Option<Self::Input>, String> {
+        let body = if let Some(b) = event.comment_body() {
+            b
+        } else {
+            // not interested in other events
+            return Ok(None);
+        };
+
+        if let Event::Issue(e) = event {
+            if !matches!(e.action, github::IssuesAction::Opened | github::IssuesAction::Edited) {
+                // skip events other than opening or editing the issue to avoid retriggering commands in the
+                // issue body
+                return Ok(None);
+            }
+        }
+
+        let mut input = Input::new(&body, &ctx.username);
+        let command = input.parse_command();
+        
+        if let Some(previous) = event.comment_from() {
+            let mut prev_input = Input::new(&previous, &ctx.username);
+            let prev_command = prev_input.parse_command();
+            if command == prev_command {
+                return Ok(None);
+            }
+        }
+        
+        match command {
+            Command::Relabel(Ok(command)) => Ok(Some(command)),
+            Command::Relabel(Err(err)) => {
+                return Err(format!(
+                    "Parsing label command in [comment]({}) failed: {}",
+                    event.html_url().expect("has html url"),
+                    err
+                ));
+            }
+            _ => Ok(None),
+        }
+    }
+
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        config: &'a RelabelConfig,
+        event: &'a Event,
+        input: RelabelCommand,
+    ) -> BoxFuture<'a, anyhow::Result<()>> {
+        handle_input(ctx, config, event, input).boxed()
+    }
+}
+
+async fn handle_input(
     ctx: &Context,
     config: &RelabelConfig,
     event: &Event,

+ 92 - 0
src/handlers/tracking_issue.rs

@@ -0,0 +1,92 @@
+#![cfg(empty)]
+use crate::{
+    github::GithubClient,
+    registry::{Event, Handler},
+    team::Team,
+    IssueCommentAction, IssueCommentEvent,
+};
+use failure::Error;
+use lazy_static::lazy_static;
+use regex::Regex;
+
+pub struct TrackingIssueHandler {
+    pub client: GithubClient,
+}
+
+impl TrackingIssueHandler {
+    /// Automates creating tracking issues.
+    ///
+    /// This command is initially restricted to members of Rust teams.
+    ///
+    /// This command is rare, and somewhat high-impact, so it requires the `@bot` prefix.
+    /// The syntax for creating a tracking issue follows. Note that only the libs and lang teams are
+    /// currently supported; it's presumed that the other teams may want significantly different
+    /// issue formats, so only these two are supported for the time being.
+    ///
+    /// `@bot tracking-issue create feature="<short feature description>" team=[libs|lang]`
+    ///
+    /// This creates the tracking issue, though it's likely that the invokee will want to edit its
+    /// body/title.
+    ///
+    /// Long-term, this will also create a thread on internals and lock the tracking issue,
+    /// directing commentary to the thread, but for the time being we limit the scope of work as
+    /// well as project impact.
+    fn handle_create(&self, event: &IssueCommentEvent) -> Result<(), Error> {
+        lazy_static! {
+            static ref RE_TRACKING: Regex = Regex::new(&format!(
+                r#"\b@{} tracking-issue create feature=("[^"]+|\S+) team=(libs|lang)"#,
+                crate::BOT_USER_NAME,
+            ))
+            .unwrap();
+        }
+
+        // Skip this event if the comment is edited or deleted.
+        if event.action != IssueCommentAction::Created {
+            return Ok(());
+        }
+
+        #[allow(unused)]
+        let feature;
+        #[allow(unused)]
+        let team;
+
+        if let Some(captures) = RE_TRACKING.captures(&event.comment.body) {
+            #[allow(unused)]
+            {
+                feature = captures.get(1).unwrap();
+                team = captures.get(2).unwrap().as_str().parse::<Team>()?;
+            }
+        } else {
+            // no tracking issue creation comment
+            return Ok(());
+        }
+
+        // * Create tracking issue (C-tracking-issue, T-{team})
+        // * Post comment with link to issue and suggestion on what to do next
+
+        Ok(())
+    }
+
+    /// Links issues to tracking issues.
+    ///
+    /// We verify that the tracking issue listed is in fact a tracking issue (i.e., has the
+    /// C-tracking-issue label). Next, the tracking issue's top comment is updated with a link and
+    /// title of the issue linked as a checkbox in the bugs list.
+    ///
+    /// We also label the issue with `tracked-bug`.
+    ///
+    /// TODO: Check the checkbox in the tracking issue when `tracked-bug` is closed.
+    ///
+    /// Syntax: `link: #xxx`
+    fn handle_link(&self, _event: &IssueCommentEvent) -> Result<(), Error> {
+        Ok(())
+    }
+}
+
+impl Handler for TrackingIssueHandler {
+    fn handle_event(&self, event: &Event) -> Result<(), Error> {
+        //self.handle_create(&event)?;
+        //self.handle_link(&event)?;
+        Ok(())
+    }
+}