const GLOBAL_FLAGS: &[&str] = &["--offline", "--mock", "-v", "--verbose", "-q", "--quiet"];
const ENV_GITHUB_TOKEN: &str = concat!(env!("CARGO_PKG_NAME"), "__GITHUB_TOKEN");
const ENV_MOCK_STATE: &str = concat!(env!("CARGO_PKG_NAME"), "_MOCK_STATE");
const ENV_MOCK_PIPE: &str = concat!(env!("CARGO_PKG_NAME"), "_MOCK_PIPE");
const BASE_TIMESTAMP_SECS: i64 = 1000209600;
const HALF_DAY_SECS: i64 = 12 * 60 * 60;
const DEFAULT_OWNER: &str = "owner";
const DEFAULT_REPO: &str = "repo";
const DEFAULT_NUMBER: u64 = 1;
const OWNER: &str = "o";
const REPO: &str = "r";
pub const USER: &str = "mock_user";
impl Seed {
pub fn new(value: i64) -> Self {
assert!((-100..=100).contains(&value), "seed must be in range -100..=100, got {value}");
Self(value)
}
}
impl From<i8> for Seed {
fn from(value: i8) -> Self {
Self(value as i64)
}
}
impl TestContext {
pub fn build() -> Self {
Self::build_with_preexisting_state_unsafe("")
}
pub fn build_with_preexisting_state_unsafe(fixture_str: &str) -> Self {
let fixture = Fixture::parse(fixture_str);
let xdg = Xdg::new(fixture.write_to_tempdir(), env!("CARGO_PKG_NAME"));
let mock_state_path = xdg.inner.root.join("mock_state.json");
let pipe_path = xdg.inner.create_pipe("editor_pipe");
tedi::mocks::set_issues_dir(xdg.data_dir().join("issues"));
tedi::current_user::set(USER.to_string());
let ctx = Self {
xdg,
mock_state_path,
pipe_path,
is_virtual_repo: false,
};
ctx.init_git();
ctx
}
pub fn virtual_repo(mut self) -> Self {
self.is_virtual_repo = true;
self
}
pub fn run(&self, args: &[&str]) -> RunOutput {
let mut cmd = Command::new(get_binary_path());
cmd.args(args);
cmd.env("__IS_INTEGRATION_TEST", "1");
cmd.env(ENV_GITHUB_TOKEN, "test_token");
for (key, value) in self.xdg.env_vars() {
cmd.env(key, value);
}
let output = cmd.output().unwrap();
RunOutput {
status: output.status,
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
pub fn open_issue<'a>(&'a self, issue: &'a Issue) -> OpenBuilder<'a> {
OpenBuilder {
ctx: self,
target: BuilderTarget::Issue(issue),
extra_args: Vec::new(),
edit_op: None,
ghost_edit: false,
}
}
pub fn open_url(&self, repo_info: tedi::RepoInfo, number: u64) -> OpenBuilder<'_> {
let url = format!("https://github.com/{}/{}/issues/{number}", repo_info.owner(), repo_info.repo());
OpenBuilder {
ctx: self,
target: BuilderTarget::Url(url),
extra_args: Vec::new(),
edit_op: None,
ghost_edit: false,
}
}
pub fn open_touch(&self, pattern: &str) -> OpenBuilder<'_> {
OpenBuilder {
ctx: self,
target: BuilderTarget::Touch(pattern.to_string()),
extra_args: Vec::new(),
edit_op: None,
ghost_edit: false,
}
}
fn setup_mock_state(&self, state: &serde_json::Value) {
std::fs::write(&self.mock_state_path, serde_json::to_string_pretty(state).unwrap()).unwrap();
}
pub fn init_git(&self) -> Git {
let git = Git::init(self.xdg.data_dir().join("issues"));
git.run(&["config", "merge.conflictStyle", "diff3"]).expect("git config merge.conflictStyle failed");
git
}
pub fn remote(&self, issue: &tedi::VirtualIssue, seed: Option<Seed>) -> Issue {
let issue = with_timestamps(issue, seed, self.is_virtual_repo);
let (owner, repo, number) = extract_issue_coords(&issue);
with_state(self, |state| {
assert!(
!state.remote_issue_ids.contains(&(owner.clone(), repo.clone(), number)),
"remote() called twice for same issue: {owner}/{repo}#{number}"
);
add_issue_recursive(state, tedi::RepoInfo::new(&owner, &repo), number, None, &issue, issue.identity.as_linked().map(|m| &m.timestamps));
});
self.rebuild_mock_state();
issue
}
pub async fn local(&self, issue: &tedi::VirtualIssue, seed: Option<Seed>) -> Issue {
let mut issue = with_timestamps(issue, seed, self.is_virtual_repo);
let (owner, repo, number) = extract_issue_coords(&issue);
with_state(self, |state| assert!(state.local_issues.insert((owner, repo, number)), "local() called twice for same issue"));
self.sink_local(&mut issue, seed).await;
issue
}
pub async fn consensus(&self, issue: &tedi::VirtualIssue, seed: Option<Seed>) -> Issue {
let mut issue = with_timestamps(issue, seed, self.is_virtual_repo);
let (owner, repo, number) = extract_issue_coords(&issue);
with_state(self, |state| {
assert!(state.consensus_issues.insert((owner, repo, number)), "consensus() called twice for same issue")
});
self.init_git();
self.sink_local(&mut issue, seed).await;
<Issue as Sink<Consensus>>::sink(&mut issue, None).await.expect("consensus sink failed");
issue
}
pub(crate) fn set_issues_dir_override(&self) {
tedi::mocks::set_issues_dir(self.xdg.data_dir().join("issues"));
}
async fn sink_local(&self, issue: &mut Issue, seed: Option<Seed>) {
self.set_issues_dir_override();
<Issue as Sink<LocalFs>>::sink(issue, None).await.expect("local sink failed");
if let Some(seed) = seed {
let (owner, repo, number) = extract_issue_coords(issue);
let timestamps = timestamps_from_seed(seed);
let meta = IssueMeta {
user: Some(USER.to_string()),
timestamps,
};
Local::save_issue_meta(tedi::RepoInfo::new(&owner, &repo), number, &meta).expect("save_issue_meta failed");
}
}
fn rebuild_mock_state(&self) {
with_state(self, |state| {
let issues: Vec<serde_json::Value> = state
.remote_issues
.iter()
.map(|i| {
let mut json = serde_json::json!({
"owner": i.owner,
"repo": i.repo,
"number": i.number,
"title": i.title,
"body": i.body,
"state": i.state,
"owner_login": i.owner_login
});
if let Some(reason) = &i.state_reason {
json["state_reason"] = serde_json::Value::String(reason.clone());
}
if !i.labels.is_empty() {
json["labels"] = serde_json::json!(i.labels);
}
if let Some(ts) = &i.timestamps {
if let Some(t) = ts.title {
json["title_timestamp"] = serde_json::Value::String(t.to_string());
}
if let Some(t) = ts.description {
json["description_timestamp"] = serde_json::Value::String(t.to_string());
}
if let Some(t) = ts.labels {
json["labels_timestamp"] = serde_json::Value::String(t.to_string());
}
if let Some(t) = ts.state {
json["state_timestamp"] = serde_json::Value::String(t.to_string());
}
}
json
})
.collect();
let mut sub_issues_map: std::collections::HashMap<(String, String, u64), Vec<u64>> = std::collections::HashMap::new();
for rel in &state.remote_sub_issues {
sub_issues_map.entry((rel.owner.clone(), rel.repo.clone(), rel.parent)).or_default().push(rel.child);
}
let sub_issues: Vec<serde_json::Value> = sub_issues_map
.into_iter()
.map(|((owner, repo, parent), children)| {
serde_json::json!({
"owner": owner,
"repo": repo,
"parent": parent,
"children": children
})
})
.collect();
let comments: Vec<serde_json::Value> = state
.remote_comments
.iter()
.map(|c| {
let ts = c.timestamp.unwrap_or_else(|| jiff::Timestamp::from_second(BASE_TIMESTAMP_SECS).unwrap());
serde_json::json!({
"owner": c.owner,
"repo": c.repo,
"issue_number": c.issue_number,
"comment_id": c.comment_id,
"body": c.body,
"owner_login": c.owner_login,
"created_at": ts.to_string(),
"updated_at": ts.to_string()
})
})
.collect();
let mut mock_state = serde_json::json!({ "issues": issues });
if !sub_issues.is_empty() {
mock_state["sub_issues"] = serde_json::Value::Array(sub_issues);
}
if !comments.is_empty() {
mock_state["comments"] = serde_json::Value::Array(comments);
}
self.setup_mock_state(&mock_state);
});
}
}
impl<'a> OpenBuilder<'a> {
pub fn args(mut self, args: &[&'a str]) -> Self {
self.extra_args.extend(args);
self
}
pub fn edit(mut self, issue: &tedi::VirtualIssue) -> Self {
self.edit_op = Some(EditOperation::FullIssue(Box::new(issue.clone())));
self
}
pub fn ghost_edit(mut self) -> Self {
self.ghost_edit = true;
self
}
pub fn break_to_edit(self) -> (PathBuf, PausedEdit) {
self.ctx.set_issues_dir_override();
let (global_args, subcommand_args): (Vec<&str>, Vec<&str>) = self.extra_args.into_iter().partition(|arg| GLOBAL_FLAGS.iter().any(|f| arg.starts_with(f)));
let mut cmd = Command::new(get_binary_path());
cmd.arg("--mock");
cmd.args(&global_args);
cmd.arg("open");
cmd.args(&subcommand_args);
match &self.target {
BuilderTarget::Issue(issue) => {
let issue_path = tedi::local::LocalPath::from(*issue)
.resolve_parent(tedi::local::FsReader)
.expect("failed to resolve issue parent path")
.search()
.expect("failed to find issue file")
.path();
cmd.arg(&issue_path);
}
BuilderTarget::Url(url) => {
cmd.arg(url);
}
BuilderTarget::Touch(pattern) => {
cmd.arg("--touch").arg(pattern);
}
}
cmd.env("__IS_INTEGRATION_TEST", "1");
cmd.env(ENV_GITHUB_TOKEN, "test_token");
for (key, value) in self.ctx.xdg.env_vars() {
cmd.env(key, value);
}
cmd.env(ENV_MOCK_STATE, &self.ctx.mock_state_path);
cmd.env(ENV_MOCK_PIPE, &self.ctx.pipe_path);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().unwrap();
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
set_nonblocking(&stdout);
set_nonblocking(&stderr);
let pipe_path = self.ctx.pipe_path.clone();
let virtual_edit_base = self.ctx.xdg.inner.root.clone();
let vpath = loop {
std::thread::sleep(std::time::Duration::from_millis(50));
if let Some(vpath) = find_virtual_edit_file(&virtual_edit_base) {
break vpath;
}
if child.try_wait().unwrap().is_some() {
panic!("Process exited before creating virtual file");
}
};
(vpath, PausedEdit { child, stdout, stderr, pipe_path })
}
pub fn run(self) -> RunOutput {
self.ctx.set_issues_dir_override();
let (global_args, subcommand_args): (Vec<&str>, Vec<&str>) = self.extra_args.into_iter().partition(|arg| GLOBAL_FLAGS.iter().any(|f| arg.starts_with(f)));
let mut cmd = Command::new(get_binary_path());
if self.ghost_edit {
cmd.arg("--mock=ghost-edit");
} else {
cmd.arg("--mock");
}
cmd.args(&global_args);
cmd.arg("open");
cmd.args(&subcommand_args);
match &self.target {
BuilderTarget::Issue(issue) => {
let issue_path = tedi::local::LocalPath::from(*issue)
.resolve_parent(tedi::local::FsReader)
.expect("failed to resolve issue parent path")
.search()
.expect("failed to find issue file")
.path();
cmd.arg(&issue_path);
}
BuilderTarget::Url(url) => {
cmd.arg(url);
}
BuilderTarget::Touch(pattern) => {
cmd.arg("--touch").arg(pattern);
}
}
cmd.env("__IS_INTEGRATION_TEST", "1");
cmd.env(ENV_GITHUB_TOKEN, "test_token");
for (key, value) in self.ctx.xdg.env_vars() {
cmd.env(key, value);
}
cmd.env(ENV_MOCK_STATE, &self.ctx.mock_state_path);
cmd.env(ENV_MOCK_PIPE, &self.ctx.pipe_path);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
set_nonblocking(&stdout);
set_nonblocking(&stderr);
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let pipe_path = self.ctx.pipe_path.clone();
let is_virtual = self.ctx.is_virtual_repo;
let edit_op = self.edit_op.clone();
let mut signaled = false;
while child.try_wait().unwrap().is_none() {
drain_pipe(&mut stdout, &mut stdout_buf);
drain_pipe(&mut stderr, &mut stderr_buf);
if !signaled {
std::thread::sleep(std::time::Duration::from_millis(100));
if let Some(EditOperation::FullIssue(virtual_issue)) = &edit_op {
let issue = with_timestamps(virtual_issue, None, is_virtual);
let vpath = tedi::local::Local::virtual_edit_path(&issue);
let content = issue.serialize_virtual();
eprintln!("[test:OpenBuilder] submitting user input // writing to {vpath:?}:\n{content}");
std::fs::write(&vpath, &content).unwrap();
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
if let Ok(mut pipe) = std::fs::OpenOptions::new().write(true).custom_flags(0x800).open(&pipe_path)
&& pipe.write_all(b"x").is_ok()
{
signaled = true;
}
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
drain_pipe(&mut stdout, &mut stdout_buf);
drain_pipe(&mut stderr, &mut stderr_buf);
child.wait().unwrap();
RunOutput {
status: child.try_wait().unwrap().unwrap(),
stdout: String::from_utf8_lossy(&stdout_buf).into_owned(),
stderr: String::from_utf8_lossy(&stderr_buf).into_owned(),
}
}
}
impl PausedEdit {
pub fn resume(mut self) -> RunOutput {
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut pipe = std::fs::OpenOptions::new()
.write(true)
.custom_flags(0x800) .open(&self.pipe_path)
.expect("failed to open pipe");
pipe.write_all(b"x").expect("failed to signal pipe");
}
while self.child.try_wait().unwrap().is_none() {
drain_pipe(&mut self.stdout, &mut stdout_buf);
drain_pipe(&mut self.stderr, &mut stderr_buf);
std::thread::sleep(std::time::Duration::from_millis(10));
}
drain_pipe(&mut self.stdout, &mut stdout_buf);
drain_pipe(&mut self.stderr, &mut stderr_buf);
self.child.wait().unwrap();
RunOutput {
status: self.child.try_wait().unwrap().unwrap(),
stdout: String::from_utf8_lossy(&stdout_buf).into_owned(),
stderr: String::from_utf8_lossy(&stderr_buf).into_owned(),
}
}
}
pub mod are_you_sure {
use std::path::{Path, PathBuf};
use tedi::local::{FsReader, LocalPath};
use super::TestContext;
pub trait UnsafePathExt {
fn flat_issue_path(&self, repo_info: tedi::RepoInfo, number: u64, title: &str) -> PathBuf;
fn dir_issue_path(&self, repo_info: tedi::RepoInfo, number: u64, title: &str) -> PathBuf;
fn resolve_issue_path(&self, issue: &tedi::Issue) -> PathBuf;
}
impl UnsafePathExt for TestContext {
fn flat_issue_path(&self, repo_info: tedi::RepoInfo, number: u64, title: &str) -> PathBuf {
let sanitized = title.replace(' ', "_");
self.xdg.data_dir().join(format!("issues/{}/{}/{number}_-_{sanitized}.md", repo_info.owner(), repo_info.repo()))
}
fn dir_issue_path(&self, repo_info: tedi::RepoInfo, number: u64, title: &str) -> PathBuf {
let sanitized = title.replace(' ', "_");
self.xdg
.data_dir()
.join(format!("issues/{}/{}/{number}_-_{sanitized}/__main__.md", repo_info.owner(), repo_info.repo()))
}
fn resolve_issue_path(&self, issue: &tedi::Issue) -> PathBuf {
self.set_issues_dir_override();
LocalPath::from(issue).resolve_parent(FsReader).unwrap().search().unwrap().path()
}
}
pub fn read_issue_file(path: &Path) -> String {
std::fs::read_to_string(path).expect("failed to read issue file")
}
pub fn write_to_path(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("failed to create parent dirs");
}
std::fs::write(path, content).expect("failed to write file");
}
}
mod snapshot;
use std::{
cell::RefCell,
collections::HashSet,
io::{Read, Write},
os::fd::AsRawFd,
path::{Path, PathBuf},
process::{Command, ExitStatus},
};
pub use snapshot::FixtureIssuesExt;
use tedi::{
Issue, IssueTimestamps,
local::{Consensus, IssueMeta, Local, LocalFs},
sink::Sink,
};
use v_fixtures::{
Fixture, FixtureRenderer,
fs_standards::{git::Git, xdg::Xdg},
};
pub fn set_timestamps(issue: &mut Issue, seed: Seed) {
let timestamps = timestamps_from_seed(seed);
for (_, node) in issue.iter_mut() {
node.identity.mut_linked_issue_meta().unwrap().timestamps = timestamps.clone();
}
}
pub fn timestamps_from_seed(seed: Seed) -> IssueTimestamps {
IssueTimestamps {
title: Some(timestamp_for_field(seed, -2)),
description: Some(timestamp_for_field(seed, -1)),
labels: Some(timestamp_for_field(seed, 0)),
state: Some(timestamp_for_field(seed, 1)),
comments: vec![],
}
}
pub struct PausedEdit {
child: std::process::Child,
stdout: std::process::ChildStdout,
stderr: std::process::ChildStderr,
pipe_path: PathBuf,
}
pub struct RunOutput {
pub status: ExitStatus,
pub stdout: String,
pub stderr: String,
}
pub fn render_fixture(renderer: FixtureRenderer<'_>, output: &RunOutput) -> String {
let result = renderer.always_show_filepath().render();
let s = format!("\n\nBINARY FAILED\nstatus: {}\nstdout:\n{}\nstderr:\n{}", output.status, output.stdout, output.stderr);
eprintln!("{s}");
result
}
pub fn parse_virtual(content: &str) -> tedi::VirtualIssue {
tedi::VirtualIssue::parse(content, PathBuf::from("test.md")).expect("failed to parse test issue")
}
pub struct OpenBuilder<'a> {
ctx: &'a TestContext,
target: BuilderTarget<'a>,
extra_args: Vec<&'a str>,
edit_op: Option<EditOperation>,
ghost_edit: bool,
}
pub struct TestContext {
pub xdg: Xdg,
pub mock_state_path: PathBuf,
pub pipe_path: PathBuf,
pub(crate) is_virtual_repo: bool,
}
#[derive(Clone, Copy, Debug, derive_more::Deref, derive_more::DerefMut, derive_more::Display, Eq, derive_more::Into, PartialEq)]
pub struct Seed(i64);
enum BuilderTarget<'a> {
Issue(&'a Issue),
Url(String),
Touch(String),
}
pub(crate) fn set_nonblocking<F: AsRawFd>(f: &F) {
unsafe {
let fd = f.as_raw_fd();
let flags = libc::fcntl(fd, libc::F_GETFL);
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}
pub(crate) fn drain_pipe<R: Read>(pipe: &mut R, buf: &mut Vec<u8>) {
let mut tmp = [0u8; 4096];
loop {
match pipe.read(&mut tmp) {
Ok(0) => break,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(e) => panic!("pipe read error: {e}"),
}
}
}
pub(crate) fn get_binary_path() -> PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push(env!("CARGO_PKG_NAME"));
path
}
#[derive(Clone)]
enum EditOperation {
FullIssue(Box<tedi::VirtualIssue>),
}
fn find_virtual_edit_file(base: &Path) -> Option<PathBuf> {
if !base.exists() {
return None;
}
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
fn walk(dir: &Path, best: &mut Option<(PathBuf, std::time::SystemTime)>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, best);
} else if path.extension().is_some_and(|e| e == "md")
&& let Ok(meta) = path.metadata()
&& let Ok(mtime) = meta.modified()
&& best.as_ref().map(|(_, t)| mtime > *t).unwrap_or(true)
{
*best = Some((path, mtime));
}
}
}
}
walk(base, &mut best);
best.map(|(p, _)| p)
}
pub(crate) fn with_timestamps(virtual_issue: &tedi::VirtualIssue, seed: Option<Seed>, is_virtual: bool) -> Issue {
let timestamps = seed.map(timestamps_from_seed).unwrap_or_default();
let hollow = build_hollow_from_virtual(virtual_issue, ×tamps);
let parent_idx = tedi::IssueIndex::repo_only((OWNER, REPO).into());
Issue::from_combined(hollow, virtual_issue.clone(), parent_idx, is_virtual).expect("test hollow must match virtual")
}
fn timestamp_for_field(seed: Seed, field_index: i64) -> jiff::Timestamp {
let random_offset = pseudo_random_offset(seed, field_index);
let deterministic_offset = (*seed * HALF_DAY_SECS) / 100;
let total_offset = random_offset + deterministic_offset;
jiff::Timestamp::from_second(BASE_TIMESTAMP_SECS + total_offset).expect("valid timestamp")
}
fn pseudo_random_offset(seed: Seed, index: i64) -> i64 {
let combined = (*seed as u64).wrapping_mul(31).wrapping_add(index as u64);
let mut x = combined;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let normalized = (x % (2 * HALF_DAY_SECS as u64 + 1)) as i64;
normalized - HALF_DAY_SECS
}
thread_local! {
static GIT_STATE: RefCell<std::collections::HashMap<usize, GitState>> = RefCell::new(std::collections::HashMap::new());
}
fn get_ctx_id(ctx: &TestContext) -> usize {
ctx as *const TestContext as usize
}
fn with_state<F, R>(ctx: &TestContext, f: F) -> R
where
F: FnOnce(&mut GitState) -> R, {
GIT_STATE.with(|state| {
let mut map = state.borrow_mut();
let id = get_ctx_id(ctx);
let entry = map.entry(id).or_default();
f(entry)
})
}
fn build_hollow_from_virtual(virtual_issue: &tedi::VirtualIssue, timestamps: &IssueTimestamps) -> tedi::HollowIssue {
let remote = match &virtual_issue.selector {
tedi::IssueSelector::GitId(n) => {
let link = tedi::IssueLink::parse(&format!("https://github.com/{OWNER}/{REPO}/issues/{n}")).unwrap();
Some(Box::new(tedi::LinkedIssueMeta::new(Some(USER.to_string()), link, timestamps.clone())))
}
tedi::IssueSelector::Title(_) | tedi::IssueSelector::Regex(_) => None,
};
let children = virtual_issue
.children
.iter()
.map(|(selector, child)| {
let child_hollow = build_hollow_from_virtual(child, timestamps);
(*selector, child_hollow)
})
.collect();
tedi::HollowIssue::new(remote, children)
}
fn extract_issue_coords(issue: &Issue) -> (String, String, u64) {
if let Some(link) = issue.identity.link() {
(link.owner().to_string(), link.repo().to_string(), link.number())
} else {
(DEFAULT_OWNER.to_string(), DEFAULT_REPO.to_string(), DEFAULT_NUMBER)
}
}
fn add_issue_recursive(state: &mut GitState, repo_info: tedi::RepoInfo, number: u64, parent_number: Option<u64>, issue: &Issue, timestamps: Option<&IssueTimestamps>) {
let owner = repo_info.owner();
let repo = repo_info.repo();
let key = (owner.to_string(), repo.to_string(), number);
if state.remote_issue_ids.contains(&key) {
panic!("remote() would add duplicate issue: {owner}/{repo}#{number}");
}
state.remote_issue_ids.insert(key);
let issue_owner_login = issue.user().expect("issue identity must have user - use @user format in test fixtures").to_string();
state.remote_issues.push(MockIssue {
owner: owner.to_string(),
repo: repo.to_string(),
number,
title: issue.contents.title.clone(),
body: issue.body().into(),
state: issue.contents.state.to_github_state().to_string(),
state_reason: issue.contents.state.to_github_state_reason().map(|s| s.to_string()),
labels: issue.contents.labels.clone(),
owner_login: issue_owner_login,
timestamps: timestamps.cloned(),
});
if let Some(parent) = parent_number {
state.remote_sub_issues.push(SubIssueRelation {
owner: owner.to_string(),
repo: repo.to_string(),
parent,
child: number,
});
}
let comment_timestamps = timestamps.map(|ts| &ts.comments);
for (i, comment) in issue.contents.comments.iter().skip(1).enumerate() {
if let Some(id) = comment.id() {
let comment_owner_login = comment.user().expect("comment identity must have user - use @user format in test fixtures").to_string();
let comment_ts = comment_timestamps.and_then(|ts| ts.get(i).copied());
state.remote_comments.push(MockComment {
owner: owner.to_string(),
repo: repo.to_string(),
issue_number: number,
comment_id: id,
body: comment.body.to_string(),
owner_login: comment_owner_login,
timestamp: comment_ts,
});
}
}
for child in issue.children.values() {
let child_number = child.git_id().expect("child issue must have number for remote mock state");
add_issue_recursive(state, repo_info, child_number, Some(number), child, timestamps);
}
}
#[derive(Default)]
struct GitState {
local_issues: HashSet<(String, String, u64)>,
consensus_issues: HashSet<(String, String, u64)>,
remote_issues: Vec<MockIssue>,
remote_sub_issues: Vec<SubIssueRelation>,
remote_comments: Vec<MockComment>,
remote_issue_ids: HashSet<(String, String, u64)>,
}
struct MockIssue {
owner: String,
repo: String,
number: u64,
title: String,
body: String,
state: String,
state_reason: Option<String>,
labels: Vec<String>,
owner_login: String,
timestamps: Option<IssueTimestamps>,
}
struct MockComment {
owner: String,
repo: String,
issue_number: u64,
comment_id: u64,
body: String,
owner_login: String,
timestamp: Option<jiff::Timestamp>,
}
struct SubIssueRelation {
owner: String,
repo: String,
parent: u64,
child: u64,
}