use std::{
collections::HashMap,
path::Path,
sync::{
Mutex,
atomic::{AtomicU64, Ordering},
},
};
use async_trait::async_trait;
use tedi::{
github::{CreatedIssue, GithubClient, GithubComment, GithubError, GithubIssue, GithubLabel, GithubUser, RepoInfo},
local::Local,
};
use tracing::instrument;
const ENV_MOCK_STATE: &str = concat!(env!("CARGO_PKG_NAME"), "_MOCK_STATE");
pub struct MockGithubClient {
user_login: String,
next_issue_id: AtomicU64,
next_comment_id: AtomicU64,
issues: Mutex<HashMap<RepoKey, HashMap<u64, MockIssueData>>>,
comments: Mutex<HashMap<RepoKey, HashMap<u64, MockCommentData>>>,
sub_issues: Mutex<HashMap<RepoKey, HashMap<u64, Vec<u64>>>>,
call_log: Mutex<Vec<String>>,
}
impl MockGithubClient {
pub fn new(user_login: &str) -> Self {
let client = Self {
user_login: user_login.to_string(),
next_issue_id: AtomicU64::new(1000),
next_comment_id: AtomicU64::new(5000),
issues: Mutex::new(HashMap::new()),
comments: Mutex::new(HashMap::new()),
sub_issues: Mutex::new(HashMap::new()),
call_log: Mutex::new(Vec::new()),
};
if let Ok(state_file) = std::env::var(ENV_MOCK_STATE)
&& let Ok(content) = std::fs::read_to_string(&state_file)
{
if let Err(e) = client.load_state_json(&content) {
eprintln!("[mock] Failed to load state from {state_file}: {e}");
} else {
eprintln!("[mock] Loaded state from {state_file}");
}
}
client
}
fn load_state_json(&self, content: &str) -> Result<(), String> {
use serde_json::Value;
let state: Value = serde_json::from_str(content).map_err(|e| e.to_string())?;
if let Some(issues) = state.get("issues").and_then(|v| v.as_array()) {
for issue in issues {
let owner = issue.get("owner").and_then(|v| v.as_str()).ok_or("missing owner")?;
let repo = issue.get("repo").and_then(|v| v.as_str()).ok_or("missing repo")?;
let number = issue.get("number").and_then(|v| v.as_u64()).ok_or("missing number")?;
let title = issue.get("title").and_then(|v| v.as_str()).unwrap_or("");
let body = issue.get("body").and_then(|v| v.as_str()).unwrap_or("");
let state_str = issue.get("state").and_then(|v| v.as_str()).unwrap_or("open");
let state_reason = issue.get("state_reason").and_then(|v| v.as_str()).map(|s| s.to_string());
let owner_login = issue.get("owner_login").and_then(|v| v.as_str()).unwrap_or("mock_user");
let labels: Vec<String> = issue
.get("labels")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let key = RepoKey::new(owner, repo);
let id = self.next_issue_id.fetch_add(1, Ordering::SeqCst);
let parse_ts = |field: &str| -> Option<jiff::Timestamp> { issue.get(field).and_then(|v| v.as_str()).map(|s| s.parse().expect("valid timestamp in mock JSON")) };
let title_timestamp = parse_ts("title_timestamp");
let description_timestamp = parse_ts("description_timestamp");
let labels_timestamp = parse_ts("labels_timestamp");
let state_timestamp = parse_ts("state_timestamp");
let issue_data = MockIssueData {
number,
id,
title: title.to_string(),
body: body.to_string(),
state: state_str.to_string(),
state_reason: state_reason.clone(),
labels,
owner_login: owner_login.to_string(),
title_timestamp,
description_timestamp,
labels_timestamp,
state_timestamp,
};
self.issues.lock().unwrap().entry(key).or_default().insert(number, issue_data);
}
}
if let Some(sub_issue_arr) = state.get("sub_issues").and_then(|v| v.as_array()) {
let mut sub_issues = self.sub_issues.lock().unwrap();
for rel in sub_issue_arr {
let owner = rel.get("owner").and_then(|v| v.as_str()).ok_or("missing owner")?;
let repo = rel.get("repo").and_then(|v| v.as_str()).ok_or("missing repo")?;
let parent = rel.get("parent").and_then(|v| v.as_u64()).ok_or("missing parent")?;
let children: Vec<u64> = rel
.get("children")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_u64()).collect())
.unwrap_or_default();
let key = RepoKey::new(owner, repo);
sub_issues.entry(key).or_default().insert(parent, children);
}
}
if let Some(comments_arr) = state.get("comments").and_then(|v| v.as_array()) {
let mut comments = self.comments.lock().unwrap();
for comment in comments_arr {
let owner = comment.get("owner").and_then(|v| v.as_str()).ok_or("missing owner")?;
let repo = comment.get("repo").and_then(|v| v.as_str()).ok_or("missing repo")?;
let issue_number = comment.get("issue_number").and_then(|v| v.as_u64()).ok_or("missing issue_number")?;
let comment_id = comment.get("comment_id").and_then(|v| v.as_u64()).ok_or("missing comment_id")?;
let body = comment.get("body").and_then(|v| v.as_str()).unwrap_or("");
let owner_login = comment.get("owner_login").and_then(|v| v.as_str()).unwrap_or("mock_user");
let key = RepoKey::new(owner, repo);
let created_at = comment.get("created_at").and_then(|v| v.as_str()).expect("created_at required in mock JSON").to_string();
let updated_at = comment.get("updated_at").and_then(|v| v.as_str()).expect("updated_at required in mock JSON").to_string();
let comment_data = MockCommentData {
id: comment_id,
issue_number,
body: body.to_string(),
owner_login: owner_login.to_string(),
created_at,
updated_at,
};
comments.entry(key).or_default().insert(comment_id, comment_data);
}
}
Ok(())
}
#[cfg(test)]
#[expect(clippy::too_many_arguments)]
pub fn add_issue(&self, repo_info: RepoInfo, number: u64, title: &str, body: &str, state: &str, labels: Vec<&str>, owner_login: &str, timestamp: Option<jiff::Timestamp>) {
let key = RepoKey::from(repo_info);
let id = self.next_issue_id.fetch_add(1, Ordering::SeqCst);
let issue = MockIssueData {
number,
id,
title: title.to_string(),
body: body.to_string(),
state: state.to_string(),
state_reason: None,
labels: labels.into_iter().map(|s| s.to_string()).collect(),
owner_login: owner_login.to_string(),
title_timestamp: timestamp,
description_timestamp: timestamp,
labels_timestamp: timestamp,
state_timestamp: timestamp,
};
let mut issues = self.issues.lock().unwrap();
issues.entry(key).or_default().insert(number, issue);
}
#[cfg(test)]
pub fn add_comment(&self, repo_info: RepoInfo, issue_number: u64, comment_id: u64, body: &str, owner_login: &str, timestamp: jiff::Timestamp) {
let key = RepoKey::from(repo_info);
let ts_str = timestamp.to_string();
let comment = MockCommentData {
id: comment_id,
issue_number,
body: body.to_string(),
owner_login: owner_login.to_string(),
created_at: ts_str.clone(),
updated_at: ts_str,
};
let mut comments = self.comments.lock().unwrap();
comments.entry(key).or_default().insert(comment_id, comment);
}
#[cfg(test)]
pub fn add_sub_issue_relation(&self, repo_info: RepoInfo, parent_number: u64, child_number: u64) {
let key = RepoKey::from(repo_info);
let mut sub_issues = self.sub_issues.lock().unwrap();
sub_issues.entry(key).or_default().entry(parent_number).or_default().push(child_number);
}
#[cfg(test)]
pub fn get_call_log(&self) -> Vec<String> {
self.call_log.lock().unwrap().clone()
}
#[cfg(test)]
pub fn clear_call_log(&self) {
self.call_log.lock().unwrap().clear();
}
fn log_call(&self, call: &str) {
self.call_log.lock().unwrap().push(call.to_string());
}
fn with_issue_mut<F, R>(&self, repo: RepoInfo, issue_number: u64, f: F) -> Result<R, GithubError>
where
F: FnOnce(&mut MockIssueData) -> R, {
let key = RepoKey::new(repo.owner(), repo.repo());
let mut issues = self.issues.lock().unwrap();
let repo_issues = issues
.get_mut(&key)
.ok_or_else(|| GithubError::new_other(format!("Repository not found: {}/{}", repo.owner(), repo.repo())))?;
let issue = repo_issues
.get_mut(&issue_number)
.ok_or_else(|| GithubError::new_other(format!("Issue not found: #{issue_number}")))?;
Ok(f(issue))
}
fn with_comment_mut<F, R>(&self, repo: RepoInfo, comment_id: u64, f: F) -> Result<R, GithubError>
where
F: FnOnce(&mut MockCommentData) -> R, {
let key = RepoKey::new(repo.owner(), repo.repo());
let mut comments = self.comments.lock().unwrap();
let repo_comments = comments
.get_mut(&key)
.ok_or_else(|| GithubError::new_other(format!("Repository not found: {}/{}", repo.owner(), repo.repo())))?;
let comment = repo_comments
.get_mut(&comment_id)
.ok_or_else(|| GithubError::new_other(format!("Comment not found: {comment_id}")))?;
Ok(f(comment))
}
fn convert_issue_data(&self, data: &MockIssueData) -> GithubIssue {
GithubIssue {
number: data.number,
title: data.title.clone(),
body: if data.body.is_empty() { None } else { Some(data.body.clone()) },
labels: data.labels.iter().map(|name| GithubLabel { name: name.clone() }).collect(),
user: GithubUser { login: data.owner_login.clone() },
state: data.state.clone(),
state_reason: data.state_reason.clone(),
}
}
}
fn scan_max_issue_number(repo_info: RepoInfo) -> u64 {
fn scan_dir(path: &Path, max: &mut u64) {
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
let number_part = if let Some(sep_pos) = name_str.find("_-_") {
&name_str[..sep_pos]
} else {
name_str.strip_suffix(".md.bak").or_else(|| name_str.strip_suffix(".md")).unwrap_or(&name_str)
};
if let Ok(num) = number_part.parse::<u64>() {
*max = (*max).max(num);
}
let entry_path = entry.path();
if entry_path.is_dir() {
scan_dir(&entry_path, max);
}
}
}
let project_dir = Local::project_dir(repo_info);
let mut max = 0u64;
scan_dir(&project_dir, &mut max);
max
}
#[derive(Clone, Debug)]
struct MockIssueData {
number: u64,
id: u64,
title: String,
body: String,
state: String,
state_reason: Option<String>,
labels: Vec<String>,
owner_login: String,
title_timestamp: Option<jiff::Timestamp>,
description_timestamp: Option<jiff::Timestamp>,
labels_timestamp: Option<jiff::Timestamp>,
state_timestamp: Option<jiff::Timestamp>,
}
#[derive(Clone, Debug)]
struct MockCommentData {
id: u64,
issue_number: u64,
body: String,
owner_login: String,
created_at: String,
updated_at: String,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct RepoKey {
owner: String,
repo: String,
}
impl RepoKey {
fn new(owner: &str, repo: &str) -> Self {
Self {
owner: owner.to_string(),
repo: repo.to_string(),
}
}
}
impl From<RepoInfo> for RepoKey {
fn from(info: RepoInfo) -> Self {
Self::new(info.owner(), info.repo())
}
}
#[async_trait]
impl GithubClient for MockGithubClient {
#[instrument(skip_all)]
async fn fetch_authenticated_user(&self) -> Result<String, GithubError> {
tracing::info!(target: "mock_github", "fetch_authenticated_user");
self.log_call("fetch_authenticated_user()");
Ok(self.user_login.clone())
}
#[instrument(skip_all, fields(issue_number))]
async fn fetch_issue(&self, repo: RepoInfo, issue_number: u64) -> Result<GithubIssue, GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, "fetch_issue");
self.log_call(&format!("fetch_issue({owner}, {repo_name}, {issue_number})"));
let key = RepoKey::new(owner, repo_name);
let issues = self.issues.lock().unwrap();
let repo_issues = issues.get(&key).ok_or_else(|| GithubError::new_other(format!("Repository not found: {owner}/{repo_name}")))?;
let issue_data = repo_issues
.get(&issue_number)
.ok_or_else(|| GithubError::new_other(format!("Issue not found: #{issue_number}")))?;
Ok(self.convert_issue_data(issue_data))
}
#[instrument(skip_all, fields(issue_number))]
async fn fetch_comments(&self, repo: RepoInfo, issue_number: u64) -> Result<Vec<GithubComment>, GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, "fetch_comments");
self.log_call(&format!("fetch_comments({owner}, {repo_name}, {issue_number})"));
let key = RepoKey::new(owner, repo_name);
let comments = self.comments.lock().unwrap();
let repo_comments = match comments.get(&key) {
Some(c) => c,
None => return Ok(Vec::new()),
};
let issue_comments: Vec<GithubComment> = repo_comments
.values()
.filter(|c| c.issue_number == issue_number)
.map(|c| GithubComment {
id: c.id,
body: if c.body.is_empty() { None } else { Some(c.body.clone()) },
user: GithubUser { login: c.owner_login.clone() },
created_at: c.created_at.clone(),
updated_at: c.updated_at.clone(),
})
.collect();
Ok(issue_comments)
}
#[instrument(skip_all, fields(issue_number))]
async fn fetch_sub_issues(&self, repo: RepoInfo, issue_number: u64) -> Result<Vec<GithubIssue>, GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, "fetch_sub_issues");
self.log_call(&format!("fetch_sub_issues({owner}, {repo_name}, {issue_number})"));
let key = RepoKey::new(owner, repo_name);
let sub_issue_numbers = {
let sub_issues = self.sub_issues.lock().unwrap();
match sub_issues.get(&key).and_then(|m| m.get(&issue_number)) {
Some(numbers) => numbers.clone(),
None => return Ok(Vec::new()),
}
};
let issues = self.issues.lock().unwrap();
let repo_issues = match issues.get(&key) {
Some(i) => i,
None => return Ok(Vec::new()),
};
let result: Vec<GithubIssue> = sub_issue_numbers
.iter()
.filter_map(|num| repo_issues.get(num).map(|data| self.convert_issue_data(data)))
.collect();
Ok(result)
}
#[instrument(skip_all, fields(issue_number))]
async fn update_issue_body(&self, repo: RepoInfo, issue_number: u64, body: &str) -> Result<(), GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, "update_issue_body");
self.log_call(&format!("update_issue_body({owner}, {repo_name}, {issue_number}, <body>)"));
self.with_issue_mut(repo, issue_number, |issue| issue.body = body.to_string())
}
#[instrument(skip_all, fields(issue_number, state))]
async fn update_issue_state(&self, repo: RepoInfo, issue_number: u64, state: &str) -> Result<(), GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, state, "update_issue_state");
self.log_call(&format!("update_issue_state({owner}, {repo_name}, {issue_number}, {state})"));
self.with_issue_mut(repo, issue_number, |issue| issue.state = state.to_string())
}
#[instrument(skip_all, fields(issue_number))]
async fn set_labels(&self, repo: RepoInfo, issue_number: u64, labels: &[String]) -> Result<(), GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, ?labels, "set_labels");
self.log_call(&format!("set_labels({owner}, {repo_name}, {issue_number}, {labels:?})"));
self.with_issue_mut(repo, issue_number, |issue| issue.labels = labels.to_vec())
}
#[instrument(skip_all, fields(comment_id))]
async fn update_comment(&self, repo: RepoInfo, comment_id: u64, body: &str) -> Result<(), GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, comment_id, "update_comment");
self.log_call(&format!("update_comment({owner}, {repo_name}, {comment_id}, <body>)"));
self.with_comment_mut(repo, comment_id, |comment| comment.body = body.to_string())
}
#[instrument(skip_all, fields(issue_number))]
async fn create_comment(&self, repo: RepoInfo, issue_number: u64, body: &str) -> Result<(), GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, "create_comment");
self.log_call(&format!("create_comment({owner}, {repo_name}, {issue_number}, <body>)"));
let key = RepoKey::new(owner, repo_name);
let comment_id = self.next_comment_id.fetch_add(1, Ordering::SeqCst);
let now = jiff::Timestamp::now().to_string();
let comment = MockCommentData {
id: comment_id,
issue_number,
body: body.to_string(),
owner_login: self.user_login.clone(),
created_at: now.clone(),
updated_at: now,
};
let mut comments = self.comments.lock().unwrap();
comments.entry(key).or_default().insert(comment_id, comment);
Ok(())
}
#[instrument(skip_all, fields(comment_id))]
async fn delete_comment(&self, repo: RepoInfo, comment_id: u64) -> Result<(), GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, comment_id, "delete_comment");
self.log_call(&format!("delete_comment({owner}, {repo_name}, {comment_id})"));
let key = RepoKey::new(owner, repo_name);
let mut comments = self.comments.lock().unwrap();
if let Some(repo_comments) = comments.get_mut(&key) {
repo_comments.remove(&comment_id);
}
Ok(())
}
#[instrument(skip_all, fields(title))]
async fn create_issue(&self, repo: RepoInfo, title: &str, body: &str) -> Result<CreatedIssue, GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, title, "create_issue");
self.log_call(&format!("create_issue({owner}, {repo_name}, {title}, <body>)"));
let key = RepoKey::new(owner, repo_name);
let id = self.next_issue_id.fetch_add(1, Ordering::SeqCst);
let number = {
let max_from_memory = self.issues.lock().unwrap().get(&key).map(|m| m.keys().max().copied().unwrap_or(0)).unwrap_or(0);
let max_from_files = scan_max_issue_number(repo);
let project_meta = Local::load_project_meta(repo);
let max_from_meta = project_meta.issues.keys().max().copied().unwrap_or(0);
max_from_files.max(max_from_meta).max(max_from_memory) + 1
};
let now = Some(jiff::Timestamp::now());
let issue = MockIssueData {
number,
id,
title: title.to_string(),
body: body.to_string(),
state: "open".to_string(),
state_reason: None,
labels: Vec::new(),
owner_login: self.user_login.clone(),
title_timestamp: now,
description_timestamp: now,
labels_timestamp: now,
state_timestamp: now,
};
let mut issues = self.issues.lock().unwrap();
issues.entry(key).or_default().insert(number, issue);
Ok(CreatedIssue {
id,
number,
html_url: format!("https://github.com/{owner}/{repo_name}/issues/{number}"),
})
}
#[instrument(skip_all, fields(parent_issue_number, child_issue_id))]
async fn add_sub_issue(&self, repo: RepoInfo, parent_issue_number: u64, child_issue_id: u64) -> Result<(), GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, parent_issue_number, child_issue_id, "add_sub_issue");
self.log_call(&format!("add_sub_issue({owner}, {repo_name}, parent={parent_issue_number}, child_id={child_issue_id})"));
let key = RepoKey::new(owner, repo_name);
let child_number = {
let issues = self.issues.lock().unwrap();
let repo_issues = issues.get(&key).ok_or_else(|| GithubError::new_other(format!("Repository not found: {owner}/{repo_name}")))?;
repo_issues
.values()
.find(|i| i.id == child_issue_id)
.map(|i| i.number)
.ok_or_else(|| GithubError::new_other(format!("Child issue with id {child_issue_id} not found")))?
};
let mut sub_issues = self.sub_issues.lock().unwrap();
sub_issues.entry(key).or_default().entry(parent_issue_number).or_default().push(child_number);
Ok(())
}
#[instrument(skip_all, fields(title))]
async fn find_issue_by_title(&self, repo: RepoInfo, title: &str) -> Result<Option<u64>, GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, title, "find_issue_by_title");
self.log_call(&format!("find_issue_by_title({owner}, {repo_name}, {title})"));
let key = RepoKey::new(owner, repo_name);
let issues = self.issues.lock().unwrap();
let repo_issues = match issues.get(&key) {
Some(i) => i,
None => return Ok(None),
};
for issue in repo_issues.values() {
if issue.title == title {
return Ok(Some(issue.number));
}
}
Ok(None)
}
#[instrument(skip_all, fields(issue_number))]
async fn issue_exists(&self, repo: RepoInfo, issue_number: u64) -> Result<bool, GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, "issue_exists");
self.log_call(&format!("issue_exists({owner}, {repo_name}, {issue_number})"));
let key = RepoKey::new(owner, repo_name);
let issues = self.issues.lock().unwrap();
if let Some(repo_issues) = issues.get(&key) {
return Ok(repo_issues.contains_key(&issue_number));
}
Ok(false)
}
#[instrument(skip_all, fields(issue_number))]
async fn fetch_parent_issue(&self, repo: RepoInfo, issue_number: u64) -> Result<Option<GithubIssue>, GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, "fetch_parent_issue");
self.log_call(&format!("fetch_parent_issue({owner}, {repo_name}, {issue_number})"));
let key = RepoKey::new(owner, repo_name);
let parent_number = {
let sub_issues = self.sub_issues.lock().unwrap();
if let Some(repo_sub_issues) = sub_issues.get(&key) {
repo_sub_issues
.iter()
.find_map(|(parent, children)| if children.contains(&issue_number) { Some(*parent) } else { None })
} else {
None
}
};
match parent_number {
Some(parent_num) => {
let issues = self.issues.lock().unwrap();
let repo_issues = issues.get(&key).ok_or_else(|| GithubError::new_other(format!("Repository not found: {owner}/{repo_name}")))?;
let parent_data = repo_issues
.get(&parent_num)
.ok_or_else(|| GithubError::new_other(format!("Parent issue not found: #{parent_num}")))?;
Ok(Some(self.convert_issue_data(parent_data)))
}
None => Ok(None),
}
}
#[instrument(skip_all, fields(issue_number))]
async fn fetch_timeline_timestamps(&self, repo: RepoInfo, issue_number: u64) -> Result<tedi::github::GraphqlTimelineTimestamps, GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, "fetch_timeline_timestamps");
self.log_call(&format!("fetch_timeline_timestamps({owner}, {repo_name}, {issue_number})"));
let issues = self.issues.lock().unwrap();
let key = RepoKey::new(owner, repo_name);
if let Some(repo_issues) = issues.get(&key)
&& let Some(issue) = repo_issues.get(&issue_number)
{
return Ok(tedi::github::GraphqlTimelineTimestamps {
title: issue.title_timestamp,
description: issue.description_timestamp,
labels: issue.labels_timestamp,
state: issue.state_timestamp,
});
}
Ok(tedi::github::GraphqlTimelineTimestamps::default())
}
#[instrument(skip_all, fields(issue_number, ?milestone))]
async fn set_issue_milestone(&self, repo: RepoInfo, issue_number: u64, milestone: Option<u64>) -> Result<(), GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, issue_number, ?milestone, "set_issue_milestone");
self.log_call(&format!("set_issue_milestone({owner}, {repo_name}, {issue_number}, {milestone:?})"));
Ok(())
}
#[instrument(skip_all)]
async fn repo_exists(&self, repo: RepoInfo) -> Result<bool, GithubError> {
let owner = repo.owner();
let repo_name = repo.repo();
tracing::info!(target: "mock_github", owner, repo_name, "repo_exists");
self.log_call(&format!("repo_exists({owner}, {repo_name})"));
let key = RepoKey::new(owner, repo_name);
let issues = self.issues.lock().unwrap();
Ok(issues.contains_key(&key))
}
}
#[cfg(test)]
mod tests {
use insta::{assert_debug_snapshot, assert_snapshot};
use super::*;
fn test_ts() -> jiff::Timestamp {
jiff::Timestamp::from_second(1704067200).unwrap() }
#[tokio::test]
async fn test_mock_basic_operations() {
let client = MockGithubClient::new("testuser");
let repo = RepoInfo::new("owner", "repo");
client.add_issue(repo, 123, "Test Issue", "Body content", "open", vec!["bug"], "testuser", Some(test_ts()));
let issue = client.fetch_issue(repo, 123).await.unwrap();
assert_eq!(issue.number, 123);
assert_eq!(issue.title, "Test Issue");
assert_eq!(issue.body, Some("Body content".to_string()));
assert_eq!(issue.state, "open");
client.update_issue_body(repo, 123, "New body").await.unwrap();
let issue = client.fetch_issue(repo, 123).await.unwrap();
assert_eq!(issue.body, Some("New body".to_string()));
client.update_issue_state(repo, 123, "closed").await.unwrap();
let issue = client.fetch_issue(repo, 123).await.unwrap();
assert_eq!(issue.state, "closed");
}
#[tokio::test]
async fn test_mock_sub_issues() {
let client = MockGithubClient::new("testuser");
let repo = RepoInfo::new("owner", "repo");
client.add_issue(repo, 1, "Parent Issue", "", "open", vec![], "testuser", Some(test_ts()));
client.add_issue(repo, 2, "Child Issue", "", "open", vec![], "testuser", Some(test_ts()));
client.add_sub_issue_relation(repo, 1, 2);
let sub_issues = client.fetch_sub_issues(repo, 1).await.unwrap();
assert_debug_snapshot!(format!("{sub_issues:?}"), @r#""[GithubIssue { number: 2, title: \"Child Issue\", body: None, labels: [], user: GithubUser { login: \"testuser\" }, state: \"open\", state_reason: None }]""#);
}
#[tokio::test]
async fn test_mock_create_issue() {
let client = MockGithubClient::new("testuser");
let repo = RepoInfo::new("owner", "repo");
let created = client.create_issue(repo, "New Issue", "Issue body").await.unwrap();
assert!(created.number > 0);
assert!(created.html_url.contains("owner/repo/issues"));
let issue = client.fetch_issue(repo, created.number).await.unwrap();
assert_snapshot!(issue.title, "New Issue", @"New Issue");
}
#[tokio::test]
async fn test_mock_comments() {
let client = MockGithubClient::new("testuser");
let repo = RepoInfo::new("owner", "repo");
client.add_issue(repo, 1, "Issue", "", "open", vec![], "testuser", Some(test_ts()));
client.add_comment(repo, 1, 100, "First comment", "testuser", test_ts());
client.add_comment(repo, 1, 101, "Second comment", "other", test_ts());
let comments = client.fetch_comments(repo, 1).await.unwrap();
assert_eq!(comments.len(), 2);
client.delete_comment(repo, 100).await.unwrap();
let comments = client.fetch_comments(repo, 1).await.unwrap();
assert_eq!(comments.len(), 1);
}
#[tokio::test]
async fn test_mock_call_log() {
let client = MockGithubClient::new("testuser");
let repo = RepoInfo::new("owner", "repo");
client.add_issue(repo, 1, "Issue", "", "open", vec![], "testuser", Some(test_ts()));
let _ = client.fetch_issue(repo, 1).await;
let _ = client.fetch_comments(repo, 1).await;
let log = client.get_call_log();
assert_eq!(log.len(), 2);
assert!(log[0].contains("fetch_issue"));
assert!(log[1].contains("fetch_comments"));
client.clear_call_log();
assert!(client.get_call_log().is_empty());
}
#[tokio::test]
async fn test_mock_fetch_parent_issue() {
let client = MockGithubClient::new("testuser");
let repo = RepoInfo::new("owner", "repo");
client.add_issue(repo, 1, "Parent Issue", "", "open", vec![], "testuser", Some(test_ts()));
client.add_issue(repo, 2, "Child Issue", "", "open", vec![], "testuser", Some(test_ts()));
client.add_issue(repo, 3, "Grandchild Issue", "", "open", vec![], "testuser", Some(test_ts()));
client.add_sub_issue_relation(repo, 1, 2);
client.add_sub_issue_relation(repo, 2, 3);
let parent = client.fetch_parent_issue(repo, 1).await.unwrap();
assert!(parent.is_none());
let parent = client.fetch_parent_issue(repo, 2).await.unwrap();
assert!(parent.is_some());
assert_eq!(parent.unwrap().number, 1);
let parent = client.fetch_parent_issue(repo, 3).await.unwrap();
assert!(parent.is_some());
assert_eq!(parent.unwrap().number, 2);
}
}