use std::collections::BTreeMap;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
fn github_status_error(action: &str, status: reqwest::StatusCode) -> anyhow::Error {
anyhow::anyhow!(
"{} failed with GitHub API status {}",
action,
status.as_u16()
)
}
fn require_success(action: &str, response: reqwest::Response) -> Result<reqwest::Response> {
let status = response.status();
if status.is_success() {
Ok(response)
} else {
Err(github_status_error(action, status))
}
}
#[derive(Clone)]
pub struct GitHubClient {
client: reqwest::Client,
token: String,
base: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubUser {
pub login: String,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubLabel {
pub name: String,
pub color: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubIssue {
pub number: i64,
pub title: String,
pub body: Option<String>,
pub state: String,
pub user: GitHubUser,
pub labels: Vec<GitHubLabel>,
pub comments: i64,
pub created_at: String,
pub updated_at: String,
pub closed_at: Option<String>,
pub html_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubComment {
pub id: i64,
pub body: String,
pub user: GitHubUser,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubGistFile {
pub filename: Option<String>,
#[serde(rename = "type")]
pub mime_type: Option<String>,
pub language: Option<String>,
pub raw_url: Option<String>,
pub size: Option<u64>,
pub truncated: Option<bool>,
pub content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubGist {
pub id: String,
pub description: Option<String>,
pub public: bool,
pub html_url: String,
pub created_at: String,
pub updated_at: String,
pub owner: Option<GitHubUser>,
pub files: BTreeMap<String, GitHubGistFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GistFileInput {
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateGistRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub public: bool,
pub files: BTreeMap<String, GistFileInput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateGistRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub files: BTreeMap<String, GistFileInput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateIssueRequest {
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateIssueRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubPull {
pub number: i64,
pub title: String,
pub body: Option<String>,
pub state: String,
#[serde(default)]
pub draft: bool,
pub user: GitHubUser,
#[serde(default)]
pub labels: Vec<GitHubLabel>,
pub created_at: String,
pub updated_at: String,
pub closed_at: Option<String>,
pub merged_at: Option<String>,
pub html_url: String,
pub head: GitHubPullRef,
pub base: GitHubPullRef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubPullRef {
#[serde(rename = "ref")]
pub branch: String,
pub sha: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubChecks {
pub sha: String,
pub state: String,
pub runs: Vec<GitHubCheckRun>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubCheckRun {
pub name: String,
pub status: String,
pub conclusion: Option<String>,
pub url: Option<String>,
}
impl GitHubChecks {
pub fn rollup(runs: &[GitHubCheckRun]) -> String {
if runs.is_empty() {
return "neutral".to_string();
}
let failed = runs.iter().any(|run| {
matches!(
run.conclusion.as_deref(),
Some("failure") | Some("timed_out") | Some("action_required") | Some("startup_failure")
)
});
if failed {
return "failure".to_string();
}
let running = runs
.iter()
.any(|run| run.status != "completed" || run.conclusion.is_none());
if running {
return "pending".to_string();
}
let succeeded = runs
.iter()
.any(|run| run.conclusion.as_deref() == Some("success"));
if succeeded {
"success".to_string()
} else {
"neutral".to_string()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubRelease {
pub id: i64,
pub tag_name: String,
pub name: Option<String>,
pub body: Option<String>,
pub draft: bool,
pub prerelease: bool,
pub created_at: String,
pub published_at: Option<String>,
pub html_url: String,
#[serde(default)]
pub assets: Vec<GitHubReleaseAsset>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubReleaseAsset {
pub name: String,
pub size: i64,
pub download_count: i64,
pub browser_download_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubTag {
pub name: String,
pub commit: GitHubTagCommit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubTagCommit {
pub sha: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubRepository {
pub full_name: String,
pub private: bool,
pub archived: bool,
pub fork: bool,
pub default_branch: String,
pub description: Option<String>,
pub html_url: String,
#[serde(default)]
pub topics: Vec<String>,
pub stargazers_count: i64,
pub watchers_count: i64,
pub forks_count: i64,
pub open_issues_count: i64,
pub pushed_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubMilestone {
pub number: i64,
pub title: String,
pub description: Option<String>,
pub state: String,
pub open_issues: i64,
pub closed_issues: i64,
pub due_on: Option<String>,
pub updated_at: String,
pub html_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubNotification {
pub id: String,
pub unread: bool,
pub reason: String,
pub updated_at: String,
pub subject: GitHubNotificationSubject,
pub repository: GitHubNotificationRepo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubNotificationSubject {
pub title: String,
#[serde(rename = "type")]
pub kind: String,
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubNotificationRepo {
pub full_name: String,
pub private: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubRateLimit {
pub limit: i64,
pub remaining: i64,
pub reset: i64,
pub used: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubTraffic {
pub views: i64,
pub unique_views: i64,
pub clones: i64,
pub unique_clones: i64,
#[serde(default)]
pub view_days: Vec<GitHubTrafficDay>,
#[serde(default)]
pub clone_days: Vec<GitHubTrafficDay>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubTrafficDay {
pub timestamp: String,
pub count: i64,
pub uniques: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TrafficSeries {
pub count: i64,
pub uniques: i64,
#[serde(default)]
pub views: Vec<GitHubTrafficDay>,
#[serde(default)]
pub clones: Vec<GitHubTrafficDay>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RepositoryRuleset {
pub id: i64,
pub name: String,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub enforcement: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateCommentRequest {
pub body: String,
}
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
impl GitHubClient {
pub fn new(token: &str) -> Self {
Self::against("https://api.github.com", token)
}
pub fn against(base: &str, token: &str) -> Self {
Self::bounded(base, token, REQUEST_TIMEOUT)
}
pub fn against_within(base: &str, token: &str, request: std::time::Duration) -> Self {
Self::bounded(base, token, request)
}
fn bounded(base: &str, token: &str, request: std::time::Duration) -> Self {
let client = reqwest::Client::builder()
.user_agent(concat!("wsb-github/", env!("CARGO_PKG_VERSION")))
.no_proxy()
.connect_timeout(CONNECT_TIMEOUT.min(request))
.timeout(request)
.build()
.expect("failed to build reqwest client");
Self {
client,
token: token.to_string(),
base: base.trim_end_matches('/').to_string(),
}
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base, path)
}
pub async fn validate_token(&self) -> Result<String> {
let response = require_success(
"GitHub token validation",
self.client
.get(self.url("/user"))
.bearer_auth(&self.token)
.send()
.await
.context("GitHub API request failed")?,
)?;
let resp: GitHubUser = response
.json()
.await
.context("failed to parse GitHub user response")?;
Ok(resp.login)
}
pub async fn list_rulesets(&self, owner: &str, repo: &str) -> Result<Vec<RepositoryRuleset>> {
let response = require_success(
"listing rulesets",
self.client
.get(self.url(&format!("/repos/{owner}/{repo}/rulesets")))
.bearer_auth(&self.token)
.send()
.await
.context("GitHub API request failed")?,
)?;
response
.json()
.await
.context("failed to parse the ruleset list")
}
pub async fn put_ruleset(
&self,
owner: &str,
repo: &str,
name: &str,
body: &serde_json::Value,
) -> Result<RepositoryRuleset> {
let existing = self
.list_rulesets(owner, repo)
.await?
.into_iter()
.find(|ruleset| ruleset.name == name);
let response = match &existing {
Some(ruleset) => {
self.client
.put(self.url(&format!(
"/repos/{owner}/{repo}/rulesets/{}",
ruleset.id
)))
.bearer_auth(&self.token)
.json(body)
.send()
.await
}
None => {
self.client
.post(self.url(&format!("/repos/{owner}/{repo}/rulesets")))
.bearer_auth(&self.token)
.json(body)
.send()
.await
}
}
.context("GitHub API request failed")?;
let response = require_success(
if existing.is_some() {
"updating a ruleset"
} else {
"creating a ruleset"
},
response,
)?;
response
.json()
.await
.context("failed to parse the ruleset GitHub wrote")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fetched<T> {
Fresh { value: T, etag: Option<String> },
Unchanged,
}
impl<T> Fetched<T> {
pub fn value(self) -> Option<T> {
match self {
Self::Fresh { value, .. } => Some(value),
Self::Unchanged => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Conditions {
pub etag: Option<String>,
pub since: Option<String>,
}
impl GitHubClient {
pub async fn list_issues_changed(
&self,
owner: &str,
repo: &str,
conditions: &Conditions,
) -> Result<Fetched<Vec<GitHubIssue>>> {
let mut url = self.url(&format!(
"/repos/{owner}/{repo}/issues?state=all&per_page=100&sort=updated&direction=desc"
));
if let Some(since) = &conditions.since {
url.push_str(&format!("&since={since}"));
}
let mut request = self.client.get(&url).bearer_auth(&self.token);
if let Some(etag) = &conditions.etag {
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
}
let response = request
.send()
.await
.with_context(|| format!("failed to list issues for {owner}/{repo}"))?;
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
return Ok(Fetched::Unchanged);
}
let etag = header_value(&response, reqwest::header::ETAG);
let response = require_success(
&format!("GitHub API error listing issues for {owner}/{repo}"),
response,
)?;
let issues: Vec<GitHubIssue> = response
.json()
.await
.context("failed to parse issues response")?;
Ok(Fetched::Fresh {
value: issues
.into_iter()
.filter(|issue| !issue.html_url.contains("/pull/"))
.collect(),
etag,
})
}
pub async fn list_gists_changed(
&self,
conditions: &Conditions,
) -> Result<Fetched<Vec<GitHubGist>>> {
let mut request = self
.client
.get(self.url("/gists?per_page=100"))
.bearer_auth(&self.token);
if let Some(etag) = &conditions.etag {
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
}
let response = request.send().await.context("failed to list gists")?;
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
return Ok(Fetched::Unchanged);
}
let etag = header_value(&response, reqwest::header::ETAG);
let response = require_success("failed to list gists", response)?;
let gists: Vec<GitHubGist> = response
.json()
.await
.context("failed to parse gists response")?;
Ok(Fetched::Fresh { value: gists, etag })
}
}
impl GitHubClient {
async fn conditional<T: serde::de::DeserializeOwned>(
&self,
what: &str,
path: &str,
conditions: &Conditions,
) -> Result<Fetched<T>> {
let mut request = self.client.get(self.url(path)).bearer_auth(&self.token);
if let Some(etag) = &conditions.etag {
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
}
let response = request
.send()
.await
.with_context(|| format!("failed {what}"))?;
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
return Ok(Fetched::Unchanged);
}
let etag = header_value(&response, reqwest::header::ETAG);
let response = require_success(what, response)?;
let value: T = response
.json()
.await
.with_context(|| format!("failed to parse the answer to {what}"))?;
Ok(Fetched::Fresh { value, etag })
}
pub async fn list_pulls_changed(
&self,
owner: &str,
repo: &str,
conditions: &Conditions,
) -> Result<Fetched<Vec<GitHubPull>>> {
self.conditional(
&format!("listing pull requests for {owner}/{repo}"),
&format!("/repos/{owner}/{repo}/pulls?state=all&per_page=100&sort=updated&direction=desc"),
conditions,
)
.await
}
pub async fn checks_for(&self, owner: &str, repo: &str, commit: &str) -> Result<GitHubChecks> {
#[derive(Deserialize)]
struct CheckRuns {
check_runs: Vec<RawCheckRun>,
}
#[derive(Deserialize)]
struct RawCheckRun {
name: String,
status: String,
conclusion: Option<String>,
html_url: Option<String>,
}
#[derive(Deserialize)]
struct CombinedStatus {
statuses: Vec<RawStatus>,
}
#[derive(Deserialize)]
struct RawStatus {
context: String,
state: String,
target_url: Option<String>,
}
let runs: CheckRuns = require_success(
&format!("listing check runs for {owner}/{repo}@{commit}"),
self.client
.get(self.url(&format!(
"/repos/{owner}/{repo}/commits/{commit}/check-runs?per_page=100"
)))
.bearer_auth(&self.token)
.send()
.await
.context("GitHub API request failed")?,
)?
.json()
.await
.context("failed to parse the check runs")?;
let combined: CombinedStatus = require_success(
&format!("listing commit statuses for {owner}/{repo}@{commit}"),
self.client
.get(self.url(&format!(
"/repos/{owner}/{repo}/commits/{commit}/status"
)))
.bearer_auth(&self.token)
.send()
.await
.context("GitHub API request failed")?,
)?
.json()
.await
.context("failed to parse the commit statuses")?;
let mut all: Vec<GitHubCheckRun> = runs
.check_runs
.into_iter()
.map(|run| GitHubCheckRun {
name: run.name,
status: run.status,
conclusion: run.conclusion,
url: run.html_url,
})
.collect();
all.extend(combined.statuses.into_iter().map(|status| GitHubCheckRun {
name: status.context,
status: "completed".to_string(),
conclusion: Some(match status.state.as_str() {
"success" => "success".to_string(),
"failure" | "error" => "failure".to_string(),
other => other.to_string(),
}),
url: status.target_url,
}));
Ok(GitHubChecks {
sha: commit.to_string(),
state: GitHubChecks::rollup(&all),
runs: all,
})
}
pub async fn list_releases_changed(
&self,
owner: &str,
repo: &str,
conditions: &Conditions,
) -> Result<Fetched<Vec<GitHubRelease>>> {
self.conditional(
&format!("listing releases for {owner}/{repo}"),
&format!("/repos/{owner}/{repo}/releases?per_page=100"),
conditions,
)
.await
}
pub async fn list_tags_changed(
&self,
owner: &str,
repo: &str,
conditions: &Conditions,
) -> Result<Fetched<Vec<GitHubTag>>> {
self.conditional(
&format!("listing tags for {owner}/{repo}"),
&format!("/repos/{owner}/{repo}/tags?per_page=100"),
conditions,
)
.await
}
pub async fn repository_changed(
&self,
owner: &str,
repo: &str,
conditions: &Conditions,
) -> Result<Fetched<GitHubRepository>> {
self.conditional(
&format!("reading {owner}/{repo}"),
&format!("/repos/{owner}/{repo}"),
conditions,
)
.await
}
pub async fn list_milestones_changed(
&self,
owner: &str,
repo: &str,
conditions: &Conditions,
) -> Result<Fetched<Vec<GitHubMilestone>>> {
self.conditional(
&format!("listing milestones for {owner}/{repo}"),
&format!("/repos/{owner}/{repo}/milestones?state=all&per_page=100"),
conditions,
)
.await
}
pub async fn list_labels_changed(
&self,
owner: &str,
repo: &str,
conditions: &Conditions,
) -> Result<Fetched<Vec<GitHubLabel>>> {
self.conditional(
&format!("listing labels for {owner}/{repo}"),
&format!("/repos/{owner}/{repo}/labels?per_page=100"),
conditions,
)
.await
}
pub async fn list_comments_changed(
&self,
owner: &str,
repo: &str,
number: i64,
conditions: &Conditions,
) -> Result<Fetched<Vec<GitHubComment>>> {
self.conditional(
&format!("listing comments on {owner}/{repo}#{number}"),
&format!("/repos/{owner}/{repo}/issues/{number}/comments?per_page=100"),
conditions,
)
.await
}
pub async fn list_notifications_changed(
&self,
conditions: &Conditions,
) -> Result<Fetched<Vec<GitHubNotification>>> {
self.conditional(
"listing notifications",
"/notifications?all=false&per_page=50",
conditions,
)
.await
}
pub async fn rate_limit(&self) -> Result<GitHubRateLimit> {
#[derive(Deserialize)]
struct Envelope {
resources: Resources,
}
#[derive(Deserialize)]
struct Resources {
core: GitHubRateLimit,
}
let envelope: Envelope = require_success(
"reading the rate limit",
self.client
.get(self.url("/rate_limit"))
.bearer_auth(&self.token)
.send()
.await
.context("GitHub API request failed")?,
)?
.json()
.await
.context("failed to parse the rate limit")?;
Ok(envelope.resources.core)
}
pub async fn traffic(&self, owner: &str, repo: &str) -> Result<GitHubTraffic> {
let views: TrafficSeries = require_success(
&format!("reading traffic for {owner}/{repo}"),
self.client
.get(self.url(&format!("/repos/{owner}/{repo}/traffic/views")))
.bearer_auth(&self.token)
.send()
.await
.context("GitHub API request failed")?,
)?
.json()
.await
.context("failed to parse the view traffic")?;
let clones: TrafficSeries = require_success(
&format!("reading clone traffic for {owner}/{repo}"),
self.client
.get(self.url(&format!("/repos/{owner}/{repo}/traffic/clones")))
.bearer_auth(&self.token)
.send()
.await
.context("GitHub API request failed")?,
)?
.json()
.await
.context("failed to parse the clone traffic")?;
Ok(GitHubTraffic {
views: views.count,
unique_views: views.uniques,
clones: clones.count,
unique_clones: clones.uniques,
view_days: views.views,
clone_days: clones.clones,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubViewer {
pub login: String,
pub organizations: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubProject {
pub id: String,
pub number: i64,
pub title: String,
pub url: String,
pub short_description: Option<String>,
pub closed: bool,
pub updated_at: String,
pub owner: String,
pub fields: Vec<GitHubProjectField>,
}
impl GitHubProject {
pub fn column_field(&self) -> Option<&GitHubProjectField> {
self.fields
.iter()
.find(|field| field.kind == "single_select" && field.name == "Status")
.or_else(|| {
self.fields
.iter()
.find(|field| field.kind == "single_select")
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubProjectField {
pub id: String,
pub name: String,
pub kind: String,
#[serde(default)]
pub options: Vec<GitHubProjectFieldOption>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubProjectFieldOption {
pub id: String,
pub name: String,
pub color: String,
#[serde(deserialize_with = "text_or_nothing")]
pub description: String,
}
fn text_or_nothing<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Debug, Clone, Serialize)]
struct ProjectColumnOptionInput {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
name: String,
color: String,
description: String,
}
impl From<&GitHubProjectFieldOption> for ProjectColumnOptionInput {
fn from(option: &GitHubProjectFieldOption) -> Self {
Self {
id: Some(option.id.clone()),
name: option.name.clone(),
color: option.color.clone(),
description: option.description.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitHubProjectItem {
pub id: String,
pub kind: String,
pub title: String,
pub content_id: Option<String>,
pub number: Option<i64>,
pub url: Option<String>,
pub repository: Option<String>,
pub state: Option<String>,
pub state_reason: Option<String>,
pub body: Option<String>,
pub author: Option<GitHubUser>,
#[serde(default)]
pub assignees: Vec<GitHubUser>,
#[serde(default)]
pub labels: Vec<GitHubLabel>,
pub milestone: Option<String>,
#[serde(default)]
pub comment_count: i64,
pub created_at: Option<String>,
pub closed_at: Option<String>,
#[serde(default)]
pub is_draft: bool,
#[serde(default)]
pub merged: bool,
pub review_decision: Option<String>,
#[serde(default)]
pub archived: bool,
#[serde(default)]
pub field_values: BTreeMap<String, GitHubProjectValue>,
pub updated_at: String,
}
impl GitHubProjectItem {
pub fn column(&self, field_id: &str) -> Option<&str> {
match self.field_values.get(field_id)? {
GitHubProjectValue::SingleSelect { option_id, .. } => Some(option_id),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GitHubProjectValue {
SingleSelect {
option_id: String,
name: String,
},
Text {
text: String,
},
Number {
number: f64,
},
Date {
date: String,
},
Iteration {
title: String,
start_date: String,
},
}
const ITEM_FRAGMENT: &str = r#"
fragment Card on ProjectV2Item {
id type updatedAt isArchived
fieldValues(first: 30) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue { name optionId field { ... on ProjectV2FieldCommon { id } } }
... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2FieldCommon { id } } }
... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { id } } }
... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2FieldCommon { id } } }
... on ProjectV2ItemFieldIterationValue { title startDate field { ... on ProjectV2FieldCommon { id } } }
}
}
content {
... on DraftIssue {
id title body createdAt updatedAt
creator { login avatarUrl }
assignees(first: 10) { nodes { login avatarUrl } }
}
... on Issue {
id title body number url state stateReason createdAt closedAt
repository { nameWithOwner }
author { login avatarUrl }
assignees(first: 10) { nodes { login avatarUrl } }
labels(first: 20) { nodes { name color description } }
milestone { title }
comments { totalCount }
}
... on PullRequest {
id title body number url state createdAt closedAt isDraft merged reviewDecision
repository { nameWithOwner }
author { login avatarUrl }
assignees(first: 10) { nodes { login avatarUrl } }
labels(first: 20) { nodes { name color description } }
milestone { title }
comments { totalCount }
}
}
}"#;
impl GitHubClient {
async fn graphql(
&self,
what: &str,
query: &str,
variables: serde_json::Value,
) -> Result<serde_json::Value> {
let response = require_success(
what,
self.client
.post(self.url("/graphql"))
.bearer_auth(&self.token)
.json(&serde_json::json!({ "query": query, "variables": variables }))
.send()
.await
.with_context(|| format!("GitHub API request failed while {what}"))?,
)?;
let body: serde_json::Value = response
.json()
.await
.with_context(|| format!("failed to parse the answer to {what}"))?;
if let Some(errors) = body.get("errors").and_then(|errors| errors.as_array()) {
if !errors.is_empty() {
let said = errors
.iter()
.filter_map(|error| error.get("message").and_then(|m| m.as_str()))
.collect::<Vec<_>>()
.join("; ");
anyhow::bail!("{what}: {said}");
}
}
body.get("data")
.cloned()
.ok_or_else(|| anyhow::anyhow!("{what}: the answer carried no data"))
}
pub async fn list_projects(&self, login: &str) -> Result<Vec<GitHubProject>> {
const QUERY: &str = r#"
query($login: String!, $after: String) {
repositoryOwner(login: $login) {
__typename
... on Organization { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
... on User { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
}
}
fragment Page on ProjectV2Connection {
pageInfo { hasNextPage endCursor }
nodes { ...P }
}
fragment P on ProjectV2 {
id number title url shortDescription closed updatedAt
fields(first: 50) {
nodes {
... on ProjectV2FieldCommon { id name dataType }
... on ProjectV2SingleSelectField { id name dataType options { id name color description } }
}
}
}"#;
let mut projects = Vec::new();
let mut after: Option<String> = None;
loop {
let data = self
.graphql(
&format!("listing projects for {login}"),
QUERY,
serde_json::json!({ "login": login, "after": after }),
)
.await?;
let page = projects_of_owner(&data, login)?;
projects.extend(page.items);
match page.next {
Some(cursor) => after = Some(cursor),
None => break,
}
}
Ok(projects)
}
pub async fn viewer(&self) -> Result<GitHubViewer> {
const QUERY: &str = r#"
query($after: String) {
viewer {
login
organizations(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { login }
}
}
}"#;
let mut organizations = Vec::new();
let mut after: Option<String> = None;
let login = loop {
let data = self
.graphql(
"reading who the token belongs to",
QUERY,
serde_json::json!({ "after": after }),
)
.await?;
let page = viewer_page(&data)?;
organizations.extend(page.organizations.items);
match page.organizations.next {
Some(cursor) => after = Some(cursor),
None => break page.login,
}
};
Ok(GitHubViewer { login, organizations })
}
async fn project(&self, project_id: &str) -> Result<GitHubProject> {
const QUERY: &str = r#"
query($id: ID!) {
node(id: $id) {
... on ProjectV2 {
id number title url shortDescription closed updatedAt
owner {
... on Organization { login }
... on User { login }
}
fields(first: 50) {
nodes {
... on ProjectV2FieldCommon { id name dataType }
... on ProjectV2SingleSelectField {
id name dataType options { id name color description }
}
}
}
}
}
}"#;
let data = self
.graphql(
&format!("reading project {project_id}"),
QUERY,
serde_json::json!({ "id": project_id }),
)
.await?;
let node = data
.get("node")
.filter(|node| !node.is_null())
.ok_or_else(|| anyhow::anyhow!("project {project_id} no longer exists"))?;
let owner = node
.get("owner")
.and_then(|owner| owner.get("login"))
.and_then(|login| login.as_str())
.ok_or_else(|| anyhow::anyhow!("project {project_id} has no readable owner"))?;
parse_project(node, owner)
}
pub async fn add_project_column(
&self,
project_id: &str,
field_id: &str,
name: &str,
color: &str,
description: &str,
) -> Result<GitHubProject> {
validate_column_color(color)?;
let name = name.trim();
if name.is_empty() {
anyhow::bail!("a project column needs a name");
}
let project = self.project(project_id).await?;
let field = project
.fields
.iter()
.find(|field| field.id == field_id)
.ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
if field.kind != "single_select" {
anyhow::bail!(
"field {field_id} is {}, not a single-select column field",
field.kind
);
}
if field
.options
.iter()
.any(|option| option.name.eq_ignore_ascii_case(name))
{
anyhow::bail!("project {project_id} already has a column named '{name}'");
}
let mut options: Vec<ProjectColumnOptionInput> = field
.options
.iter()
.map(ProjectColumnOptionInput::from)
.collect();
options.push(ProjectColumnOptionInput {
id: None,
name: name.to_string(),
color: color.to_string(),
description: description.trim().to_string(),
});
self.update_project_columns(field_id, &options).await?;
self.project(project_id).await
}
pub async fn remove_project_column(
&self,
project_id: &str,
field_id: &str,
option_id: &str,
) -> Result<GitHubProject> {
let project = self.project(project_id).await?;
let field = project
.fields
.iter()
.find(|field| field.id == field_id)
.ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
if field.kind != "single_select" {
anyhow::bail!(
"field {field_id} is {}, not a single-select column field",
field.kind
);
}
if !field.options.iter().any(|option| option.id == option_id) {
anyhow::bail!("field {field_id} has no column {option_id}");
}
if field.options.len() == 1 {
anyhow::bail!("the last project column cannot be removed; a single-select field requires an option");
}
let options: Vec<ProjectColumnOptionInput> = field
.options
.iter()
.filter(|option| option.id != option_id)
.map(ProjectColumnOptionInput::from)
.collect();
self.update_project_columns(field_id, &options).await?;
self.project(project_id).await
}
async fn update_project_columns(
&self,
field_id: &str,
options: &[ProjectColumnOptionInput],
) -> Result<()> {
const MUTATION: &str = r#"
mutation($field: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
updateProjectV2Field(input: { fieldId: $field, singleSelectOptions: $options }) {
projectV2Field { ... on ProjectV2SingleSelectField { id } }
}
}"#;
self.graphql(
&format!("updating the columns of field {field_id}"),
MUTATION,
serde_json::json!({ "field": field_id, "options": options }),
)
.await?;
Ok(())
}
pub async fn project_items(&self, project_id: &str) -> Result<Vec<GitHubProjectItem>> {
let query = format!(
r#"
query($id: ID!, $after: String) {{
node(id: $id) {{
... on ProjectV2 {{
items(first: 50, after: $after) {{
pageInfo {{ hasNextPage endCursor }}
nodes {{ ...Card }}
}}
}}
}}
}}
{ITEM_FRAGMENT}"#
);
let mut items = Vec::new();
let mut after: Option<String> = None;
loop {
let data = self
.graphql(
&format!("reading the items of project {project_id}"),
&query,
serde_json::json!({ "id": project_id, "after": after }),
)
.await?;
let page = data
.get("node")
.and_then(|node| node.get("items"))
.ok_or_else(|| anyhow::anyhow!("project {project_id} has no items to read"))?;
for node in page
.get("nodes")
.and_then(|nodes| nodes.as_array())
.unwrap_or(&Vec::new())
{
items.push(parse_project_item(node)?);
}
let more = page
.get("pageInfo")
.and_then(|info| info.get("hasNextPage"))
.and_then(|more| more.as_bool())
.unwrap_or(false);
if !more {
break;
}
after = page
.get("pageInfo")
.and_then(|info| info.get("endCursor"))
.and_then(|cursor| cursor.as_str())
.map(str::to_string);
if after.is_none() {
anyhow::bail!("project {project_id} says there is another page and gives no cursor");
}
}
Ok(items)
}
pub async fn project_item(&self, item_id: &str) -> Result<GitHubProjectItem> {
let query = format!(
r#"
query($id: ID!) {{ node(id: $id) {{ ...Card }} }}
{ITEM_FRAGMENT}"#
);
let data = self
.graphql(
&format!("re-reading item {item_id}"),
&query,
serde_json::json!({ "id": item_id }),
)
.await?;
let node = data
.get("node")
.filter(|node| !node.is_null())
.ok_or_else(|| anyhow::anyhow!("item {item_id} is not on this board any more"))?;
parse_project_item(node)
}
pub async fn set_project_field(
&self,
project_id: &str,
item_id: &str,
field_id: &str,
value: &ProjectFieldWrite,
) -> Result<GitHubProjectItem> {
const MUTATION: &str = r#"
mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
updateProjectV2ItemFieldValue(input: {
projectId: $project, itemId: $item, fieldId: $field, value: $value
}) { projectV2Item { id } }
}"#;
self.graphql(
&format!("setting a field on item {item_id}"),
MUTATION,
serde_json::json!({
"project": project_id,
"item": item_id,
"field": field_id,
"value": value.as_input(),
}),
)
.await?;
self.project_item(item_id).await
}
pub async fn clear_project_field(
&self,
project_id: &str,
item_id: &str,
field_id: &str,
) -> Result<GitHubProjectItem> {
const MUTATION: &str = r#"
mutation($project: ID!, $item: ID!, $field: ID!) {
clearProjectV2ItemFieldValue(input: {
projectId: $project, itemId: $item, fieldId: $field
}) { projectV2Item { id } }
}"#;
self.graphql(
&format!("clearing a field on item {item_id}"),
MUTATION,
serde_json::json!({ "project": project_id, "item": item_id, "field": field_id }),
)
.await?;
self.project_item(item_id).await
}
pub async fn move_project_item_after(
&self,
project_id: &str,
item_id: &str,
after: Option<&str>,
) -> Result<GitHubProjectItem> {
const MUTATION: &str = r#"
mutation($project: ID!, $item: ID!, $after: ID) {
updateProjectV2ItemPosition(input: {
projectId: $project, itemId: $item, afterId: $after
}) { items { totalCount } }
}"#;
self.graphql(
&format!("ordering item {item_id}"),
MUTATION,
serde_json::json!({ "project": project_id, "item": item_id, "after": after }),
)
.await?;
self.project_item(item_id).await
}
pub async fn add_project_draft(
&self,
project_id: &str,
title: &str,
body: &str,
) -> Result<GitHubProjectItem> {
const MUTATION: &str = r#"
mutation($project: ID!, $title: String!, $body: String!) {
addProjectV2DraftIssue(input: { projectId: $project, title: $title, body: $body }) {
projectItem { id }
}
}"#;
let data = self
.graphql(
"adding a draft card",
MUTATION,
serde_json::json!({ "project": project_id, "title": title, "body": body }),
)
.await?;
let id = data
.get("addProjectV2DraftIssue")
.and_then(|added| added.get("projectItem"))
.and_then(|item| item.get("id"))
.and_then(|id| id.as_str())
.ok_or_else(|| {
anyhow::anyhow!("the draft was created and GitHub did not say its id")
})?;
self.project_item(id).await
}
pub async fn update_project_draft(
&self,
item_id: &str,
draft_id: &str,
title: &str,
body: &str,
) -> Result<GitHubProjectItem> {
const MUTATION: &str = r#"
mutation($draft: ID!, $title: String!, $body: String!) {
updateProjectV2DraftIssue(input: { draftIssueId: $draft, title: $title, body: $body }) {
draftIssue { id }
}
}"#;
self.graphql(
&format!("editing draft {draft_id}"),
MUTATION,
serde_json::json!({ "draft": draft_id, "title": title, "body": body }),
)
.await?;
self.project_item(item_id).await
}
pub async fn add_project_item_by_url(
&self,
project_id: &str,
url: &str,
) -> Result<GitHubProjectItem> {
const RESOLVE: &str = r#"
query($url: URI!) {
resource(url: $url) {
__typename
... on Issue { id }
... on PullRequest { id }
}
}"#;
let found = self
.graphql(
&format!("looking up {url}"),
RESOLVE,
serde_json::json!({ "url": url }),
)
.await?;
let resource = found
.get("resource")
.filter(|resource| !resource.is_null())
.ok_or_else(|| {
anyhow::anyhow!("{url} is not something on GitHub this token can see")
})?;
let kind = resource
.get("__typename")
.and_then(|kind| kind.as_str())
.unwrap_or("that");
let content = resource.get("id").and_then(|id| id.as_str()).ok_or_else(|| {
anyhow::anyhow!("{url} is a {kind}, and only an issue or a pull request can go on a board")
})?;
const MUTATION: &str = r#"
mutation($project: ID!, $content: ID!) {
addProjectV2ItemById(input: { projectId: $project, contentId: $content }) {
item { id }
}
}"#;
let data = self
.graphql(
&format!("adding {url} to the board"),
MUTATION,
serde_json::json!({ "project": project_id, "content": content }),
)
.await?;
let id = data
.get("addProjectV2ItemById")
.and_then(|added| added.get("item"))
.and_then(|item| item.get("id"))
.and_then(|id| id.as_str())
.ok_or_else(|| anyhow::anyhow!("the item was added and GitHub did not say its id"))?;
self.project_item(id).await
}
pub async fn archive_project_item(
&self,
project_id: &str,
item_id: &str,
archived: bool,
) -> Result<GitHubProjectItem> {
const ARCHIVE: &str = r#"
mutation($project: ID!, $item: ID!) {
archiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
}"#;
const UNARCHIVE: &str = r#"
mutation($project: ID!, $item: ID!) {
unarchiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
}"#;
self.graphql(
&format!("archiving item {item_id}"),
if archived { ARCHIVE } else { UNARCHIVE },
serde_json::json!({ "project": project_id, "item": item_id }),
)
.await?;
self.project_item(item_id).await
}
pub async fn delete_project_item(&self, project_id: &str, item_id: &str) -> Result<()> {
const MUTATION: &str = r#"
mutation($project: ID!, $item: ID!) {
deleteProjectV2Item(input: { projectId: $project, itemId: $item }) { deletedItemId }
}"#;
self.graphql(
&format!("removing item {item_id}"),
MUTATION,
serde_json::json!({ "project": project_id, "item": item_id }),
)
.await?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProjectFieldWrite {
SingleSelect {
option_id: String,
},
Text {
text: String,
},
Number {
number: f64,
},
Date {
date: String,
},
Iteration {
iteration_id: String,
},
}
impl ProjectFieldWrite {
fn as_input(&self) -> serde_json::Value {
match self {
Self::SingleSelect { option_id } => {
serde_json::json!({ "singleSelectOptionId": option_id })
}
Self::Text { text } => serde_json::json!({ "text": text }),
Self::Number { number } => serde_json::json!({ "number": number }),
Self::Date { date } => serde_json::json!({ "date": date }),
Self::Iteration { iteration_id } => {
serde_json::json!({ "iterationId": iteration_id })
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct Page<T> {
items: Vec<T>,
next: Option<String>,
}
fn next_page(connection: &serde_json::Value, what: &str) -> Result<Option<String>> {
let info = connection.get("pageInfo");
let more = info
.and_then(|info| info.get("hasNextPage"))
.and_then(|more| more.as_bool())
.unwrap_or(false);
if !more {
return Ok(None);
}
info.and_then(|info| info.get("endCursor"))
.and_then(|cursor| cursor.as_str())
.map(|cursor| Some(cursor.to_string()))
.ok_or_else(|| anyhow::anyhow!("{what} says there is another page and gives no cursor"))
}
fn projects_of_owner(data: &serde_json::Value, login: &str) -> Result<Page<GitHubProject>> {
let nothing = Page { items: Vec::new(), next: None };
let Some(owner) = data.get("repositoryOwner").filter(|owner| !owner.is_null()) else {
return Ok(nothing);
};
let Some(connection) = owner.get("projectsV2") else {
return Ok(nothing);
};
let Some(nodes) = connection.get("nodes").and_then(|nodes| nodes.as_array()) else {
return Ok(nothing);
};
Ok(Page {
items: nodes
.iter()
.map(|node| parse_project(node, login))
.collect::<Result<_>>()?,
next: next_page(connection, &format!("{login}'s board list"))?,
})
}
#[derive(Debug, Clone, PartialEq)]
struct ViewerPage {
login: String,
organizations: Page<String>,
}
fn viewer_page(data: &serde_json::Value) -> Result<ViewerPage> {
let viewer = data
.get("viewer")
.filter(|viewer| !viewer.is_null())
.ok_or_else(|| anyhow::anyhow!("GitHub did not say who the token belongs to"))?;
let login = string_at(viewer, "login", "the token's account")?;
let connection = viewer
.get("organizations")
.ok_or_else(|| anyhow::anyhow!("GitHub did not list {login}'s organisations"))?;
let organizations = connection
.get("nodes")
.and_then(|nodes| nodes.as_array())
.ok_or_else(|| anyhow::anyhow!("{login}'s organisations are not a list"))?
.iter()
.map(|node| string_at(node, "login", "an organisation"))
.collect::<Result<Vec<_>>>()?;
Ok(ViewerPage {
organizations: Page {
items: organizations,
next: next_page(connection, &format!("{login}'s organisation list"))?,
},
login,
})
}
fn validate_column_color(color: &str) -> Result<()> {
const COLORS: &[&str] = &[
"GRAY", "BLUE", "GREEN", "YELLOW", "ORANGE", "RED", "PINK", "PURPLE",
];
if COLORS.contains(&color) {
return Ok(());
}
anyhow::bail!(
"'{color}' is not a GitHub project column color; expected {}",
COLORS.join(", ")
)
}
fn parse_project(node: &serde_json::Value, login: &str) -> Result<GitHubProject> {
let field_nodes = node
.get("fields")
.and_then(|fields| fields.get("nodes"))
.and_then(|nodes| nodes.as_array())
.ok_or_else(|| anyhow::anyhow!("a project has no field list"))?;
let fields = field_nodes
.iter()
.map(|field| -> Result<GitHubProjectField> {
let options = match field.get("options") {
None => Vec::new(),
Some(options) => options
.as_array()
.ok_or_else(|| anyhow::anyhow!("a project field's options are not a list"))?
.iter()
.map(|option| -> Result<GitHubProjectFieldOption> {
Ok(GitHubProjectFieldOption {
id: string_at(option, "id", "a project column")?,
name: string_at(option, "name", "a project column")?,
color: string_at(option, "color", "a project column")?,
description: optional_string_at(option, "description", "a project column")?,
})
})
.collect::<Result<Vec<_>>>()?,
};
Ok(GitHubProjectField {
id: string_at(field, "id", "a project field")?,
name: string_at(field, "name", "a project field")?,
kind: string_at(field, "dataType", "a project field")?.to_ascii_lowercase(),
options,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(GitHubProject {
id: string_at(node, "id", "a project")?,
number: node
.get("number")
.and_then(|number| number.as_i64())
.ok_or_else(|| anyhow::anyhow!("a project has no number"))?,
title: string_at(node, "title", "a project")?,
url: string_at(node, "url", "a project")?,
short_description: node
.get("shortDescription")
.and_then(|text| text.as_str())
.map(str::to_string),
closed: node
.get("closed")
.and_then(|closed| closed.as_bool())
.unwrap_or(false),
updated_at: string_at(node, "updatedAt", "a project")?,
owner: login.to_string(),
fields,
})
}
fn parse_project_item(node: &serde_json::Value) -> Result<GitHubProjectItem> {
let content = node.get("content");
let mut values = BTreeMap::new();
if let Some(nodes) = node
.get("fieldValues")
.and_then(|values| values.get("nodes"))
.and_then(|nodes| nodes.as_array())
{
for value in nodes {
let Some(field_id) = value
.get("field")
.and_then(|field| field.get("id"))
.and_then(|id| id.as_str())
else {
continue;
};
let parsed = if let (Some(option), Some(name)) = (
value.get("optionId").and_then(|id| id.as_str()),
value.get("name").and_then(|name| name.as_str()),
) {
GitHubProjectValue::SingleSelect {
option_id: option.to_string(),
name: name.to_string(),
}
} else if let Some(text) = value.get("text").and_then(|text| text.as_str()) {
GitHubProjectValue::Text {
text: text.to_string(),
}
} else if let Some(number) = value.get("number").and_then(|number| number.as_f64()) {
GitHubProjectValue::Number { number }
} else if let Some(date) = value.get("date").and_then(|date| date.as_str()) {
GitHubProjectValue::Date {
date: date.to_string(),
}
} else if let (Some(title), Some(start)) = (
value.get("title").and_then(|title| title.as_str()),
value.get("startDate").and_then(|date| date.as_str()),
) {
GitHubProjectValue::Iteration {
title: title.to_string(),
start_date: start.to_string(),
}
} else {
continue;
};
values.insert(field_id.to_string(), parsed);
}
}
Ok(GitHubProjectItem {
id: string_at(node, "id", "a project item")?,
kind: string_at(node, "type", "a project item")?,
title: content
.and_then(|content| content.get("title"))
.and_then(|title| title.as_str())
.unwrap_or("(untitled)")
.to_string(),
content_id: text(content, "id"),
number: content
.and_then(|content| content.get("number"))
.and_then(|number| number.as_i64()),
url: content
.and_then(|content| content.get("url"))
.and_then(|url| url.as_str())
.map(str::to_string),
repository: content
.and_then(|content| content.get("repository"))
.and_then(|repo| repo.get("nameWithOwner"))
.and_then(|name| name.as_str())
.map(str::to_string),
state: content
.and_then(|content| content.get("state"))
.and_then(|state| state.as_str())
.map(str::to_string),
state_reason: text(content, "stateReason"),
body: text(content, "body"),
author: content
.and_then(|content| content.get("author").or_else(|| content.get("creator")))
.and_then(parse_user),
assignees: people(content, "assignees"),
labels: content
.and_then(|content| content.get("labels"))
.and_then(|labels| labels.get("nodes"))
.and_then(|nodes| nodes.as_array())
.map(|nodes| {
nodes
.iter()
.filter_map(|label| {
Some(GitHubLabel {
name: label.get("name")?.as_str()?.to_string(),
color: label
.get("color")
.and_then(|color| color.as_str())
.unwrap_or_default()
.to_string(),
description: label
.get("description")
.and_then(|text| text.as_str())
.map(str::to_string),
})
})
.collect()
})
.unwrap_or_default(),
milestone: content
.and_then(|content| content.get("milestone"))
.and_then(|milestone| milestone.get("title"))
.and_then(|title| title.as_str())
.map(str::to_string),
comment_count: content
.and_then(|content| content.get("comments"))
.and_then(|comments| comments.get("totalCount"))
.and_then(|count| count.as_i64())
.unwrap_or(0),
created_at: text(content, "createdAt"),
closed_at: text(content, "closedAt"),
is_draft: flag(content, "isDraft"),
merged: flag(content, "merged"),
review_decision: text(content, "reviewDecision"),
archived: node
.get("isArchived")
.and_then(|archived| archived.as_bool())
.unwrap_or(false),
field_values: values,
updated_at: string_at(node, "updatedAt", "a project item")?,
})
}
fn text(content: Option<&serde_json::Value>, key: &str) -> Option<String> {
content
.and_then(|content| content.get(key))
.and_then(|value| value.as_str())
.map(str::to_string)
}
fn flag(content: Option<&serde_json::Value>, key: &str) -> bool {
content
.and_then(|content| content.get(key))
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn parse_user(node: &serde_json::Value) -> Option<GitHubUser> {
Some(GitHubUser {
login: node.get("login")?.as_str()?.to_string(),
avatar_url: node
.get("avatarUrl")
.and_then(|url| url.as_str())
.map(str::to_string),
})
}
fn people(content: Option<&serde_json::Value>, key: &str) -> Vec<GitHubUser> {
content
.and_then(|content| content.get(key))
.and_then(|people| people.get("nodes"))
.and_then(|nodes| nodes.as_array())
.map(|nodes| nodes.iter().filter_map(parse_user).collect())
.unwrap_or_default()
}
fn string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
node.get(key)
.and_then(|value| value.as_str())
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("{what} has no {key}"))
}
fn optional_string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
match node.get(key) {
Some(serde_json::Value::Null) => Ok(String::new()),
Some(serde_json::Value::String(text)) => Ok(text.clone()),
Some(other) => Err(anyhow::anyhow!(
"{what} has a {key} that is not text: {other}"
)),
None => Err(anyhow::anyhow!(
"{what} came back without a {key}, so this cannot say what GitHub \
holds for it — and a refresh writes the whole column back, which \
would erase it. Refresh Projects to read them again."
)),
}
}
fn header_value(response: &reqwest::Response, name: reqwest::header::HeaderName) -> Option<String> {
response
.headers()
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
}
impl GitHubClient {
pub async fn list_issues(
&self,
owner: &str,
repo: &str,
state: &str,
) -> Result<Vec<GitHubIssue>> {
let url = self.url(&format!(
"/repos/{}/{}/issues?state={}&per_page=100&sort=updated&direction=desc",
owner, repo, state
));
let response = require_success(
&format!("GitHub API error listing issues for {}/{}", owner, repo),
self.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await
.with_context(|| format!("failed to list issues for {}/{}", owner, repo))?,
)?;
let resp: Vec<GitHubIssue> = response
.json()
.await
.context("failed to parse issues response")?;
Ok(resp
.into_iter()
.filter(|i| !i.html_url.contains("/pull/"))
.collect())
}
pub async fn get_issue(&self, owner: &str, repo: &str, number: i64) -> Result<GitHubIssue> {
let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
let issue: GitHubIssue = require_success(
&format!("failed to get issue {}/{}#{}", owner, repo, number),
self.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await?,
)?
.json()
.await?;
Ok(issue)
}
pub async fn create_issue(
&self,
owner: &str,
repo: &str,
req: &CreateIssueRequest,
) -> Result<GitHubIssue> {
let url = self.url(&format!("/repos/{}/{}/issues", owner, repo));
let issue: GitHubIssue = require_success(
&format!("failed to create issue in {}/{}", owner, repo),
self.client
.post(&url)
.bearer_auth(&self.token)
.json(req)
.send()
.await?,
)?
.json()
.await?;
Ok(issue)
}
pub async fn update_issue(
&self,
owner: &str,
repo: &str,
number: i64,
req: &UpdateIssueRequest,
) -> Result<GitHubIssue> {
let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
let issue: GitHubIssue = require_success(
&format!("failed to update issue {}/{}#{}", owner, repo, number),
self.client
.patch(&url)
.bearer_auth(&self.token)
.json(req)
.send()
.await?,
)?
.json()
.await?;
Ok(issue)
}
pub async fn list_comments(
&self,
owner: &str,
repo: &str,
number: i64,
) -> Result<Vec<GitHubComment>> {
let url = self.url(&format!(
"/repos/{}/{}/issues/{}/comments?per_page=100",
owner, repo, number
));
let comments: Vec<GitHubComment> = require_success(
&format!("failed to list comments for {}/{}#{}", owner, repo, number),
self.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await?,
)?
.json()
.await?;
Ok(comments)
}
pub async fn add_comment(
&self,
owner: &str,
repo: &str,
number: i64,
body: &str,
) -> Result<GitHubComment> {
let url = self.url(&format!("/repos/{}/{}/issues/{}/comments", owner, repo, number));
let comment: GitHubComment = require_success(
&format!("failed to add comment to {}/{}#{}", owner, repo, number),
self.client
.post(&url)
.bearer_auth(&self.token)
.json(&CreateCommentRequest {
body: body.to_string(),
})
.send()
.await?,
)?
.json()
.await?;
Ok(comment)
}
pub async fn list_gists(&self) -> Result<Vec<GitHubGist>> {
let response = require_success(
"failed to list gists",
self.client
.get(self.url("/gists?per_page=100"))
.bearer_auth(&self.token)
.send()
.await?,
)?;
let gists: Vec<GitHubGist> = response
.json()
.await
.context("failed to parse gists response")?;
Ok(gists)
}
pub async fn get_gist(&self, gist_id: &str) -> Result<GitHubGist> {
let url = self.url(&format!("/gists/{}", gist_id));
let gist: GitHubGist = require_success(
&format!("failed to get gist {}", gist_id),
self.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await?,
)?
.json()
.await
.context("failed to parse gist response")?;
Ok(gist)
}
pub async fn create_gist(&self, req: &CreateGistRequest) -> Result<GitHubGist> {
let gist: GitHubGist = require_success(
"failed to create gist",
self.client
.post(&self.url("/gists"))
.bearer_auth(&self.token)
.json(req)
.send()
.await?,
)?
.json()
.await
.context("failed to parse create gist response")?;
Ok(gist)
}
pub async fn update_gist(&self, gist_id: &str, req: &UpdateGistRequest) -> Result<GitHubGist> {
let url = self.url(&format!("/gists/{}", gist_id));
let gist: GitHubGist = require_success(
&format!("failed to update gist {}", gist_id),
self.client
.patch(&url)
.bearer_auth(&self.token)
.json(req)
.send()
.await?,
)?
.json()
.await
.context("failed to parse update gist response")?;
Ok(gist)
}
}
pub fn parse_github_url(url: &str) -> Option<(String, String)> {
let url = url.trim();
if let Some(rest) = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
{
return parse_owner_repo(rest);
}
if let Some(rest) = url
.strip_prefix("git@github.com:")
.or_else(|| url.strip_prefix("ssh://git@github.com/"))
{
return parse_owner_repo(rest);
}
None
}
fn parse_owner_repo(path: &str) -> Option<(String, String)> {
let path = path.trim_end_matches(".git").trim_end_matches('/');
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
Some((parts[0].to_string(), parts[1].to_string()))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_github_url_https() {
assert_eq!(
parse_github_url("https://github.com/wesm/agentsview"),
Some(("wesm".to_string(), "agentsview".to_string()))
);
}
#[test]
fn test_parse_github_url_https_with_git_suffix() {
assert_eq!(
parse_github_url("https://github.com/wesm/agentsview.git"),
Some(("wesm".to_string(), "agentsview".to_string()))
);
}
#[test]
fn test_parse_github_url_ssh() {
assert_eq!(
parse_github_url("git@github.com:wesm/agentsview.git"),
Some(("wesm".to_string(), "agentsview".to_string()))
);
}
#[test]
fn test_parse_github_url_ssh_url_form() {
assert_eq!(
parse_github_url("ssh://git@github.com/wesm/agentsview.git"),
Some(("wesm".to_string(), "agentsview".to_string()))
);
}
#[test]
fn test_parse_github_url_non_github() {
assert_eq!(parse_github_url("https://gitlab.com/user/repo"), None);
}
#[test]
fn test_parse_github_url_invalid() {
assert_eq!(parse_github_url("not a url"), None);
}
fn owner_with_a_board(kind: &str) -> serde_json::Value {
serde_json::json!({
"repositoryOwner": {
"__typename": kind,
"projectsV2": { "nodes": [{
"id": "PVT_1",
"number": 7,
"title": "Roadmap",
"url": "https://github.com/orgs/machinefabric/projects/7",
"shortDescription": null,
"closed": false,
"updatedAt": "2026-08-19T09:00:00Z",
"fields": { "nodes": [] }
}]}
}
})
}
#[test]
fn test10074_a_board_is_read_for_either_kind_of_owner() {
for kind in ["Organization", "User"] {
let boards = projects_of_owner(&owner_with_a_board(kind), "machinefabric")
.expect("the answer parses")
.items;
assert_eq!(boards.len(), 1, "no board came back for a {kind} owner");
assert_eq!(boards[0].number, 7);
assert_eq!(boards[0].title, "Roadmap");
assert_eq!(boards[0].owner, "machinefabric");
}
}
#[test]
fn test10075_an_owner_who_does_not_resolve_has_no_boards() {
let nobody = serde_json::json!({ "repositoryOwner": serde_json::Value::Null });
assert!(projects_of_owner(&nobody, "gone")
.expect("a null owner is an answer")
.items
.is_empty());
let unknown = serde_json::json!({
"repositoryOwner": { "__typename": "Mannequin" }
});
assert!(projects_of_owner(&unknown, "someone")
.expect("an unknown owner kind is an answer")
.items
.is_empty());
}
#[test]
fn test20001_a_board_list_continues_or_is_refused() {
let mut answer = owner_with_a_board("Organization");
answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
serde_json::json!({ "hasNextPage": true, "endCursor": "Y3Vyc29y" });
let page = projects_of_owner(&answer, "machinefabric").expect("the answer parses");
assert_eq!(page.items.len(), 1);
assert_eq!(page.next.as_deref(), Some("Y3Vyc29y"), "the next page was not carried");
answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
serde_json::json!({ "hasNextPage": false, "endCursor": "Y3Vyc29y" });
assert_eq!(
projects_of_owner(&answer, "machinefabric").expect("parses").next,
None,
"the last page named a next one"
);
answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
serde_json::json!({ "hasNextPage": true, "endCursor": null });
let said = format!(
"{:#}",
projects_of_owner(&answer, "machinefabric").expect_err("a contradiction is refused")
);
assert!(said.contains("machinefabric's board list"), "{said}");
}
#[test]
fn test20002_the_viewer_is_a_login_and_its_organisations() {
let answer = serde_json::json!({ "viewer": {
"login": "bahram",
"organizations": {
"pageInfo": { "hasNextPage": true, "endCursor": "T1JH" },
"nodes": [{ "login": "machinefabric" }, { "login": "floom" }]
}
}});
let page = viewer_page(&answer).expect("the answer parses");
assert_eq!(page.login, "bahram");
assert_eq!(page.organizations.items, vec!["machinefabric".to_string(), "floom".to_string()]);
assert_eq!(page.organizations.next.as_deref(), Some("T1JH"));
let nobody = serde_json::json!({ "viewer": serde_json::Value::Null });
assert!(viewer_page(&nobody).is_err(), "a token with no account was read as one");
let unlisted = serde_json::json!({ "viewer": { "login": "bahram" } });
assert!(
viewer_page(&unlisted).is_err(),
"an answer without the organisation list was read as no organisations"
);
}
#[test]
fn test10196_a_column_update_preserves_the_complete_existing_options() {
let option = GitHubProjectFieldOption {
id: "OPT_TODO".to_string(),
name: "Todo".to_string(),
color: "GRAY".to_string(),
description: "not started".to_string(),
};
let input = serde_json::to_value(ProjectColumnOptionInput::from(&option))
.expect("the GraphQL input serializes");
assert_eq!(input["id"], "OPT_TODO");
assert_eq!(input["name"], "Todo");
assert_eq!(input["color"], "GRAY");
assert_eq!(input["description"], "not started");
let new_option = ProjectColumnOptionInput {
id: None,
name: "Blocked".to_string(),
color: "RED".to_string(),
description: String::new(),
};
let new_input = serde_json::to_value(new_option).expect("new option serializes");
assert!(
new_input.get("id").is_none(),
"GitHub assigns a new option id"
);
assert!(validate_column_color("RED").is_ok());
assert!(validate_column_color("#ff0000").is_err());
}
}
#[cfg(test)]
mod column_description_tests {
use super::*;
fn option(json: serde_json::Value) -> Result<GitHubProjectFieldOption, serde_json::Error> {
serde_json::from_value(json)
}
#[test]
fn test11607_a_null_description_is_no_description() {
let read = option(serde_json::json!({
"id": "OPT_1", "name": "Todo", "color": "GRAY", "description": null
}))
.expect("a column with no description is an ordinary column");
assert_eq!(read.description, "");
}
#[test]
fn test11608_an_absent_description_is_not_read_as_empty() {
let refusal = option(serde_json::json!({
"id": "OPT_1", "name": "Todo", "color": "GRAY"
}))
.expect_err("a column with no description FIELD was read as having none");
assert!(
refusal.to_string().contains("description"),
"the refusal does not say which field: {refusal}"
);
}
#[test]
fn test11609_a_real_description_survives() {
let read = option(serde_json::json!({
"id": "OPT_1", "name": "Todo", "color": "GRAY",
"description": "not started yet"
}))
.expect("a described column reads");
assert_eq!(read.description, "not started yet");
}
}