use crate::github::domain::client::run_gh_json;
use crate::projects::domain::types::*;
pub const ITEM_LIMIT: usize = 500;
pub const ITEM_FIRST_FETCH: usize = 100;
pub const FIELD_LIMIT: usize = 30;
pub fn needs_bigger_fetch(fetched: usize, total: u64, limit: usize, max: usize) -> Option<usize> {
(fetched >= limit && limit < max && (total == 0 || total > limit as u64)).then_some(max)
}
pub fn repo_info() -> Result<RepoInfo, String> {
run_gh_json(
&["repo", "view", "--json", "nameWithOwner,owner,projectsV2"],
"gh repo view failed",
)
}
pub fn list_fields(owner: &str, number: u64) -> Result<Vec<ProjectField>, String> {
let fields = list_fields_limited(owner, number, FIELD_LIMIT)?;
match needs_bigger_fetch(fields.len(), 0, FIELD_LIMIT, 100) {
Some(bigger) => list_fields_limited(owner, number, bigger),
None => Ok(fields),
}
}
fn list_fields_limited(
owner: &str,
number: u64,
limit: usize,
) -> Result<Vec<ProjectField>, String> {
let list: FieldList = run_gh_json(
&[
"project",
"field-list",
&number.to_string(),
"--owner",
owner,
"--format",
"json",
"--limit",
&limit.to_string(),
],
"gh project field-list failed",
)?;
Ok(list.fields)
}
pub fn list_items(owner: &str, number: u64) -> Result<ItemList, String> {
let list = list_items_limited(owner, number, ITEM_FIRST_FETCH)?;
match needs_bigger_fetch(
list.items.len(),
list.total_count,
ITEM_FIRST_FETCH,
ITEM_LIMIT,
) {
Some(bigger) => list_items_limited(owner, number, bigger),
None => Ok(list),
}
}
fn list_items_limited(owner: &str, number: u64, limit: usize) -> Result<ItemList, String> {
run_gh_json(
&[
"project",
"item-list",
&number.to_string(),
"--owner",
owner,
"--format",
"json",
"--limit",
&limit.to_string(),
],
"gh project item-list failed",
)
}
pub fn fetch_board(owner: &str, owner_kind: &str, number: u64) -> Result<Board, String> {
let fields = list_fields(owner, number)?;
let items = list_items(owner, number)?;
let (views, _api_remaining) = fetch_views(owner, owner_kind, number).unwrap_or_default();
Ok(Board {
number,
fields,
items: items.items,
total_count: items.total_count,
views,
})
}
pub fn fetch_views(
owner: &str,
owner_kind: &str,
number: u64,
) -> Result<(Vec<ProjectView>, Option<u64>), String> {
match owner_kind {
"User" => fetch_views_as(owner, number, false),
"Organization" => fetch_views_as(owner, number, true),
_ => fetch_views_as(owner, number, false).or_else(|_| fetch_views_as(owner, number, true)),
}
}
fn fetch_views_as(
owner: &str,
number: u64,
org: bool,
) -> Result<(Vec<ProjectView>, Option<u64>), String> {
use serde::Deserialize;
#[derive(Deserialize)]
struct Resp {
data: serde_json::Value,
}
#[derive(Deserialize, Default)]
struct RateLimit {
#[serde(default)]
remaining: Option<u64>,
}
#[derive(Deserialize, Default)]
struct Views {
nodes: Vec<ViewNode>,
}
#[derive(Deserialize)]
struct ViewNode {
number: u64,
#[serde(default)]
name: String,
#[serde(default)]
layout: String,
#[serde(default)]
filter: Option<String>,
#[serde(rename = "groupByFields", default)]
group_by: Named,
#[serde(rename = "verticalGroupByFields", default)]
vertical_group_by: Named,
#[serde(rename = "sortByFields", default)]
sort_by: Sorts,
#[serde(default)]
fields: Named,
}
#[derive(Deserialize, Default)]
struct Named {
nodes: Vec<NameNode>,
}
#[derive(Deserialize, Default)]
struct NameNode {
#[serde(default)]
name: String,
}
#[derive(Deserialize, Default)]
struct Sorts {
nodes: Vec<SortNode>,
}
#[derive(Deserialize)]
struct SortNode {
#[serde(default)]
direction: String,
#[serde(default)]
field: NameNode,
}
let root = if org { "organization" } else { "user" };
let query = format!(
"query($login: String!, $number: Int!) {{ {root}(login: $login) {{ \
projectV2(number: $number) {{ views(first: 20) {{ nodes {{ \
name number layout filter \
groupByFields(first: 5) {{ nodes {{ ... on ProjectV2FieldCommon {{ name }} }} }} \
verticalGroupByFields(first: 5) {{ nodes {{ ... on ProjectV2FieldCommon {{ name }} }} }} \
sortByFields(first: 5) {{ nodes {{ direction field {{ ... on ProjectV2FieldCommon {{ name }} }} }} }} \
fields(first: 30) {{ nodes {{ ... on ProjectV2FieldCommon {{ name }} }} }} }} }} }} }} \
rateLimit {{ remaining }} }}"
);
let resp: Resp = crate::github::domain::client::run_gh_json(
&[
"api",
"graphql",
"-f",
&format!("query={query}"),
"-F",
&format!("login={owner}"),
"-F",
&format!("number={number}"),
],
"gh api graphql (project views) failed",
)?;
let api_remaining: Option<u64> = resp
.data
.get("rateLimit")
.and_then(|v| serde_json::from_value::<RateLimit>(v.clone()).ok())
.and_then(|r| r.remaining);
if let Some(left) = api_remaining {
crate::core::api_budget::note(left);
}
let views: Views = resp
.data
.get(root)
.and_then(|v| v.get("projectV2"))
.and_then(|v| v.get("views"))
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|e| format!("project views: JSON parse error: {e}"))?
.unwrap_or_default();
let names = |n: &Named| -> Vec<String> {
n.nodes
.iter()
.map(|f| f.name.clone())
.filter(|s| !s.is_empty())
.collect()
};
let views = views
.nodes
.into_iter()
.filter_map(|v| {
Some(ProjectView {
number: v.number,
name: v.name,
layout: ViewLayout::parse(&v.layout)?,
filter: v.filter.filter(|f| !f.trim().is_empty()),
group_by: names(&v.group_by),
vertical_group_by: names(&v.vertical_group_by),
sort_by: v
.sort_by
.nodes
.into_iter()
.filter(|sn| !sn.field.name.is_empty())
.map(|sn| ViewSort {
field: sn.field.name.clone(),
desc: sn.direction == "DESC",
})
.collect(),
visible_fields: names(&v.fields),
})
})
.collect();
Ok((views, api_remaining))
}
pub fn viewer_login() -> Option<String> {
let out = crate::github::domain::client::run_gh(
&["api", "user", "--jq", ".login"],
"gh api user failed",
)
.ok()?;
let login = String::from_utf8_lossy(&out).trim().to_string();
(!login.is_empty()).then_some(login)
}
pub fn is_scope_error(msg: &str) -> bool {
let m = msg.to_lowercase();
m.contains("read:project") || (m.contains("scope") && m.contains("project"))
}
pub fn is_gh_missing(msg: &str) -> bool {
msg.contains("gh not found")
|| msg.contains("gh repo view failed: No such file")
|| msg.contains("os error 2")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scope_errors_are_recognised() {
assert!(is_scope_error(
"error: your authentication token is missing required scopes [project]\nTo request it, run: gh auth refresh -s project"
));
assert!(is_scope_error(
"Your token has not been granted the required scopes to execute this query. The 'projectsV2' field requires one of the following scopes: ['read:project']"
));
assert!(!is_scope_error("gh not found: No such file or directory"));
assert!(!is_scope_error(
"Could not resolve to a ProjectV2 with the number 99."
));
assert!(!is_scope_error("HTTP 404: Not Found"));
}
#[test]
fn bigger_fetch_only_when_the_first_page_was_full() {
assert_eq!(needs_bigger_fetch(8, 8, 100, 500), None);
assert_eq!(needs_bigger_fetch(99, 99, 100, 500), None);
assert_eq!(needs_bigger_fetch(100, 340, 100, 500), Some(500));
assert_eq!(needs_bigger_fetch(30, 0, 30, 100), Some(100));
assert_eq!(needs_bigger_fetch(100, 100, 100, 500), None);
assert_eq!(needs_bigger_fetch(500, 900, 500, 500), None);
}
#[test]
fn missing_cli_is_recognised() {
assert!(is_gh_missing(
"gh not found: No such file or directory (os error 2)"
));
assert!(!is_gh_missing("gh project list failed: exit status 1"));
}
}