diff --git a/src/config.rs b/src/config.rs index df043173b..7fbb16703 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,6 +46,7 @@ pub(crate) struct Config { pub(crate) merge_conflicts: Option, pub(crate) bot_pull_requests: Option, pub(crate) rendered_link: Option, + pub(crate) canonicalize_issue_links: Option, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] @@ -414,6 +415,11 @@ pub(crate) struct RenderedLinkConfig { pub(crate) trigger_files: Vec, } +#[derive(PartialEq, Eq, Debug, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +pub(crate) struct CanonicalizeIssueLinksConfig {} + fn get_cached_config(repo: &str) -> Option, ConfigurationError>> { let cache = CONFIG_CACHE.read().unwrap(); cache.get(repo).and_then(|(config, fetch_time)| { @@ -535,6 +541,8 @@ mod tests { [shortcut] + [canonicalize-issue-links] + [rendered-link] trigger-files = ["posts/"] "#; @@ -598,7 +606,8 @@ mod tests { bot_pull_requests: None, rendered_link: Some(RenderedLinkConfig { trigger_files: vec!["posts/".to_string()] - }) + }), + canonicalize_issue_links: Some(CanonicalizeIssueLinksConfig {}), } ); } @@ -662,6 +671,7 @@ mod tests { merge_conflicts: None, bot_pull_requests: None, rendered_link: None, + canonicalize_issue_links: None } ); } diff --git a/src/github.rs b/src/github.rs index 8e0212a4c..fc4c57a93 100644 --- a/src/github.rs +++ b/src/github.rs @@ -522,7 +522,7 @@ impl IssueRepository { ) } - fn full_repo_name(&self) -> String { + pub(crate) fn full_repo_name(&self) -> String { format!("{}/{}", self.organization, self.repository) } diff --git a/src/handlers.rs b/src/handlers.rs index 3b62cb18a..9dca005c8 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -27,6 +27,7 @@ impl fmt::Display for HandlerError { mod assign; mod autolabel; mod bot_pull_requests; +mod canonicalize_issue_links; mod check_commits; mod close; pub mod docs_update; @@ -224,6 +225,7 @@ macro_rules! issue_handlers { issue_handlers! { assign, autolabel, + canonicalize_issue_links, major_change, mentions, no_merges, diff --git a/src/handlers/canonicalize_issue_links.rs b/src/handlers/canonicalize_issue_links.rs new file mode 100644 index 000000000..ab1050ef9 --- /dev/null +++ b/src/handlers/canonicalize_issue_links.rs @@ -0,0 +1,117 @@ +//! This handler is used to canonicalize linked GitHub issues into their long form +//! so that when pulling subtree into the main repository we don't accidentaly +//! close issues in the wrong repository. +//! +//! Example: `Fixes #123` (in rust-lang/clippy) would now become `Fixes rust-lang/clippy#123` + +use std::borrow::Cow; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::{ + config::CanonicalizeIssueLinksConfig, + github::{IssuesAction, IssuesEvent}, + handlers::Context, +}; + +// Taken from https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue?quot#linking-a-pull-request-to-an-issue-using-a-keyword +static LINKED_RE: LazyLock = LazyLock::new(|| { + Regex::new("(?i)(?Pclose|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)(?P:? +)(?P#[0-9]+)") + .unwrap() +}); + +pub(super) struct CanonicalizeIssueLinksInput {} + +pub(super) async fn parse_input( + _ctx: &Context, + event: &IssuesEvent, + config: Option<&CanonicalizeIssueLinksConfig>, +) -> Result, String> { + if !event.issue.is_pr() { + return Ok(None); + } + + if !matches!( + event.action, + IssuesAction::Opened | IssuesAction::Reopened | IssuesAction::Edited + ) { + return Ok(None); + } + + // Require a `[canonicalize-issue-links]` configuration block to enable the handler. + if config.is_none() { + return Ok(None); + }; + + Ok(Some(CanonicalizeIssueLinksInput {})) +} + +pub(super) async fn handle_input( + ctx: &Context, + _config: &CanonicalizeIssueLinksConfig, + e: &IssuesEvent, + _input: CanonicalizeIssueLinksInput, +) -> anyhow::Result<()> { + let full_repo_name = e.issue.repository().full_repo_name(); + + let new_body = fix_linked_issues(&e.issue.body, full_repo_name.as_str()); + + if e.issue.body != new_body { + e.issue.edit_body(&ctx.github, &new_body).await?; + } + + Ok(()) +} + +fn fix_linked_issues<'a>(body: &'a str, full_repo_name: &str) -> Cow<'a, str> { + let replace_by = format!("${{action}}${{spaces}}{full_repo_name}${{issue}}"); + LINKED_RE.replace_all(body, replace_by) +} + +#[test] +fn fixed_body() { + let full_repo_name = "rust-lang/rust"; + + let body = r#" + This is a PR. + + Fix #123 + fixed #456 + Fixes #7895 + Closes: #987 + resolves: #655 + Resolves #00000 Closes #888 + "#; + + let fixed_body = r#" + This is a PR. + + Fix rust-lang/rust#123 + fixed rust-lang/rust#456 + Fixes rust-lang/rust#7895 + Closes: rust-lang/rust#987 + resolves: rust-lang/rust#655 + Resolves rust-lang/rust#00000 Closes rust-lang/rust#888 + "#; + + let new_body = fix_linked_issues(body, full_repo_name); + assert_eq!(new_body, fixed_body); +} + +#[test] +fn untouched_body() { + let full_repo_name = "rust-lang/rust"; + + let body = r#" + This is a PR. + + Fix rust-lang#123 + Fixesd #7895 + Resolves #abgt + Resolves: #abgt + "#; + + let new_body = fix_linked_issues(body, full_repo_name); + assert_eq!(new_body, body); +}