Skip to content

Commit f3d524f

Browse files
Shahinyanmclaude
andcommitted
feat(artifacts): capture short PR refs + harvest merged PR by commit
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 <HEAD-sha> --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) <noreply@anthropic.com>
1 parent 7aef408 commit f3d524f

5 files changed

Lines changed: 69 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.26.6] - 2026-06-17
11+
12+
### Added
13+
- **Short PR references are captured too.** The artifact extractor now reads
14+
`PR #51` / `PR#51` / `pull request #51` from event text (normalised to
15+
`PR #51`), not just full `/pull/N` URLs — anchored to the PR keyword so a
16+
bare `#3` in prose is not mistaken for a PR.
17+
- **Close harvest finds the merged PR even after the branch is gone.** When a
18+
task is closed on `main` (the branch already deleted, so `gh pr view` finds
19+
nothing), the harvest falls back to GitHub commit search
20+
(`gh pr list --search <HEAD-sha> --state merged`) to recover the PR that
21+
introduced the current commit. Still best-effort — no `gh`/match just yields
22+
no PR.
23+
1024
## [0.26.5] - 2026-06-17
1125

1226
### Added

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.26.5"
10+
version = "0.26.6"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-core/src/artifacts.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ pub fn extract(text: &str) -> Artifacts {
8686
text,
8787
);
8888

89+
// Short PR references: "PR #51", "PR#51", "pull request #51". Anchored to
90+
// the PR / "pull request" keyword so a bare "#3" in prose (step #3, issue
91+
// #3) is NOT captured. Normalised to "PR #<n>" so it dedupes cleanly and
92+
// renders next to full URLs under the same `PRs:` group.
93+
if let Ok(re) = Regex::new(r"(?i)\b(?:PR|pull request)\s*#(\d+)\b") {
94+
for cap in re.captures_iter(text) {
95+
if let Some(m) = cap.get(1) {
96+
a.pr_urls.push(format!("PR #{}", m.as_str()));
97+
}
98+
}
99+
}
100+
89101
// Ticket IDs: ABC-123. At least 2 letters to avoid matching version
90102
// strings like v1-2 and minimum 1 digit.
91103
static_re(
@@ -240,6 +252,15 @@ mod tests {
240252
assert!(a.is_empty());
241253
}
242254

255+
#[test]
256+
fn captures_short_pr_reference_but_not_bare_hash() {
257+
let a = extract("merged PR #51 and PR#52, see pull request #53");
258+
assert_eq!(a.pr_urls, vec!["PR #51", "PR #52", "PR #53"]);
259+
// bare hashes in prose must NOT be captured as PRs
260+
let b = extract("step #3 of 5, issue #7, line #42");
261+
assert!(b.pr_urls.is_empty(), "got: {:?}", b.pr_urls);
262+
}
263+
243264
#[test]
244265
fn json_round_trip() {
245266
let a = Artifacts {

crates/tj-core/src/harvest.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ pub fn build(branch: Option<String>, commit: Option<String>, pr_url: Option<Stri
4949
pub fn harvest(dir: &Path) -> Artifacts {
5050
let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]);
5151
let commit = git(dir, &["rev-parse", "--short", "HEAD"]);
52-
let pr_url = gh_pr_url(dir);
52+
// PR resolution, best-effort and in order of reliability:
53+
// 1. the open PR for the current branch (pre-merge close), else
54+
// 2. the merged PR that contains HEAD (post-merge close, branch gone).
55+
// The second covers the common case where the task is closed on `main`
56+
// after the branch was deleted, so `gh pr view` finds nothing.
57+
let pr_url = gh_pr_url(dir)
58+
.or_else(|| git(dir, &["rev-parse", "HEAD"]).and_then(|sha| gh_pr_for_commit(dir, &sha)));
5359
build(branch, commit, pr_url)
5460
}
5561

@@ -94,6 +100,29 @@ fn gh_pr_url(dir: &Path) -> Option<String> {
94100
}
95101
}
96102

103+
/// Best-effort URL of the merged PR that introduced `sha`, via GitHub's commit
104+
/// search. Used as a fallback when the branch's open PR is gone (task closed on
105+
/// `main` after the branch was deleted). `None` on any failure.
106+
fn gh_pr_for_commit(dir: &Path, sha: &str) -> Option<String> {
107+
let out = Command::new("gh")
108+
.args([
109+
"pr", "list", "--state", "merged", "--search", sha, "--limit", "1", "--json", "url",
110+
"-q", ".[0].url",
111+
])
112+
.current_dir(dir)
113+
.output()
114+
.ok()?;
115+
if !out.status.success() {
116+
return None;
117+
}
118+
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
119+
if s.starts_with("http") {
120+
Some(s)
121+
} else {
122+
None
123+
}
124+
}
125+
97126
#[cfg(test)]
98127
mod tests {
99128
use super::*;

0 commit comments

Comments
 (0)