1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//! Cross-host SCM aggregation — fans out to the `mnml-forge-*`
//! integration binaries via their `--list-prs --json` and
//! `--find-pipeline-for-pr --json` headless modes, merges results,
//! and exposes them to the `pr.picker` command + the rail's
//! "Open PRs" subsection.
//!
//! Integration contract (matches every `mnml-forge-*` v0.1+):
//!
//! ```text
//! mnml-forge-<host> --list-prs --json
//! → stdout: { host: "...", prs: [IntegrationPr, ...] }
//!
//! mnml-forge-<host> --find-pipeline-for-pr --owner <o> --repo <r>
//! --branch <b> --json
//! → stdout: { url: "..." | null }
//! ```
//!
//! Per-integration errors land on stderr and don't tank the merge — we
//! just skip that host and surface what the others returned.
use serde::Deserialize;
use std::process::Command;
use std::time::{Duration, Instant};
/// One PR row in the cross-host JSON schema. Field set must stay in
/// sync with each `mnml-forge-*/src/headless.rs`.
#[derive(Debug, Clone, Deserialize)]
pub struct IntegrationPr {
pub id: String,
pub url: String,
pub owner: String,
pub repo: String,
pub title: String,
pub author: String,
/// Null on hosts where the list endpoint doesn't return head.ref
/// (e.g. GitHub's Issues search). Cross-nav falls back to "most
/// recent run on the repo" when null.
#[serde(default)]
pub source_branch: Option<String>,
#[serde(default)]
pub dest_branch: Option<String>,
pub state: String,
pub updated_at: String,
pub remote_url_https: String,
pub remote_url_ssh: String,
/// Set by `aggregate_all` to the `mnml-forge-*` host tag the row
/// came from. Used for cross-nav dispatch + remote-URL matching.
#[serde(default)]
pub host: String,
}
#[derive(Debug, Deserialize)]
struct ListPrsResponse {
host: String,
prs: Vec<IntegrationPr>,
}
#[derive(Debug, Deserialize)]
struct PipelineResponse {
url: Option<String>,
}
/// All known forge-integration binaries — the order is the merge order
/// for the picker. New integrations just get added here; missing
/// binaries (not on `$PATH`) are silently skipped, so users only
/// see what they have installed.
pub const KNOWN_FORGE_SIBLINGS: &[&str] = &[
"mnml-forge-bitbucket",
"mnml-forge-github",
"mnml-forge-gitlab",
"mnml-forge-azdevops",
];
/// Cache wrapper for the cross-host PR list. Refreshed on
/// `pr.refresh`, on `pr.picker` if older than `MAX_AGE`, and
/// rebuilt on demand from the rail's open-PRs path.
#[derive(Debug, Clone)]
pub struct ScmPrCache {
pub prs: Vec<IntegrationPr>,
pub fetched_at: Instant,
/// Stderr blobs per integration — surfaces in a "PRs (with errors)"
/// toast when something failed.
pub errors: Vec<(String, String)>,
}
impl ScmPrCache {
pub const MAX_AGE: Duration = Duration::from_secs(5 * 60);
pub fn is_stale(&self) -> bool {
self.fetched_at.elapsed() > Self::MAX_AGE
}
}
/// Synchronous fan-out: run `--list-prs --json` against every
/// installed forge integration, collect results. Per-integration failures
/// are captured in the returned errors vec; we never propagate a
/// single integration's error up.
///
/// Called from a worker thread (each call spawns the integration
/// binaries and blocks on their HTTP calls — total wall-clock is
/// max of the four, ~1-3 seconds typically).
pub fn aggregate_all() -> ScmPrCache {
let mut prs: Vec<IntegrationPr> = Vec::new();
let mut errors: Vec<(String, String)> = Vec::new();
for bin in KNOWN_FORGE_SIBLINGS {
match run_list_prs(bin) {
Ok(mut response) => {
for pr in &mut response.prs {
pr.host = response.host.clone();
}
prs.extend(response.prs);
}
Err(e) => {
// Missing-binary is the most common path — silent.
if !is_missing_binary(&e) {
errors.push((bin.to_string(), e));
}
}
}
}
// Sort by `updated_at` descending — "what's happening now" first.
prs.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
ScmPrCache {
prs,
fetched_at: Instant::now(),
errors,
}
}
fn run_list_prs(bin: &str) -> Result<ListPrsResponse, String> {
let output = Command::new(bin)
.arg("--list-prs")
.arg("--json")
.output()
.map_err(|e| format!("spawn: {e}"))?;
if !output.status.success() {
return Err(format!(
"exit {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
serde_json::from_slice(&output.stdout).map_err(|e| format!("parse JSON: {e}"))
}
fn is_missing_binary(err: &str) -> bool {
err.contains("No such file or directory")
|| err.contains("not found")
|| err.contains("entity not found")
}
/// Synchronous lookup for a PR's pipeline URL — dispatches to the
/// matching forge integration based on `host`. Returns `Some(url)` on
/// success, `None` when the integration reports no matching pipeline or
/// errors. Called from a worker thread; takes ~1 second typically.
pub fn find_pipeline_url(host: &str, owner: &str, repo: &str, branch: &str) -> Option<String> {
let bin = match host {
"bitbucket" => "mnml-forge-bitbucket",
"github" => "mnml-forge-github",
"gitlab" => "mnml-forge-gitlab",
"azdevops" => "mnml-forge-azdevops",
_ => return None,
};
let output = Command::new(bin)
.arg("--find-pipeline-for-pr")
.arg("--owner")
.arg(owner)
.arg("--repo")
.arg(repo)
.arg("--branch")
.arg(branch)
.arg("--json")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let resp: PipelineResponse = serde_json::from_slice(&output.stdout).ok()?;
resp.url
}
/// Match a PR against a `remote.origin.url` from `git config`. Each
/// integration emits both `https://…` and `git@…` forms so we just
/// substring-match. Returns true when the URL matches either form
/// (with or without a trailing `.git`).
pub fn pr_matches_remote(pr: &IntegrationPr, remote: &str) -> bool {
let r = remote.trim_end_matches(".git");
let https = pr.remote_url_https.trim_end_matches(".git");
let ssh = pr.remote_url_ssh.trim_end_matches(".git");
r == https || r == ssh
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pr_matches_https_remote() {
let pr = IntegrationPr {
id: "1".into(),
url: "u".into(),
owner: "foo".into(),
repo: "bar".into(),
title: "t".into(),
author: "a".into(),
source_branch: None,
dest_branch: None,
state: "open".into(),
updated_at: "x".into(),
remote_url_https: "https://github.com/foo/bar.git".into(),
remote_url_ssh: "git@github.com:foo/bar.git".into(),
host: "github".into(),
};
assert!(pr_matches_remote(&pr, "https://github.com/foo/bar.git"));
assert!(pr_matches_remote(&pr, "https://github.com/foo/bar"));
assert!(pr_matches_remote(&pr, "git@github.com:foo/bar.git"));
assert!(!pr_matches_remote(&pr, "https://github.com/foo/baz"));
}
}