diff --git a/README.md b/README.md index f13df254..374bf43b 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,34 @@ ti sync named git-meta remote is used; otherwise git-meta resolves the default metadata remote from Git config. +## GitButler + +TicGit is great on its own, but it's even better with +[GitButler](https://gitbutler.com). GitButler is a wonderful Git client that +lets you work on several branches at once in a single working directory, so you +can juggle a pile of tickets without stashing, switching, or losing your place. +Its `but` CLI is a joy to use, and TicGit is built to take advantage of it. + +If `but` is on your `PATH`, TicGit's review commands use it automatically: + +```sh +ti review new --branch --ticket +ti review show +ti review update +``` + +- Branch pickers are populated from `but branch list`, so every applied virtual + branch and stacked head shows up as a review candidate, complete with commit + counts, authors, and last-commit times. +- Review snapshots come from `but branch show`, which knows the real base and + commit range of a stacked branch instead of guessing from refs. +- GitButler's own bookkeeping refs (`gitbutler/*`) are filtered out, so the list + only ever offers branches you actually want to review. + +None of this is required. When `but` is not installed, TicGit falls back to +plain `git for-each-ref` and `git rev-list` and everything keeps working. You +just get a nicer experience with GitButler installed. + ## What It Stores All TicGit data is written on the git-meta `project` target under the diff --git a/crates/ticgit/docs/agents.md b/crates/ticgit/docs/agents.md index eaaee881..f2361b59 100644 --- a/crates/ticgit/docs/agents.md +++ b/crates/ticgit/docs/agents.md @@ -147,6 +147,11 @@ ti review new --ticket ti review update ``` +Reviews work best under [GitButler](https://gitbutler.com): when the `but` CLI +is installed, TicGit reads branch lists and commit ranges from it, so stacked +and virtual branches are resolved correctly. Without `but`, TicGit falls back to +plain Git refs. + ## Agent Practices - Use ticket IDs or unique prefixes. diff --git a/crates/ticgit/src/cli.rs b/crates/ticgit/src/cli.rs index 96826636..1d1aeaa2 100644 --- a/crates/ticgit/src/cli.rs +++ b/crates/ticgit/src/cli.rs @@ -25,6 +25,7 @@ use crate::commands; mine List tickets assigned to you history Show change history for a ticket tui Browse open tickets in an interactive terminal UI + serve Browse tickets in your web browser \x1b[1;36mWork on Tickets:\x1b[0m checkout, co Select a ticket as \"current\" @@ -123,6 +124,9 @@ pub enum Command { /// Browse open tickets in an interactive terminal UI. Tui(commands::tui::Args), + /// Serve the ticket list over HTTP for browsing in a web browser. + Serve(commands::serve::Args), + /// Print or install AI agent integration guidance. Agent(commands::agent::Args), @@ -257,6 +261,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { } Some(Command::History(args)) => commands::history::run(args), Some(Command::Tui(args)) => commands::tui::run(args), + Some(Command::Serve(args)) => commands::serve::run(args), Some(Command::Agent(args)) => commands::agent::run(args), Some(Command::Tag(args)) => commands::tag::run(args), Some(Command::State(args)) => commands::state::run(args), diff --git a/crates/ticgit/src/commands/mod.rs b/crates/ticgit/src/commands/mod.rs index 4f478100..50141c4c 100644 --- a/crates/ticgit/src/commands/mod.rs +++ b/crates/ticgit/src/commands/mod.rs @@ -23,6 +23,7 @@ pub mod priority; pub mod pull; pub mod recent; pub mod review; +pub mod serve; pub mod setup; pub mod show; pub mod spec; diff --git a/crates/ticgit/src/commands/serve/mod.rs b/crates/ticgit/src/commands/serve/mod.rs new file mode 100644 index 00000000..0388812a --- /dev/null +++ b/crates/ticgit/src/commands/serve/mod.rs @@ -0,0 +1,509 @@ +//! `ti serve` - a small read-only web view of the repo's tickets. +//! +//! Shows the same thing the TUI's issue list does (id, age, priority, +//! title, tags) plus a per-ticket detail page, served over plain HTTP +//! from a hand-rolled `std::net` listener so we pull in no web stack. +//! +//! The ticket pages live in [`tickets`] and the writeup pages in +//! [`writeups`]; this module owns the listener, the request/response +//! plumbing, and the shared page chrome both use. + +mod tickets; +mod writeups; + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use clap::Parser; +use ticgit_lib::TicketStore; +use time::OffsetDateTime; + +use crate::commands::open_store; +use crate::render::{self, NickMap}; + +/// How long a client gets to send its request line and headers. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +/// Cap on the request line + headers we're willing to read. +const MAX_HEADER_BYTES: usize = 16 * 1024; + +#[derive(Debug, Parser)] +pub struct Args { + /// Port to listen on. Use 0 to pick a free port. + #[arg(short = 'p', long = "port", default_value_t = 8177)] + pub port: u16, + + /// Address to bind. Defaults to localhost only. + #[arg(long = "bind", default_value = "127.0.0.1")] + pub bind: String, + + /// Open the served page in your browser. + #[arg(long = "open")] + pub open: bool, +} + +pub fn run(args: Args) -> Result<()> { + // Fail early (and with the usual error) if we're not in a ticgit repo. + let store = open_store()?; + drop(store); + + let listener = TcpListener::bind((args.bind.as_str(), args.port)) + .with_context(|| format!("binding {}:{}", args.bind, args.port))?; + let addr = listener.local_addr()?; + let url = format!("http://{addr}/"); + println!("ti serve: listening on {url} (ctrl-c to stop)"); + if args.open { + open_browser(&url); + } + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + if let Err(err) = handle_connection(stream) { + eprintln!("ti serve: {err:#}"); + } + } + Err(err) => eprintln!("ti serve: accept failed: {err}"), + } + } + Ok(()) +} + +fn handle_connection(mut stream: TcpStream) -> Result<()> { + let _ = stream.set_read_timeout(Some(REQUEST_TIMEOUT)); + let _ = stream.set_write_timeout(Some(REQUEST_TIMEOUT)); + + let request = match read_request(&stream)? { + Some(request) => request, + None => return Ok(()), + }; + + let response = match route(&request) { + Ok(response) => response, + Err(err) => Response::html(500, error_page("500 - server error", &format!("{err:#}"))), + }; + response.write_to(&mut stream) +} + +/// A parsed request line: everything we care about from the client. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Request { + method: String, + path: String, + params: Vec<(String, String)>, +} + +impl Request { + fn param(&self, key: &str) -> Option<&str> { + self.params + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + fn param_values(&self, key: &str) -> Vec { + self.params + .iter() + .filter(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + .collect() + } + + fn flag(&self, key: &str) -> bool { + matches!(self.param(key), Some("1" | "true" | "yes" | "")) + } +} + +fn read_request(stream: &TcpStream) -> Result> { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Ok(None); + } + // Drain headers so the client doesn't see a reset before our response. + let mut read = line.len(); + loop { + let mut header = String::new(); + let n = reader.read_line(&mut header)?; + read += n; + if n == 0 || header == "\r\n" || header == "\n" || read > MAX_HEADER_BYTES { + break; + } + } + Ok(parse_request_line(&line)) +} + +fn parse_request_line(line: &str) -> Option { + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_string(); + let target = parts.next()?; + let (path, query) = match target.split_once('?') { + Some((path, query)) => (path, query), + None => (target, ""), + }; + Some(Request { + method, + path: percent_decode(path), + params: parse_query(query), + }) +} + +fn parse_query(query: &str) -> Vec<(String, String)> { + query + .split('&') + .filter(|pair| !pair.is_empty()) + .map(|pair| match pair.split_once('=') { + Some((k, v)) => (percent_decode(k), percent_decode(v)), + None => (percent_decode(pair), String::new()), + }) + .collect() +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => match u8::from_str_radix(&value[i + 1..i + 3], 16) { + Ok(byte) => { + out.push(byte); + i += 3; + } + Err(_) => { + out.push(bytes[i]); + i += 1; + } + }, + byte => { + out.push(byte); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn percent_encode(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(*byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +// -- routing --------------------------------------------------------------- + +fn route(request: &Request) -> Result { + if request.method != "GET" && request.method != "HEAD" { + return Ok(Response::html( + 405, + error_page("405 - method not allowed", "This server only answers GET."), + )); + } + + match request.path.as_str() { + "/" => tickets::list_response(request), + "/tickets.json" => tickets::json_response(request), + "/writeups" => writeups::list_response(request), + "/writeups.json" => writeups::json_response(request), + "/favicon.ico" => Ok(Response::empty(204)), + path => { + if let Some(reference) = path.strip_prefix("/t/").filter(|r| !r.is_empty()) { + return tickets::detail_response(reference); + } + if let Some(reference) = path.strip_prefix("/w/").filter(|r| !r.is_empty()) { + return writeups::detail_response(request, reference); + } + Ok(Response::html( + 404, + error_page("404 - not found", "No page at that address."), + )) + } + } +} + +/// Per-request context shared by both pages. +struct Page { + repo: String, + current_user: String, + nicks: NickMap, + now: OffsetDateTime, +} + +impl Page { + fn new(store: &TicketStore) -> Result { + Ok(Self { + repo: repo_name(), + current_user: store.email().to_string(), + nicks: render::build_nick_map(&store.list_users().unwrap_or_default()), + now: OffsetDateTime::now_utc(), + }) + } +} + +fn repo_name() -> String { + std::env::current_dir() + .ok() + .and_then(|dir| { + dir.file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| "tickets".to_string()) +} + +// -- shared chrome --------------------------------------------------------- + +/// A jump to the other half of the site (tickets <-> writeups), set off +/// from the view tabs it sits next to. +fn section_link(href: &str, label: &str) -> String { + format!( + "{}", + escape(href), + escape(label) + ) +} + +/// Carries the active narrowing through the search form, which would +/// otherwise drop it on submit. +fn hidden_input(name: &str, value: &str) -> String { + format!( + "", + escape(name), + escape(value) + ) +} + +/// One active filter, linking to itself removed. +fn filter_chip(label: &str, href: &str) -> String { + format!( + "{} \u{d7}", + escape(href), + escape(label) + ) +} + +/// Stable per-tag colour bucket, mirroring the TUI's tag colouring. +fn tag_hue(tag: &str) -> usize { + tag.bytes().fold(0usize, |acc, byte| { + acc.wrapping_mul(31).wrapping_add(byte as usize) + }) % 8 +} + +fn error_page(title: &str, detail: &str) -> String { + document( + title, + &format!( + "
\u{2190} all tickets\ +

