use std::collections::{BTreeMap, HashSet};
use std::num::NonZeroU64;
use std::sync::Mutex;
use serde_json::{Map, Value};
const KEYWORDS: [&str; 9] = [
"close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Closing {
keyword: String,
reference: String,
}
impl Closing {
pub(crate) fn line(&self) -> String {
format!("{} {}", self.keyword, self.reference)
}
pub(crate) fn issue(&self, own: Option<&str>) -> Option<Issue> {
if self.reference.contains("://") {
return None;
}
let (repository, number) = match self.reference.strip_prefix('#') {
Some(number) => (own?, number),
None => self.reference.split_once('#')?,
};
let (owner, name) = repository.split_once('/')?;
Issue::new(owner, name, number.parse().ok()?)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Issue {
owner: String,
name: String,
number: NonZeroU64,
}
impl Issue {
fn new(owner: &str, name: &str, number: u64) -> Option<Self> {
let number = NonZeroU64::new(number)?;
(is_owner(owner) && is_name(name)).then(|| Issue {
owner: owner.to_owned(),
name: name.to_owned(),
number,
})
}
}
impl std::fmt::Display for Issue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}#{}", self.owner, self.name, self.number)
}
}
pub(crate) fn references<'a>(messages: impl IntoIterator<Item = &'a str>) -> Vec<Closing> {
let mut seen = HashSet::new();
let mut answer = Vec::new();
for message in messages {
for line in message.lines() {
for closing in in_line(line) {
if seen.insert(closing.reference.to_lowercase()) {
answer.push(closing);
}
}
}
}
answer
}
pub(crate) fn issues(closings: &[Closing], own: Option<&str>) -> Vec<Issue> {
closings
.iter()
.filter_map(|closing| closing.issue(own))
.collect()
}
pub(crate) fn report(payload: &mut Map<String, Value>, issues: impl IntoIterator<Item = Issue>) {
let mut seen = HashSet::new();
let closes: Vec<Value> = issues
.into_iter()
.map(|issue| issue.to_string())
.filter(|spelled| seen.insert(spelled.to_lowercase()))
.map(Value::String)
.collect();
payload.insert("closes".to_owned(), Value::Array(closes));
}
pub(crate) fn reported(payload: &Map<String, Value>) -> crate::Result<Vec<String>> {
let Some(closes) = payload.get("closes") else {
return Ok(Vec::new());
};
let refused = |what: &str| crate::Error::Invalid {
reason: format!("a landing event's `closes` {what}: {closes}"),
};
let Value::Array(items) = closes else {
return Err(refused("is not a list"));
};
items
.iter()
.map(|item| {
let spelled = item
.as_str()
.ok_or_else(|| refused("holds an item that is not a string"))?;
let readable = spelled
.split_once('#')
.and_then(|(repository, number)| {
let (owner, name) = repository.split_once('/')?;
Issue::new(owner, name, number.parse().ok()?)
})
.is_some_and(|issue| issue.to_string() == spelled);
if readable {
Ok(spelled.to_owned())
} else {
Err(refused(&format!(
"holds `{spelled}`, which is not `owner/name#N`"
)))
}
})
.collect()
}
fn from_host(answered: Option<&Value>) -> std::result::Result<Vec<Issue>, String> {
let nodes = match answered {
Some(Value::Array(nodes)) => nodes,
Some(connection) => connection
.get("nodes")
.and_then(Value::as_array)
.ok_or_else(|| format!("{connection} is neither a list nor a connection of nodes"))?,
None => return Err("the answer carries no closingIssuesReferences".to_owned()),
};
nodes
.iter()
.map(|node| {
let owner = node
.pointer("/repository/owner/login")
.and_then(Value::as_str);
let name = node.pointer("/repository/name").and_then(Value::as_str);
let number = node.get("number").and_then(Value::as_u64);
owner
.zip(name)
.zip(number)
.and_then(|((owner, name), number)| Issue::new(owner, name, number))
.ok_or_else(|| format!("{node} names no repository and issue number"))
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Heard {
Issues(Vec<Issue>),
Unanswered,
}
impl Heard {
pub(crate) fn issues(self) -> Vec<Issue> {
match self {
Heard::Issues(issues) => issues,
Heard::Unanswered => Vec::new(),
}
}
}
static HEARD: Mutex<BTreeMap<String, Heard>> = Mutex::new(BTreeMap::new());
fn record(url: &str, heard: Heard) {
HEARD
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(url.to_owned(), heard);
}
pub(crate) fn hear(url: &str, answered: Option<&Value>) {
match from_host(answered) {
Ok(issues) => record(url, Heard::Issues(issues)),
Err(problem) => {
eprintln!(
"onevcs: warning: the host's answer about which issues {url} closes could \
not be read ({problem}), so its landing reports none"
);
record(url, Heard::Unanswered);
}
}
}
pub(crate) fn unanswered(url: &str) {
record(url, Heard::Unanswered);
}
pub(crate) fn heard_of(url: &str) -> Option<Heard> {
HEARD
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(url)
.cloned()
}
fn in_line(line: &str) -> Vec<Closing> {
let mut found = Vec::new();
let mut at = 0;
while at < line.len() {
let rest = &line[at..];
let word_len = rest
.find(|c: char| !c.is_ascii_alphabetic())
.unwrap_or(rest.len());
let starts_a_word = line[..at]
.chars()
.next_back()
.is_none_or(|c| !c.is_alphanumeric() && c != '_');
if word_len > 0 && starts_a_word {
let word = &rest[..word_len];
if KEYWORDS.contains(&word.to_ascii_lowercase().as_str()) {
if let Some((reference, used)) = reference_after(&rest[word_len..]) {
found.push(Closing {
keyword: capitalised(word),
reference,
});
at += word_len + used;
continue;
}
}
at += word_len;
continue;
}
at += rest.chars().next().map_or(1, char::len_utf8);
}
found
}
fn reference_after(after: &str) -> Option<(String, usize)> {
let colon = usize::from(after.starts_with(':'));
let spaced = after[colon..].len() - after[colon..].trim_start().len();
if colon + spaced == 0 {
return None;
}
let start = colon + spaced;
let candidate = &after[start..];
let end = candidate
.find(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | ')' | ']' | '>' | '"'))
.unwrap_or(candidate.len());
let token = candidate[..end].trim_end_matches(['.', ':', '!', '?']);
let reference = issue(token)?;
Some((reference, start + token.len()))
}
fn issue(token: &str) -> Option<String> {
if let Some(number) = token.strip_prefix('#') {
return is_number(number).then(|| format!("#{number}"));
}
if let Some(url) = token
.strip_prefix("https://")
.or_else(|| token.strip_prefix("http://"))
{
let mut parts = url.split('/');
let (host, owner, name, kind, number) = (
parts.next()?,
parts.next()?,
parts.next()?,
parts.next()?,
parts.next()?,
);
if parts.next().is_some()
|| kind != "issues"
|| !is_owner(owner)
|| !is_name(name)
|| !is_number(number)
{
return None;
}
return Some(if host.eq_ignore_ascii_case("github.com") {
format!("{owner}/{name}#{number}")
} else {
token.to_owned()
});
}
let (repository, number) = token.split_once('#')?;
let (owner, name) = repository.split_once('/')?;
(is_owner(owner) && is_name(name) && is_number(number))
.then(|| format!("{owner}/{name}#{number}"))
}
fn is_number(text: &str) -> bool {
!text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())
}
fn is_owner(text: &str) -> bool {
!text.is_empty() && text.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}
fn is_name(text: &str) -> bool {
!text.is_empty()
&& text
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
}
fn capitalised(word: &str) -> String {
let lower = word.to_ascii_lowercase();
let mut chars = lower.chars();
chars.next().map_or_else(String::new, |first| {
first.to_ascii_uppercase().to_string() + chars.as_str()
})
}
#[cfg(test)]
mod tests {
fn lines<'a>(messages: impl IntoIterator<Item = &'a str>) -> Vec<String> {
super::references(messages)
.iter()
.map(super::Closing::line)
.collect()
}
#[test]
fn every_form_github_reads_is_collected_once_in_order() {
let messages = [
"feat: add it\n\nSome prose that fixes nothing.\n\nCloses owner/name#1071\nfixes: #7",
"fix: more\n\nThis resolves https://github.com/owner/name/issues/1071 and CLOSED #8.",
"chore: tidy\n\nResolves #7, resolves other/repo#3",
];
assert_eq!(
lines(messages),
vec![
"Closes owner/name#1071",
"Fixes #7",
"Closed #8",
"Resolves other/repo#3",
]
);
}
#[test]
fn nothing_that_only_resembles_a_reference_is_collected() {
let messages = [
"fix: the #12 thing\n\nprefixes #3\nCloses#4\ncloses #5abc\nfixes owner/name#\n\
closes https://github.com/owner/name/pull/9\nclose issue #6",
];
assert!(lines(messages).is_empty(), "{:?}", lines(messages));
}
}