use serde::{Deserialize, Serialize};
use crate::api::browse::{Results, wire};
use crate::api::client::{ApiError, GitHub, encode_path};
use crate::api::model::{NodeId, RepoId};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LabelInfo {
pub name: String,
pub color: String,
pub description: String,
pub open: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabelChange {
pub name: String,
pub color: String,
pub description: String,
}
pub(crate) const LABELS: &str = "query($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { labels(first: 100, after: $after, orderBy: {field: NAME, direction: ASC}) { totalCount pageInfo { hasNextPage endCursor } nodes { name color description issues(states: OPEN) { totalCount } pullRequests(states: OPEN) { totalCount } } } } }";
pub(crate) const OWNERS: &str = "query { viewer { login organizations(first: 100) { totalCount nodes { login viewerCanCreateRepositories } } } }";
pub(crate) const FILE_LOOK: &str = "query($owner: String!, $name: String!, $qualified: String!, $rev: String!, $spec: String!) { repository(owner: $owner, name: $name) { id ref(qualifiedName: $qualified) { target { oid } } commit: object(expression: $rev) { ... on Commit { oid } ... on Tag { target { oid } } } file: object(expression: $spec) { ... on Blob { oid text isBinary isTruncated } } } }";
pub(crate) const COMMIT_ON_BRANCH: &str = "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }";
#[cfg(test)]
pub(crate) fn queries() -> Vec<(&'static str, String)> {
vec![
("LABELS", LABELS.to_owned()),
("OWNERS", OWNERS.to_owned()),
("FILE_LOOK", FILE_LOOK.to_owned()),
("COMMIT_ON_BRANCH", COMMIT_ON_BRANCH.to_owned()),
]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MilestoneChange {
pub title: String,
pub description: String,
pub due_on: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct MadeMilestone {
pub number: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RefKind {
Branch,
Tag,
}
impl RefKind {
pub fn prefix(self) -> &'static str {
match self {
RefKind::Branch => "heads",
RefKind::Tag => "tags",
}
}
pub fn noun(self) -> &'static str {
match self {
RefKind::Branch => "branch",
RefKind::Tag => "tag",
}
}
pub fn full(self, name: &str) -> String {
format!("refs/{}/{name}", self.prefix())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchRules {
pub protected: bool,
pub undeletable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Owners {
pub viewer: String,
pub orgs: Vec<(String, bool)>,
pub more: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Templates {
pub gitignores: Vec<String>,
pub licenses: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewFork {
pub repo: RepoId,
pub org: Option<String>,
pub name: String,
pub default_branch_only: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewRepo {
pub org: Option<String>,
pub name: String,
pub description: String,
pub private: bool,
pub readme: bool,
pub gitignore: Option<String>,
pub license: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct MadeRepo {
pub full_name: String,
pub clone_url: String,
}
impl MadeRepo {
pub fn repo(&self) -> Option<RepoId> {
RepoId::parse(&self.full_name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileLook {
pub repo_id: NodeId,
pub branch_head: Option<String>,
pub commit: String,
pub file: Option<FileAt>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileAt {
pub oid: String,
pub text: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileCommit {
pub repo: RepoId,
pub branch: String,
pub head: String,
pub headline: String,
pub body: String,
pub additions: Vec<(String, String)>,
pub deletions: Vec<String>,
}
pub fn workflow_scoped(err: ApiError) -> ApiError {
let lacks = |m: &str| m.contains("workflow") && m.contains("scope");
let denied = || {
ApiError::Denied(crate::api::scope_missing(
"Changing a workflow's file",
"workflow",
))
};
match &err {
ApiError::GraphQl(m) if m.iter().any(|m| lacks(m)) => denied(),
ApiError::Http { message, .. } if lacks(message) => denied(),
_ => err,
}
}
fn segments(path: &str) -> String {
path.split('/')
.map(encode_path)
.collect::<Vec<_>>()
.join("/")
}
impl GitHub {
pub async fn labels(
&self,
repo: &RepoId,
after: Option<String>,
) -> Result<Results<LabelInfo>, ApiError> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Wire {
name: String,
color: String,
description: Option<String>,
issues: wire::Count,
pull_requests: wire::Count,
}
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name, "after": after });
let page: wire::Connection<Wire> = self
.graphql_json(LABELS, vars, repo, "/repository/labels")
.await?;
self.check_connection(&page, 100, format!("{repo}'s labels"));
let labels = page.into_results(|l| LabelInfo {
name: l.name,
color: l.color,
description: l.description.unwrap_or_default(),
open: l.issues.total_count + l.pull_requests.total_count,
});
Ok(labels)
}
pub async fn create_label(&self, repo: &RepoId, label: &LabelChange) -> Result<(), ApiError> {
let body = serde_json::json!({
"name": label.name,
"color": label.color,
"description": label.description,
});
let path = format!("/repos/{repo}/labels");
self.rest_write(http::Method::POST, &path, Some(&body))
.await
}
pub async fn update_label(
&self,
repo: &RepoId,
was: &str,
label: &LabelChange,
) -> Result<(), ApiError> {
let body = serde_json::json!({
"new_name": label.name,
"color": label.color,
"description": label.description,
});
let path = format!("/repos/{repo}/labels/{}", encode_path(was));
self.rest_write(http::Method::PATCH, &path, Some(&body))
.await
}
pub async fn delete_label(&self, repo: &RepoId, name: &str) -> Result<(), ApiError> {
let path = format!("/repos/{repo}/labels/{}", encode_path(name));
self.rest_write(http::Method::DELETE, &path, None).await
}
pub async fn create_milestone(
&self,
repo: &RepoId,
m: &MilestoneChange,
) -> Result<MadeMilestone, ApiError> {
let path = format!("/repos/{repo}/milestones");
self.rest_write_json(http::Method::POST, &path, Some(&milestone_body(m)))
.await
}
pub async fn update_milestone(
&self,
repo: &RepoId,
number: u64,
m: &MilestoneChange,
) -> Result<(), ApiError> {
let path = format!("/repos/{repo}/milestones/{number}");
self.rest_write(http::Method::PATCH, &path, Some(&milestone_body(m)))
.await
}
pub async fn set_milestone_closed(
&self,
repo: &RepoId,
number: u64,
closed: bool,
) -> Result<(), ApiError> {
let state = if closed { "closed" } else { "open" };
let body = serde_json::json!({ "state": state });
let path = format!("/repos/{repo}/milestones/{number}");
self.rest_write(http::Method::PATCH, &path, Some(&body))
.await
}
pub async fn delete_milestone(&self, repo: &RepoId, number: u64) -> Result<(), ApiError> {
let path = format!("/repos/{repo}/milestones/{number}");
self.rest_write(http::Method::DELETE, &path, None).await
}
pub async fn commit_of(&self, repo: &RepoId, rev: &str) -> Result<String, ApiError> {
#[derive(Deserialize)]
struct Wire {
sha: String,
}
let path = format!("/repos/{repo}/commits/{}", segments(rev));
let answer = self.rest_get(&path, format!("{rev} in {repo}")).await?;
Ok(serde_json::from_str::<Wire>(&answer)?.sha)
}
pub async fn create_ref(
&self,
repo: &RepoId,
kind: RefKind,
name: &str,
sha: &str,
) -> Result<(), ApiError> {
let body = serde_json::json!({ "ref": kind.full(name), "sha": sha });
let path = format!("/repos/{repo}/git/refs");
self.rest_write(http::Method::POST, &path, Some(&body))
.await
}
pub async fn delete_ref(
&self,
repo: &RepoId,
kind: RefKind,
name: &str,
) -> Result<(), ApiError> {
let path = format!(
"/repos/{repo}/git/refs/{}/{}",
kind.prefix(),
segments(name)
);
self.rest_write(http::Method::DELETE, &path, None).await
}
pub async fn branch_rules(&self, repo: &RepoId, name: &str) -> Result<BranchRules, ApiError> {
#[derive(Deserialize)]
struct Branch {
protected: bool,
}
#[derive(Deserialize)]
struct Rule {
#[serde(rename = "type")]
kind: String,
}
let path = format!("/repos/{repo}/branches/{}", segments(name));
let branch: Branch = serde_json::from_str(&self.rest_get(&path, name).await?)?;
let path = format!("/repos/{repo}/rules/branches/{}", segments(name));
let rules: Vec<Rule> = match self.rest_get(&path, name).await {
Ok(body) => serde_json::from_str(&body)?,
Err(ApiError::NotFound(_)) => Vec::new(),
Err(err) => return Err(err),
};
Ok(BranchRules {
protected: branch.protected,
undeletable: rules.iter().any(|r| r.kind == "deletion"),
})
}
pub async fn owners(&self) -> Result<Owners, ApiError> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Org {
login: String,
viewer_can_create_repositories: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Viewer {
login: String,
organizations: wire::Counted<Org>,
}
let viewer: Viewer = self
.graphql_json(OWNERS, serde_json::json!({}), "your account", "/viewer")
.await?;
let orgs: Vec<(String, bool)> = (viewer.organizations.nodes.into_iter().flatten())
.map(|o| (o.login, o.viewer_can_create_repositories))
.collect();
let more = (viewer.organizations.total_count).saturating_sub(orgs.len() as u64);
Ok(Owners {
viewer: viewer.login,
orgs,
more,
})
}
pub async fn templates(&self) -> Result<Templates, ApiError> {
#[derive(Deserialize)]
struct License {
key: String,
name: String,
}
let gitignores = self.rest_get("/gitignore/templates", "templates").await?;
let licenses = self.rest_get("/licenses", "licences").await?;
let licenses: Vec<License> = serde_json::from_str(&licenses)?;
Ok(Templates {
gitignores: serde_json::from_str(&gitignores)?,
licenses: licenses.into_iter().map(|l| (l.key, l.name)).collect(),
})
}
pub async fn fork(&self, new: &NewFork) -> Result<MadeRepo, ApiError> {
let mut body = serde_json::json!({
"name": new.name,
"default_branch_only": new.default_branch_only,
});
if let (Some(org), Some(map)) = (&new.org, body.as_object_mut()) {
map.insert("organization".into(), org.clone().into());
}
let path = format!("/repos/{}/forks", new.repo);
self.rest_write_json(http::Method::POST, &path, Some(&body))
.await
}
pub async fn create_repo(&self, new: &NewRepo) -> Result<MadeRepo, ApiError> {
let mut body = serde_json::json!({
"name": new.name,
"description": new.description,
"private": new.private,
"auto_init": new.readme,
});
if let Some(map) = body.as_object_mut() {
if let Some(g) = &new.gitignore {
map.insert("gitignore_template".into(), g.clone().into());
}
if let Some(l) = &new.license {
map.insert("license_template".into(), l.clone().into());
}
}
let path = match &new.org {
Some(org) => format!("/orgs/{}/repos", encode_path(org)),
None => "/user/repos".to_owned(),
};
self.rest_write_json(http::Method::POST, &path, Some(&body))
.await
}
pub async fn file_look(
&self,
repo: &RepoId,
rev: &str,
path: &str,
) -> Result<FileLook, ApiError> {
#[derive(Deserialize)]
struct Oid {
oid: String,
}
#[derive(Deserialize)]
struct Target {
target: Oid,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Commit {
Commit(Oid),
Tag(Target),
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Blob {
oid: Option<String>,
text: Option<String>,
is_binary: Option<bool>,
#[serde(default)]
is_truncated: bool,
}
#[derive(Deserialize)]
struct Wire {
id: NodeId,
#[serde(rename = "ref")]
branch: Option<Target>,
commit: Option<Commit>,
file: Option<Blob>,
}
let vars = serde_json::json!({
"owner": repo.owner,
"name": repo.name,
"qualified": format!("refs/heads/{rev}"),
"rev": rev,
"spec": format!("{rev}:{path}"),
});
let wire: Wire = self
.graphql_json(FILE_LOOK, vars, repo, "/repository")
.await?;
let commit = match wire.commit {
Some(Commit::Commit(c)) => c.oid,
Some(Commit::Tag(t)) => t.target.oid,
None => return Err(ApiError::NotFound(format!("{rev} in {repo}"))),
};
let file = (wire.file).and_then(|b| {
Some(FileAt {
oid: b.oid?,
text: (b.text).filter(|_| !b.is_truncated && b.is_binary != Some(true)),
})
});
Ok(FileLook {
repo_id: wire.id,
branch_head: wire.branch.map(|b| b.target.oid),
commit,
file,
})
}
pub async fn commit_files(&self, c: &FileCommit) -> Result<(), ApiError> {
use base64::Engine;
let b64 = |text: &str| base64::engine::general_purpose::STANDARD.encode(text);
let additions: Vec<serde_json::Value> = (c.additions.iter())
.map(|(path, text)| serde_json::json!({ "path": path, "contents": b64(text) }))
.collect();
let deletions: Vec<serde_json::Value> = (c.deletions.iter())
.map(|path| serde_json::json!({ "path": path }))
.collect();
let mut message = serde_json::json!({ "headline": c.headline });
if !c.body.is_empty()
&& let Some(map) = message.as_object_mut()
{
map.insert("body".into(), c.body.clone().into());
}
let input = serde_json::json!({
"branch": {
"repositoryNameWithOwner": c.repo.to_string(),
"branchName": c.branch,
},
"expectedHeadOid": c.head,
"message": message,
"fileChanges": { "additions": additions, "deletions": deletions },
});
let vars = serde_json::json!({ "input": input });
self.mutate_json(COMMIT_ON_BRANCH, vars)
.await
.map_err(workflow_scoped)
}
}
fn milestone_body(m: &MilestoneChange) -> serde_json::Value {
let due = m.due_on.as_ref().map(|d| format!("{d}T12:00:00Z"));
serde_json::json!({
"title": m.title,
"description": m.description,
"due_on": due,
})
}