Skip to content

Automatically fixup linked issues in subtree repository #1897

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub(crate) struct Config {
pub(crate) merge_conflicts: Option<MergeConflictConfig>,
pub(crate) bot_pull_requests: Option<BotPullRequests>,
pub(crate) rendered_link: Option<RenderedLinkConfig>,
pub(crate) canonicalize_issue_links: Option<CanonicalizeIssueLinksConfig>,
}

#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
Expand Down Expand Up @@ -414,6 +415,11 @@ pub(crate) struct RenderedLinkConfig {
pub(crate) trigger_files: Vec<String>,
}

#[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<Result<Arc<Config>, ConfigurationError>> {
let cache = CONFIG_CACHE.read().unwrap();
cache.get(repo).and_then(|(config, fetch_time)| {
Expand Down Expand Up @@ -535,6 +541,8 @@ mod tests {

[shortcut]

[canonicalize-issue-links]

[rendered-link]
trigger-files = ["posts/"]
"#;
Expand Down Expand Up @@ -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 {}),
}
);
}
Expand Down Expand Up @@ -662,6 +671,7 @@ mod tests {
merge_conflicts: None,
bot_pull_requests: None,
rendered_link: None,
canonicalize_issue_links: None
}
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 2 additions & 0 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -224,6 +225,7 @@ macro_rules! issue_handlers {
issue_handlers! {
assign,
autolabel,
canonicalize_issue_links,
major_change,
mentions,
no_merges,
Expand Down
117 changes: 117 additions & 0 deletions src/handlers/canonicalize_issue_links.rs
Original file line number Diff line number Diff line change
@@ -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<Regex> = LazyLock::new(|| {
Regex::new("(?i)(?P<action>close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)(?P<spaces>:? +)(?P<issue>#[0-9]+)")
.unwrap()
});

pub(super) struct CanonicalizeIssueLinksInput {}

pub(super) async fn parse_input(
_ctx: &Context,
event: &IssuesEvent,
config: Option<&CanonicalizeIssueLinksConfig>,
) -> Result<Option<CanonicalizeIssueLinksInput>, 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);
}
Loading