Skip to content

Commit c4a995d

Browse files
committed
Add Jira project list command
1 parent 77ad09c commit c4a995d

9 files changed

Lines changed: 386 additions & 17 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,11 @@ atla auth status
4848
atla config set default-project PROJ
4949
atla config list --output json
5050
```
51+
52+
First Jira command:
53+
54+
```bash
55+
atla jira project list
56+
atla jira project list --query platform --limit 25 --output json
57+
atla jira project list --output keys
58+
```

crates/atla-cli/src/cli.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,31 @@ pub struct JiraCommand {
8989
#[derive(Debug, Subcommand)]
9090
pub enum JiraResource {
9191
Issue,
92-
Project,
92+
Project(ProjectCommand),
9393
Sprint,
9494
Board,
9595
Search { jql: String },
9696
}
9797

98+
#[derive(Debug, Args)]
99+
pub struct ProjectCommand {
100+
#[command(subcommand)]
101+
pub action: ProjectAction,
102+
}
103+
104+
#[derive(Debug, Subcommand)]
105+
pub enum ProjectAction {
106+
List {
107+
#[arg(long)]
108+
query: Option<String>,
109+
#[arg(long, default_value_t = 50)]
110+
limit: u32,
111+
},
112+
View {
113+
key: String,
114+
},
115+
}
116+
98117
#[derive(Debug, Args)]
99118
pub struct ConfluenceCommand {
100119
#[command(subcommand)]
Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,147 @@
1-
use crate::cli::{GlobalArgs, JiraCommand, JiraResource};
1+
use anyhow::Context;
2+
use atla_core::auth::{CredentialStore, KeyringCredentialStore};
3+
use atla_core::{
4+
AtlaConfig, AtlassianClient, ConfigStore, JiraClient, JiraProject, JiraProjectSearch, Profile,
5+
};
26

3-
pub async fn run(command: JiraCommand, _global: &GlobalArgs) -> anyhow::Result<()> {
7+
use crate::cli::{
8+
GlobalArgs, JiraCommand, JiraResource, OutputFormat, ProjectAction, ProjectCommand,
9+
};
10+
use crate::config;
11+
use crate::output;
12+
13+
pub async fn run(command: JiraCommand, global: &GlobalArgs) -> anyhow::Result<()> {
414
match command.resource {
515
JiraResource::Issue => println!("jira issue commands are planned"),
6-
JiraResource::Project => println!("jira project commands are planned"),
16+
JiraResource::Project(command) => run_project(command, global).await?,
717
JiraResource::Sprint => println!("jira sprint commands are planned"),
818
JiraResource::Board => println!("jira board commands are planned"),
919
JiraResource::Search { jql } => println!("jira search is planned: {jql}"),
1020
}
1121

1222
Ok(())
1323
}
24+
25+
async fn run_project(command: ProjectCommand, global: &GlobalArgs) -> anyhow::Result<()> {
26+
match command.action {
27+
ProjectAction::List { query, limit } => {
28+
let store = ConfigStore::default_store().context("failed to find config location")?;
29+
let atla_config = store.load().context("failed to load config")?;
30+
let (profile_name, profile) = active_profile(&atla_config, global)?;
31+
let search = JiraProjectSearch {
32+
start_at: 0,
33+
max_results: limit.clamp(1, 100),
34+
query,
35+
};
36+
37+
if global.dry_run {
38+
let url = format!(
39+
"{}/rest/api/3/project/search?startAt={}&maxResults={}",
40+
profile.instance.trim_end_matches('/'),
41+
search.start_at,
42+
search.max_results
43+
);
44+
if let Some(query) = &search.query {
45+
println!("Would GET {url} with query `{query}` using profile `{profile_name}`");
46+
} else {
47+
println!("Would GET {url} using profile `{profile_name}`");
48+
}
49+
return Ok(());
50+
}
51+
52+
let token = token_for_profile(profile_name, profile)?;
53+
let client = JiraClient::new(AtlassianClient::from_profile(profile, token));
54+
let page = client.search_projects(&search).await.with_context(|| {
55+
format!(
56+
"failed to list Jira projects from {}",
57+
client.instance_url()
58+
)
59+
})?;
60+
61+
print_projects(&page.values, page.total, global)?;
62+
}
63+
ProjectAction::View { key } => {
64+
println!("jira project view is planned: {key}");
65+
}
66+
}
67+
68+
Ok(())
69+
}
70+
71+
fn active_profile<'a>(
72+
atla_config: &'a AtlaConfig,
73+
global: &GlobalArgs,
74+
) -> anyhow::Result<(&'a str, &'a Profile)> {
75+
atla_config
76+
.active_profile(config::active_profile(global))
77+
.ok_or_else(|| anyhow::anyhow!("no active profile; run `atla auth login` first"))
78+
}
79+
80+
fn token_for_profile(profile_name: &str, profile: &Profile) -> anyhow::Result<String> {
81+
let credential = profile.credential_ref(profile_name);
82+
let token = KeyringCredentialStore::default()
83+
.get_token(&credential)
84+
.context("failed to read API token from keyring")?;
85+
86+
token.ok_or_else(|| {
87+
anyhow::anyhow!("missing API token; run `atla auth login --profile {profile_name}`")
88+
})
89+
}
90+
91+
fn print_projects(
92+
projects: &[JiraProject],
93+
total: Option<u64>,
94+
global: &GlobalArgs,
95+
) -> anyhow::Result<()> {
96+
match global.output.unwrap_or(OutputFormat::Table) {
97+
OutputFormat::Json => output::print_json(projects),
98+
OutputFormat::Keys => {
99+
for project in projects {
100+
if let Some(key) = &project.key {
101+
println!("{key}");
102+
}
103+
}
104+
Ok(())
105+
}
106+
OutputFormat::Csv => {
107+
println!("key,name,type,style,archived");
108+
for project in projects {
109+
println!(
110+
"{},{},{},{},{}",
111+
csv_cell(project.key.as_deref().unwrap_or_default()),
112+
csv_cell(project.name.as_deref().unwrap_or_default()),
113+
csv_cell(project.project_type_key.as_deref().unwrap_or_default()),
114+
csv_cell(project.style.as_deref().unwrap_or_default()),
115+
csv_cell(&project.archived.unwrap_or(false).to_string())
116+
);
117+
}
118+
Ok(())
119+
}
120+
OutputFormat::Table => {
121+
println!("{:<12} {:<16} {:<12} NAME", "KEY", "TYPE", "STYLE");
122+
for project in projects {
123+
println!(
124+
"{:<12} {:<16} {:<12} {}",
125+
project.key.as_deref().unwrap_or("-"),
126+
project.project_type_key.as_deref().unwrap_or("-"),
127+
project.style.as_deref().unwrap_or("-"),
128+
project.name.as_deref().unwrap_or("-")
129+
);
130+
}
131+
132+
if let Some(total) = total {
133+
println!();
134+
println!("Showing {} of {total} projects.", projects.len());
135+
}
136+
Ok(())
137+
}
138+
}
139+
}
140+
141+
fn csv_cell(value: &str) -> String {
142+
if value.contains(',') || value.contains('"') || value.contains('\n') {
143+
format!("\"{}\"", value.replace('"', "\"\""))
144+
} else {
145+
value.to_owned()
146+
}
147+
}

crates/atla-cli/src/output.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use serde::Serialize;
22

3-
pub fn print_json<T: Serialize>(value: &T) -> anyhow::Result<()> {
3+
pub fn print_json<T: Serialize + ?Sized>(value: &T) -> anyhow::Result<()> {
44
println!("{}", serde_json::to_string_pretty(value)?);
55
Ok(())
66
}

crates/atla-core/Cargo.toml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,7 @@ serde.workspace = true
1313
serde_json.workspace = true
1414
thiserror.workspace = true
1515
toml.workspace = true
16-
17-
reqwest = { workspace = true, optional = true }
16+
reqwest.workspace = true
1817

1918
[dev-dependencies]
2019
tempfile.workspace = true
21-
22-
[features]
23-
default = []
24-
http-client = ["dep:reqwest"]

crates/atla-core/src/auth.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub enum AuthState {
2323

2424
pub trait CredentialStore {
2525
fn save_token(&self, credential: &CredentialRef, token: &str) -> Result<(), AuthError>;
26+
fn get_token(&self, credential: &CredentialRef) -> Result<Option<String>, AuthError>;
2627
fn delete_token(&self, credential: &CredentialRef) -> Result<(), AuthError>;
2728
fn has_token(&self, credential: &CredentialRef) -> Result<bool, AuthError>;
2829
}
@@ -58,6 +59,14 @@ impl CredentialStore for KeyringCredentialStore {
5859
.map_err(|error| AuthError::Backend(error.to_string()))
5960
}
6061

62+
fn get_token(&self, credential: &CredentialRef) -> Result<Option<String>, AuthError> {
63+
match self.entry(credential)?.get_password() {
64+
Ok(token) => Ok(Some(token)),
65+
Err(keyring::Error::NoEntry) => Ok(None),
66+
Err(error) => Err(AuthError::Backend(error.to_string())),
67+
}
68+
}
69+
6170
fn delete_token(&self, credential: &CredentialRef) -> Result<(), AuthError> {
6271
match self.entry(credential)?.delete_credential() {
6372
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
@@ -66,11 +75,7 @@ impl CredentialStore for KeyringCredentialStore {
6675
}
6776

6877
fn has_token(&self, credential: &CredentialRef) -> Result<bool, AuthError> {
69-
match self.entry(credential)?.get_password() {
70-
Ok(_) => Ok(true),
71-
Err(keyring::Error::NoEntry) => Ok(false),
72-
Err(error) => Err(AuthError::Backend(error.to_string())),
73-
}
78+
self.get_token(credential).map(|token| token.is_some())
7479
}
7580
}
7681

crates/atla-core/src/client.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use serde::{Deserialize, Serialize};
22

3+
use crate::Profile;
4+
35
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46
pub struct AtlassianInstance {
57
pub base_url: String,
@@ -12,3 +14,97 @@ impl AtlassianInstance {
1214
}
1315
}
1416
}
17+
18+
#[derive(Debug, Clone)]
19+
pub struct AtlassianClient {
20+
http: reqwest::Client,
21+
instance: AtlassianInstance,
22+
email: String,
23+
token: String,
24+
}
25+
26+
impl AtlassianClient {
27+
pub fn new(
28+
instance: AtlassianInstance,
29+
email: impl Into<String>,
30+
token: impl Into<String>,
31+
) -> Self {
32+
Self {
33+
http: reqwest::Client::new(),
34+
instance,
35+
email: email.into(),
36+
token: token.into(),
37+
}
38+
}
39+
40+
pub fn from_profile(profile: &Profile, token: impl Into<String>) -> Self {
41+
Self::new(
42+
AtlassianInstance::new(&profile.instance),
43+
profile.email.clone(),
44+
token,
45+
)
46+
}
47+
48+
pub fn instance(&self) -> &AtlassianInstance {
49+
&self.instance
50+
}
51+
52+
pub fn get(&self, path: &str) -> reqwest::RequestBuilder {
53+
self.http
54+
.get(self.url(path))
55+
.basic_auth(&self.email, Some(&self.token))
56+
.header(reqwest::header::ACCEPT, "application/json")
57+
}
58+
59+
pub fn url(&self, path: &str) -> String {
60+
format!(
61+
"{}/{}",
62+
self.instance.base_url,
63+
path.trim_start_matches('/')
64+
)
65+
}
66+
}
67+
68+
#[derive(Debug, thiserror::Error)]
69+
pub enum ApiError {
70+
#[error("request failed: {0}")]
71+
Request(#[from] reqwest::Error),
72+
#[error("Atlassian API returned {status}: {body}")]
73+
Http {
74+
status: reqwest::StatusCode,
75+
body: String,
76+
},
77+
}
78+
79+
pub async fn read_json<T: serde::de::DeserializeOwned>(
80+
request: reqwest::RequestBuilder,
81+
) -> Result<T, ApiError> {
82+
let response = request.send().await?;
83+
let status = response.status();
84+
85+
if !status.is_success() {
86+
let body = response.text().await.unwrap_or_default();
87+
return Err(ApiError::Http { status, body });
88+
}
89+
90+
response.json::<T>().await.map_err(ApiError::Request)
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::*;
96+
97+
#[test]
98+
fn joins_base_url_and_paths() {
99+
let client = AtlassianClient::new(
100+
AtlassianInstance::new("https://example.atlassian.net/"),
101+
"neo@example.com",
102+
"token",
103+
);
104+
105+
assert_eq!(
106+
client.url("/rest/api/3/project/search"),
107+
"https://example.atlassian.net/rest/api/3/project/search"
108+
);
109+
}
110+
}

0 commit comments

Comments
 (0)