From f3d524fb3e8d94407936b0d3b461a27f08116d97 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Wed, 17 Jun 2026 14:21:28 +0400 Subject: [PATCH] feat(artifacts): capture short PR refs + harvest merged PR by commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two refinements to the close-time ledger: - artifact extractor now reads "PR #51" / "PR#51" / "pull request #51" from event text (normalised to "PR #51"), anchored to the PR keyword so a bare "#3" in prose is not captured. - harvest falls back to GitHub commit search (gh pr list --search --state merged) when the branch's open PR is gone — the common case of closing a task on main after the branch was deleted. Best-effort. Bump 0.26.6. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++++++++++++++ Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/tj-core/src/artifacts.rs | 21 +++++++++++++++++++++ crates/tj-core/src/harvest.rs | 31 ++++++++++++++++++++++++++++++- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d85bf14..03bd5b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --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 diff --git a/Cargo.lock b/Cargo.lock index 9dee049..b6756b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2516,7 +2516,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.26.5" +version = "0.26.6" dependencies = [ "anyhow", "assert_cmd", @@ -2540,7 +2540,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.26.5" +version = "0.26.6" dependencies = [ "anyhow", "chrono", @@ -2565,7 +2565,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.26.5" +version = "0.26.6" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 2755a62..e83283e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.26.5" +version = "0.26.6" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-core/src/artifacts.rs b/crates/tj-core/src/artifacts.rs index c4aa1af..9e7ee76 100644 --- a/crates/tj-core/src/artifacts.rs +++ b/crates/tj-core/src/artifacts.rs @@ -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 #" 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( @@ -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 { diff --git a/crates/tj-core/src/harvest.rs b/crates/tj-core/src/harvest.rs index 933e909..31520a8 100644 --- a/crates/tj-core/src/harvest.rs +++ b/crates/tj-core/src/harvest.rs @@ -49,7 +49,13 @@ pub fn build(branch: Option, commit: Option, pr_url: Option 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) } @@ -94,6 +100,29 @@ fn gh_pr_url(dir: &Path) -> Option { } } +/// 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 { + 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::*;