use crate::config;
use crate::store::{self, CliTaskRef, TaskStore};
use std::path::{Path, PathBuf, MAIN_SEPARATOR_STR};
pub async fn resolve(store: &TaskStore, id: &str) -> anyhow::Result<CliTaskRef> {
resolve_in(store, id, current_repo().as_deref()).await
}
pub async fn resolve_store_id(store: &TaskStore, id: &str) -> anyhow::Result<i64> {
resolve(store, id)
.await?
.store_id
.ok_or_else(|| anyhow::anyhow!("task not found in store: {id}"))
}
pub async fn resolve_in(
store: &TaskStore,
id: &str,
current_repo: Option<&str>,
) -> anyhow::Result<CliTaskRef> {
let spec = expand_repo(store, &normalize(id), current_repo).await?;
if let Some(task) = store.resolve_cli_id(&spec, current_repo).await? {
return Ok(task);
}
match store::parse_cli_task_spec(&spec)? {
store::CliTaskSpec::Issue { repo, number } => {
Ok(store::CliTaskRef::unsynced_issue(repo, number))
}
store::CliTaskSpec::Bare(number) => {
let repo = current_repo
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("task not found: {id}"))?;
Ok(store::CliTaskRef::unsynced_issue(repo, number))
}
store::CliTaskSpec::Internal(_) => anyhow::bail!("internal task '{id}' not found"),
}
}
pub fn state_dir(task: &CliTaskRef) -> anyhow::Result<PathBuf> {
Ok(dirs_under(&crate::home::state_dir()?, task).1)
}
pub fn state_dirs(task: &CliTaskRef) -> anyhow::Result<Vec<PathBuf>> {
let (review, own) = dirs_under(&crate::home::state_dir()?, task);
Ok(vec![review, own])
}
fn dirs_under(state: &Path, task: &CliTaskRef) -> (PathBuf, PathBuf) {
let tasks = state
.join(task.repo.replace('/', MAIN_SEPARATOR_STR))
.join("tasks");
let name = &task.external_id;
(tasks.join(format!("{name}-review")), tasks.join(name))
}
async fn expand_repo(
store: &TaskStore,
spec: &str,
current_repo: Option<&str>,
) -> anyhow::Result<String> {
let Some((name, number)) = spec.rsplit_once('#') else {
return Ok(spec.to_string());
};
if name.is_empty() || name.contains('/') || number.parse::<i64>().is_err() {
return Ok(spec.to_string());
}
if let Some(repo) = current_repo {
if repo.rsplit_once('/').is_some_and(|(_, n)| n == name) {
return Ok(format!("{repo}#{number}"));
}
}
let owners: Vec<String> = store
.distinct_repos()
.await?
.into_iter()
.filter(|repo| repo.rsplit_once('/').is_some_and(|(_, n)| n == name))
.collect();
match owners.as_slice() {
[repo] => Ok(format!("{repo}#{number}")),
[] => Ok(spec.to_string()),
many => anyhow::bail!(
"repo '{name}' matches {}, use owner/repo#{number} to pick one",
many.join(" and ")
),
}
}
fn normalize(id: &str) -> String {
let id = id.trim();
match id.strip_prefix("internal-") {
Some(rest) if rest.chars().all(|c| c.is_ascii_digit()) => format!("internal:{rest}"),
_ => id.to_string(),
}
}
fn current_repo() -> Option<String> {
config::get_current_repo()
.ok()
.filter(|repo| !repo.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{NewTask, TaskStore};
async fn store_with_tasks() -> (TaskStore, i64) {
let store = TaskStore::open_memory().await.unwrap();
store
.create(&NewTask {
external_id: Some("5".to_string()),
repo: "owner/repo".to_string(),
origin: "github".to_string(),
title: "GitHub task 5".to_string(),
..Default::default()
})
.await
.unwrap();
let internal = store
.create_internal("owner/other", "Internal task", "body", "job", "j:1", None)
.await
.unwrap();
(store, internal)
}
#[tokio::test]
async fn resolve_accepts_every_id_form() {
let (store, internal) = store_with_tasks().await;
let forms = [
("owner/repo#5", Some("owner/other")),
("repo#5", Some("owner/other")),
("5", Some("owner/repo")),
("#5", Some("owner/repo")),
];
for (id, repo) in forms {
let task = resolve_in(&store, id, repo).await.unwrap();
assert_eq!(task.display_spec(), "owner/repo#5", "{id}");
}
for id in [
format!("internal:{internal}"),
format!("internal-{internal}"),
] {
let task = resolve_in(&store, &id, None).await.unwrap();
assert_eq!(task.display_spec(), format!("internal:{internal}"), "{id}");
}
}
#[tokio::test]
async fn bare_id_in_a_project_does_not_clash_with_another_project() {
let store = TaskStore::open_memory().await.unwrap();
store
.create(&NewTask {
external_id: Some("5".to_string()),
repo: "owner/repo".to_string(),
origin: "github".to_string(),
title: "GitHub task 5".to_string(),
..Default::default()
})
.await
.unwrap();
for _ in 0..5 {
store
.create_internal("owner/other", "Internal", "body", "job", "j:1", None)
.await
.unwrap();
}
let task = resolve_in(&store, "5", Some("owner/repo")).await.unwrap();
assert_eq!(task.display_spec(), "owner/repo#5");
let err = resolve_in(&store, "5", None).await.unwrap_err().to_string();
assert!(
err.contains("Use owner/repo#5 for the GitHub task"),
"{err}"
);
assert!(err.contains("or internal:5 for the internal one"), "{err}");
}
#[tokio::test]
async fn state_dirs_follow_the_resolved_task() {
let (store, internal) = store_with_tasks().await;
let state = Path::new("/state");
let github = resolve_in(&store, "owner/repo#5", None).await.unwrap();
assert_eq!(
dirs_under(state, &github),
(
PathBuf::from("/state/owner/repo/tasks/5-review"),
PathBuf::from("/state/owner/repo/tasks/5"),
)
);
let task = resolve_in(&store, &format!("internal:{internal}"), None)
.await
.unwrap();
assert_eq!(
dirs_under(state, &task).1,
PathBuf::from(format!("/state/owner/other/tasks/internal:{internal}"))
);
}
#[tokio::test]
async fn bare_repo_name_needs_an_owner_only_when_two_owners_share_it() {
let (store, _) = store_with_tasks().await;
for repo in ["other/repo", "owner/repo"] {
store
.create(&NewTask {
external_id: Some("9".to_string()),
repo: repo.to_string(),
origin: "github".to_string(),
title: "Task 9".to_string(),
..Default::default()
})
.await
.unwrap();
}
let err = resolve_in(&store, "repo#9", None)
.await
.unwrap_err()
.to_string();
assert!(err.contains("owner/repo#9"), "{err}");
let task = resolve_in(&store, "repo#9", Some("owner/repo"))
.await
.unwrap();
assert_eq!(task.display_spec(), "owner/repo#9");
let err = resolve_in(&store, "nosuch#9", None)
.await
.unwrap_err()
.to_string();
assert!(err.contains("invalid task id"), "{err}");
}
#[tokio::test]
async fn unknown_bare_id_outside_a_project_is_an_error() {
let (store, _) = store_with_tasks().await;
assert!(resolve_in(&store, "999", None).await.is_err());
assert!(resolve_in(&store, "internal:999", None).await.is_err());
let task = resolve_in(&store, "999", Some("owner/repo")).await.unwrap();
assert_eq!(task.display_spec(), "owner/repo#999");
}
}