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