use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use serde_json::Value;
use super::{CommandFailure, GitHub};
use crate::issue::IssueUrl;
use crate::labels::READY_FOR_AGENT;
use crate::trust::{Action, IssueText, Reads};
impl Reads for GitHub {
fn author_association(&self, issue: &IssueUrl) -> Result<String> {
let json = self.gh_json(&[
"api",
&format!("repos/{}/issues/{}", issue.repo_slug(), issue.number),
])?;
Ok(json["author_association"]
.as_str()
.context("gh api output has no author_association")?
.to_string())
}
fn ready_labellings(&self, issue: &IssueUrl) -> Result<Vec<Action>> {
let events = self.gh_api_items(
&format!(
"repos/{}/issues/{}/events?per_page=100",
issue.repo_slug(),
issue.number
),
"",
)?;
events
.iter()
.filter(|event| {
event["event"] == "labeled"
&& event["label"]["name"]
.as_str()
.is_some_and(|name| READY_FOR_AGENT.is_named(name))
})
.map(|event| action(event, "created_at"))
.collect()
}
fn edits(
&self,
issue: &IssueUrl,
text: IssueText,
) -> Result<impl Iterator<Item = Result<Action>>> {
let (query, field) = match text {
IssueText::Body => (BODY_EDITS_QUERY, "editedAt"),
IssueText::Title => (TITLE_EDITS_QUERY, "createdAt"),
};
let nodes = self.paged_nodes(issue, query, "edits", "edit history")?.1;
Ok(nodes.into_iter().map(move |node| action(&node, field)))
}
fn is_member(&self, issue: &IssueUrl, login: &str) -> Result<bool> {
if self.membership(&format!(
"repos/{}/collaborators/{login}",
issue.repo_slug()
))? {
return Ok(true);
}
let repo = self.gh_json(&["api", &format!("repos/{}", issue.repo_slug())])?;
if repo["owner"]["type"] == "Organization" {
return self.membership(&format!("orgs/{}/members/{login}", issue.owner));
}
Ok(false)
}
}
impl GitHub {
fn membership(&self, path: &str) -> Result<bool> {
let output = self.output(&["api", "--include", path])?;
let stdout = String::from_utf8_lossy(&output.stdout);
let status = stdout
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1));
match status {
Some("204") if output.status.success() => Ok(true),
Some("404") => Ok(false),
_ => bail!(
"could not check Trusted account membership: gh api {path}: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
}
}
}
fn action(node: &Value, field: &str) -> Result<Action> {
Ok(Action {
actor: node["actor"]["login"].as_str().map(str::to_string),
at: time_at(node, field)?,
})
}
fn time_at(node: &Value, field: &str) -> Result<DateTime<Utc>> {
let at = node[field].as_str().with_context(|| {
CommandFailure::InvalidResponse(format!("issue history has an entry with no {field}"))
})?;
Ok(DateTime::parse_from_rfc3339(at)
.with_context(|| {
CommandFailure::InvalidResponse(format!("issue history has an unreadable {field} {at}"))
})?
.to_utc())
}
const BODY_EDITS_QUERY: &str = "\
query($owner: String!, $repo: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
edits: userContentEdits(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { editedAt actor: editor { login } }
}
}
}
}";
const TITLE_EDITS_QUERY: &str = "\
query($owner: String!, $repo: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
edits: timelineItems(first: 100, after: $after, itemTypes: [RENAMED_TITLE_EVENT]) {
pageInfo { hasNextPage endCursor }
nodes { ... on RenamedTitleEvent { createdAt actor { login } } }
}
}
}
}";