...
 
Commits (2)
use chrono::{Local, NaiveDate};
use crate::opinion::*;
use std::collections::{hash_map::{DefaultHasher, Entry::{Occupied, Vacant}, OccupiedEntry}, HashMap};
use std::hash::{Hash, Hasher};
use std::ops::Deref;
use std::ops::DerefMut;
#[derive(Serialize, Deserialize, Clone, Copy, Eq, PartialEq, Hash)]
use chrono::{Local, NaiveDate};
use crate::opinion::*;
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
pub struct BallotId(u32);
impl std::fmt::Display for BallotId {
......@@ -24,7 +26,7 @@ impl std::convert::Into<u32> for BallotId {
fn into(self) -> u32 { self.0 }
}
#[derive(Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Ballot {
creation_date: NaiveDate,
creator: String,
......@@ -87,7 +89,7 @@ impl<'a> DerefMut for Entry<'a> {
}
}
#[derive(Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct State {
ballots: HashMap<BallotId, Ballot>,
}
......@@ -106,7 +108,7 @@ impl State {
}
}
pub fn iter<'a>(&'a self) -> impl Iterator<Item=(&BallotId, &Ballot)> + 'a {
pub fn iter(&self) -> impl Iterator<Item=(&BallotId, &Ballot)> {
self.ballots.iter()
}
......
......@@ -8,15 +8,18 @@ extern crate serde_json;
extern crate spacebot;
extern crate time;
use std::collections::HashSet;
use std::fmt;
use std::iter::FromIterator;
use clap::{Arg, SubCommand};
use itertools::Itertools;
use spacebot::prelude::*;
use crate::ballot::*;
use crate::opinion::*;
use crate::repo::*;
use itertools::Itertools;
use spacebot::prelude::*;
use std::collections::HashSet;
use std::iter::FromIterator;
use failure::Error;
mod opinion;
mod ballot;
......@@ -51,6 +54,65 @@ impl ModuleConfig for Config {
}
}
#[derive(Clone, Debug)]
struct BallotFormat<'a> {
ballot: &'a Ballot,
verbose: bool,
}
impl<'a> BallotFormat<'a> {
pub fn quiet(ballot: &'a Ballot) -> Self {
return Self {
ballot,
verbose: false,
};
}
pub fn verbose(ballot: &'a Ballot) -> Self {
return Self {
ballot,
verbose: true,
};
}
}
impl<'a> fmt::Display for BallotFormat<'a> {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
write!(f, "\x0314<\x0F\x0312{}\x0F\x0314>\x0F {}",
self.ballot.id(),
self.ballot.text())?;
if self.ballot.opinions().count() > 0 {
write!(f, " \x0314[\x0F{}\x0314]\x0F", self.ballot.opinions().iter()
.format_with(" ", |(nick, opinion), format| match opinion {
Opinion::Yes => format(&format_args!("\x0303\x02+\x0F\x0303{}\x0F", nick)),
Opinion::No => format(&format_args!("\x0304\x02-\x0F\x0304{}\x0F", nick)),
}))?;
}
if self.verbose {
write!(f, " \x0315(created by {} at {})\x0F",
self.ballot.creator(),
self.ballot.creation_date())?;
}
return Ok(());
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum FinishReason<'a> {
Completed,
Committed {
committer: &'a str,
},
}
impl<'a> FinishReason<'a> {
pub fn completed() -> Self { return FinishReason::Completed; }
pub fn committed(committer: &'a str) -> Self { return FinishReason::Committed { committer }; }
}
struct BoardBot {
board_members: HashSet<String>,
log_channels: HashSet<String>,
......@@ -68,9 +130,15 @@ impl BoardBot {
let mut state = self.state.write();
let ballot = state.new(nick, text);
// Inform all members
self.announce(response, ballot,
&format!("New poll: {} (created by {})", ballot.text(), ballot.creator()))?;
// Respond to creator
response.respond_message(&format!("Your proposal has been recorded: {}",
BallotFormat::quiet(ballot)))?;
// Inform the board
for member in self.board_members.iter() {
response.message(member, &format!("New ballot: {}",
BallotFormat::verbose(ballot)))?;
}
return Ok(());
}
......@@ -79,13 +147,8 @@ impl BoardBot {
let state = self.state.read();
if !state.is_empty() {
response.respond_message(&format!("The following ballots are currently open:"))?;
for (id, poll) in state.iter() {
response.respond_message(&format!(" <{}> {} [{}] (created by {} at {})",
id,
poll.text(),
poll.opinions(),
poll.creator(),
poll.creation_date()))?;
for (_, ballot) in state.iter() {
response.respond_message(&format!(" {}", BallotFormat::verbose(&ballot)))?;
}
response.respond_message("--")?;
} else {
......@@ -101,17 +164,19 @@ impl BoardBot {
if let Some(mut ballot) = state.get(id) {
// Save the opinion and inform the channel
if ballot.vote(nick, opinion) {
self.announce(response, &ballot, &match opinion {
Opinion::Yes => format!("{} voted for \"{}\"", nick, ballot.text()),
Opinion::No => format!("{} voted against \"{}\"", nick, ballot.text()),
})?;
for member in self.board_members.iter() {
response.message(member, &match opinion {
Opinion::Yes => format!("{} voted for {}", nick, BallotFormat::quiet(&ballot)),
Opinion::No => format!("{} voted against {}", nick, BallotFormat::quiet(&ballot)),
})?;
}
}
response.respond_message(&format!("Your opinion for ballot <{}> has been recorded", id))?;
// Auto-commit if everybody has voted
if ballot.opinions().count() == self.board_members.len() {
self.finish(response, ballot.delete())?;
self.finish(response, ballot.delete(), FinishReason::completed())?;
}
} else {
response.respond_message(&format!("No such ballot: <{}>", id))?;
......@@ -125,9 +190,9 @@ impl BoardBot {
if let Some(ballot) = state.get(id) {
if ballot.opinion_count() > self.board_members.len() / 2 {
self.finish(response, ballot.delete())?;
self.finish(response, ballot.delete(), FinishReason::committed(nick))?;
} else {
response.respond_message(&format!("Not enough votes to commit: <{}> ({})", id, ballot.opinions()))?;
response.respond_message(&format!("Not enough votes to commit: {}", BallotFormat::quiet(&ballot)))?;
}
} else {
response.respond_message(&format!("No such ballot: <{}>", id))?;
......@@ -141,9 +206,15 @@ impl BoardBot {
if let Some(ballot) = state.get(id) {
let ballot = ballot.delete();
self.announce(response, &ballot,
&format!("{} has canceled this ballot: {}: [{}] (created by {} at {})",
nick, ballot.text(), ballot.opinions(), ballot.creator(), ballot.creation_date()))?;
for member in self.board_members.iter() {
response.message(member, &format!("{} canceled {}",
nick,
BallotFormat::verbose(&ballot)))?;
}
// Inform the creator
response.message(ballot.creator(), &format!("Your proposal has been canceled: {}",
BallotFormat::quiet(&ballot)))?;
} else {
response.respond_message(&format!("Unknown ballot: <{}>", id))?;
}
......@@ -160,9 +231,11 @@ impl BoardBot {
match pattern {
Ok(pattern) => {
// TODO: Parse the resolution and colorize output
for line in self.repository.search(&pattern) {
response.respond_message(&line)?;
}
response.respond_message("--")?;
}
Err(err) => {
......@@ -173,17 +246,30 @@ impl BoardBot {
return Ok(());
}
fn finish(&self, response: &Response, ballot: Ballot) -> Result<(), CmdError> {
fn finish(&self, response: &Response, ballot: Ballot, reason: FinishReason) -> Result<(), CmdError> {
let resolution = Resolution::from_ballot(&ballot);
self.repository.append(&resolution);
self.announce(response, &ballot,
&format!("Resolution {}: {} [{}]",
resolution.conclusion(), resolution.text(), resolution.opinions()))?;
let message = match resolution.conclusion() {
Opinion::Yes => format!("Accepted resolution: {}", BallotFormat::quiet(&ballot)),
Opinion::No => format!("Rejected resolution: {}", BallotFormat::quiet(&ballot)),
};
response.message(&ballot.creator(),
&format!("Your proposed resolution: {} has been {} [{}]",
resolution.text(), resolution.conclusion(), resolution.opinions()))?;
// Inform the channel
for channel in self.log_channels.iter() {
response.message(&channel, &format!("{}", &message))?;
}
// Inform the creator
response.message(ballot.creator(), &format!("{}", &message))?;
// Inform the board
for member in self.board_members.iter() {
match reason {
FinishReason::Completed => response.message(member, &format!("{} (completed)", &message))?,
FinishReason::Committed { committer } => response.message(member, &format!("{} (committed by {})", &message, committer))?,
}
}
return Ok(());
}
......@@ -199,30 +285,12 @@ impl BoardBot {
return Ok(());
}
fn announce(&self, response: &Response, ballot: &Ballot, message: &str) -> Result<(), Error> {
let message = format!("<{}> {}", ballot.id(), message);
for channel in self.log_channels.iter() {
response.notice(&channel, &message)?;
}
for member in self.board_members.iter() {
response.message(&member, &message)?;
}
if !self.board_members.contains(ballot.creator()) {
response.message(&ballot.creator(), &message)?;
}
return Ok(());
}
}
impl CommandModule for BoardBot {
type Config = Config;
fn init(config: &Config, initializer: &mut Initializer) -> Result<Self, failure::Error> {
fn init(config: &Config, _: &mut Initializer) -> Result<Self, failure::Error> {
return Ok(BoardBot {
log_channels: HashSet::from_iter(config.log_channels.clone()),
board_members: HashSet::from_iter(config.board_members.clone()),
......
use itertools::Itertools;
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum Opinion {
Yes,
No,
}
impl std::fmt::Display for Opinion {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
&Opinion::Yes => write!(f, "accepted"),
&Opinion::No => write!(f, "rejected"),
}
}
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Opinions(HashMap<String, Opinion>);
impl Opinions {
......@@ -44,18 +34,8 @@ impl Opinions {
return if y > n { Opinion::Yes } else { Opinion::No };
}
}
impl std::fmt::Display for Opinions {
fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
return f.write_str(&self.0.iter()
.map(|(member, opinion)| {
format!("{}{}", match *opinion {
Opinion::Yes => '+',
Opinion::No => '-',
}, member)
})
.join(" ")
);
pub fn iter(&self) -> impl Iterator<Item=(&String, &Opinion)> {
return self.0.iter();
}
}
use chrono::{Datelike, Local, NaiveDate};
use crate::ballot::*;
use crate::opinion::*;
use regex::Regex;
use std::fs::OpenOptions;
use std::fs::read_dir;
use std::io::BufRead;
......@@ -9,10 +5,18 @@ use std::io::BufReader;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use log::debug;
use chrono::{Datelike, Local, NaiveDate};
use itertools::Itertools;
use regex::Regex;
use crate::ballot::*;
use crate::opinion::*;
#[derive(Clone, Debug)]
pub struct Repository(PathBuf);
#[derive(Clone, Debug)]
pub struct Resolution {
id: u32,
date: NaiveDate,
......@@ -48,10 +52,10 @@ impl Repository {
fn pull(&self) {
Command::new("git")
.arg("pull")
.current_dir(&self.0)
.status()
.expect(&format!("Failed to git-pull resolution repository: {:?}", &self.0));
.arg("pull")
.current_dir(&self.0)
.status()
.expect(&format!("Failed to git-pull resolution repository: {:?}", &self.0));
}
pub fn append(&self, resolution: &Resolution) {
......@@ -60,11 +64,11 @@ impl Repository {
let path = self.0.join("resolutions").join(format!("{}", resolution.date().year()));
let mut file = OpenOptions::new()
.create(true)
.write(true)
.append(true)
.open(&path)
.expect(&format!("Failed to open resolution file: {:?}", &path));
.create(true)
.write(true)
.append(true)
.open(&path)
.expect(&format!("Failed to open resolution file: {:?}", &path));
writeln!(file, "[{:08x}] {:04}-{:02}-{:02}: {} ({})",
resolution.id(),
......@@ -72,49 +76,54 @@ impl Repository {
resolution.date().month(),
resolution.date().day(),
resolution.text(),
resolution.opinions())
.expect(&format!("Failed to append to resolution file: {:?}", &path));
resolution.opinions().iter()
.format_with(" ", |(nick, opinion), f|
f(&format_args!("{}{}", match opinion {
Opinion::Yes => "+",
Opinion::No => "-",
}, nick)),
))
.expect(&format!("Failed to append to resolution file: {:?}", &path));
Command::new("git")
.arg("add").arg(&path)
.current_dir(&self.0)
.status()
.expect(&format!("Failed to git-add resolution repository: {:?}: {:?}", &self.0, &path));
.arg("add").arg(&path)
.current_dir(&self.0)
.status()
.expect(&format!("Failed to git-add resolution repository: {:?}: {:?}", &self.0, &path));
Command::new("git")
.arg("commit")
.arg("-m").arg(format!("[bot] Added resolution {:08x}: {}", resolution.id(), resolution.text()))
.current_dir(&self.0)
.status()
.expect(&format!("Failed to git-commit resolution repository: {:?}", &self.0));
.arg("commit")
.arg("-m").arg(format!("[bot] Added resolution {:08x}: {}", resolution.id(), resolution.text()))
.current_dir(&self.0)
.status()
.expect(&format!("Failed to git-commit resolution repository: {:?}", &self.0));
Command::new("git")
.arg("push")
.current_dir(&self.0)
.status()
.expect(&format!("Failed to git-push resolution repository: {:?}", &self.0));
.arg("push")
.current_dir(&self.0)
.status()
.expect(&format!("Failed to git-push resolution repository: {:?}", &self.0));
}
pub fn search(&self, pattern: &Regex) -> Vec<String> {
self.pull();
return read_dir(self.0.join("resolutions")).unwrap()
.map(|entry| {
let path = entry.unwrap().path();
let file = OpenOptions::new()
.read(true)
.open(&path)
.expect("Failed to open resolution file");
let file = BufReader::new(file);
return file.lines()
.filter_map(|line| {
let line = line.unwrap();
if pattern.is_match(&line) {
return Some(line);
} else {
return None;
}
});
})
.flatten()
.collect();
.flat_map(|entry| {
let path = entry.unwrap().path();
let file = OpenOptions::new()
.read(true)
.open(&path)
.expect("Failed to open resolution file");
let file = BufReader::new(file);
return file.lines()
.filter_map(|line| {
let line = line.unwrap();
if pattern.is_match(&line) {
return Some(line);
} else {
return None;
}
});
})
.collect();
}
}
......@@ -64,7 +64,7 @@ impl<M> Module for M
return Ok(());
};
if let Ok(args) = shellwords::split(&request.message) { // FIXME: Error handling of mismatched quotes
if let Ok(args) = shellwords::split(message) { // FIXME: Error handling of mismatched quotes
let app = App::new("")
.bin_name("FIXME") // FIXME: Use right name and set width to max line length
.setting(AppSettings::ArgRequiredElseHelp)
......