use std::time::Duration;
pub const OFFICIAL_CLIENT_ID: &str = "Iv23liAv6nlb4RaYaJSp";
pub const OFFICIAL_REPO: &str = "Metrale/metrale-inference";
pub const MARKER: &str = "<!-- metrale-tui-report -->";
pub const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
pub const TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
pub fn issues_url(repo: &str) -> String {
format!("https://api.github.com/repos/{repo}/issues")
}
pub const NETWORK_FAILED: &str = "could not reach github.com — check network and retry (s)";
pub const NOT_CONFIGURED: &str = "issue reporting is not configured for this build (set METRALE_REPORT_CLIENT_ID / METRALE_REPORT_REPO)";
pub struct SecretString(String);
impl SecretString {
pub fn new(s: String) -> Self {
Self(s)
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl Clone for SecretString {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl Drop for SecretString {
fn drop(&mut self) {
unsafe { self.0.as_mut_vec() }.fill(0);
}
}
pub struct Target {
pub client_id: String,
pub repo: String,
}
pub fn target() -> Result<Target, &'static str> {
target_from(
std::env::var("METRALE_REPORT_CLIENT_ID").ok(),
std::env::var("METRALE_REPORT_REPO").ok(),
)
}
pub fn target_from(id: Option<String>, repo: Option<String>) -> Result<Target, &'static str> {
let client_id = id.unwrap_or_else(|| OFFICIAL_CLIENT_ID.to_string());
let repo = repo.unwrap_or_else(|| OFFICIAL_REPO.to_string());
if client_id.trim().is_empty() || repo.trim().is_empty() {
return Err(NOT_CONFIGURED);
}
Ok(Target { client_id, repo })
}
pub enum ReportEvent {
CodeReady {
user_code: String,
verification_uri: String,
expires_in: Duration,
},
Authorized {
access: SecretString,
refresh: Option<SecretString>,
},
AuthFailed {
message: String,
},
Created {
number: u64,
url: String,
},
SubmitFailed {
message: String,
drop_auth: bool,
},
}
pub struct DeviceGrant {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: Duration,
pub interval: Duration,
}
fn json(body: &str) -> Option<serde_json::Value> {
serde_json::from_str(body).ok()
}
fn str_field(v: &serde_json::Value, k: &str) -> Option<String> {
v.get(k)?.as_str().map(str::to_string)
}
pub fn parse_device_code(status: u16, body: &str) -> Result<DeviceGrant, String> {
let Some(v) = json(body) else {
return Err(format!(
"unexpected response from github.com (HTTP {status})"
));
};
if let Some(err) = str_field(&v, "error") {
return Err(map_oauth_error(&err, &v));
}
let grant = (|| {
Some(DeviceGrant {
device_code: str_field(&v, "device_code")?,
user_code: str_field(&v, "user_code")?,
verification_uri: str_field(&v, "verification_uri")?,
expires_in: Duration::from_secs(v.get("expires_in")?.as_u64()?),
interval: Duration::from_secs(v.get("interval")?.as_u64()?),
})
})();
let Some(grant) = grant else {
return Err(format!(
"github.com answered without the device-code fields (HTTP {status})"
));
};
if !grant.verification_uri.starts_with("https://github.com/") {
return Err("unexpected verification URL in GitHub's response — refusing".to_string());
}
Ok(grant)
}
pub enum PollOutcome {
Authorized {
access: SecretString,
refresh: Option<SecretString>,
},
Pending,
SlowDown,
Expired,
Denied,
Fatal(String),
}
pub fn parse_poll(status: u16, body: &str) -> PollOutcome {
let Some(v) = json(body) else {
return PollOutcome::Fatal(format!(
"unexpected response from github.com while polling (HTTP {status})"
));
};
if let Some(access) = str_field(&v, "access_token") {
return PollOutcome::Authorized {
access: SecretString::new(access),
refresh: str_field(&v, "refresh_token").map(SecretString::new),
};
}
match str_field(&v, "error").as_deref() {
Some("authorization_pending") => PollOutcome::Pending,
Some("slow_down") => PollOutcome::SlowDown,
Some("expired_token") => PollOutcome::Expired,
Some("access_denied") => PollOutcome::Denied,
Some(err) => PollOutcome::Fatal(map_oauth_error(err, &v)),
None => PollOutcome::Fatal(format!(
"github.com answered the poll without a token or an error (HTTP {status})"
)),
}
}
pub fn parse_refresh(status: u16, body: &str) -> Option<(SecretString, Option<SecretString>)> {
match parse_poll(status, body) {
PollOutcome::Authorized { access, refresh } => Some((access, refresh)),
_ => None,
}
}
fn map_oauth_error(err: &str, v: &serde_json::Value) -> String {
match err {
"device_flow_disabled" => {
"this build's GitHub App is misconfigured (device flow disabled) — report to the maintainers"
.to_string()
}
"unsupported_grant_type" | "incorrect_client_credentials" | "incorrect_device_code" => {
format!("GitHub refused the authorization request ({err})")
}
other => {
let detail = str_field(v, "error_description").unwrap_or_default();
if detail.is_empty() {
format!("GitHub authorization failed ({other})")
} else {
format!("GitHub authorization failed: {detail}")
}
}
}
}
pub fn parse_issue_created(status: u16, body: &str) -> Result<(u64, String), IssueFailure> {
if status == 201 {
let v = json(body);
let number = v.as_ref().and_then(|v| v.get("number")?.as_u64());
let url = v.as_ref().and_then(|v| str_field(v, "html_url"));
return match (number, url) {
(Some(n), Some(u)) => Ok((n, u)),
_ => Ok((0, String::new())),
};
}
Err(IssueFailure {
status,
message: github_message(body),
})
}
#[derive(Debug)]
pub struct IssueFailure {
pub status: u16,
pub message: String,
}
fn github_message(body: &str) -> String {
json(body)
.and_then(|v| {
let msg = str_field(&v, "message")?;
let detail = v
.get("errors")
.and_then(|e| e.as_array())
.map(|a| {
a.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect::<Vec<_>>()
.join("; ")
})
.unwrap_or_default();
Some(if detail.is_empty() {
msg
} else {
format!("{msg}: {detail}")
})
})
.unwrap_or_default()
}
pub fn describe_issue_failure(f: &IssueFailure, repo: &str, retry_after: Option<u64>) -> String {
match f.status {
403 | 429 => match retry_after {
Some(n) => format!("GitHub is rate-limiting — retry in {n}s"),
None if f.message.to_lowercase().contains("rate limit") => {
"GitHub is rate-limiting — retry shortly".to_string()
}
None => format!(
"GitHub refused the request (403): {}",
or_unstated(&f.message)
),
},
404 => format!(
"GitHub answered 404 for {repo} — the repository may not exist, or the reporter app is not installed on it"
),
410 => {
format!("the issue tracker at {repo} is archived or disabled — it cannot accept issues")
}
422 => format!("GitHub rejected the issue: {}", or_unstated(&f.message)),
s if (500..600).contains(&s) => format!("GitHub returned {s} — try again shortly"),
s => format!(
"GitHub returned an unexpected {s}: {}",
or_unstated(&f.message)
),
}
}
fn or_unstated(msg: &str) -> &str {
if msg.is_empty() {
"(no detail given)"
} else {
msg
}
}
#[derive(Clone, Debug)]
pub struct Composed {
pub body: String,
pub chars: usize,
pub logs_included: usize,
pub logs_total: usize,
}
pub fn env_line(model: &str, engine_ready: bool) -> String {
format!(
"Metrale Engine {} · {} · {}/{} · model: {} · engine ready: {engine_ready}",
crate::cli::METRALE_VERSION,
option_env!("METRALE_BUILD_COMMIT").unwrap_or("commit unknown"),
std::env::consts::OS,
std::env::consts::ARCH,
if model.is_empty() { "none" } else { model },
)
}
pub fn compose_body(
user_text: &str,
env: &str,
logs: Option<&[String]>,
tee_path: Option<&str>,
) -> Result<Composed, String> {
use super::redact::{BODY_BUDGET, GITHUB_BODY_LIMIT, fence_for, trim_to_budget};
let head = format!("{}\n\n## Environment\n\n{env}\n", user_text.trim_end());
let tail = format!("\n{MARKER}\n");
const SECTION_RESERVE: usize = 200;
let fixed = head.chars().count() + tail.chars().count();
if fixed + if logs.is_some() { SECTION_RESERVE } else { 0 } > BODY_BUDGET {
return Err(format!(
"the report text is {fixed} characters; GitHub's limit is {GITHUB_BODY_LIMIT} and the dashboard reserves headroom — trim it below {BODY_BUDGET}",
));
}
let (section, logs_included, logs_total) = match logs {
None => (String::new(), 0, 0),
Some(lines) => {
let trimmed = trim_to_budget(lines, BODY_BUDGET - fixed - SECTION_RESERVE, tee_path);
let fence = fence_for(&trimmed.text);
let section = format!(
"\n## Server log (last {} of {} lines, redacted best-effort)\n\n{fence}text\n{}\n{fence}\n",
trimmed.included, trimmed.total, trimmed.text
);
(section, trimmed.included, trimmed.total)
}
};
let body = format!("{head}{section}{tail}");
let chars = body.chars().count();
if chars > GITHUB_BODY_LIMIT {
return Err(format!(
"assembled body is {chars} characters — over GitHub's limit"
));
}
Ok(Composed {
body,
chars,
logs_included,
logs_total,
})
}
#[cfg(test)]
#[path = "report_tests.rs"]
mod tests;