Skeletons for new files only; existing files change by exact edits, so no task overwrites an earlier one's work. Each task's tests were run against a reference built in order from master, with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and every todo!() comment was read beside its signature. The reference is kept off this machine. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
144 lines
5.9 KiB
Rust
144 lines
5.9 KiB
Rust
//! The serve loop's part in approvals (M4b spec, sections 6 and 8): posting what a `gateway.sock`
|
|
//! connection brings, marking it when it ends, and marking what a restart left behind. The loop
|
|
//! alone touches Mattermost and the state file; connection threads only ask.
|
|
|
|
use std::sync::mpsc::Sender;
|
|
|
|
use proto::{ApprovalReply, GatewayOutcome, PendingApproval};
|
|
|
|
use crate::approvals::Posted;
|
|
use crate::approvals::text::{self, Ended};
|
|
use crate::gateway::{Ending, ToLoop};
|
|
use crate::serve::{Gateway, Stop};
|
|
use crate::state::ApprovalPosts;
|
|
|
|
/// An approval's last post, the one that is edited when it ends.
|
|
#[derive(Debug)]
|
|
pub(super) struct Open {
|
|
pub(super) last_post: String,
|
|
pub(super) last_text: String,
|
|
}
|
|
|
|
impl Gateway {
|
|
/// Everything the connection threads asked since the last look, never blocking.
|
|
pub(super) fn gateway_requests(&mut self) -> Result<(), Stop> {
|
|
while let Ok(asked) = self.gateway_rx.try_recv() {
|
|
match asked {
|
|
ToLoop::Show {
|
|
info,
|
|
answer,
|
|
shown,
|
|
} => {
|
|
let result = self.show(&info, answer)?;
|
|
let _ = shown.send(result);
|
|
}
|
|
ToLoop::Ended { approval, ending } => self.ended(approval, ending)?,
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Where an approval goes: its session's turn in flight, or the first approver's direct
|
|
/// messages as a new thread (an empty root).
|
|
fn place(&self, info: &PendingApproval) -> Result<(String, String), String> {
|
|
// 1. `self.state.turn_of(info.session.as_str())`: if Some(turn), `Ok((turn.channel,
|
|
// turn.root))`.
|
|
// 2. Otherwise the first approver's direct messages, as a new thread:
|
|
// `self.config.first_approver()` (None is Err("no approver is configured")), then
|
|
// `self.client.direct_channel(&self.me.id, first)` (an Err(e) is Err(format!("no direct
|
|
// channel with the first approver: {e}"))). `Ok((channel, String::new()))`.
|
|
todo!()
|
|
}
|
|
|
|
/// Post an approval's parts in order, record them, and register it for answers. An `Err`
|
|
/// inside is why it could not be posted; the outer one stops `gatewayd` (the state file).
|
|
fn show(
|
|
&mut self,
|
|
info: &PendingApproval,
|
|
answer: Sender<ApprovalReply>,
|
|
) -> Result<Result<Vec<String>, String>, Stop> {
|
|
let id = info.approval;
|
|
let (channel, mut root) = match self.place(info) {
|
|
Ok(place) => place,
|
|
Err(why) => return Ok(Err(why)),
|
|
};
|
|
let shown = text::render(info);
|
|
let mut posts = Vec::new();
|
|
let mut shown_at = 0;
|
|
for body in &shown.posts {
|
|
match self.client.create_post(&channel, &root, body) {
|
|
Ok(post) => {
|
|
if root.is_empty() {
|
|
root = post.id.clone();
|
|
}
|
|
shown_at = post.create_at;
|
|
posts.push(post.id);
|
|
}
|
|
Err(e) => {
|
|
// Parts already posted must not look answerable.
|
|
if let (Some(last), Some(text)) =
|
|
(posts.last(), shown.posts.get(posts.len().wrapping_sub(1)))
|
|
{
|
|
self.mark(id, last, &text::with_ending(text, &Ended::NoLonger));
|
|
}
|
|
return Ok(Err(format!("cannot post approval {id}: {e}")));
|
|
}
|
|
}
|
|
}
|
|
self.state.add_approval(ApprovalPosts {
|
|
approval: id,
|
|
channel: channel.clone(),
|
|
posts: posts.clone(),
|
|
})?;
|
|
self.registry.add(Posted {
|
|
approval: id,
|
|
session: info.session.as_str().to_string(),
|
|
channel,
|
|
root,
|
|
shown_at,
|
|
answerable: shown.answerable,
|
|
answer,
|
|
});
|
|
if let (Some(last_post), Some(last_text)) = (posts.last(), shown.posts.last()) {
|
|
self.open.insert(
|
|
id,
|
|
Open {
|
|
last_post: last_post.clone(),
|
|
last_text: last_text.clone(),
|
|
},
|
|
);
|
|
}
|
|
Ok(Ok(posts))
|
|
}
|
|
|
|
/// An approval ended: forget it, and add how it ended to its last post.
|
|
fn ended(&mut self, id: u64, ending: Ending) -> Result<(), Stop> {
|
|
// 1. `self.registry.remove(id);` and `self.state.remove_approval(id)?;`.
|
|
// 2. `let Some(open) = self.open.remove(&id) else { return Ok(()); };`
|
|
// 3. The `Ended`: Ending::NoLonger -> Ended::NoLonger. Ending::Answered { by, reason,
|
|
// outcome } -> `by` becomes "@<username>" from `self.client.username(&by)`, or stays the
|
|
// id if that fails; then outcome Runs -> Ended::Runs { by }, Denied { reason } ->
|
|
// Ended::Denied { by, reason }, Refused -> Ended::Refused { by, reason }, Gone ->
|
|
// Ended::NoLonger.
|
|
// 4. `self.mark(id, &open.last_post, &text::with_ending(&open.last_text, &ended));` and
|
|
// Ok(()).
|
|
todo!()
|
|
}
|
|
|
|
/// At the first connection after a start: the approval posts the last run left are no longer
|
|
/// answerable. Their last post is replaced by its title and the restart line.
|
|
pub(super) fn mark_left_behind(&mut self) -> Result<(), Stop> {
|
|
// For each `left` in `self.state.take_approvals()?`: if it has a last post,
|
|
// `self.mark(left.approval, last, &body)` with `body = format!("**Approval {}**\n\n{}",
|
|
// left.approval, text::ended_line(&Ended::Restarted))`. Then Ok(()).
|
|
todo!()
|
|
}
|
|
|
|
/// Edit a post; a failure is logged, not retried.
|
|
fn mark(&self, id: u64, post: &str, body: &str) {
|
|
// `if let Err(e) = self.client.patch_post(post, body)`: log "gatewayd: cannot mark approval
|
|
// {id} as ended: {e}" with `(self.log)(&line)`.
|
|
todo!()
|
|
}
|
|
}
|