use std::ops::Range;
use crate::shell::{Parsed, Simple};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
TagPush,
PrMerge,
}
impl Kind {
pub fn skill(self) -> &'static str {
match self {
Kind::TagPush => "tag-release",
Kind::PrMerge => "merge-when-green",
}
}
}
#[derive(Debug, Clone)]
pub struct Publish {
pub kind: Kind,
pub what: String,
pub span: Range<usize>,
}
pub fn is_version_tag(text: &str) -> bool {
let text = text.strip_prefix('+').unwrap_or(text);
let rest = text.strip_prefix("refs/tags/").unwrap_or(text);
matches!(rest.strip_prefix('v'), Some(r) if r.starts_with(|c: char| c.is_ascii_digit()))
}
pub fn tag_push(cmd: &Simple) -> Option<(String, Range<usize>)> {
if cmd.program() != Some("git") || cmd.subcommand() != Some("push") || cmd.is_dry_run() {
return None;
}
if cmd.has_flag("--delete") || cmd.has_flag("-d") {
return None;
}
if let Some(w) = cmd.args().iter().find(|w| is_version_tag(&w.text)) {
return Some((w.text.clone(), w.at..cmd.end));
}
for flag in ["--tags", "--mirror"] {
if cmd.has_flag(flag) {
return Some((flag.to_string(), cmd.at..cmd.end));
}
}
None
}
pub fn gh_pr_merge(cmd: &Simple) -> bool {
cmd.program() == Some("gh")
&& cmd.subcommand() == Some("pr")
&& cmd.operands().get(1).map(|w| w.text.as_str()) == Some("merge")
}
pub fn is_pr_merge_path(text: &str) -> bool {
let mut rest = text;
while let Some(i) = rest.find("/pulls/") {
let after = &rest[i + "/pulls/".len()..];
let digits = after.len() - after.trim_start_matches(|c: char| c.is_ascii_digit()).len();
if digits > 0 {
let tail = &after[digits..];
if tail == "/merge" || tail.starts_with("/merge?") || tail.starts_with("/merge/") {
return true;
}
}
rest = after;
}
false
}
pub fn is_http_client(program: &str) -> bool {
matches!(
program,
"curl" | "wget" | "http" | "https" | "httpie" | "xh"
)
}
pub fn api_merge(cmd: &Simple) -> Option<(String, Range<usize>)> {
let program = cmd.program()?;
let gh_api = program == "gh" && cmd.subcommand() == Some("api");
if !is_http_client(program) && !gh_api {
return None;
}
let hit = cmd.args().iter().find(|w| is_pr_merge_path(&w.text))?;
Some((program.to_string(), hit.at..cmd.end))
}
pub fn classify(parsed: &Parsed) -> Option<Publish> {
for cmd in parsed.judgeable() {
if let Some((what, span)) = tag_push(cmd) {
return Some(Publish {
kind: Kind::TagPush,
what,
span,
});
}
if gh_pr_merge(cmd) {
return Some(Publish {
kind: Kind::PrMerge,
what: "gh pr merge".to_string(),
span: cmd.at..cmd.end,
});
}
if let Some((what, span)) = api_merge(cmd) {
return Some(Publish {
kind: Kind::PrMerge,
what,
span,
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn kind(src: &str) -> Option<Kind> {
classify(&crate::shell::lex(src)).map(|p| p.kind)
}
#[test]
fn version_tags_only() {
assert!(is_version_tag("v0.9.1"));
assert!(is_version_tag("v2"));
assert!(is_version_tag("refs/tags/v1.0.0-rc2"));
assert!(is_version_tag("+refs/tags/v1.0.0"));
assert!(is_version_tag("+v3.1.0"));
assert!(!is_version_tag("verify"));
assert!(!is_version_tag("main"));
assert!(!is_version_tag("docs-snapshot"));
assert!(!is_version_tag("feat/v2-rewrite"));
}
#[test]
fn tag_pushes() {
assert_eq!(kind("git push origin v1.2.3"), Some(Kind::TagPush));
assert_eq!(
kind("git push origin refs/tags/v1.2.3"),
Some(Kind::TagPush)
);
assert_eq!(kind("git push --tags"), Some(Kind::TagPush));
assert_eq!(kind("git push --mirror backup"), Some(Kind::TagPush));
assert_eq!(
kind("git push origin +refs/tags/v1.2.3"),
Some(Kind::TagPush)
);
assert_eq!(kind("git push --dry-run origin v1.2.3"), None);
assert_eq!(kind("git push --delete origin v1.2.3"), None);
assert_eq!(kind("git push --follow-tags origin main"), None);
assert_eq!(kind("git push -u origin feat/v2"), None);
}
#[test]
fn merges() {
assert_eq!(kind("gh pr merge 12 --squash"), Some(Kind::PrMerge));
assert_eq!(kind("gh pr merge --auto 12"), Some(Kind::PrMerge));
assert_eq!(kind("gh pr view 12"), None);
assert_eq!(kind("gh pr create --body 'gh pr merge later'"), None);
assert_eq!(
kind("curl -X POST https://git.example/api/v1/repos/o/r/pulls/7/merge -d '{}'"),
Some(Kind::PrMerge)
);
assert_eq!(
kind("gh api -X PUT repos/o/r/pulls/7/merge"),
Some(Kind::PrMerge)
);
assert_eq!(
kind("curl https://git.example/api/v1/repos/o/r/pulls/7"),
None
);
}
#[test]
fn the_first_publish_wins() {
let p = classify(&crate::shell::lex("cd /r && git push origin v1.0.0")).unwrap();
assert_eq!(p.what, "v1.0.0");
assert_eq!(p.kind.skill(), "tag-release");
}
#[test]
fn merge_paths() {
assert!(is_pr_merge_path(
"https://git.daddyshome.fr/api/v1/repos/fredericrous/sre-agent/pulls/10/merge"
));
assert!(is_pr_merge_path(
"https://api.github.com/repos/o/r/pulls/1234/merge?foo=1"
));
assert!(!is_pr_merge_path(
"https://git.daddyshome.fr/api/v1/repos/o/r/pulls/10"
));
assert!(!is_pr_merge_path(
"https://git.daddyshome.fr/api/v1/repos/o/r/pulls/merge"
));
assert!(!is_pr_merge_path("https://example.com/merge"));
}
}