Skip to content

Commit b168375

Browse files
author
Test User
committed
feat(merge_coordinator): add BlockerKind classification to distinguish CI failures from policy holds Refs #2465
Step 1: Added BlockerKind enum (ci_failed/ci_pending/ci_no_status/not_mergeable) Step 2: Added head_sha to PrSummary, CommitCombinedStatus, get_commit_status() Step 3: Wired blocker classification into evaluate_one (now async) Tests: 34 pass, 0 fail. Clippy clean.
1 parent dccaac1 commit b168375

3 files changed

Lines changed: 151 additions & 28 deletions

File tree

crates/terraphim_merge_coordinator/src/evaluator.rs

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use tracing::{error, info, warn};
99

1010
use crate::extract_fixes;
1111
use crate::gitea::{GiteaClient, PrSummary};
12-
use crate::types::{EvalVerdict, MergeCoordinatorError, MergeOutcome};
12+
use crate::types::{BlockerKind, EvalVerdict, MergeCoordinatorError, MergeOutcome};
1313

1414
/// One evaluation of one open PR.
1515
#[derive(Debug, Clone)]
@@ -22,6 +22,8 @@ pub struct PrEvaluation {
2222
pub fixes_issues: Vec<u64>,
2323
/// Verdict reached during evaluation.
2424
pub verdict: EvalVerdict,
25+
/// Classified blocker kind when verdict is Hold (None for Merge).
26+
pub blocker_kind: Option<BlockerKind>,
2527
}
2628

2729
/// Evaluate all open PRs in `owner/repo`, sequentially. Each PR gets
@@ -34,28 +36,63 @@ pub async fn evaluate_all(
3436
let prs = gitea.list_open_prs(owner, repo).await?;
3537
let mut out = Vec::with_capacity(prs.len());
3638
for pr in prs {
37-
out.push(evaluate_one(&pr));
39+
out.push(evaluate_one(Some(gitea), owner, repo, &pr).await);
3840
}
3941
info!(count = out.len(), owner, repo, "evaluated open PRs");
4042
Ok(out)
4143
}
4244

