1#![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
13pub 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
151fn 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}