{}

{}
", + escape(title), + escape(detail) + ), + ) +} + +fn document(title: &str, body: &str) -> String { + format!( + "\n\ + \ + {}
{body}
\n", + escape(title) + ) +} + +const STYLE: &str = "\ +:root{color-scheme:light dark;--bg:#fff;--fg:#1c1c1e;--dim:#6b7280;--line:#e5e7eb;\ +--accent:#2563eb;--chip:#f3f4f6;--hover:#f9fafb}\ +@media(prefers-color-scheme:dark){:root{--bg:#111317;--fg:#e6e8eb;--dim:#8b93a1;--line:#262a31;\ +--accent:#7aa2f7;--chip:#1c2027;--hover:#171a20}}\ +*{box-sizing:border-box}\ +body{margin:0;background:var(--bg);color:var(--fg);\ +font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}\ +main{max-width:1100px;margin:0 auto;padding:24px 20px 60px}\ +a{color:inherit;text-decoration:none}a:hover{text-decoration:underline}\ +header{display:flex;flex-wrap:wrap;gap:12px;align-items:center;\ +padding-bottom:12px;border-bottom:1px solid var(--line);margin-bottom:16px}\ +h1{font-size:18px;margin:0;font-weight:600}\ +header nav{display:flex;gap:4px;margin-left:8px}\ +nav .view{padding:3px 10px;border-radius:999px;color:var(--dim)}\ +nav .view:hover{background:var(--hover);text-decoration:none}\ +nav .view.active{background:var(--accent);color:#fff}\ +header form{margin-left:auto}\ +input[type=search]{font:inherit;padding:5px 10px;border:1px solid var(--line);\ +border-radius:6px;background:var(--bg);color:var(--fg);min-width:200px}\ +.filters{display:flex;gap:6px;flex-wrap:wrap;margin:-4px 0 14px}\ +.chip{background:var(--chip);color:var(--dim);border-radius:999px;padding:2px 10px;font-size:12px}\ +table{width:100%;border-collapse:collapse}\ +th{text-align:left;font-weight:600;color:var(--dim);font-size:12px;\ +text-transform:uppercase;letter-spacing:.04em;padding:6px 8px;border-bottom:1px solid var(--line)}\ +th a{color:inherit}\ +td{padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}\ +tbody tr:hover{background:var(--hover)}\ +td.id a,td.age{color:var(--dim)}\ +td.prio{color:#a855f7}td.age,td.prio,td.id{white-space:nowrap}\ +td.title a{font-weight:500}\ +tr.closed td.title a{color:var(--dim);text-decoration:line-through}\ +td.who{color:var(--dim);white-space:nowrap}td.who.mine{color:#d97706;font-weight:600}\ +.children{color:var(--dim)}\ +.tag{font-size:12px;border-radius:4px;padding:1px 6px;background:var(--chip);white-space:nowrap}\ +.tag-0{color:#2563eb}.tag-1{color:#0891b2}.tag-2{color:#16a34a}.tag-3{color:#ca8a04}\ +.tag-4{color:#c026d3}.tag-5{color:#0ea5e9}.tag-6{color:#65a30d}.tag-7{color:#e11d48}\ +.badge{font-size:12px;border-radius:4px;padding:1px 6px;background:var(--chip)}\ +.state-in-progress{color:#d97706}.state-blocked{color:#dc2626}.state-review{color:#2563eb}\ +.state-resolved{color:#16a34a}.state-wontfix,.state-duplicate,.state-invalid{color:var(--dim)}\ +.count,.empty{color:var(--dim);margin-top:16px}\ +header.detail{display:block}.back{color:var(--dim);font-size:12px}\ +.subtitle{color:var(--dim);margin:6px 0 0}\ +.fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin:0 0 20px}\ +dt{color:var(--dim);font-size:12px;text-transform:uppercase;letter-spacing:.04em}\ +dd{margin:2px 0 0}\ +h2{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);margin:24px 0 8px}\ +.prose{white-space:pre-wrap;word-wrap:break-word;font:inherit;margin:0;\ +background:var(--chip);border-radius:6px;padding:12px}\ +.comment{margin-bottom:12px}.byline{color:var(--dim);font-size:12px;margin:0 0 4px}\ +nav .view.section{color:var(--accent)}\ +.links{list-style:none;margin:0;padding:0}\ +.links li{padding:5px 0;border-bottom:1px solid var(--line)}\ +.links code{color:var(--dim);margin-right:8px}\ +.versions{display:flex;gap:4px;flex-wrap:wrap;margin:0 0 10px}\ +.vtab{background:var(--chip);color:var(--dim);border-radius:6px;padding:2px 9px;font-size:12px}\ +.vtab.active{background:var(--accent);color:#fff}\ +td.vers,td.who2{color:var(--dim);white-space:nowrap}\ +.state-open{color:#16a34a}.state-closed{color:var(--dim)}"; + +fn escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +fn flatten(value: &str) -> String { + value.replace(['\n', '\r', '\t'], " ") +} + +// -- responses ------------------------------------------------------------- + +struct Response { + status: u16, + content_type: &'static str, + body: Vec, +} + +impl Response { + fn new(status: u16, content_type: &'static str, body: Vec) -> Self { + Self { + status, + content_type, + body, + } + } + + fn html(status: u16, body: String) -> Self { + Self::new(status, "text/html; charset=utf-8", body.into_bytes()) + } + + fn empty(status: u16) -> Self { + Self::new(status, "text/plain; charset=utf-8", Vec::new()) + } + + fn write_to(&self, stream: &mut TcpStream) -> Result<()> { + let head = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n\ + Cache-Control: no-store\r\nConnection: close\r\n\r\n", + self.status, + reason(self.status), + self.content_type, + self.body.len() + ); + stream.write_all(head.as_bytes())?; + stream.write_all(&self.body)?; + stream.flush()?; + Ok(()) + } +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 204 => "No Content", + 404 => "Not Found", + 405 => "Method Not Allowed", + 500 => "Internal Server Error", + _ => "OK", + } +} + +fn open_browser(url: &str) { + let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") { + ("open", &[]) + } else if cfg!(target_os = "windows") { + ("cmd", &["/C", "start", ""]) + } else { + ("xdg-open", &[]) + }; + let _ = std::process::Command::new(program) + .args(args) + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(target: &str) -> Request { + parse_request_line(&format!("GET {target} HTTP/1.1\r\n")).unwrap() + } + + #[test] + fn parses_request_line_into_path_and_params() { + let req = request("/?status=closed&tag=bug&tag=ui"); + assert_eq!(req.method, "GET"); + assert_eq!(req.path, "/"); + assert_eq!(req.param("status"), Some("closed")); + assert_eq!(req.param_values("tag"), vec!["bug", "ui"]); + } + + #[test] + fn percent_decoding_handles_escapes_and_plus() { + assert_eq!(percent_decode("a%20b+c"), "a b c"); + assert_eq!(percent_decode("caf%C3%A9"), "café"); + assert_eq!(percent_decode("100%"), "100%"); + } + + #[test] + fn percent_encode_round_trips() { + let value = "tag with spaces & ?=#"; + assert_eq!(percent_decode(&percent_encode(value)), value); + } + + #[test] + fn unknown_paths_are_404_and_non_get_is_405() { + let response = route(&request("/nope")).unwrap(); + assert_eq!(response.status, 404); + + let post = parse_request_line("POST / HTTP/1.1\r\n").unwrap(); + assert_eq!(route(&post).unwrap().status, 405); + } +} diff --git a/crates/ticgit/src/commands/serve/tickets.rs b/crates/ticgit/src/commands/serve/tickets.rs new file mode 100644 index 00000000..46edfb4a --- /dev/null +++ b/crates/ticgit/src/commands/serve/tickets.rs @@ -0,0 +1,742 @@ +//! The ticket half of `ti serve`. +//! +//! The list at `/`, a detail page at `/t/`, and `/tickets.json` for +//! scripting. Page chrome, escaping and the HTTP types all come from the +//! parent module so both halves of the site look and behave the same. + +use anyhow::Result; +use ticgit_lib::{Filter, SearchFilter, SortOrder, Ticket, TicketLifecycle, TicketStatus, Writeup}; +use time::format_description::well_known::Rfc3339; + +use super::{ + document, error_page, escape, filter_chip, flatten, hidden_input, percent_encode, section_link, + tag_hue, Page, Request, Response, +}; +use crate::commands::open_store; +use crate::render; +use crate::timefmt::relative_time; + +// -- responses ------------------------------------------------------------- + +pub(super) fn list_response(request: &Request) -> Result { + let store = open_store()?; + let query = ListQuery::from_request(request); + let tickets = ticgit_lib::query::apply(store.list()?, &query.filter()?); + let page = Page::new(&store)?; + Ok(Response::html(200, list_page(&page, &query, &tickets))) +} + +pub(super) fn json_response(request: &Request) -> Result { + let store = open_store()?; + let query = ListQuery::from_request(request); + let tickets = ticgit_lib::query::apply(store.list()?, &query.filter()?); + Ok(Response::new( + 200, + "application/json; charset=utf-8", + render::tickets_json(&tickets)?.into_bytes(), + )) +} + +pub(super) fn detail_response(reference: &str) -> Result { + let store = open_store()?; + let id = match store.resolve_id(reference) { + Ok(id) => id, + Err(err) => { + return Ok(Response::html( + 404, + error_page("404 - no such ticket", &err.to_string()), + )) + } + }; + let ticket = store.load(&id)?; + let page = Page::new(&store)?; + // Writeups point at tickets, not the other way round, so the back + // link has to come from a scan of the writeup list. + let linked: Vec = store + .list_writeups() + .unwrap_or_default() + .into_iter() + .filter(|writeup| writeup.tickets.contains(&id)) + .collect(); + Ok(Response::html(200, detail_page(&page, &ticket, &linked))) +} + +// -- query ----------------------------------------------------------------- + +/// The list filters we accept as query params. Mirrors `ti list`'s flags. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ListQuery { + status: Option, + state: Option, + tags: Vec, + assigned: Option, + search: Option, + order: Option, + all: bool, + subissues: bool, +} + +impl ListQuery { + fn from_request(request: &Request) -> Self { + let clean = |value: Option<&str>| { + value + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + Self { + status: clean(request.param("status")), + state: clean(request.param("state")), + tags: request + .param_values("tag") + .into_iter() + .filter(|tag| !tag.trim().is_empty()) + .collect(), + assigned: clean(request.param("assigned")), + search: clean(request.param("q")), + order: clean(request.param("order")), + all: request.flag("all"), + subissues: request.flag("subissues"), + } + } + + /// Translate into a `ticgit-lib` filter, defaulting to open tickets + /// the way `ti list` and the TUI's Default view do. + fn filter(&self) -> Result { + let mut status = match self.status.as_deref() { + Some("all") => None, + Some(spec) => Some(TicketStatus::parse(spec)?), + None if self.all || self.state.is_some() => None, + None => Some(TicketStatus::Open), + }; + let mut state = None; + if let Some(spec) = self.state.as_deref() { + let lifecycle = TicketLifecycle::parse(spec)?; + status = Some(lifecycle.status); + if TicketStatus::parse(spec).is_err() { + state = Some(lifecycle.state); + } + } + let order = match self.order.as_deref() { + Some(spec) => Some( + SortOrder::parse(spec) + .ok_or_else(|| anyhow::anyhow!("unknown sort order `{spec}`"))?, + ), + None => None, + }; + let search = match self.search.as_deref() { + Some(spec) => Some(SearchFilter::parse(spec).map_err(|e| anyhow::anyhow!(e))?), + None => None, + }; + Ok(Filter { + status, + state, + tag: self.tags.first().cloned(), + tags: self.tags.clone(), + tag_match_all: true, + assigned: self.assigned.clone(), + only_tagged: false, + search, + order, + hide_subissues: !(self.subissues || self.all), + }) + } + + /// Rebuild the query string, optionally replacing the sort order. + fn href(&self, order: Option<&str>) -> String { + let mut pairs: Vec<(&str, String)> = Vec::new(); + if let Some(status) = &self.status { + pairs.push(("status", status.clone())); + } + if let Some(state) = &self.state { + pairs.push(("state", state.clone())); + } + for tag in &self.tags { + pairs.push(("tag", tag.clone())); + } + if let Some(assigned) = &self.assigned { + pairs.push(("assigned", assigned.clone())); + } + if let Some(search) = &self.search { + pairs.push(("q", search.clone())); + } + if self.all { + pairs.push(("all", "1".to_string())); + } + if self.subissues { + pairs.push(("subissues", "1".to_string())); + } + let order = match order { + Some(order) => Some(order.to_string()), + None => self.order.clone(), + }; + if let Some(order) = order { + pairs.push(("order", order)); + } + if pairs.is_empty() { + return "/".to_string(); + } + let query = pairs + .iter() + .map(|(key, value)| format!("{key}={}", percent_encode(value))) + .collect::>() + .join("&"); + format!("/?{query}") + } + + /// Toggle direction when re-sorting by the column already in use. + fn order_href(&self, key: &str) -> String { + let next = match self.order.as_deref() { + Some(current) if current == key => format!("{key}.desc"), + Some(current) if current == format!("{key}.desc") => key.to_string(), + _ => key.to_string(), + }; + self.href(Some(&next)) + } + + fn order_marker(&self, key: &str) -> &'static str { + match self.order.as_deref() { + Some(current) if current == key => " \u{2191}", + Some(current) if current == format!("{key}.desc") => " \u{2193}", + _ => "", + } + } + + /// True when the list can contain closed tickets, in which case we + /// show a state column (the TUI's closed views do the same). + fn shows_closed(&self) -> bool { + self.all + || self.status.as_deref() != Some("open") && self.status.is_some() + || self.state.is_some() + } +} + +// -- HTML ------------------------------------------------------------------ + +fn list_page(page: &Page, query: &ListQuery, tickets: &[Ticket]) -> String { + let mut body = String::new(); + body.push_str(&header(page, query)); + + if tickets.is_empty() { + body.push_str("

No tickets match this view.

"); + } else { + let show_state = query.shows_closed(); + body.push_str(""); + body.push_str(&format!( + "\ + ", + escape(&query.order_href("created")), + query.order_marker("created"), + escape(&query.order_href("priority")), + query.order_marker("priority"), + )); + if show_state { + body.push_str(&format!( + "", + escape(&query.order_href("state")), + query.order_marker("state"), + )); + } + body.push_str(&format!( + "\ + ", + escape(&query.order_href("title")), + query.order_marker("title"), + escape(&query.order_href("assigned")), + query.order_marker("assigned"), + )); + body.push_str(""); + + for ticket in tickets { + body.push_str(&row(page, query, ticket, show_state)); + } + body.push_str("
IdAge{}P{}State{}Title{}Assigned{}Tags
"); + } + + body.push_str(&format!( + "

{} ticket{} \u{b7} JSON

", + tickets.len(), + if tickets.len() == 1 { "" } else { "s" }, + escape(&query.href(None).replacen('/', "/tickets.json", 1)), + )); + document(&format!("{} tickets", page.repo), &body) +} + +fn row(page: &Page, query: &ListQuery, ticket: &Ticket, show_state: bool) -> String { + let assigned = ticket + .assigned + .as_deref() + .map(|email| render::display_name(email, Some(&page.nicks))) + .unwrap_or_default(); + let mine = ticket.assigned.as_deref() == Some(page.current_user.as_str()); + let priority = ticket + .priority + .map(|priority| format!("p{priority}")) + .unwrap_or_default(); + let children = if ticket.children.is_empty() { + String::new() + } else { + format!( + " [+{}]", + ticket.children.len() + ) + }; + + let mut out = format!( + "{}\ + {}{}", + if ticket.status == TicketStatus::Closed { + "closed" + } else { + "open" + }, + escape(&ticket.short_id()), + escape(&ticket.short_id()), + escape(&relative_time(ticket.created_at, page.now)), + escape(&priority), + ); + if show_state { + out.push_str(&format!( + "{}", + escape(ticket.state.as_str()), + escape(ticket.state.as_str()), + )); + } + out.push_str(&format!( + "{}{}\ + {}{}", + escape(&ticket.short_id()), + escape(&flatten(&ticket.title)), + children, + if mine { " mine" } else { "" }, + escape(&assigned), + tag_chips(query, ticket), + )); + out +} + +fn tag_chips(query: &ListQuery, ticket: &Ticket) -> String { + ticket + .tags + .iter() + .map(|tag| { + let mut scoped = query.clone(); + if !scoped.tags.contains(tag) { + scoped.tags.push(tag.clone()); + } + format!( + "{}", + tag_hue(tag), + escape(&scoped.href(None)), + escape(tag) + ) + }) + .collect::>() + .join(" ") +} + +fn header(page: &Page, query: &ListQuery) -> String { + let views: [(&str, String); 4] = [ + ("Open", ListQuery::default().href(None)), + ( + "Mine", + ListQuery { + assigned: Some(page.current_user.clone()), + ..Default::default() + } + .href(None), + ), + ( + "Closed", + ListQuery { + status: Some("closed".to_string()), + order: Some("created.desc".to_string()), + ..Default::default() + } + .href(None), + ), + ( + "All", + ListQuery { + all: true, + subissues: true, + ..Default::default() + } + .href(None), + ), + ]; + let current = query.href(None); + let mut nav = views + .iter() + .map(|(label, href)| { + format!( + "{label}", + if *href == current { " active" } else { "" }, + escape(href) + ) + }) + .collect::>() + .join(""); + nav.push_str(§ion_link("/writeups", "Writeups")); + + let mut hidden = String::new(); + if let Some(status) = &query.status { + hidden.push_str(&hidden_input("status", status)); + } + if let Some(state) = &query.state { + hidden.push_str(&hidden_input("state", state)); + } + for tag in &query.tags { + hidden.push_str(&hidden_input("tag", tag)); + } + if let Some(assigned) = &query.assigned { + hidden.push_str(&hidden_input("assigned", assigned)); + } + if query.all { + hidden.push_str(&hidden_input("all", "1")); + } + if query.subissues { + hidden.push_str(&hidden_input("subissues", "1")); + } + + format!( + "

{}

\ +
{hidden}\ +
{}", + escape(&page.repo), + escape(query.search.as_deref().unwrap_or_default()), + active_filters(query), + ) +} + +/// Chips for whatever narrowing is active, each linking to itself removed. +fn active_filters(query: &ListQuery) -> String { + let mut chips: Vec = Vec::new(); + for tag in &query.tags { + let mut without = query.clone(); + without.tags.retain(|t| t != tag); + chips.push(filter_chip(&format!("tag:{tag}"), &without.href(None))); + } + if let Some(assigned) = &query.assigned { + let mut without = query.clone(); + without.assigned = None; + chips.push(filter_chip( + &format!("assigned:{assigned}"), + &without.href(None), + )); + } + if let Some(search) = &query.search { + let mut without = query.clone(); + without.search = None; + chips.push(filter_chip( + &format!("search:{search}"), + &without.href(None), + )); + } + if chips.is_empty() { + return String::new(); + } + format!("
{}
", chips.join("")) +} + +fn detail_page(page: &Page, ticket: &Ticket, linked_writeups: &[Writeup]) -> String { + let mut body = String::new(); + body.push_str(&format!( + "
\u{2190} all tickets\ +

{}

{} \ + {} \u{b7} opened {} ago by {}

", + escape(&ticket.title), + escape(ticket.state.as_str()), + escape(ticket.state.as_str()), + escape(&ticket.short_id()), + escape(&relative_time(ticket.created_at, page.now)), + escape(&render::display_name(&ticket.created_by, Some(&page.nicks))), + )); + + let mut fields: Vec<(&str, String)> = Vec::new(); + fields.push(("Status", ticket.status.as_str().to_string())); + if let Some(assigned) = &ticket.assigned { + fields.push(( + "Assigned", + render::display_name(assigned, Some(&page.nicks)), + )); + } + if let Some(priority) = ticket.priority { + fields.push(("Priority", priority.to_string())); + } + if let Some(points) = ticket.points { + fields.push(("Points", points.to_string())); + } + if let Some(milestone) = &ticket.milestone { + fields.push(("Milestone", milestone.clone())); + } + if let Some(code) = &ticket.code { + fields.push(("Code", code.clone())); + } + if !ticket.tags.is_empty() { + fields.push(( + "Tags", + ticket.tags.iter().cloned().collect::>().join(", "), + )); + } + if let Some(parent) = ticket.parent { + fields.push(("Parent", short_uuid(&parent))); + } + if !ticket.children.is_empty() { + fields.push(("Sub-issues", join_uuids(&ticket.children))); + } + if !ticket.depends_on.is_empty() { + fields.push(("Depends on", join_uuids(&ticket.depends_on))); + } + if !ticket.blocks.is_empty() { + fields.push(("Blocks", join_uuids(&ticket.blocks))); + } + fields.push(( + "Created", + ticket + .created_at + .format(&Rfc3339) + .unwrap_or_else(|_| ticket.created_at.to_string()), + )); + for (key, value) in &ticket.meta { + fields.push((key.as_str(), value.clone())); + } + + body.push_str("
"); + for (label, value) in fields { + body.push_str(&format!( + "
{}
{}
", + escape(label), + escape(&value) + )); + } + body.push_str("
"); + + if let Some(description) = ticket + .description + .as_deref() + .filter(|d| !d.trim().is_empty()) + { + body.push_str(&format!( + "

Description

{}
", + escape(description) + )); + } + if let Some(spec) = ticket.spec.as_deref().filter(|s| !s.trim().is_empty()) { + body.push_str(&format!( + "

Spec

{}
", + escape(spec) + )); + } + if !linked_writeups.is_empty() { + body.push_str(&format!( + "

Writeups ({})

    ", + linked_writeups.len() + )); + for writeup in linked_writeups { + body.push_str(&format!( + "
  • {} {}
  • ", + escape(&writeup.short_id()), + escape(&writeup.short_id()), + escape(&flatten(&writeup.title)), + )); + } + body.push_str("
"); + } + + if !ticket.comments.is_empty() { + body.push_str(&format!( + "

Comments ({})

", + ticket.comments.len() + )); + for comment in &ticket.comments { + body.push_str(&format!( + "

{} \u{b7} {} ago

\ +
{}
", + escape(&render::display_name(&comment.author, Some(&page.nicks))), + escape(&relative_time(comment.at, page.now)), + escape(&comment.body), + )); + } + body.push_str("
"); + } + + document( + &format!("{} \u{b7} {}", ticket.short_id(), ticket.title), + &body, + ) +} + +fn short_uuid(id: &uuid::Uuid) -> String { + id.to_string().chars().take(6).collect() +} + +fn join_uuids(ids: &std::collections::BTreeSet) -> String { + ids.iter().map(short_uuid).collect::>().join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::NickMap; + use std::collections::{BTreeMap, BTreeSet}; + use ticgit_lib::TicketState; + use time::OffsetDateTime; + use uuid::Uuid; + + fn ticket(id: &str, title: &str, state: TicketState) -> Ticket { + Ticket { + id: Uuid::parse_str(id).unwrap(), + title: title.to_string(), + description: None, + spec: None, + status: state.status(), + state, + assigned: None, + closed_by: None, + priority: None, + points: None, + milestone: None, + code: None, + parent: None, + children: BTreeSet::new(), + depends_on: BTreeSet::new(), + blocks: BTreeSet::new(), + tags: BTreeSet::new(), + meta: BTreeMap::new(), + comments: vec![], + created_at: OffsetDateTime::UNIX_EPOCH, + created_by: "tester@example.com".into(), + } + } + + fn page() -> Page { + Page { + repo: "ticgit".to_string(), + current_user: "tester@example.com".to_string(), + nicks: NickMap::new(), + now: OffsetDateTime::UNIX_EPOCH, + } + } + + fn request(target: &str) -> Request { + super::super::parse_request_line(&format!("GET {target} HTTP/1.1\r\n")).unwrap() + } + + #[test] + fn query_defaults_to_open_tickets_without_subissues() { + let filter = ListQuery::from_request(&request("/")).filter().unwrap(); + assert_eq!(filter.status, Some(TicketStatus::Open)); + assert!(filter.hide_subissues); + } + + #[test] + fn query_all_clears_status_and_shows_subissues() { + let filter = ListQuery::from_request(&request("/?all=1&subissues=1")) + .filter() + .unwrap(); + assert_eq!(filter.status, None); + assert!(!filter.hide_subissues); + } + + #[test] + fn query_state_narrows_status_and_state() { + let filter = ListQuery::from_request(&request("/?state=blocked")) + .filter() + .unwrap(); + assert_eq!(filter.status, Some(TicketStatus::Open)); + assert_eq!(filter.state, Some(TicketState::Blocked)); + } + + #[test] + fn query_rejects_unknown_status() { + assert!(ListQuery::from_request(&request("/?status=frob")) + .filter() + .is_err()); + } + + #[test] + fn href_round_trips_through_the_request_parser() { + let query = ListQuery::from_request(&request("/?tag=bug&q=parser+bug&order=priority")); + let reparsed = ListQuery::from_request(&request(&query.href(None))); + assert_eq!(query, reparsed); + } + + #[test] + fn order_href_toggles_direction_for_the_active_column() { + let query = ListQuery::from_request(&request("/?order=priority")); + assert!(query.order_href("priority").contains("order=priority.desc")); + let desc = ListQuery::from_request(&request("/?order=priority.desc")); + assert!(desc.order_href("priority").ends_with("order=priority")); + } + + #[test] + fn list_page_renders_rows_and_links_to_detail() { + let mut open = ticket( + "d7f2d8f6-d6ec-3da1-a180-0a33fb090d59", + "fix parser", + TicketState::New, + ); + open.priority = Some(2); + open.tags.insert("bug".to_string()); + let html = list_page(&page(), &ListQuery::default(), &[open]); + assert!(html.contains("href=\"/t/d7f2d8\"")); + assert!(html.contains("fix parser")); + assert!(html.contains("p2")); + assert!(html.contains(">bug")); + assert!(html.contains("1 ticket ")); + } + + #[test] + fn list_page_shows_state_column_only_when_closed_tickets_can_appear() { + let t = ticket( + "d7f2d8f6-d6ec-3da1-a180-0a33fb090d59", + "x", + TicketState::New, + ); + let open_view = list_page(&page(), &ListQuery::default(), std::slice::from_ref(&t)); + assert!(!open_view.contains("class=\"state\"")); + + let all = ListQuery { + all: true, + ..Default::default() + }; + assert!(list_page(&page(), &all, &[t]).contains("class=\"state\"")); + } + + #[test] + fn html_is_escaped_in_titles_and_tags() { + let mut t = ticket( + "d7f2d8f6-d6ec-3da1-a180-0a33fb090d59", + "", + TicketState::New, + ); + t.tags.insert("a\"b".to_string()); + let html = list_page(&page(), &ListQuery::default(), &[t]); + assert!(!html.contains("", + WriteupStatus::Open, + ); + w.tags.insert("a\"b".to_string()); + let html = list_page(&page(), &WriteupQuery::default(), &[w]); + assert!(!html.contains("