use std::collections::BTreeMap;
use std::sync::MutexGuard;
use onetaskgraph_plugin_api::{NativeId, SourceError};
use serde_json::{Map, Value, json};
use super::{ANSWER_ERRORS, GitHubProjectsSource, PEOPLE_PER_LOOKUP, graphql, required_str};
pub(super) fn assignee_logins(content: &Value) -> Result<Vec<String>, SourceError> {
let id = content
.get("id")
.and_then(Value::as_str)
.unwrap_or("an issue");
let Some(connection) = content.get("assignees") else {
return Err(SourceError::Malformed {
message: format!(
"GitHub answered {id} without the assignees this source asked for beside it"
),
});
};
let nodes = connection
.get("nodes")
.and_then(Value::as_array)
.ok_or_else(|| SourceError::Malformed {
message: "GitHub issue assignees.nodes is not an array".into(),
})?;
nodes
.iter()
.map(|node| {
node.get("login")
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| SourceError::Malformed {
message: "GitHub issue assignee has no login".into(),
})
})
.collect::<Result<Vec<_>, _>>()
}
pub(super) fn same_people(left: &[String], right: &[String]) -> bool {
let set = |people: &[String]| {
people
.iter()
.map(|login| login.to_lowercase())
.collect::<std::collections::BTreeSet<_>>()
};
set(left) == set(right)
}
fn not_found_at(data: &Value, alias: &str) -> bool {
data.get(ANSWER_ERRORS)
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|error| {
error.get("type").and_then(Value::as_str) == Some("NOT_FOUND")
&& error
.get("path")
.and_then(Value::as_array)
.and_then(|path| path.first())
.and_then(Value::as_str)
== Some(alias)
})
}
fn named(issue: Option<&Value>, fallback: &str) -> String {
let field = |key: &str| issue.and_then(|issue| issue.get(key));
if let Some(url) = field("url").and_then(Value::as_str) {
return url.to_owned();
}
if let Some(number) = field("number").and_then(Value::as_u64) {
return format!("issue #{number}");
}
match field("id").and_then(Value::as_str) {
Some(id) => format!("issue {id}"),
None => fallback.to_owned(),
}
}
impl super::Resolved {
pub(super) fn written_name(&self) -> String {
self.url
.clone()
.unwrap_or_else(|| format!("item {}", self.id.0))
}
}
impl GitHubProjectsSource {
pub(super) fn assigns_a_draft(&self) -> SourceError {
SourceError::Refused {
message: format!(
"source {} writes the assignees of an issue, and this task is a GitHub draft item; next: convert the draft to an issue, then assign it",
self.name
),
}
}
pub(super) fn assigning(
&self,
input: &mut Value,
incoming: &super::Incoming<'_>,
) -> Result<(), SourceError> {
if let Some(people) = incoming.assignees.filter(|people| !people.is_empty()) {
input["assigneeIds"] =
json!(self.people_ids(people, &format!("the new {}", incoming.title))?);
}
Ok(())
}
fn people(&self) -> MutexGuard<'_, BTreeMap<String, Option<String>>> {
self.people
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(super) async fn resolve_people<'a>(
&self,
logins: impl IntoIterator<Item = &'a String>,
) -> Result<(), SourceError> {
let mut wanted: Vec<&String> = Vec::new();
{
let held = self.people();
for login in logins {
if !held.contains_key(&login.to_lowercase())
&& !wanted.iter().any(|named| named.eq_ignore_ascii_case(login))
{
wanted.push(login);
}
}
}
for chunk in wanted.chunks(PEOPLE_PER_LOOKUP) {
let mut variables = Map::new();
for (slot, login) in chunk.iter().enumerate() {
variables.insert(format!("login{slot}"), json!(login));
}
let data = self
.graphql(graphql::USER_IDS, Value::Object(variables))
.await?;
let mut found = Vec::with_capacity(chunk.len());
for (slot, login) in chunk.iter().enumerate() {
let alias = format!("u{slot}");
match data.get(&alias).filter(|user| !user.is_null()) {
Some(user) => {
let answered = required_str(user, "login")?;
if !answered.eq_ignore_ascii_case(login) {
return Err(SourceError::Malformed {
message: format!(
"GitHub answered the lookup of the login {login} with the \
user {answered}"
),
});
}
found.push((
login.to_lowercase(),
Some(required_str(user, "id")?.to_owned()),
));
self.spellings
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(login.to_lowercase(), answered.to_owned());
}
None if not_found_at(&data, &alias) => found.push((login.to_lowercase(), None)),
None => {
return Err(SourceError::Malformed {
message: format!(
"GitHub answered nothing for the login {login} while looking up \
the people an assignee write names"
),
});
}
}
}
self.people().extend(found);
}
Ok(())
}
pub(super) fn people_ids(
&self,
logins: &[String],
item: &str,
) -> Result<Vec<String>, SourceError> {
let held = self.people();
logins
.iter()
.map(|login| match held.get(&login.to_lowercase()) {
Some(Some(id)) => Ok(id.clone()),
Some(None) => Err(SourceError::Refused {
message: format!(
"GitHub knows no user {login}, so {item} cannot be assigned to them; \
next: check the login's spelling, or name someone GitHub knows"
),
}),
None => Err(SourceError::Malformed {
message: format!(
"{login} was not looked up before the write of {item} named them"
),
}),
})
.collect()
}
pub(super) fn spelled(&self, people: &[String]) -> Vec<String> {
let spellings = self
.spellings
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
people
.iter()
.map(|person| {
spellings
.get(&person.to_lowercase())
.cloned()
.unwrap_or_else(|| person.clone())
})
.collect()
}
pub(super) async fn taken_back(
&self,
content: &NativeId,
created: &Value,
refused: SourceError,
) -> SourceError {
let Err(cleanup) = self.delete_issue(content).await else {
return refused;
};
let address = created
.get("url")
.and_then(Value::as_str)
.map_or_else(String::new, |url| format!(" at {url}"));
let left = format!(
"; GitHub then refused taking back the issue this write created, {content}{address}, \
so it is still in the repository ({cleanup}); next: delete that issue, or assign it \
as wanted, before writing this task again, which would otherwise create a second issue"
);
match refused {
SourceError::Malformed { message } => SourceError::Malformed {
message: format!("{message}{left}"),
},
SourceError::Refused { message } => SourceError::Refused {
message: format!("{message}{left}"),
},
other => SourceError::Refused {
message: format!("{other}{left}"),
},
}
}
fn login_of(&self, id: &str) -> String {
self.people()
.iter()
.find(|(_, held)| held.as_deref() == Some(id))
.map_or_else(|| id.to_owned(), |(login, _)| login.clone())
}
fn named_in(&self, input: &Value) -> Vec<String> {
input
.get("assigneeIds")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(|id| self.login_of(id))
.collect()
}
pub(super) fn forget_people(&self, input: &Value) {
let Some(ids) = input.get("assigneeIds").and_then(Value::as_array) else {
return;
};
self.forgotten
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extend(ids.iter().filter_map(Value::as_str).map(str::to_owned));
}
pub(super) fn forget_at_end_of_call(&self) {
let ids = std::mem::take(
&mut *self
.forgotten
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
if ids.is_empty() {
return;
}
self.people().retain(|_, held| {
!held
.as_deref()
.is_some_and(|held| ids.iter().any(|id| id == held))
});
}
pub(super) fn refusing_people(&self, input: &Value, error: SourceError) -> SourceError {
let people = self.named_in(input);
if input.get("assigneeIds").is_none() {
return error;
}
self.forget_people(input);
if people.is_empty() {
return error;
}
super::noting(
error,
&format!(
"; this write assigned {}, so it is not assigned to them",
people.join(", ")
),
)
}
pub(super) fn assigned_as_asked(
&self,
input: &Value,
issue: Option<&Value>,
) -> Result<(), SourceError> {
let Some(asked) = input.get("assigneeIds").and_then(Value::as_array) else {
return Ok(());
};
let asked: Vec<&str> = asked.iter().filter_map(Value::as_str).collect();
let what = named(issue, "the issue written");
let Some(holds) = issue
.and_then(|issue| issue.pointer("/assignees/nodes"))
.and_then(Value::as_array)
else {
self.forget_people(input);
return Err(SourceError::Malformed {
message: format!(
"GitHub answered the assignee write of {what} without the people it left \
the issue assigned to"
),
});
};
let holds: Vec<(&str, &str)> = match holds
.iter()
.map(|node| Some((node.get("id")?.as_str()?, node.get("login")?.as_str()?)))
.collect::<Option<Vec<_>>>()
{
Some(holds) => holds,
None => {
self.forget_people(input);
return Err(SourceError::Malformed {
message: format!(
"GitHub answered the assignee write of {what} with an assignee that has \
no id or no login"
),
});
}
};
let left_off: Vec<String> = asked
.iter()
.filter(|id| !holds.iter().any(|(held, _)| held == *id))
.map(|id| self.login_of(id))
.collect();
if !left_off.is_empty() {
self.forget_people(input);
let people = left_off.join(", ");
return Err(SourceError::Refused {
message: format!(
"GitHub left {people} off {what}: it assigns only people who can be assigned \
to issues in that repository, and leaves anyone else off without an error; \
next: give {people} access to the repository, or name someone who has it"
),
});
}
let kept: Vec<&str> = holds
.iter()
.filter(|(held, _)| !asked.contains(held))
.map(|(_, login)| *login)
.collect();
if !kept.is_empty() {
self.forget_people(input);
return Err(SourceError::Refused {
message: format!(
"GitHub kept {} on {what}, whom this write did not name, so the issue is not \
assigned as asked; next: run the write again",
kept.join(", ")
),
});
}
Ok(())
}
}