Skip to content
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.26.6] - 2026-06-17

### Added
- **Short PR references are captured too.** The artifact extractor now reads
`PR #51` / `PR#51` / `pull request #51` from event text (normalised to
`PR #51`), not just full `/pull/N` URLs — anchored to the PR keyword so a
bare `#3` in prose is not mistaken for a PR.
- **Close harvest finds the merged PR even after the branch is gone.** When a
task is closed on `main` (the branch already deleted, so `gh pr view` finds
nothing), the harvest falls back to GitHub commit search
(`gh pr list --search <HEAD-sha> --state merged`) to recover the PR that
introduced the current commit. Still best-effort — no `gh`/match just yields
no PR.

## [0.26.5] - 2026-06-17

### Added
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.26.5"
version = "0.26.6"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
21 changes: 21 additions & 0 deletions crates/tj-core/src/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ pub fn extract(text: &str) -> Artifacts {
text,
);

// Short PR references: "PR #51", "PR#51", "pull request #51". Anchored to
// the PR / "pull request" keyword so a bare "#3" in prose (step #3, issue
// #3) is NOT captured. Normalised to "PR #<n>" so it dedupes cleanly and
// renders next to full URLs under the same `PRs:` group.
if let Ok(re) = Regex::new(r"(?i)\b(?:PR|pull request)\s*#(\d+)\b") {
for cap in re.captures_iter(text) {
if let Some(m) = cap.get(1) {
a.pr_urls.push(format!("PR #{}", m.as_str()));
}
}
}

// Ticket IDs: ABC-123. At least 2 letters to avoid matching version
// strings like v1-2 and minimum 1 digit.
static_re(
Expand Down Expand Up @@ -240,6 +252,15 @@ mod tests {
assert!(a.is_empty());
}

#[test]
fn captures_short_pr_reference_but_not_bare_hash() {
let a = extract("merged PR #51 and PR#52, see pull request #53");
assert_eq!(a.pr_urls, vec!["PR #51", "PR #52", "PR #53"]);
// bare hashes in prose must NOT be captured as PRs
let b = extract("step #3 of 5, issue #7, line #42");
assert!(b.pr_urls.is_empty(), "got: {:?}", b.pr_urls);
}

#[test]
fn json_round_trip() {
let a = Artifacts {
Expand Down
31 changes: 30 additions & 1 deletion crates/tj-core/src/harvest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ pub fn build(branch: Option<String>, commit: Option<String>, pr_url: Option<Stri
pub fn harvest(dir: &Path) -> Artifacts {
let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]);
let commit = git(dir, &["rev-parse", "--short", "HEAD"]);
let pr_url = gh_pr_url(dir);
// PR resolution, best-effort and in order of reliability:
// 1. the open PR for the current branch (pre-merge close), else
// 2. the merged PR that contains HEAD (post-merge close, branch gone).
// The second covers the common case where the task is closed on `main`
// after the branch was deleted, so `gh pr view` finds nothing.
let pr_url = gh_pr_url(dir)
.or_else(|| git(dir, &["rev-parse", "HEAD"]).and_then(|sha| gh_pr_for_commit(dir, &sha)));
build(branch, commit, pr_url)
}

Expand Down Expand Up @@ -94,6 +100,29 @@ fn gh_pr_url(dir: &Path) -> Option<String> {
}
}

/// Best-effort URL of the merged PR that introduced `sha`, via GitHub's commit
/// search. Used as a fallback when the branch's open PR is gone (task closed on
/// `main` after the branch was deleted). `None` on any failure.
fn gh_pr_for_commit(dir: &Path, sha: &str) -> Option<String> {
let out = Command::new("gh")
.args([
"pr", "list", "--state", "merged", "--search", sha, "--limit", "1", "--json", "url",
"-q", ".[0].url",
])
.current_dir(dir)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.starts_with("http") {
Some(s)
} else {
None
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading