Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 134 additions & 18 deletions .config/jp/tools/src/ticket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@
//! Tickets the assistant writes are attributed to `jp`, and comments are
//! rendered with their 1-based positions so a reply can name the comment it
//! answers.
//!
//! `ticket_create` and `ticket_comment` also answer the format-arguments
//! action, previewing the document they are about to write in the shape it
//! takes on disk.

use std::path::MAIN_SEPARATOR;
use std::{fs, path::MAIN_SEPARATOR};

// The leading `::` picks the crate over this module, which shares its name.
use ::ticket::{Kind, ParseError, Status, Ticket, TicketId, store};
use ::ticket::{Comment, Kind, ParseError, Status, Ticket, TicketId, parse, render, store};
use camino::{Utf8Path, Utf8PathBuf};
use chrono::{Local, SecondsFormat, Utc};
use jp_md::format::Formatter;
use serde_json::Value;

use crate::{
Expand All @@ -35,21 +40,51 @@ pub fn run(ctx: Context, t: Tool) -> ToolResult {
let Ok(kind) = t.req::<String>("kind")?.parse::<Kind>() else {
return error("`kind` must be one of: bug, feature, chore.");
};
create(
root,
kind,
&t.req::<String>("title")?,
t.opt::<String>("implements")?.as_deref(),
t.opt("body")?,
)
let title = t.req::<String>("title")?;
let implements = t.opt::<String>("implements")?;
let body = t.opt::<String>("body")?;

// Checked before the action split so a preview fails too: an
// unattended formatter that errors fails the call ahead of the
// approval prompt, which tells the assistant to fix the arguments
// rather than asking the user about a call that cannot land.
if title.trim().is_empty() {
return error("`title` must not be empty.");
}

if ctx.action.is_format_arguments() {
let date = Local::now().format("%Y-%m-%d").to_string();
return Ok(preview_create(
kind,
&title,
implements.as_deref(),
body.as_deref(),
&date,
)
.into());
}

create(root, kind, &title, implements.as_deref(), body)
}

"comment" => {
let id = match id_arg(&t.req("id")?) {
Ok(id) => id,
Err(message) => return error(message),
};
comment(root, id, t.opt("re")?, &t.req::<String>("body")?)
let re = t.opt("re")?;
let body = t.req::<String>("body")?;

if body.trim().is_empty() {
return error("`body` must not be empty.");
}

if ctx.action.is_format_arguments() {
let date = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
return preview_comment(root, id, re, &body, &date);
}

comment(root, id, re, &body)
}

"close" => match id_arg(&t.req("id")?) {
Expand Down Expand Up @@ -89,10 +124,6 @@ fn create(
implements: Option<&str>,
body: Option<String>,
) -> ToolResult {
if title.trim().is_empty() {
return error("`title` must not be empty.");
}

let date = Local::now().format("%Y-%m-%d").to_string();
let (id, path) = store::create(
&dir(root),
Expand All @@ -107,11 +138,28 @@ fn create(
Ok(format!("Created {id} at {}", relative(root, &path)).into())
}

fn comment(root: &Utf8Path, id: TicketId, re: Option<usize>, body: &str) -> ToolResult {
if body.trim().is_empty() {
return error("`body` must not be empty.");
}
/// Render the ticket file `create` is about to write.
///
/// The id is left out because the file doesn't carry one: it is drawn when the
/// ticket is claimed, and the result names it.
fn preview_create(
kind: Kind,
title: &str,
implements: Option<&str>,
body: Option<&str>,
date: &str,
) -> String {
preview(&render::ticket(
title.trim(),
kind,
HANDLE,
date,
implements,
body.unwrap_or_default(),
))
}

fn comment(root: &Utf8Path, id: TicketId, re: Option<usize>, body: &str) -> ToolResult {
let date = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
match store::append_comment(&dir(root), id, HANDLE, &date, re, body.trim()) {
Ok(position) => Ok(format!("Added {id}#{position}").into()),
Expand All @@ -123,6 +171,52 @@ fn comment(root: &Utf8Path, id: TicketId, re: Option<usize>, body: &str) -> Tool
}
}

/// Render the comment block `comment` is about to append, under the heading of
/// the ticket it lands on.
///
/// Fails when the ticket or the reply target isn't there, the same two
/// conditions the append itself rejects, so a call that cannot land is answered
/// before it is put to the user.
fn preview_comment(
root: &Utf8Path,
id: TicketId,
re: Option<usize>,
body: &str,
date: &str,
) -> ToolResult {
let path = match store::locate_ticket(&dir(root), id) {
Ok(path) => path,
Err(store::Error::NoSuchTicket(_)) => return error(format!("No {id}.")),
Err(other) => return Err(other.into()),
};
let document = fs::read_to_string(path)?;

// The count comes from the same tolerant reader the append uses, so the
// two agree on a file with a hand-mangled header.
let count = parse::comment_count(&document);
if let Some(position) = re
&& (position == 0 || position > count)
{
return error(format!("No comment #{position} on {id}."));
}

// The heading names the ticket, which its own file doesn't: the id is what
// makes the preview readable next to the call that produced it.
let heading = match parse::title(&document) {
Some(title) => format!("# {id}: {title}"),
None => format!("# {id}"),
};

let comment = Comment {
from: HANDLE.to_owned(),
date: date.to_owned(),
re: re.map(|position| format!("#{position}")),
body: body.to_owned(),
};

Ok(preview(&format!("{heading}\n\n{}", render::comment(&comment))).into())
}

fn close(root: &Utf8Path, id: TicketId) -> ToolResult {
match store::close(&dir(root), id) {
Ok((_, Status::Done)) => Ok(format!("{id} was already Done.").into()),
Expand Down Expand Up @@ -185,6 +279,28 @@ fn relative(root: &Utf8Path, path: &Utf8Path) -> String {
.replace(MAIN_SEPARATOR, "/")
}

/// Style a document for the terminal as a tool-call preview.
///
/// The document is quoted first, so the transcript carries a marker down the
/// whole preview and the reader can see where the ticket ends and the
/// conversation resumes.
/// Falls back to the unstyled source if the markdown can't be formatted.
fn preview(document: &str) -> String {
let mut quoted = String::with_capacity(document.len() * 2);
for line in document.lines() {
quoted.push('>');
if !line.is_empty() {
quoted.push(' ');
quoted.push_str(line);
}
quoted.push('\n');
}

Formatter::new()
.format_terminal(&quoted)
.unwrap_or_else(|_| quoted.clone())
}

/// Render the board as one line per ticket.
fn render_list(tickets: &[(TicketId, &Ticket)], unreadable: &[String]) -> String {
let mut out = String::new();
Expand Down
Loading
Loading