use std::sync::{Arc, Mutex};
use super::{
CreatedIssue, ExistingIssue, GithubApi, GithubFilingError, extract_fingerprint, file_issue,
file_issue_with,
};
use crate::daemon::bug_report::preview::IssuePreview;
use crate::daemon::bug_report::token::{
EnvFileTokenProvider, TOKEN_ENV_VAR, TOKEN_FILE_ENV_VAR, TokenProvider,
};
struct StaticTokenProvider(Option<String>);
impl TokenProvider for StaticTokenProvider {
fn token(&self) -> Option<String> {
self.0.clone()
}
}
#[derive(Debug, Clone)]
enum MockCall {
SearchIssues,
CreateIssue { labels: Vec<String> },
AddComment(u64),
}
struct MockGithubApi {
existing: Vec<ExistingIssue>,
calls: Arc<Mutex<Vec<MockCall>>>,
}
impl MockGithubApi {
fn with_existing(existing: Vec<ExistingIssue>) -> Self {
Self {
existing,
calls: Arc::new(Mutex::new(Vec::new())),
}
}
fn no_existing() -> Self {
Self::with_existing(vec![])
}
}
impl GithubApi for MockGithubApi {
fn search_open_issues(
&self,
_fingerprint: &str,
) -> Result<Vec<ExistingIssue>, GithubFilingError> {
self.calls.lock().unwrap().push(MockCall::SearchIssues);
Ok(self.existing.clone())
}
fn create_issue(
&self,
_title: &str,
_body: &str,
labels: &[String],
) -> Result<CreatedIssue, GithubFilingError> {
self.calls.lock().unwrap().push(MockCall::CreateIssue {
labels: labels.to_vec(),
});
Ok(CreatedIssue {
html_url: "https://github.com/bobmatnyc/trusty-tools/issues/999".to_string(),
number: 999,
})
}
fn add_comment(&self, issue_number: u64, _body: &str) -> Result<(), GithubFilingError> {
self.calls
.lock()
.unwrap()
.push(MockCall::AddComment(issue_number));
Ok(())
}
}
fn dummy_preview(fingerprint: &str) -> IssuePreview {
IssuePreview {
title: "[trusty_mpm] test error".to_string(),
body: format!(
"## Auto-reported\n\n<!-- trusty-bug-fingerprint: {fingerprint} -->\n\nbody text"
),
labels: vec![
"bug".to_string(),
"auto-reported".to_string(),
"trusty-mpm".to_string(),
],
fingerprint: fingerprint.to_string(),
scrub_changes: vec![],
}
}
struct FixedTokenProvider(Option<&'static str>);
impl TokenProvider for FixedTokenProvider {
fn token(&self) -> Option<String> {
self.0.map(str::to_string)
}
}
#[test]
fn token_resolution_from_env() {
let sentinel = "ghp_test_token_from_env_unique_trusty_phase3"; unsafe { std::env::set_var(TOKEN_ENV_VAR, sentinel) };
let token = EnvFileTokenProvider.token();
unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
assert!(
token.is_some(),
"expected Some token when env var is set, got: {token:?}"
);
}
#[test]
fn token_resolution_absent_uses_fixed_provider() {
let provider = FixedTokenProvider(None);
let preview = dummy_preview(&"a".repeat(64));
let err = file_issue(&preview, &provider).unwrap_err();
assert!(
matches!(err, GithubFilingError::NoToken),
"expected NoToken: {err}"
);
}
#[test]
fn token_resolution_from_file() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "ghp_from_file_xyz\n").unwrap();
unsafe {
std::env::remove_var(TOKEN_ENV_VAR);
std::env::set_var(TOKEN_FILE_ENV_VAR, tmp.path().as_os_str());
}
let token = EnvFileTokenProvider.token();
unsafe { std::env::remove_var(TOKEN_FILE_ENV_VAR) };
assert!(
token.is_some(),
"expected a token from file (or env): {token:?}"
);
}
#[test]
fn no_token_yields_no_token_error() {
let provider = StaticTokenProvider(None);
let preview = dummy_preview(&"a".repeat(64));
let err = file_issue(&preview, &provider).unwrap_err();
assert!(
matches!(err, GithubFilingError::NoToken),
"expected NoToken, got: {err:?}"
);
let msg = err.to_string();
assert!(msg.contains("TRUSTY_BUGREPORT_GITHUB_TOKEN"), "{msg}");
}
#[test]
fn mock_create_path_called_when_no_existing_issue() {
let fp = "b".repeat(64);
let preview = dummy_preview(&fp);
let mock = MockGithubApi::no_existing();
let result = file_issue_with(&preview, &mock).unwrap();
assert!(result.filed);
assert!(!result.deduped, "should not be deduped");
assert_eq!(result.issue_number, 999);
let calls = mock.calls.lock().unwrap();
assert!(
calls.iter().any(|c| matches!(c, MockCall::SearchIssues)),
"search not called: {calls:?}"
);
assert!(
calls
.iter()
.any(|c| matches!(c, MockCall::CreateIssue { .. })),
"create not called: {calls:?}"
);
assert!(
!calls.iter().any(|c| matches!(c, MockCall::AddComment(_))),
"comment should not be called on create path: {calls:?}"
);
}
#[test]
fn mock_comment_path_when_existing_issue_found() {
let fp = "c".repeat(64);
let preview = dummy_preview(&fp);
let existing = vec![ExistingIssue {
html_url: "https://github.com/bobmatnyc/trusty-tools/issues/42".to_string(),
number: 42,
}];
let mock = MockGithubApi::with_existing(existing);
let result = file_issue_with(&preview, &mock).unwrap();
assert!(result.filed);
assert!(result.deduped, "should be deduped");
assert_eq!(result.issue_number, 42);
assert!(result.issue_url.contains("42"), "{}", result.issue_url);
let calls = mock.calls.lock().unwrap();
assert!(
calls.iter().any(|c| matches!(c, MockCall::AddComment(42))),
"comment(42) not called: {calls:?}"
);
assert!(
!calls
.iter()
.any(|c| matches!(c, MockCall::CreateIssue { .. })),
"create should not be called on dedup path: {calls:?}"
);
}
#[test]
fn label_mapping_known_crate() {
use crate::daemon::bug_report::preview::build_preview;
use crate::daemon::bug_report::types::AggregatedError;
use trusty_common::error_capture::CapturedError;
let agg = AggregatedError {
record: CapturedError {
timestamp_secs: 0,
crate_target: "trusty_search::indexer".to_string(),
crate_version: "0.1.0".to_string(),
message: "err".to_string(),
fields: String::new(),
file: None,
line: None,
os: "linux".to_string(),
arch: "x86_64".to_string(),
fingerprint: "d".repeat(64),
},
occurrences: 1,
};
let preview = build_preview(&agg);
assert!(
preview.labels.contains(&"trusty-search".to_string()),
"expected trusty-search label: {:?}",
preview.labels
);
}
#[test]
fn label_mapping_unknown_crate_only_base_labels() {
use crate::daemon::bug_report::preview::build_preview;
use crate::daemon::bug_report::types::AggregatedError;
use trusty_common::error_capture::CapturedError;
let agg = AggregatedError {
record: CapturedError {
timestamp_secs: 0,
crate_target: "some_unknown_crate".to_string(),
crate_version: "0.1.0".to_string(),
message: "err".to_string(),
fields: String::new(),
file: None,
line: None,
os: "linux".to_string(),
arch: "x86_64".to_string(),
fingerprint: "e".repeat(64),
},
occurrences: 1,
};
let preview = build_preview(&agg);
assert_eq!(
preview.labels,
vec!["bug", "auto-reported"],
"unexpected labels for unknown crate: {:?}",
preview.labels
);
}
#[test]
fn dedup_marker_extraction() {
let fp = "f".repeat(64);
let body = format!("some text\n<!-- trusty-bug-fingerprint: {fp} -->\nmore text");
let extracted = extract_fingerprint(&body);
assert_eq!(extracted, Some(fp));
}
#[test]
fn dedup_marker_absent_returns_none() {
let body = "no fingerprint here";
assert!(extract_fingerprint(body).is_none());
}
#[test]
fn create_issue_sends_correct_labels() {
let fp = "g".repeat(64);
let preview = IssuePreview {
title: "[trusty_memory] oom".to_string(),
body: format!("<!-- trusty-bug-fingerprint: {fp} -->"),
labels: vec![
"bug".to_string(),
"auto-reported".to_string(),
"trusty-memory".to_string(),
],
fingerprint: fp,
scrub_changes: vec![],
};
let mock = MockGithubApi::no_existing();
let _ = file_issue_with(&preview, &mock).unwrap();
let calls = mock.calls.lock().unwrap();
let create = calls.iter().find_map(|c| {
if let MockCall::CreateIssue { labels } = c {
Some(labels.clone())
} else {
None
}
});
let labels = create.expect("create call expected");
assert!(labels.contains(&"trusty-memory".to_string()), "{labels:?}");
assert!(labels.contains(&"bug".to_string()), "{labels:?}");
assert!(labels.contains(&"auto-reported".to_string()), "{labels:?}");
}