249 lines
9.9 KiB
Rust
249 lines
9.9 KiB
Rust
//! Detection of `git commit` / `gh pr create` / `gh pr merge` in terminal
|
|
//! commands, shared by the bash tool's counter spans and the shell's PR-metric
|
|
//! session signals (the shell inspects `BashOutput.command` / output at its
|
|
//! tool-result chokepoint rather than receiving detection through the tool).
|
|
|
|
/// Git/GitHub operations detected in a successful terminal command.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct DetectedGitOps {
|
|
/// A non-dry-run `git commit` statement ran.
|
|
pub committed: bool,
|
|
/// A `gh pr create` statement ran; url/number parsed from output when printed.
|
|
pub pr_created: Option<PrRef>,
|
|
/// A `gh pr merge` statement ran.
|
|
pub pr_merged: bool,
|
|
}
|
|
|
|
/// Reference to a pull request parsed from command output.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct PrRef {
|
|
/// Full PR URL (e.g. `https://github.com/owner/repo/pull/123`).
|
|
pub url: Option<String>,
|
|
/// PR number parsed from the URL path.
|
|
pub number: Option<u64>,
|
|
}
|
|
|
|
impl PrRef {
|
|
/// Find the last `http(s)://…/pull/<N>` URL in `text` — `gh pr create`
|
|
/// stdout, or an MCP create_pull_request result (URLs may be embedded in
|
|
/// JSON strings). Returns `None` when no PR URL is present (e.g.
|
|
/// `gh pr create --web`).
|
|
pub fn find_in(text: &str) -> Option<Self> {
|
|
let mut last = None;
|
|
for (start, _) in text.match_indices("http") {
|
|
let rest = &text[start..];
|
|
if !rest.starts_with("https://") && !rest.starts_with("http://") {
|
|
continue;
|
|
}
|
|
let end = rest
|
|
.find(|c: char| {
|
|
c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | '\\' | '`')
|
|
})
|
|
.unwrap_or(rest.len());
|
|
let url = rest[..end].trim_end_matches(['.', ',', ';', ':', ')', ']', '}']);
|
|
// rsplit: an owner/repo literally named "pull" must not eat the marker.
|
|
let Some((_, tail)) = url.rsplit_once("/pull/") else {
|
|
continue;
|
|
};
|
|
let digits: String = tail.chars().take_while(char::is_ascii_digit).collect();
|
|
let Ok(number) = digits.parse::<u64>() else {
|
|
continue;
|
|
};
|
|
let url_len = url.len() - tail.len() + digits.len();
|
|
last = Some(PrRef {
|
|
url: Some(url[..url_len].to_string()),
|
|
number: Some(number),
|
|
});
|
|
}
|
|
last
|
|
}
|
|
}
|
|
|
|
/// Strip invocation prefixes that precede the actual binary in a statement:
|
|
/// `env` (with `-u NAME` args), `VAR=value` assignments, and an absolute /
|
|
/// relative path on the binary itself (`/opt/homebrew/bin/gh` → `gh`).
|
|
/// Covers common `env` / `VAR=value` / absolute-path wrappers around git/gh.
|
|
fn strip_invocation_prefixes(statement: &str) -> &str {
|
|
let mut rest = statement.trim_start();
|
|
loop {
|
|
let token_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
|
let token = &rest[..token_end];
|
|
let is_env = token == "env";
|
|
let is_env_unset = token == "-u";
|
|
let is_assignment = token.split_once('=').is_some_and(|(name, _)| {
|
|
!name.is_empty()
|
|
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
|
&& !name.starts_with(|c: char| c.is_ascii_digit())
|
|
});
|
|
if (is_env || is_env_unset || is_assignment) && token_end < rest.len() {
|
|
rest = rest[token_end..].trim_start();
|
|
// `-u` consumes its NAME argument too.
|
|
if is_env_unset {
|
|
let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
|
if name_end < rest.len() {
|
|
rest = rest[name_end..].trim_start();
|
|
} else {
|
|
return rest;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
// Path-invoked binary: keep only the basename token.
|
|
let token_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
|
if let Some(slash) = rest[..token_end].rfind('/')
|
|
&& matches!(&rest[slash + 1..token_end], "git" | "gh")
|
|
{
|
|
rest = &rest[slash + 1..];
|
|
}
|
|
rest
|
|
}
|
|
|
|
/// Detect `git commit` / `gh pr create` / `gh pr merge` statements in a
|
|
/// successful command. Matched per shell statement, anchored at the statement
|
|
/// start (after invocation prefixes), so `echo "git commit"`, comments, and
|
|
/// `git commit-graph` don't count.
|
|
///
|
|
/// `output_for_prompt` is scanned for the created PR's URL (`gh pr create`
|
|
/// prints it as the last stdout line; absent for `--web`, leaving an empty
|
|
/// [`PrRef`]). Callers must only pass exit-code-0 results.
|
|
pub fn detect_git_ops(command: &str, output_for_prompt: &str) -> Option<DetectedGitOps> {
|
|
let statements = || {
|
|
command
|
|
.split(['\n', ';', '&', '|'])
|
|
.map(strip_invocation_prefixes)
|
|
};
|
|
// `excluded` guards flags that make the statement a no-op for the metric
|
|
// (`--dry-run` doesn't commit/create; `--disable-auto` un-queues a merge).
|
|
let statement_runs = |prefix: &str, excluded: &str| {
|
|
statements().any(|s| {
|
|
s.strip_prefix(prefix)
|
|
.is_some_and(|r| r.is_empty() || r.starts_with(char::is_whitespace))
|
|
&& !s.contains(excluded)
|
|
})
|
|
};
|
|
let committed = statement_runs("git commit", "--dry-run");
|
|
let pr_created = statement_runs("gh pr create", "--dry-run")
|
|
.then(|| PrRef::find_in(output_for_prompt).unwrap_or_default());
|
|
let pr_merged = statement_runs("gh pr merge", "--disable-auto");
|
|
(committed || pr_created.is_some() || pr_merged).then_some(DetectedGitOps {
|
|
committed,
|
|
pr_created,
|
|
pr_merged,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn commit_and_pr_create_with_url() {
|
|
let out = "exit: 0\nhttps://github.com/xai-org/example/pull/12345\n";
|
|
let ops = detect_git_ops("git commit -m 'x' && gh pr create --fill", out).unwrap();
|
|
assert!(ops.committed);
|
|
assert!(!ops.pr_merged);
|
|
let pr = ops.pr_created.unwrap();
|
|
assert_eq!(
|
|
pr.url.as_deref(),
|
|
Some("https://github.com/xai-org/example/pull/12345")
|
|
);
|
|
assert_eq!(pr.number, Some(12345));
|
|
}
|
|
|
|
#[test]
|
|
fn pr_create_web_has_no_url() {
|
|
let ops = detect_git_ops("gh pr create --web", "exit: 0\nOpening browser...\n").unwrap();
|
|
let pr = ops.pr_created.unwrap();
|
|
assert_eq!(pr.url, None);
|
|
assert_eq!(pr.number, None);
|
|
}
|
|
|
|
#[test]
|
|
fn pr_merge_detected() {
|
|
let ops = detect_git_ops("gh pr merge 42 --squash", "exit: 0\n").unwrap();
|
|
assert!(ops.pr_merged);
|
|
assert!(ops.pr_created.is_none());
|
|
assert!(!ops.committed);
|
|
}
|
|
|
|
#[test]
|
|
fn statement_anchoring_rejects_lookalikes() {
|
|
assert!(detect_git_ops(r#"echo "git commit""#, "").is_none());
|
|
assert!(detect_git_ops("git commit-graph write", "").is_none());
|
|
assert!(detect_git_ops(r#"echo "gh pr create is fun""#, "").is_none());
|
|
assert!(detect_git_ops("git commit --dry-run", "").is_none());
|
|
assert!(detect_git_ops("gh pr create --dry-run", "").is_none());
|
|
assert!(detect_git_ops("gh pr merge 42 --disable-auto", "").is_none());
|
|
assert!(detect_git_ops("gh pr view 42", "").is_none());
|
|
assert!(detect_git_ops("grep 'gh pr create' transcript.txt", "").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn multi_statement_split() {
|
|
let ops = detect_git_ops("cd /repo; git commit -am wip", "").unwrap();
|
|
assert!(ops.committed);
|
|
assert!(detect_git_ops("ls | grep foo", "").is_none());
|
|
}
|
|
|
|
// Representative invocation-prefix shapes (env vars, absolute paths, env -u).
|
|
#[test]
|
|
fn invocation_prefixes_are_stripped() {
|
|
for cmd in [
|
|
r#"GH_TOKEN="$GITHUB_TOKEN_FORGE" gh pr create --repo o/r --base main"#,
|
|
"NO_COLOR=1 CLICOLOR_FORCE= FORCE_COLOR= gh pr create --fill",
|
|
"/usr/local/bin/gh pr create --head my-branch",
|
|
"/opt/homebrew/bin/gh pr create --fill",
|
|
"env -u GITHUB_TOKEN gh pr create --base main",
|
|
"cd /repo && GIT_AUTHOR_NAME=x /usr/bin/git commit -m msg",
|
|
] {
|
|
assert!(detect_git_ops(cmd, "").is_some(), "should match: {cmd}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn prefix_stripping_does_not_overreach() {
|
|
// Assignment-only statements and non-git/gh path binaries don't match.
|
|
assert!(detect_git_ops("FOO=gh pr create", "").is_none());
|
|
assert!(detect_git_ops("/usr/bin/echo gh pr create", "").is_none());
|
|
assert!(detect_git_ops("env", "").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn pr_ref_find_in_takes_last_url_and_trims_punctuation() {
|
|
let text = "see https://github.com/o/r/pull/1.\nhttps://ghe.example.test/team/repo/pull/987/files\n";
|
|
let pr = PrRef::find_in(text).unwrap();
|
|
assert_eq!(
|
|
pr.url.as_deref(),
|
|
Some("https://ghe.example.test/team/repo/pull/987")
|
|
);
|
|
assert_eq!(pr.number, Some(987));
|
|
}
|
|
|
|
#[test]
|
|
fn pr_ref_find_in_handles_json_embedded_url() {
|
|
let text = r#"{"number":5,"html_url":"https://github.com/o/r/pull/5","state":"open"}"#;
|
|
let pr = PrRef::find_in(text).unwrap();
|
|
assert_eq!(pr.url.as_deref(), Some("https://github.com/o/r/pull/5"));
|
|
assert_eq!(pr.number, Some(5));
|
|
}
|
|
|
|
#[test]
|
|
fn pr_ref_find_in_handles_repo_named_pull() {
|
|
let pr = PrRef::find_in("https://github.com/org/pull/pull/7").unwrap();
|
|
assert_eq!(
|
|
pr.url.as_deref(),
|
|
Some("https://github.com/org/pull/pull/7")
|
|
);
|
|
assert_eq!(pr.number, Some(7));
|
|
}
|
|
|
|
#[test]
|
|
fn pr_ref_find_in_rejects_non_pr_text() {
|
|
assert_eq!(PrRef::find_in("no urls here"), None);
|
|
assert_eq!(PrRef::find_in("https://github.com/o/r/issues/5"), None);
|
|
assert_eq!(PrRef::find_in("git pull origin main"), None);
|
|
assert_eq!(PrRef::find_in("https://github.com/o/r/pull/"), None);
|
|
}
|
|
}
|