Skip to main content

gor/cmd/
project.rs

1//! Implementation of the `gor project` subcommand.
2//!
3//! Provides project listing for organizations and repositories.
4
5#![allow(clippy::print_stdout)]
6
7use crate::cli::ProjectCommand;
8use crate::client::Client;
9use crate::output::print_json;
10use crate::repository;
11use anyhow::Context;
12
13/// Run the `gor project` subcommand.
14///
15/// # Errors
16///
17/// Returns an error if the command execution fails.
18pub fn run(cmd: ProjectCommand) -> anyhow::Result<()> {
19    match cmd {
20        ProjectCommand::List {
21            org,
22            owner,
23            repo,
24            v2,
25            limit,
26            json,
27            hostname,
28        } => list(
29            org.as_deref(),
30            owner.as_deref(),
31            repo.as_deref(),
32            v2,
33            limit,
34            json,
35            hostname.as_deref(),
36        ),
37        ProjectCommand::View {
38            number,
39            org,
40            owner,
41            web,
42            json,
43            hostname,
44        } => view(
45            number,
46            org.as_deref(),
47            owner.as_deref(),
48            web,
49            json,
50            hostname.as_deref(),
51        ),
52        ProjectCommand::ItemAdd {
53            project,
54            issue,
55            pull_request,
56            org,
57            owner,
58            hostname,
59        } => item_add(
60            project,
61            issue,
62            pull_request,
63            org.as_deref(),
64            owner.as_deref(),
65            hostname.as_deref(),
66        ),
67    }
68}
69
70fn list(
71    org: Option<&str>,
72    owner: Option<&str>,
73    repo: Option<&str>,
74    v2: bool,
75    limit: u32,
76    json: Option<Vec<String>>,
77    hostname: Option<&str>,
78) -> anyhow::Result<()> {
79    let host = hostname.unwrap_or("github.com");
80    let client = Client::new(host).context("failed to create HTTP client")?;
81
82    if v2 {
83        return list_v2(&client, org, owner, limit, json);
84    }
85
86    let path = if let Some(o) = org {
87        format!("/orgs/{o}/projects?per_page={}", limit.min(100))
88    } else if let Some(u) = owner {
89        format!("/users/{u}/projects?per_page={}", limit.min(100))
90    } else if let Some(r) = repo {
91        let spec = repository::parse_repo_spec(r).context("invalid repository spec")?;
92        format!(
93            "/repos/{}/{}/projects?per_page={}",
94            spec.owner,
95            spec.repo,
96            limit.min(100)
97        )
98    } else {
99        let spec = repository::detect_remote().ok_or_else(|| {
100            anyhow::anyhow!("could not detect repository; specify --org, --owner, or --repo")
101        })?;
102        format!(
103            "/repos/{}/{}/projects?per_page={}",
104            spec.owner,
105            spec.repo,
106            limit.min(100)
107        )
108    };
109
110    let response = client.get(&path).context("failed to fetch projects")?;
111    let status = response.status();
112    if !status.is_success() {
113        anyhow::bail!("failed to list projects: HTTP {status}");
114    }
115
116    let mut projects: Vec<serde_json::Value> =
117        response.json().context("failed to parse response")?;
118    projects.truncate(limit as usize);
119
120    if let Some(fields) = json {
121        let fields_ref: Option<&[String]> = if fields.is_empty() {
122            None
123        } else {
124            Some(&fields)
125        };
126        print_json(&projects, fields_ref);
127        return Ok(());
128    }
129
130    if projects.is_empty() {
131        println!("No projects found.");
132        return Ok(());
133    }
134
135    println!(
136        "{:<8}  {:<30}  {:<10}  VISIBILITY",
137        "NUMBER", "TITLE", "STATE"
138    );
139    for p in &projects {
140        let number = p["number"].as_u64().unwrap_or(0);
141        let title = p["name"].as_str().unwrap_or("—");
142        let state = p["state"].as_str().unwrap_or("—");
143        let visibility = p["visibility"].as_str().unwrap_or("—");
144        let title_truncated = crate::cmd::util::truncate(title, 30);
145        println!("{number:<8}  {title_truncated:<30}  {state:<10}  {visibility}");
146    }
147
148    Ok(())
149}
150
151/// List Projects V2 using the GraphQL API.
152fn list_v2(
153    client: &Client,
154    org: Option<&str>,
155    owner: Option<&str>,
156    limit: u32,
157    json: Option<Vec<String>>,
158) -> anyhow::Result<()> {
159    let (owner_type, login) = if let Some(o) = org {
160        ("organization", o.to_string())
161    } else if let Some(u) = owner {
162        ("user", u.to_string())
163    } else {
164        anyhow::bail!("specify --org or --owner for Projects V2");
165    };
166
167    let query = format!(
168        r#"{{
169  {owner_type}(login: "{login}") {{
170    projectsV2(first: {limit}) {{
171      nodes {{
172        number
173        title
174        closed
175      }}
176    }}
177  }}
178}}"#
179    );
180
181    let result = client
182        .graphql(&query, None)
183        .context("failed to query Projects V2")?;
184
185    let projects: Vec<serde_json::Value> = result["data"][owner_type]["projectsV2"]["nodes"]
186        .as_array()
187        .map_or_else(Vec::new, Clone::clone);
188
189    if let Some(fields) = json {
190        let fields_ref: Option<&[String]> = if fields.is_empty() {
191            None
192        } else {
193            Some(&fields)
194        };
195        print_json(&projects, fields_ref);
196        return Ok(());
197    }
198
199    if projects.is_empty() {
200        println!("No Projects V2 found.");
201        return Ok(());
202    }
203
204    println!("{:<8}  {:<30}  CLOSED", "NUMBER", "TITLE");
205    for p in &projects {
206        let number = p["number"].as_u64().unwrap_or(0);
207        let title = p["title"].as_str().unwrap_or("—");
208        let closed = p["closed"].as_bool().unwrap_or(false);
209        let title_truncated = crate::cmd::util::truncate(title, 30);
210        println!("{number:<8}  {title_truncated:<30}  {closed}");
211    }
212
213    Ok(())
214}
215
216fn view(
217    number: u64,
218    _org: Option<&str>,
219    _owner: Option<&str>,
220    web: bool,
221    json: Option<Vec<String>>,
222    hostname: Option<&str>,
223) -> anyhow::Result<()> {
224    let host = hostname.unwrap_or("github.com");
225    let client = Client::new(host).context("failed to create HTTP client")?;
226
227    let path = format!("/projects/{number}");
228    let response = client.get(&path).context("failed to fetch project")?;
229
230    let status = response.status();
231    if status == reqwest::StatusCode::NOT_FOUND {
232        anyhow::bail!("project #{number} not found");
233    }
234    if !status.is_success() {
235        anyhow::bail!("failed to view project: HTTP {status}");
236    }
237
238    let project: serde_json::Value = response.json().context("failed to parse response")?;
239
240    if web {
241        if let Some(url) = project["html_url"].as_str() {
242            println!("Open {url} in your browser");
243            return Ok(());
244        }
245        anyhow::bail!("no URL found for project #{number}");
246    }
247
248    if let Some(fields) = json {
249        let fields_ref: Option<&[String]> = if fields.is_empty() {
250            None
251        } else {
252            Some(&fields)
253        };
254        print_json(&project, fields_ref);
255        return Ok(());
256    }
257
258    let title = project["name"].as_str().unwrap_or("—");
259    let body = project["body"].as_str().unwrap_or("—");
260    let state = project["state"].as_str().unwrap_or("—");
261    let creator = project["creator"]["login"].as_str().unwrap_or("—");
262    let created = project["created_at"].as_str().unwrap_or("—");
263    let updated = project["updated_at"].as_str().unwrap_or("—");
264
265    println!("Project #{number}: {title}");
266    println!("  State: {state}");
267    println!("  Creator: {creator}");
268    println!("  Created: {created}");
269    println!("  Updated: {updated}");
270    if body != "—" && !body.is_empty() {
271        println!("  Body: {body}");
272    }
273
274    Ok(())
275}
276
277fn item_add(
278    project: u64,
279    issue: Option<u64>,
280    pull_request: Option<u64>,
281    _org: Option<&str>,
282    _owner: Option<&str>,
283    hostname: Option<&str>,
284) -> anyhow::Result<()> {
285    let host = hostname.unwrap_or("github.com");
286    let client = Client::new(host).context("failed to create HTTP client")?;
287
288    let (item_type, item_id) = if let Some(i) = issue {
289        ("Issue", i)
290    } else if let Some(pr) = pull_request {
291        ("PullRequest", pr)
292    } else {
293        anyhow::bail!("specify --issue or --pr to add an item");
294    };
295
296    let body = serde_json::json!({
297        "content_id": item_id,
298        "content_type": item_type,
299    });
300
301    let path = format!("/projects/{project}/items");
302    let body_bytes = serde_json::to_vec(&body).context("serialize")?;
303
304    let response = client
305        .request("POST", &path, &[], Some(body_bytes))
306        .context("failed to add project item")?;
307
308    let status = response.status();
309    if !status.is_success() {
310        let err: serde_json::Value = response.json().unwrap_or_default();
311        let msg = err["message"].as_str().unwrap_or("add failed");
312        anyhow::bail!("failed to add item to project #{project}: {msg}");
313    }
314
315    let result: serde_json::Value = response.json().context("failed to parse response")?;
316    let item_id = result["id"].as_u64().unwrap_or(0);
317    println!("Added {item_type} #{item_id} to project #{project} (item ID: {item_id})");
318
319    Ok(())
320}