43-
fn evaluate_one(pr: &PrSummary) -> PrEvaluation {
45+
async fn evaluate_one(
46+
gitea: Option<&GiteaClient>,
47+
owner: &str,
48+
repo: &str,
49+
pr: &PrSummary,
50+
) -> PrEvaluation {
4451
let mergeable = pr.mergeable.unwrap_or(false);
4552
let fixes_issues = extract_fixes(pr.body.as_deref().unwrap_or(""));
46-
let verdict = if !mergeable {
47-
EvalVerdict::Hold("not mergeable".into())
48-
} else if fixes_issues.is_empty() {
49-
// No Fixes #N -> safe to merge but nothing to auto-close.
50-
EvalVerdict::Merge
53+
let (verdict, blocker_kind) = if !mergeable {
54+
let kind = classify_blocker(gitea, owner, repo, pr).await;
55+
let reason = format!("not mergeable ({kind})");
56+
(EvalVerdict::Hold(reason), Some(kind))
5157
} else {
52-
EvalVerdict::Merge
58+
(EvalVerdict::Merge, None)
5359
};
5460
PrEvaluation {
5561
pr_index: pr.number,
5662
mergeable,
5763
fixes_issues,
5864
verdict,
65+
blocker_kind,
66+
}
67+
}
68+
69+
/// Query CI status and classify why a PR is blocked.
70+
async fn classify_blocker(
71+
gitea: Option<&GiteaClient>,
72+
owner: &str,
73+
repo: &str,
74+
pr: &PrSummary,
75+
) -> BlockerKind {
76+
let gitea = match gitea {
77+
Some(c) => c,
78+
None => return BlockerKind::CiNoStatus,
79+
};
80+
let sha = match pr.head_sha.as_deref() {
81+
Some(s) if !s.is_empty() => s,
82+
_ => return BlockerKind::CiNoStatus,
83+
};
84+
85+
match gitea.get_commit_status(owner, repo, sha).await {
86+
Ok(Some(combined)) => match combined.state.as_str() {
87+
"failure" | "error" => BlockerKind::CiFailed,
88+
"pending" => BlockerKind::CiPending,
89+
_ => BlockerKind::NotMergeable,
90+
},
91+
Ok(None) => BlockerKind::CiNoStatus,
92+
Err(e) => {
93+
warn!(pr = pr.number, error = %e, "failed to query CI status");
94+
BlockerKind::CiNoStatus
95+
}
5996
}
6097
}
6198

@@ -126,65 +163,71 @@ mod tests {
126163
body: Some(body.into()),
127164
state: "open".into(),
128165
mergeable: Some(mergeable),
166+
head_sha: None,
129167
}
130168
}
131169

132-
#[test]
133-
fn evaluate_one_holds_when_not_mergeable() {
170+
#[tokio::test]
171+
async fn evaluate_one_holds_when_not_mergeable() {
134172
let p = pr(1, "Fixes #2", false);
135-
let e = evaluate_one(&p);
173+
let e = evaluate_one(None, "o", "r", &p).await;
136174
assert!(matches!(e.verdict, EvalVerdict::Hold(_)));
137175
assert_eq!(e.fixes_issues, vec![2]);
176+
assert_eq!(e.blocker_kind, Some(BlockerKind::CiNoStatus));
138177
}
139178

140-
#[test]
141-
fn evaluate_one_merge_with_fixes() {
179+
#[tokio::test]
180+
async fn evaluate_one_merge_with_fixes() {
142181
// Both "Fixes #42" and "Closes #43" are now recognised closing keywords.
143182
let p = pr(7, "Fixes #42 Closes #43", true);
144-
let e = evaluate_one(&p);
183+
let e = evaluate_one(None, "o", "r", &p).await;
145184
assert_eq!(e.verdict, EvalVerdict::Merge);
146185
assert_eq!(e.fixes_issues, vec![42, 43]);
186+
assert_eq!(e.blocker_kind, None);
147187
}
148188

149-
#[test]
150-
fn evaluate_one_merge_no_fixes_still_merges() {
189+
#[tokio::test]
190+
async fn evaluate_one_merge_no_fixes_still_merges() {
151191
let p = pr(9, "feat: refactor", true);
152-
let e = evaluate_one(&p);
192+
let e = evaluate_one(None, "o", "r", &p).await;
153193
assert_eq!(e.verdict, EvalVerdict::Merge);
154194
assert!(e.fixes_issues.is_empty());
195+
assert_eq!(e.blocker_kind, None);
155196
}
156197

157-
#[test]
158-
fn evaluate_one_handles_missing_body() {
198+
#[tokio::test]
199+
async fn evaluate_one_handles_missing_body() {
159200
let p = PrSummary {
160201
number: 11,
161202
title: "x".into(),
162203
body: None,
163204
state: "open".into(),
164205
mergeable: Some(true),
206+
head_sha: None,
165207
};
166-
let e = evaluate_one(&p);
208+
let e = evaluate_one(None, "o", "r", &p).await;
167209
assert_eq!(e.verdict, EvalVerdict::Merge);
168210
assert!(e.fixes_issues.is_empty());
211+
assert_eq!(e.blocker_kind, None);
169212
}
170213

171-
#[test]
172-
fn evaluate_one_processes_51_prs_without_truncation() {
173-
// Regression test for issue #2850: ensure the evaluation loop does not
174-
// impose an artificial cap at position 50. With list_open_prs returning
175-
// up to OPEN_PRS_LIMIT (300) items, all PRs must receive a verdict.
214+
#[tokio::test]
215+
async fn evaluate_one_processes_51_prs_without_truncation() {
176216
let prs: Vec<PrSummary> = (1u64..=51)
177217
.map(|n| pr(n, &format!("Fixes #{n}"), true))
178218
.collect();
179-
let evaluations: Vec<_> = prs.iter().map(evaluate_one).collect();
219+
let mut evaluations = Vec::with_capacity(prs.len());
220+
for p in &prs {
221+
evaluations.push(evaluate_one(None, "o", "r", p).await);
222+
}
180223
assert_eq!(
181224
evaluations.len(),
182225
51,
183226
"all 51 PRs must receive an evaluation verdict"
184227
);
185-
// Spot-check: the PR at position 51 gets Merge (it is mergeable, clean)
186228
let last = &evaluations[50];
187229
assert_eq!(last.pr_index, 51);
188230
assert_eq!(last.verdict, EvalVerdict::Merge);
231+
assert_eq!(last.blocker_kind, None);
189232
}
190233
}

crates/terraphim_merge_coordinator/src/gitea.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ pub struct PrSummary {
4141
pub state: String,
4242
/// Whether Gitea considers this PR mergeable; `None` if unknown.
4343
pub mergeable: Option<bool>,
44+
/// Head commit SHA (for CI status lookups).
45+
#[serde(default)]
46+
pub head_sha: Option<String>,
4447
}
4548

4649
/// A single file entry from Gitea's `/pulls/{index}/files` response.
@@ -206,6 +209,41 @@ impl GiteaClient {
206209
last_err.unwrap_or_else(|| "no error captured".into())
207210
)))
208211
}
212+
213+
/// Query CI status for a head commit.
214+
///
215+
/// Returns `None` when Gitea has no status data for the commit
216+
/// (e.g. the repo has no Actions enabled, or the commit predates
217+
/// CI instrumentation). A present but empty `statuses` list is
218+
/// treated as `CiNoStatus`, not as an error.
219+
pub async fn get_commit_status(
220+
&self,
221+
owner: &str,
222+
repo: &str,
223+
sha: &str,
224+
) -> Result<Option<CommitCombinedStatus>, MergeCoordinatorError> {
225+
let url = format!(
226+
"{}/api/v1/repos/{}/{}/commits/{}/status",
227+
self.base_url, owner, repo, sha
228+
);
229+
let resp = self.get_with_retry(&url).await?;
230+
if resp.status() == reqwest::StatusCode::NOT_FOUND {
231+
return Ok(None);
232+
}
233+
let combined: CommitCombinedStatus = resp
234+
.json()
235+
.await
236+
.map_err(|e| MergeCoordinatorError::Api(format!("decode commit status: {e}")))?;
237+
Ok(Some(combined))
238+
}
239+
}
240+
241+
/// Gitea commit combined-status response.
242+
#[derive(Debug, Clone, Deserialize)]
243+
pub struct CommitCombinedStatus {
244+
pub state: String,
245+
#[serde(default)]
246+
pub statuses: Vec<serde_json::Value>,
209247
}
210248

211249
/// Redact the token if a URL contains one inline (defence in depth).

crates/terraphim_merge_coordinator/src/types.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,48 @@ mod tests {
106106
_ => panic!("expected Merged"),
107107
}
108108
}
109+
110+
#[test]
111+
fn blocker_kind_display_and_serde() {
112+
assert_eq!(BlockerKind::CiFailed.to_string(), "ci_failed");
113+
assert_eq!(BlockerKind::CiPending.to_string(), "ci_pending");
114+
assert_eq!(BlockerKind::CiNoStatus.to_string(), "ci_no_status");
115+
assert_eq!(BlockerKind::NotMergeable.to_string(), "not_mergeable");
116+
117+
let json = serde_json::to_string(&BlockerKind::CiFailed).unwrap();
118+
assert_eq!(json, "\"ci_failed\"");
119+
let json = serde_json::to_string(&BlockerKind::CiPending).unwrap();
120+
assert_eq!(json, "\"ci_pending\"");
121+
}
122+
}
123+
124+
/// Classifies why a PR is blocked from auto-merge.
125+
///
126+
/// Enables operators and ADF monitors to distinguish CI failures
127+
/// (which may self-resolve on retry) from policy/confidence holds
128+
/// (which need human intervention) at a glance in logs.
129+
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
130+
#[serde(rename_all = "snake_case")]
131+
pub enum BlockerKind {
132+
/// CI status returned "failure" — the PR has a failing check.
133+
CiFailed,
134+
/// CI status returned "pending" — the check hasn't started or is running.
135+
CiPending,
136+
/// No CI status was found for the head commit.
137+
CiNoStatus,
138+
/// PR is not mergeable but CI is green — blocked by policy/confidence.
139+
NotMergeable,
140+
}
141+
142+
impl fmt::Display for BlockerKind {
143+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144+
match self {
145+
BlockerKind::CiFailed => write!(f, "ci_failed"),
146+
BlockerKind::CiPending => write!(f, "ci_pending"),
147+
BlockerKind::CiNoStatus => write!(f, "ci_no_status"),
148+
BlockerKind::NotMergeable => write!(f, "not_mergeable"),
149+
}
150+
}
109151
}
110152

111153
/// Error type for the merge-coordinator surface.

0 commit comments

Comments
 (0)