Skip to main content

orch/cli/
task_ref.rs

1//! The one way a CLI command turns a task id into a task, and into the state directory that
2//! holds its output. Every id form `orch task inspect` accepts works everywhere
3
4use crate::config;
5use crate::store::{self, CliTaskRef, TaskStore};
6use std::path::{Path, PathBuf, MAIN_SEPARATOR_STR};
7
8/// Resolve a task id against the store, using the project of the working directory
9pub async fn resolve(store: &TaskStore, id: &str) -> anyhow::Result<CliTaskRef> {
10    resolve_in(store, id, current_repo().as_deref()).await
11}
12
13/// Resolve a task id to its store row id
14pub async fn resolve_store_id(store: &TaskStore, id: &str) -> anyhow::Result<i64> {
15    resolve(store, id)
16        .await?
17        .store_id
18        .ok_or_else(|| anyhow::anyhow!("task not found in store: {id}"))
19}
20
21/// `resolve` against an explicit project, so tests do not read the operator's config
22pub async fn resolve_in(
23    store: &TaskStore,
24    id: &str,
25    current_repo: Option<&str>,
26) -> anyhow::Result<CliTaskRef> {
27    let spec = expand_repo(store, &normalize(id), current_repo).await?;
28    if let Some(task) = store.resolve_cli_id(&spec, current_repo).await? {
29        return Ok(task);
30    }
31    match store::parse_cli_task_spec(&spec)? {
32        store::CliTaskSpec::Issue { repo, number } => {
33            Ok(store::CliTaskRef::unsynced_issue(repo, number))
34        }
35        store::CliTaskSpec::Bare(number) => {
36            let repo = current_repo
37                .map(str::to_string)
38                .ok_or_else(|| anyhow::anyhow!("task not found: {id}"))?;
39            Ok(store::CliTaskRef::unsynced_issue(repo, number))
40        }
41        store::CliTaskSpec::Internal(_) => anyhow::bail!("internal task '{id}' not found"),
42    }
43}
44
45/// `~/.orch/state/<owner>/<repo>/tasks/<id>` for a resolved task. Does not create it
46pub fn state_dir(task: &CliTaskRef) -> anyhow::Result<PathBuf> {
47    Ok(dirs_under(&crate::home::state_dir()?, task).1)
48}
49
50/// A task's own state dir and its review agent's
51pub fn state_dirs(task: &CliTaskRef) -> anyhow::Result<Vec<PathBuf>> {
52    let (review, own) = dirs_under(&crate::home::state_dir()?, task);
53    Ok(vec![review, own])
54}
55
56/// The review dir and the task's own dir under a state root. The runner names a task dir after
57/// its external id, which `CliTaskRef` fills with `internal:<store id>` for an internal task
58fn dirs_under(state: &Path, task: &CliTaskRef) -> (PathBuf, PathBuf) {
59    let tasks = state
60        .join(task.repo.replace('/', MAIN_SEPARATOR_STR))
61        .join("tasks");
62    let name = &task.external_id;
63    (tasks.join(format!("{name}-review")), tasks.join(name))
64}
65
66/// `repo#N` names a project without its owner. Expand it against the current project, then the
67/// repos the store knows, so the short form works wherever `owner/repo#N` does
68async fn expand_repo(
69    store: &TaskStore,
70    spec: &str,
71    current_repo: Option<&str>,
72) -> anyhow::Result<String> {
73    let Some((name, number)) = spec.rsplit_once('#') else {
74        return Ok(spec.to_string());
75    };
76    if name.is_empty() || name.contains('/') || number.parse::<i64>().is_err() {
77        return Ok(spec.to_string());
78    }
79
80    if let Some(repo) = current_repo {
81        if repo.rsplit_once('/').is_some_and(|(_, n)| n == name) {
82            return Ok(format!("{repo}#{number}"));
83        }
84    }
85
86    let owners: Vec<String> = store
87        .distinct_repos()
88        .await?
89        .into_iter()
90        .filter(|repo| repo.rsplit_once('/').is_some_and(|(_, n)| n == name))
91        .collect();
92    match owners.as_slice() {
93        [repo] => Ok(format!("{repo}#{number}")),
94        // Unknown name: leave it for `parse_cli_task_spec` to reject with the forms it accepts
95        [] => Ok(spec.to_string()),
96        many => anyhow::bail!(
97            "repo '{name}' matches {}, use owner/repo#{number} to pick one",
98            many.join(" and ")
99        ),
100    }
101}
102
103/// Session names cannot hold a `:`, so `internal-8` names the same task as `internal:8`
104fn normalize(id: &str) -> String {
105    let id = id.trim();
106    match id.strip_prefix("internal-") {
107        Some(rest) if rest.chars().all(|c| c.is_ascii_digit()) => format!("internal:{rest}"),
108        _ => id.to_string(),
109    }
110}
111
112fn current_repo() -> Option<String> {
113    config::get_current_repo()
114        .ok()
115        .filter(|repo| !repo.is_empty())
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::store::{NewTask, TaskStore};
122
123    async fn store_with_tasks() -> (TaskStore, i64) {
124        let store = TaskStore::open_memory().await.unwrap();
125        store
126            .create(&NewTask {
127                external_id: Some("5".to_string()),
128                repo: "owner/repo".to_string(),
129                origin: "github".to_string(),
130                title: "GitHub task 5".to_string(),
131                ..Default::default()
132            })
133            .await
134            .unwrap();
135        let internal = store
136            .create_internal("owner/other", "Internal task", "body", "job", "j:1", None)
137            .await
138            .unwrap();
139        (store, internal)
140    }
141
142    #[tokio::test]
143    async fn resolve_accepts_every_id_form() {
144        let (store, internal) = store_with_tasks().await;
145        let forms = [
146            ("owner/repo#5", Some("owner/other")),
147            ("repo#5", Some("owner/other")),
148            ("5", Some("owner/repo")),
149            ("#5", Some("owner/repo")),
150        ];
151        for (id, repo) in forms {
152            let task = resolve_in(&store, id, repo).await.unwrap();
153            assert_eq!(task.display_spec(), "owner/repo#5", "{id}");
154        }
155
156        for id in [
157            format!("internal:{internal}"),
158            format!("internal-{internal}"),
159        ] {
160            let task = resolve_in(&store, &id, None).await.unwrap();
161            assert_eq!(task.display_spec(), format!("internal:{internal}"), "{id}");
162        }
163    }
164
165    #[tokio::test]
166    async fn bare_id_in_a_project_does_not_clash_with_another_project() {
167        let store = TaskStore::open_memory().await.unwrap();
168        store
169            .create(&NewTask {
170                external_id: Some("5".to_string()),
171                repo: "owner/repo".to_string(),
172                origin: "github".to_string(),
173                title: "GitHub task 5".to_string(),
174                ..Default::default()
175            })
176            .await
177            .unwrap();
178        // Internal task 5 in another project
179        for _ in 0..5 {
180            store
181                .create_internal("owner/other", "Internal", "body", "job", "j:1", None)
182                .await
183                .unwrap();
184        }
185
186        let task = resolve_in(&store, "5", Some("owner/repo")).await.unwrap();
187        assert_eq!(task.display_spec(), "owner/repo#5");
188
189        // Outside a project the clash names both forms
190        let err = resolve_in(&store, "5", None).await.unwrap_err().to_string();
191        assert!(
192            err.contains("Use owner/repo#5 for the GitHub task"),
193            "{err}"
194        );
195        assert!(err.contains("or internal:5 for the internal one"), "{err}");
196    }
197
198    #[tokio::test]
199    async fn state_dirs_follow_the_resolved_task() {
200        let (store, internal) = store_with_tasks().await;
201        let state = Path::new("/state");
202
203        let github = resolve_in(&store, "owner/repo#5", None).await.unwrap();
204        assert_eq!(
205            dirs_under(state, &github),
206            (
207                PathBuf::from("/state/owner/repo/tasks/5-review"),
208                PathBuf::from("/state/owner/repo/tasks/5"),
209            )
210        );
211
212        let task = resolve_in(&store, &format!("internal:{internal}"), None)
213            .await
214            .unwrap();
215        assert_eq!(
216            dirs_under(state, &task).1,
217            PathBuf::from(format!("/state/owner/other/tasks/internal:{internal}"))
218        );
219    }
220
221    #[tokio::test]
222    async fn bare_repo_name_needs_an_owner_only_when_two_owners_share_it() {
223        let (store, _) = store_with_tasks().await;
224        for repo in ["other/repo", "owner/repo"] {
225            store
226                .create(&NewTask {
227                    external_id: Some("9".to_string()),
228                    repo: repo.to_string(),
229                    origin: "github".to_string(),
230                    title: "Task 9".to_string(),
231                    ..Default::default()
232                })
233                .await
234                .unwrap();
235        }
236
237        let err = resolve_in(&store, "repo#9", None)
238            .await
239            .unwrap_err()
240            .to_string();
241        assert!(err.contains("owner/repo#9"), "{err}");
242
243        let task = resolve_in(&store, "repo#9", Some("owner/repo"))
244            .await
245            .unwrap();
246        assert_eq!(task.display_spec(), "owner/repo#9");
247
248        // A repo name no project uses is reported as an invalid id, not as a clash
249        let err = resolve_in(&store, "nosuch#9", None)
250            .await
251            .unwrap_err()
252            .to_string();
253        assert!(err.contains("invalid task id"), "{err}");
254    }
255
256    #[tokio::test]
257    async fn unknown_bare_id_outside_a_project_is_an_error() {
258        let (store, _) = store_with_tasks().await;
259        assert!(resolve_in(&store, "999", None).await.is_err());
260        assert!(resolve_in(&store, "internal:999", None).await.is_err());
261        let task = resolve_in(&store, "999", Some("owner/repo")).await.unwrap();
262        assert_eq!(task.display_spec(), "owner/repo#999");
263    }
264}