From 89cf377e73a6b4b8808cf18c92d0bc38e6fcee91 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:11:40 +0200 Subject: [PATCH] feat(panel): browse GitHub issues and pull requests in the right panel The GitHub tab follows the focused pane's repository: its root is resolved the way the Source Control tab does, its remotes are read through the Host (the tree may be on another machine), and the github.com remote is bound, upstream over origin in a fork, with a menu to pick another. The list switches between issues and pull requests, open and closed, 50 rows a page with Load more; rows carry a state glyph distinct by shape, labels (click one to filter by it) and relative times. A row opens the detail in place: title, state, author, labels, description and comments as Markdown, and for a pull request its branches, size and changed files. A file opens in the diff overlay through a new supplied-patch DiffSource, so GitHub's patch renders exactly like a local one without a git probe. Read-only, and sign-in reuses the GitHub CLI: GH_TOKEN, GITHUB_TOKEN, then `gh auth token`, found on PATH or at the Homebrew locations a Finder launch cannot see. Signed out, public repositories still work; 401, 403, 404 and rate limits are told apart and explained. Requests go out from this machine over the installer's ureq stack and proxy settings, on threads of their own, cached per repository with background revalidation. Remote images in issue text become links instead of loading, and non-web link targets are disarmed. The Info tab gains a GitHub row that opens the branch on the remote it tracks, or the repository for a branch never pushed. --- CHANGELOG.md | 15 + Cargo.toml | 5 +- assets/icons/github/issue-closed.svg | 1 + assets/icons/github/issue-not-planned.svg | 1 + assets/icons/github/issue-open.svg | 1 + assets/icons/github/pr-closed.svg | 1 + assets/icons/github/pr-draft.svg | 1 + assets/icons/github/pr-merged.svg | 1 + assets/icons/github/pr-open.svg | 1 + crates/tty7-core/Cargo.toml | 4 + crates/tty7-core/src/core/git/diff.rs | 18 + crates/tty7-core/src/core/git/log.rs | 2 +- crates/tty7-core/src/core/github/api.rs | 628 +++++++++++++ crates/tty7-core/src/core/github/http.rs | 87 ++ crates/tty7-core/src/core/github/markdown.rs | 405 +++++++++ crates/tty7-core/src/core/github/mod.rs | 20 + crates/tty7-core/src/core/github/model.rs | 525 +++++++++++ crates/tty7-core/src/core/github/remote.rs | 393 ++++++++ crates/tty7-core/src/core/github/token.rs | 292 ++++++ crates/tty7-core/src/core/mod.rs | 1 + docs/reference/privacy.mdx | 10 +- docs/window/side-panel.mdx | 44 + src/ui/app.rs | 2 + src/ui/assets.rs | 12 + src/ui/diff_overlay.rs | 73 +- src/ui/github/detail.rs | 556 ++++++++++++ src/ui/github/mod.rs | 508 +++++++++++ src/ui/i18n/en.rs | 47 + src/ui/i18n/ja.rs | 51 ++ src/ui/i18n/mod.rs | 36 + src/ui/i18n/zh.rs | 43 + src/ui/mod.rs | 1 + src/ui/panel_github.rs | 902 ++++++++++++++++++- src/ui/right_panel.rs | 36 + src/ui/scm/panel.rs | 6 +- 35 files changed, 4714 insertions(+), 15 deletions(-) create mode 100644 assets/icons/github/issue-closed.svg create mode 100644 assets/icons/github/issue-not-planned.svg create mode 100644 assets/icons/github/issue-open.svg create mode 100644 assets/icons/github/pr-closed.svg create mode 100644 assets/icons/github/pr-draft.svg create mode 100644 assets/icons/github/pr-merged.svg create mode 100644 assets/icons/github/pr-open.svg create mode 100644 crates/tty7-core/src/core/github/api.rs create mode 100644 crates/tty7-core/src/core/github/http.rs create mode 100644 crates/tty7-core/src/core/github/markdown.rs create mode 100644 crates/tty7-core/src/core/github/mod.rs create mode 100644 crates/tty7-core/src/core/github/model.rs create mode 100644 crates/tty7-core/src/core/github/remote.rs create mode 100644 crates/tty7-core/src/core/github/token.rs create mode 100644 src/ui/github/detail.rs create mode 100644 src/ui/github/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a090ed1b..e2ff1bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A GitHub tab in the right panel: issues and pull requests, read-only.** It + follows the focused pane's repository, binds to its `github.com` remote + (`upstream` over `origin` in a fork, with a menu to pick another), and lists + open or closed issues or pull requests with state glyphs, labels and + relative times; a label click filters by it. A row opens the detail — + description and comments as Markdown, and for a pull request its branches, + size and changed files, each of which opens its patch in the diff overlay. + Sign-in reuses the GitHub CLI (`GH_TOKEN`, `GITHUB_TOKEN`, then + `gh auth token`, found even from a Finder launch); public repositories work + signed out, and a private repository or a spent rate limit says to run + `gh auth login`. Remote images in issue text are shown as links rather than + loaded, and non-web link targets are disabled. The Info tab gains a GitHub + row that opens the branch you are on. The tab talks to `api.github.com` only + while you use it. + - **Reorder the active tab from the keyboard** (`MoveTabLeft` / `MoveTabRight`). The tab moves one slot past its neighbour — the keyboard form of dragging it in the tab strip or the sidebar — and wraps past either end, so one held key diff --git a/Cargo.toml b/Cargo.toml index 5bdd028f..764a826b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,10 @@ required-features = ["updater"] # client used only to *download* a `tty7-server` onto a remote machine # (decision D5). The server binary is the thing being downloaded, so it can # never take that path; the GUI, which is the client that pushes it, can. -tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install"] } +# +# `github` is the right panel's GitHub tab: the same HTTPS client, pointed at +# api.github.com. +tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install", "github"] } gpui = { workspace = true } gpui_platform = { workspace = true } diff --git a/assets/icons/github/issue-closed.svg b/assets/icons/github/issue-closed.svg new file mode 100644 index 00000000..8c5c93d0 --- /dev/null +++ b/assets/icons/github/issue-closed.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/issue-not-planned.svg b/assets/icons/github/issue-not-planned.svg new file mode 100644 index 00000000..ed1922b6 --- /dev/null +++ b/assets/icons/github/issue-not-planned.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/issue-open.svg b/assets/icons/github/issue-open.svg new file mode 100644 index 00000000..e1eeed09 --- /dev/null +++ b/assets/icons/github/issue-open.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/pr-closed.svg b/assets/icons/github/pr-closed.svg new file mode 100644 index 00000000..be486a3a --- /dev/null +++ b/assets/icons/github/pr-closed.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/pr-draft.svg b/assets/icons/github/pr-draft.svg new file mode 100644 index 00000000..0f9675f9 --- /dev/null +++ b/assets/icons/github/pr-draft.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/pr-merged.svg b/assets/icons/github/pr-merged.svg new file mode 100644 index 00000000..b36e133f --- /dev/null +++ b/assets/icons/github/pr-merged.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/pr-open.svg b/assets/icons/github/pr-open.svg new file mode 100644 index 00000000..1fcf81d1 --- /dev/null +++ b/assets/icons/github/pr-open.svg @@ -0,0 +1 @@ + diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index a016ab0c..16811655 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -222,6 +222,10 @@ remote-install = [ "dep:system-configuration", "dep:system-configuration-sys", ] +# The GitHub tab's REST client (`core::github::http`). It rides the same HTTPS +# stack and system-proxy resolution as the installer, so it is that feature +# plus a name of its own: the server, which never reads GitHub, builds neither. +github = ["remote-install"] [lints] workspace = true diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs index 552cfa90..b36c4dda 100644 --- a/crates/tty7-core/src/core/git/diff.rs +++ b/crates/tty7-core/src/core/git/diff.rs @@ -66,6 +66,14 @@ pub enum DiffSource { }, /// `base...head`: what `head` added since the two diverged. Range { base: String, head: String }, + /// A patch handed over whole rather than read from git — a GitHub pull + /// request's files. `id` is its identity (`owner/repo#12`); `label` rides + /// along the way a commit's does. Never probed: whoever opens one installs + /// the snapshot with it, and nothing can make it stale. + Patch { + id: String, + label: Option, + }, } impl PartialEq for DiffSource { @@ -109,6 +117,7 @@ impl DiffSource { // US, which can occur in neither a refname nor an object id. DiffSource::Commit { rev, .. } => format!("commit\u{1f}{rev}"), DiffSource::Range { base, head } => format!("range\u{1f}{base}\u{1f}{head}"), + DiffSource::Patch { id, .. } => format!("patch\u{1f}{id}"), } } @@ -143,6 +152,9 @@ impl DiffSource { argv.push("diff".to_string()); argv.push(format!("{base}...{head}")); } + // Not git's to produce. `probe_diff` refuses the source before an + // argv is built; this arm only keeps the match total. + DiffSource::Patch { .. } => argv.push("diff".to_string()), } argv.extend(strings(&[ "--no-color", @@ -157,6 +169,11 @@ impl DiffSource { argv } + /// Whether this patch is supplied from outside rather than read from git. + pub fn is_supplied(&self) -> bool { + matches!(self, DiffSource::Patch { .. }) + } + /// Whether untracked files belong in the snapshot. They are a property of /// the working tree, so a commit or a range has none, and a staged diff /// does not either — an untracked file is by definition not in the index. @@ -178,6 +195,7 @@ impl DiffSource { DiffSource::Worktree | DiffSource::Staged | DiffSource::Head => true, DiffSource::Commit { rev, .. } => ok(rev), DiffSource::Range { base, head } => ok(base) && ok(head), + DiffSource::Patch { .. } => false, } } } diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs index ad3251f9..9d0d1b42 100644 --- a/crates/tty7-core/src/core/git/log.rs +++ b/crates/tty7-core/src/core/git/log.rs @@ -1030,7 +1030,7 @@ fn rev(host: &dyn Host, root: &Path, spec: &str) -> Option { /// Hand-rolled because the workspace carries neither `chrono` nor `time`, and /// thirty lines of arithmetic is a poor reason to add a dependency tree to a /// crate the headless server also builds. -fn parse_iso8601(text: &str) -> Option { +pub(crate) fn parse_iso8601(text: &str) -> Option { let b = text.as_bytes(); if !text.is_ascii() || b.len() < 19 { return None; diff --git a/crates/tty7-core/src/core/github/api.rs b/crates/tty7-core/src/core/github/api.rs new file mode 100644 index 00000000..f328e590 --- /dev/null +++ b/crates/tty7-core/src/core/github/api.rs @@ -0,0 +1,628 @@ +//! The REST calls the panel makes, over an injected [`Transport`]. +//! +//! Everything here is request shaping and response decoding; the bytes move +//! through a `Transport`, which the GUI backs with HTTPS +//! ([`super::http::HttpTransport`]) and tests back with fixtures. No test in +//! this crate touches the network. + +use serde::de::DeserializeOwned; + +use super::model::{ + Comment, Detail, Item, Kind, PrFile, RawComment, RawFile, RawIssue, RawPull, StateFilter, +}; +use super::remote::RepoSlug; + +/// Rows per page. GitHub's maximum is 100; 50 keeps the first answer quick on +/// a slow link while still filling a tall panel. +pub const PAGE_SIZE: u32 = 50; + +/// Comments and files fetched with a detail view. One page each: past that the +/// view says there is more and links to GitHub. +pub const DETAIL_PAGE: u32 = 100; + +/// Why a call failed, in the terms the panel explains to the user. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApiError { + /// 401: a token was sent and GitHub refused it (revoked, expired). + Unauthorized, + /// 404: no such repository — or a private one this request cannot see, + /// which GitHub deliberately does not distinguish. + NotFound, + /// The rate limit is spent. `reset` is when it refills, unix seconds. + RateLimited { reset: Option }, + /// 403 for another reason (SSO enforcement, a blocked token), with + /// GitHub's own message. + Forbidden(String), + /// The request never got an HTTP answer. + Network(String), + /// Any other non-success status. + Http(u16), + /// A 2xx whose body did not decode. + Decode(String), +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiError::Unauthorized => f.write_str("GitHub rejected the token (401)"), + ApiError::NotFound => f.write_str("not found (404)"), + ApiError::RateLimited { reset } => match reset { + Some(at) => write!(f, "rate limited until {at}"), + None => f.write_str("rate limited"), + }, + ApiError::Forbidden(msg) => write!(f, "forbidden (403): {msg}"), + ApiError::Network(msg) => write!(f, "network error: {msg}"), + ApiError::Http(code) => write!(f, "HTTP {code}"), + ApiError::Decode(msg) => write!(f, "unexpected response: {msg}"), + } + } +} + +/// A successful answer: the body, and whether the `Link` header offered a +/// next page. +#[derive(Clone, Debug, Default)] +pub struct Reply { + pub body: Vec, + pub has_next: bool, +} + +/// One authenticated-or-not GET against `https://api.github.com`. +pub trait Transport: Send + Sync { + /// `path` starts with `/` and includes the query string. + fn get(&self, path: &str) -> Result; + /// Whether requests carry a token. Decides how a 404 or a rate limit is + /// explained: to a signed-out user, both usually mean "sign in". + fn authenticated(&self) -> bool; +} + +/// Map a response's status and headers to success or an [`ApiError`]. +/// +/// `header` looks a response header up by lower-case name. `body` is only read +/// for GitHub's `message`, which tells a secondary rate limit apart from any +/// other 403. +pub fn classify( + status: u16, + header: &dyn Fn(&str) -> Option, + body: &[u8], +) -> Result<(), ApiError> { + if (200..300).contains(&status) { + return Ok(()); + } + let message = || { + serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("message")?.as_str().map(str::to_string)) + .unwrap_or_default() + }; + let reset = || { + header("x-ratelimit-reset") + .and_then(|v| v.trim().parse::().ok()) + .or_else(|| { + let after = header("retry-after")?.trim().parse::().ok()?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs() as i64; + Some(now + after) + }) + }; + match status { + 401 => Err(ApiError::Unauthorized), + 404 => Err(ApiError::NotFound), + 429 => Err(ApiError::RateLimited { reset: reset() }), + 403 => { + let spent = header("x-ratelimit-remaining").is_some_and(|v| v.trim() == "0"); + let msg = message(); + if spent + || header("retry-after").is_some() + || msg.to_ascii_lowercase().contains("rate limit") + { + Err(ApiError::RateLimited { reset: reset() }) + } else { + Err(ApiError::Forbidden(msg)) + } + } + code => Err(ApiError::Http(code)), + } +} + +/// Whether a `Link` header names a `rel="next"` page. +pub fn link_has_next(link: &str) -> bool { + link.split(',').any(|part| { + part.split(';') + .skip(1) + .any(|p| p.trim().eq_ignore_ascii_case("rel=\"next\"")) + }) +} + +/// What a list shows: which repository, which kind, which state, which label. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ListQuery { + pub slug: RepoSlug, + pub kind: Kind, + pub state: StateFilter, + pub label: Option, +} + +/// One page of a list, and the page after it when there is one. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ListPage { + pub items: Vec, + pub next_page: Option, +} + +/// Which endpoint answers a list query. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Endpoint { + /// `/issues`, keeping only the entries whose kind matches. + Issues { want_prs: bool }, + /// `/pulls`, which can say draft and merged but cannot filter by label. + Pulls, +} + +fn repo_path(slug: &RepoSlug) -> String { + format!( + "/repos/{}/{}", + super::remote::escape_path(&slug.owner), + super::remote::escape_path(&slug.name) + ) +} + +fn list_request(q: &ListQuery, page: u32) -> (String, Endpoint) { + let base = repo_path(&q.slug); + let common = format!( + "state={}&sort=updated&direction=desc&per_page={PAGE_SIZE}&page={page}", + q.state.as_query() + ); + let label = q.label.as_deref().filter(|l| !l.is_empty()); + match (q.kind, label) { + (Kind::Pulls, None) => (format!("{base}/pulls?{common}"), Endpoint::Pulls), + (kind, label) => { + let mut path = format!("{base}/issues?{common}"); + if let Some(label) = label { + path.push_str("&labels="); + path.push_str(&escape_query(label)); + } + ( + path, + Endpoint::Issues { + want_prs: kind == Kind::Pulls, + }, + ) + } + } +} + +fn escape_query(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn decode(reply: &Reply) -> Result { + serde_json::from_slice(&reply.body).map_err(|e| ApiError::Decode(e.to_string())) +} + +/// Page `page` (1-based) of a list. +/// +/// `/issues` interleaves pull requests with issues, so a page of it filtered +/// down to one kind can be short — even empty — while later pages still hold +/// matches. `next_page` follows GitHub's `Link` header, not the row count, so +/// "load more" stays offered for exactly as long as there is more to load. +pub fn list(t: &dyn Transport, q: &ListQuery, page: u32) -> Result { + let page = page.max(1); + let (path, endpoint) = list_request(q, page); + let reply = t.get(&path)?; + let items = match endpoint { + Endpoint::Pulls => decode::>(&reply)? + .into_iter() + .map(|p| p.into_item().0) + .collect(), + Endpoint::Issues { want_prs } => decode::>(&reply)? + .into_iter() + .filter(|i| i.is_pr() == want_prs) + .map(RawIssue::into_item) + .collect(), + }; + Ok(ListPage { + items, + next_page: reply.has_next.then_some(page + 1), + }) +} + +/// One issue or pull request with its body and conversation — and, for a pull +/// request, its branches, size and changed files. +pub fn detail(t: &dyn Transport, slug: &RepoSlug, number: u64) -> Result { + let base = repo_path(slug); + let issue: RawIssue = decode(&t.get(&format!("{base}/issues/{number}"))?)?; + let is_pr = issue.is_pr(); + let body = issue.body.clone().unwrap_or_default(); + let mut item = issue.into_item(); + + let reply = t.get(&format!( + "{base}/issues/{number}/comments?per_page={DETAIL_PAGE}" + ))?; + let comments: Vec = decode::>(&reply)? + .into_iter() + .map(RawComment::into_comment) + .collect(); + let comments_truncated = reply.has_next; + + let (mut pull, mut files, mut files_truncated) = (None, None, false); + if is_pr { + let (pr_item, info) = + decode::(&t.get(&format!("{base}/pulls/{number}"))?)?.into_item(); + // `/pulls/{n}` is the one that knows about drafts; the issue's + // labels and comment count are kept, which `/pulls` omits. + item.state = pr_item.state; + pull = Some(info); + let reply = t.get(&format!( + "{base}/pulls/{number}/files?per_page={DETAIL_PAGE}" + ))?; + files = Some( + decode::>(&reply)? + .into_iter() + .map(RawFile::into_file) + .collect::>(), + ); + files_truncated = reply.has_next; + } + + Ok(Detail { + item, + body, + comments, + comments_truncated, + pull, + files, + files_truncated, + }) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::core::git::diff::FileStatus; + use crate::core::github::model::ItemState; + use std::collections::HashMap; + use std::sync::Mutex; + + /// A transport that answers from a path → reply table and records every + /// path asked for. Paths it has no answer for are a 404. + pub(crate) struct Fixture { + pub(crate) replies: HashMap>, + pub(crate) asked: Mutex>, + pub(crate) authenticated: bool, + } + + impl Fixture { + pub(crate) fn new() -> Fixture { + Fixture { + replies: HashMap::new(), + asked: Mutex::new(Vec::new()), + authenticated: true, + } + } + + pub(crate) fn on(&mut self, path: &str, body: &str, has_next: bool) { + self.replies.insert( + path.to_string(), + Ok(Reply { + body: body.as_bytes().to_vec(), + has_next, + }), + ); + } + } + + impl Transport for Fixture { + fn get(&self, path: &str) -> Result { + self.asked.lock().unwrap().push(path.to_string()); + self.replies + .get(path) + .cloned() + .unwrap_or(Err(ApiError::NotFound)) + } + + fn authenticated(&self) -> bool { + self.authenticated + } + } + + pub(crate) fn slug() -> RepoSlug { + RepoSlug { + owner: "l0ng-ai".into(), + name: "tty7".into(), + } + } + + const ISSUE_LIST: &str = r#"[ + {"number": 12, "title": "Crash on resize", "state": "open", "state_reason": null, + "user": {"login": "ada"}, "labels": [{"name": "bug", "color": "d73a4a"}], + "comments": 3, "created_at": "2026-09-01T10:00:00Z", "updated_at": "2026-09-20T08:30:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/issues/12", "body": "It crashes."}, + {"number": 13, "title": "Add panel", "state": "open", "user": {"login": "bob"}, + "labels": [], "comments": 0, "created_at": "2026-09-02T10:00:00Z", + "updated_at": "2026-09-21T08:30:00Z", "html_url": "https://github.com/l0ng-ai/tty7/pull/13", + "draft": true, "pull_request": {"url": "x", "merged_at": null}}, + {"number": 9, "title": "Old idea", "state": "closed", "state_reason": "not_planned", + "user": null, "labels": [], "comments": 1, "created_at": "2026-08-01T10:00:00Z", + "updated_at": "2026-08-02T10:00:00Z", "html_url": "https://github.com/l0ng-ai/tty7/issues/9"} + ]"#; + + const PULL_LIST: &str = r#"[ + {"number": 13, "title": "Add panel", "state": "open", "draft": true, "merged_at": null, + "user": {"login": "bob"}, "labels": [{"name": "ui", "color": "0e8a16"}], + "created_at": "2026-09-02T10:00:00Z", "updated_at": "2026-09-21T08:30:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/pull/13", + "head": {"ref": "feat/panel", "label": "bob:feat/panel"}, "base": {"ref": "main"}}, + {"number": 8, "title": "Fix typo", "state": "closed", "draft": false, + "merged_at": "2026-09-03T10:00:00Z", "user": {"login": "cy"}, "labels": [], + "created_at": "2026-09-02T10:00:00Z", "updated_at": "2026-09-03T10:00:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/pull/8", + "head": {"ref": "typo"}, "base": {"ref": "main"}} + ]"#; + + fn query(kind: Kind, label: Option<&str>) -> ListQuery { + ListQuery { + slug: slug(), + kind, + state: StateFilter::Open, + label: label.map(str::to_string), + } + } + + #[test] + fn issues_come_from_issues_with_the_pull_requests_filtered_out() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues?state=open&sort=updated&direction=desc&per_page=50&page=1", + ISSUE_LIST, + true, + ); + let page = list(&t, &query(Kind::Issues, None), 1).unwrap(); + let numbers: Vec = page.items.iter().map(|i| i.number).collect(); + assert_eq!(numbers, vec![12, 9]); + assert_eq!(page.next_page, Some(2)); + let first = &page.items[0]; + assert_eq!(first.author, "ada"); + assert_eq!(first.state, ItemState::Open); + assert_eq!(first.labels[0].name, "bug"); + assert_eq!(first.comments, 3); + assert!(first.updated_at > first.created_at); + assert_eq!(page.items[1].state, ItemState::NotPlanned); + assert_eq!( + page.items[1].author, "", + "a deleted user is blank, not a failure" + ); + } + + #[test] + fn pull_requests_come_from_pulls_with_draft_and_merged() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/pulls?state=open&sort=updated&direction=desc&per_page=50&page=2", + PULL_LIST, + false, + ); + let page = list(&t, &query(Kind::Pulls, None), 2).unwrap(); + assert_eq!(page.next_page, None); + let states: Vec = page.items.iter().map(|i| i.state).collect(); + assert_eq!(states, vec![ItemState::Draft, ItemState::Merged]); + assert!(page.items.iter().all(|i| i.is_pr)); + } + + #[test] + fn a_label_filter_on_pull_requests_goes_through_issues() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues?state=open&sort=updated&direction=desc&per_page=50&page=1&labels=good%20first%20issue", + ISSUE_LIST, + false, + ); + let page = list(&t, &query(Kind::Pulls, Some("good first issue")), 1).unwrap(); + let numbers: Vec = page.items.iter().map(|i| i.number).collect(); + assert_eq!(numbers, vec![13]); + assert_eq!(page.items[0].state, ItemState::Draft); + } + + #[test] + fn page_zero_is_page_one() { + let t = Fixture::new(); + let _ = list(&t, &query(Kind::Issues, None), 0); + assert!(t.asked.lock().unwrap()[0].ends_with("&page=1")); + } + + #[test] + fn an_issue_detail_reads_the_issue_and_its_comments_only() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues/12", + r#"{"number": 12, "title": "Crash on resize", "state": "open", + "user": {"login": "ada"}, "labels": [], "comments": 1, + "created_at": "2026-09-01T10:00:00Z", "updated_at": "2026-09-20T08:30:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/issues/12", "body": "It **crashes**."}"#, + false, + ); + t.on( + "/repos/l0ng-ai/tty7/issues/12/comments?per_page=100", + r#"[{"id": 1, "user": {"login": "bob"}, "body": "Same here", + "created_at": "2026-09-02T10:00:00Z", "html_url": "https://github.com/c/1"}]"#, + true, + ); + let d = detail(&t, &slug(), 12).unwrap(); + assert_eq!(d.body, "It **crashes**."); + assert_eq!(d.comments.len(), 1); + assert_eq!(d.comments[0].author, "bob"); + assert!(d.comments_truncated); + assert!(d.pull.is_none() && d.files.is_none()); + assert_eq!(t.asked.lock().unwrap().len(), 2); + } + + #[test] + fn a_pull_request_detail_adds_branches_and_files() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues/13", + r#"{"number": 13, "title": "Add panel", "state": "open", "user": {"login": "bob"}, + "labels": [{"name": "ui"}], "comments": 0, "created_at": "2026-09-02T10:00:00Z", + "updated_at": "2026-09-21T08:30:00Z", "html_url": "https://github.com/l0ng-ai/tty7/pull/13", + "body": null, "pull_request": {"merged_at": null}}"#, + false, + ); + t.on( + "/repos/l0ng-ai/tty7/issues/13/comments?per_page=100", + "[]", + false, + ); + t.on( + "/repos/l0ng-ai/tty7/pulls/13", + r#"{"number": 13, "title": "Add panel", "state": "open", "draft": true, + "merged_at": null, "user": {"login": "bob"}, "labels": [], + "created_at": "2026-09-02T10:00:00Z", "updated_at": "2026-09-21T08:30:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/pull/13", + "head": {"ref": "feat/panel"}, "base": {"ref": "main"}, + "additions": 120, "deletions": 4, "changed_files": 2, "commits": 3}"#, + false, + ); + t.on( + "/repos/l0ng-ai/tty7/pulls/13/files?per_page=100", + r#"[{"filename": "src/new.rs", "status": "added", "additions": 118, "deletions": 0, + "patch": "@@ -0,0 +1,2 @@\n+fn a() {}\n+fn b() {}"}, + {"filename": "assets/logo.png", "status": "renamed", + "previous_filename": "logo.png", "additions": 2, "deletions": 4}]"#, + true, + ); + let d = detail(&t, &slug(), 13).unwrap(); + assert_eq!(d.body, "", "a null body is an empty one"); + assert_eq!(d.item.state, ItemState::Draft); + assert_eq!(d.item.labels[0].name, "ui", "labels come from the issue"); + let pull = d.pull.unwrap(); + assert_eq!( + (pull.head_ref.as_str(), pull.base_ref.as_str()), + ("feat/panel", "main") + ); + assert_eq!((pull.additions, pull.deletions, pull.commits), (120, 4, 3)); + let files = d.files.unwrap(); + assert_eq!(files[0].status, FileStatus::Added); + assert!(files[0].patch.is_some()); + assert_eq!(files[1].status, FileStatus::Renamed); + assert_eq!(files[1].old_path.as_deref(), Some("logo.png")); + assert!(files[1].patch.is_none()); + assert!(d.files_truncated); + } + + #[test] + fn a_failed_sub_request_fails_the_detail() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues/12", + r#"{"number": 12, "title": "x", "state": "open"}"#, + false, + ); + t.replies.insert( + "/repos/l0ng-ai/tty7/issues/12/comments?per_page=100".into(), + Err(ApiError::RateLimited { reset: Some(5) }), + ); + assert_eq!( + detail(&t, &slug(), 12), + Err(ApiError::RateLimited { reset: Some(5) }) + ); + } + + #[test] + fn a_body_that_is_not_the_expected_shape_is_a_decode_error() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues?state=open&sort=updated&direction=desc&per_page=50&page=1", + r#"{"message": "surprise"}"#, + false, + ); + assert!(matches!( + list(&t, &query(Kind::Issues, None), 1), + Err(ApiError::Decode(_)) + )); + } + + fn headers(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |name| { + pairs + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_string()) + } + } + + #[test] + fn statuses_map_to_distinct_errors() { + let none = headers(&[]); + assert_eq!(classify(200, &none, b""), Ok(())); + assert_eq!(classify(204, &none, b""), Ok(())); + assert_eq!(classify(401, &none, b""), Err(ApiError::Unauthorized)); + assert_eq!(classify(404, &none, b""), Err(ApiError::NotFound)); + assert_eq!(classify(500, &none, b""), Err(ApiError::Http(500))); + assert_eq!( + classify( + 403, + &none, + br#"{"message": "Resource protected by organization SAML enforcement."}"# + ), + Err(ApiError::Forbidden( + "Resource protected by organization SAML enforcement.".into() + )) + ); + } + + #[test] + fn a_spent_rate_limit_is_told_apart_from_other_403s() { + let spent = headers(&[ + ("x-ratelimit-remaining", "0"), + ("x-ratelimit-reset", "1790000000"), + ]); + assert_eq!( + classify(403, &spent, b"{}"), + Err(ApiError::RateLimited { + reset: Some(1_790_000_000) + }) + ); + assert_eq!( + classify(429, &spent, b"{}"), + Err(ApiError::RateLimited { + reset: Some(1_790_000_000) + }) + ); + // A secondary limit keeps quota but says so in its message. + let left = headers(&[("x-ratelimit-remaining", "12")]); + assert_eq!( + classify( + 403, + &left, + br#"{"message": "You have exceeded a secondary rate limit."}"# + ), + Err(ApiError::RateLimited { reset: None }) + ); + let retry = headers(&[("retry-after", "60")]); + assert!(matches!( + classify(403, &retry, b"{}"), + Err(ApiError::RateLimited { reset: Some(_) }) + )); + } + + #[test] + fn the_link_header_decides_whether_there_is_more() { + assert!(link_has_next( + r#"; rel="next", ; rel="last""# + )); + assert!(!link_has_next( + r#"; rel="prev", ; rel="first""# + )); + assert!(!link_has_next("")); + } +} diff --git a/crates/tty7-core/src/core/github/http.rs b/crates/tty7-core/src/core/github/http.rs new file mode 100644 index 00000000..6466a06a --- /dev/null +++ b/crates/tty7-core/src/core/github/http.rs @@ -0,0 +1,87 @@ +//! [`Transport`] over HTTPS, with the same stack and proxy resolution the +//! installer and the update check use. +//! +//! Blocking — every call runs on a worker, never on the UI thread. + +use std::time::Duration; + +use super::api::{ApiError, Reply, Transport, classify, link_has_next}; +use super::token::Token; + +const API: &str = "https://api.github.com"; + +/// Interactive reads: long enough for a slow link, short enough that a dead +/// one is reported rather than sat on. +const TIMEOUT: Duration = Duration::from_secs(30); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +/// A pull request's file list with every patch inlined is the largest answer +/// the panel asks for; GitHub itself caps a patch well under this. +const MAX_BODY: u64 = 32 * 1024 * 1024; + +pub struct HttpTransport { + agent: ureq::Agent, + token: Option, +} + +impl HttpTransport { + /// `manual_proxy` is the `http_proxy` setting, as for tty7's other + /// downloads. + pub fn new(token: Option, manual_proxy: Option<&str>) -> HttpTransport { + let mut builder = ureq::Agent::config_builder() + .timeout_global(Some(TIMEOUT)) + .timeout_connect(Some(CONNECT_TIMEOUT)) + // 4xx/5xx come back as responses, so their headers (the rate + // limit's reset time) can be read. + .http_status_as_error(false) + .user_agent(concat!("tty7/", env!("CARGO_PKG_VERSION"))); + if let Some(proxy) = crate::daemon::install::proxy::resolve(API, manual_proxy) { + builder = builder.proxy(Some(proxy)); + } + HttpTransport { + agent: builder.build().into(), + token, + } + } +} + +impl Transport for HttpTransport { + fn get(&self, path: &str) -> Result { + let mut request = self + .agent + .get(format!("{API}{path}")) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = &self.token { + request = request.header("Authorization", format!("Bearer {}", token.expose())); + } + // ureq's error text names the URL and the transport failure, never + // the request headers, so it is safe to show. + let mut response = request + .call() + .map_err(|e| ApiError::Network(e.to_string()))?; + let status = response.status().as_u16(); + let headers = response.headers().clone(); + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + }; + let body = response + .body_mut() + .with_config() + .limit(MAX_BODY) + .read_to_vec() + .map_err(|e| ApiError::Network(e.to_string()))?; + classify(status, &header, &body)?; + Ok(Reply { + has_next: header("link").is_some_and(|l| link_has_next(&l)), + body, + }) + } + + fn authenticated(&self) -> bool { + self.token.is_some() + } +} diff --git a/crates/tty7-core/src/core/github/markdown.rs b/crates/tty7-core/src/core/github/markdown.rs new file mode 100644 index 00000000..d8bda005 --- /dev/null +++ b/crates/tty7-core/src/core/github/markdown.rs @@ -0,0 +1,405 @@ +//! Making an issue's Markdown safe to hand to the text view. +//! +//! Issue bodies and comments are written by anyone on the internet. The text +//! view renders Markdown natively (no browser, no script), so there is nothing +//! to execute — but two things in it act on the reader's machine: +//! +//! - **Images load themselves.** An `![](…)` or `` is fetched the +//! moment it is drawn, which tells whoever controls that URL that this +//! issue was opened, from which IP, and when. Every image becomes a plain +//! link instead: the reader can still open it, deliberately, in the browser. +//! - **Links open whatever they name.** A click hands the URL to the OS, and +//! `file:///…` or an app's custom scheme can launch programs. Only `http`, +//! `https` and `mailto` targets survive; anything else is pointed at `#`. +//! +//! This is a rewrite of the *source text*, not a Markdown parser. It knows +//! enough structure to leave code alone (fenced blocks and inline code spans +//! are copied verbatim, since `![x](y)` inside backticks is text) and to find +//! the link and image forms Markdown and the HTML subset actually have. + +/// Rewrite `src` as described in the module docs. `image_label` names an image +/// whose alt text is empty ("image"), in the reader's language. +pub fn sanitize(src: &str, image_label: &str) -> String { + let mut out = String::with_capacity(src.len() + 16); + let mut fence: Option<(char, usize)> = None; + for line in src.split_inclusive('\n') { + let trimmed = line.trim_start_matches(' '); + let indent = line.len() - trimmed.len(); + let marker = fence_marker(trimmed).filter(|_| indent <= 3); + match (fence, marker) { + (None, Some(m)) => { + fence = Some(m); + out.push_str(line); + continue; + } + (Some((ch, n)), Some((mch, mn))) if ch == mch && mn >= n && closes_fence(trimmed) => { + fence = None; + out.push_str(line); + continue; + } + (Some(_), _) => { + out.push_str(line); + continue; + } + (None, None) => {} + } + out.push_str(&sanitize_line(line, image_label)); + } + out +} + +/// A fence opener/closer: three or more of one of `` ` `` / `~`. +fn fence_marker(line: &str) -> Option<(char, usize)> { + let ch = line.chars().next().filter(|c| *c == '`' || *c == '~')?; + let n = line.chars().take_while(|c| *c == ch).count(); + (n >= 3).then_some((ch, n)) +} + +/// A closing fence carries nothing after its marker but whitespace. +fn closes_fence(line: &str) -> bool { + let ch = line.chars().next().unwrap_or(' '); + line.trim_start_matches(ch).trim().is_empty() +} + +/// One line outside a fenced block: code spans kept, the rest rewritten. +fn sanitize_line(line: &str, image_label: &str) -> String { + // A reference definition, `[id]: url`, is a link target of its own. + if let Some(rewritten) = reference_definition(line) { + return rewritten; + } + let mut out = String::with_capacity(line.len()); + let mut rest = line; + while !rest.is_empty() { + match rest.find('`') { + Some(start) => { + out.push_str(&sanitize_text(&rest[..start], image_label)); + let after = &rest[start..]; + let ticks = after.chars().take_while(|c| *c == '`').count(); + let closer = "`".repeat(ticks); + match after[ticks..].find(&closer) { + Some(end) => { + let span = ticks + end + ticks; + out.push_str(&after[..span]); + rest = &after[span..]; + } + // An unclosed run of backticks is literal text. + None => { + out.push_str(&after[..ticks]); + rest = &after[ticks..]; + } + } + } + None => { + out.push_str(&sanitize_text(rest, image_label)); + break; + } + } + } + out +} + +fn reference_definition(line: &str) -> Option { + let trimmed = line.trim_start_matches(' '); + if line.len() - trimmed.len() > 3 || !trimmed.starts_with('[') { + return None; + } + let close = trimmed.find("]:")?; + if trimmed[1..close].contains(']') { + return None; + } + let head_len = line.len() - trimmed.len() + close + 2; + let tail = &line[head_len..]; + let target_start = tail.len() - tail.trim_start().len(); + let target = tail[target_start..].split_whitespace().next()?; + let bare = target.trim_start_matches('<').trim_end_matches('>'); + if is_safe_target(bare) { + return None; + } + let from = head_len + target_start; + Some(format!( + "{}#{}", + &line[..from], + &line[from + target.len()..] + )) +} + +/// Plain text between code spans. +fn sanitize_text(text: &str, image_label: &str) -> String { + let mut out = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0; + while i < text.len() { + let rest = &text[i..]; + // `![alt](…)` / `![alt][ref]` → a link with the same target. + if rest.starts_with("![") && !escaped(bytes, i) { + out.push('['); + if rest[2..].starts_with(']') { + out.push_str(image_label); + } + i += 2; + continue; + } + // `](target` — the target half of an inline link or image. + if rest.starts_with("](") { + out.push_str("]("); + i += 2; + let target = &text[i..]; + let lead = target.len() - target.trim_start().len(); + let body = &target[lead..]; + let (url, len) = if let Some(inner) = body.strip_prefix('<') { + let end = inner.find('>').unwrap_or(inner.len()); + (&inner[..end], end + 2) + } else { + let end = body + .find(|c: char| c.is_whitespace() || c == ')') + .unwrap_or(body.len()); + (&body[..end], end) + }; + out.push_str(&target[..lead]); + if is_safe_target(url) { + out.push_str(&body[..len.min(body.len())]); + } else { + out.push('#'); + } + i += lead + len.min(body.len()); + continue; + } + if rest.starts_with('<') { + // `` → a link to what it would have loaded. + if starts_with_tag(rest, "img") { + let end = rest.find('>').map_or(rest.len(), |e| e + 1); + let tag = &rest[..end]; + let src = attr(tag, "src").unwrap_or_default(); + let alt = attr(tag, "alt").filter(|a| !a.trim().is_empty()); + let label = alt.as_deref().unwrap_or(image_label); + let label = label.replace(['[', ']'], ""); + if is_safe_target(&src) && !src.is_empty() { + out.push_str(&format!("[{label}]({})", src.replace(' ', "%20"))); + } else { + out.push_str(&format!("[{label}](#)")); + } + i += end; + continue; + } + // `` autolink with a scheme we do not open. + if let Some(end) = rest.find('>') { + let inner = &rest[1..end]; + if !inner.contains(char::is_whitespace) + && scheme(inner).is_some() + && !is_safe_target(inner) + { + out.push_str("\\<"); + i += 1; + continue; + } + } + // Any other tag: neutralise `href`/`src` values it carries. + if let Some(end) = tag_end(rest) { + out.push_str(&rewrite_tag_urls(&rest[..end])); + i += end; + continue; + } + } + let ch = rest.chars().next().unwrap_or(' '); + out.push(ch); + i += ch.len_utf8(); + } + out +} + +fn escaped(bytes: &[u8], i: usize) -> bool { + let mut n = 0; + let mut j = i; + while j > 0 && bytes[j - 1] == b'\\' { + n += 1; + j -= 1; + } + n % 2 == 1 +} + +fn starts_with_tag(s: &str, name: &str) -> bool { + let Some(rest) = s.strip_prefix('<') else { + return false; + }; + rest.len() > name.len() + && rest[..name.len()].eq_ignore_ascii_case(name) + && rest[name.len()..] + .chars() + .next() + .is_some_and(|c| c.is_whitespace() || c == '>' || c == '/') +} + +/// The length of an HTML tag starting at `s[0] == '<'`, when it is one. +fn tag_end(s: &str) -> Option { + let rest = s.strip_prefix('<')?; + let rest = rest.strip_prefix('/').unwrap_or(rest); + if !rest.chars().next()?.is_ascii_alphabetic() { + return None; + } + // Stop at the next `<`: a stray `<` in prose is not a tag. + let end = s.find('>')?; + (!s[1..end].contains('<')).then_some(end + 1) +} + +fn rewrite_tag_urls(tag: &str) -> String { + let mut tag = tag.to_string(); + for name in ["href", "src", "srcset", "poster", "background"] { + if let Some(value) = attr(&tag, name) + && !is_safe_target(&value) + { + tag = tag.replacen(&value, "#", 1); + } + } + tag +} + +/// An attribute's value, quoted or bare. Case-insensitive on the name. +fn attr(tag: &str, name: &str) -> Option { + let lower = tag.to_ascii_lowercase(); + let mut from = 0; + while let Some(pos) = lower[from..].find(name) { + let at = from + pos; + from = at + name.len(); + let before_ok = lower[..at] + .chars() + .last() + .is_some_and(|c| c.is_whitespace()); + let after = lower[from..].trim_start(); + if !before_ok || !after.starts_with('=') { + continue; + } + let offset = tag.len() - tag[from..].trim_start().len() + 1; + let value = tag[offset..].trim_start(); + return Some(match value.chars().next() { + Some(q @ ('"' | '\'')) => value[1..].split(q).next().unwrap_or("").to_string(), + _ => value + .split(|c: char| c.is_whitespace() || c == '>') + .next() + .unwrap_or("") + .trim_end_matches('/') + .to_string(), + }); + } + None +} + +/// The scheme of `url`, when it has one: letters first, then letters, digits, +/// `+`, `-`, `.`, up to a `:` that comes before any `/`, `?` or `#`. +fn scheme(url: &str) -> Option<&str> { + let colon = url.find(':')?; + let head = &url[..colon]; + if head.is_empty() + || url[..colon].contains(['/', '?', '#']) + || !head.chars().next()?.is_ascii_alphabetic() + || !head + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return None; + } + Some(head) +} + +/// Whether a link target may be handed to the OS opener. +/// +/// Relative targets and fragments carry no scheme and so cannot name a program; +/// they are kept. Whitespace and control characters are stripped first, the +/// way a browser does, so ` jav\tascript:` is judged as `javascript:`. +pub fn is_safe_target(url: &str) -> bool { + let cleaned: String = url + .chars() + .filter(|c| !c.is_whitespace() && !c.is_control()) + .collect(); + match scheme(&cleaned) { + None => !cleaned.contains(':') || cleaned.starts_with(['/', '#', '?', '.']), + Some(s) => matches!(s.to_ascii_lowercase().as_str(), "http" | "https" | "mailto"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(src: &str) -> String { + sanitize(src, "image") + } + + #[test] + fn images_become_links_to_the_same_target() { + assert_eq!( + s("see ![shot](https://x.io/a.png) here"), + "see [shot](https://x.io/a.png) here" + ); + assert_eq!(s("![](https://x.io/a.png)"), "[image](https://x.io/a.png)"); + assert_eq!(s("![ref style][1]"), "[ref style][1]"); + // An escaped bang is text, and stays text. + assert_eq!(s(r"\![not](x)"), r"\![not](x)"); + } + + #[test] + fn html_images_become_links_too() { + assert_eq!( + s(r#"Screenshot"#), + "[Screenshot](https://github.com/u/a.png)" + ); + assert_eq!( + s(""), + "[image](https://x.io/b.png)" + ); + assert_eq!(s(r#""#), "[image](#)"); + } + + #[test] + fn links_to_anything_but_the_web_are_disarmed() { + assert_eq!(s("[ok](https://github.com)"), "[ok](https://github.com)"); + assert_eq!(s("[mail](mailto:a@b.c)"), "[mail](mailto:a@b.c)"); + assert_eq!(s("[rel](docs/a.md)"), "[rel](docs/a.md)"); + assert_eq!(s("[frag](#heading)"), "[frag](#heading)"); + assert_eq!(s("[x](file:///Applications/Calc.app)"), "[x](#)"); + assert_eq!(s("[x](javascript:alert(1))"), "[x](#))"); + assert_eq!(s("[x]( \"t\")"), "[x](# \"t\")"); + assert_eq!(s("[x]( ssh://host )"), "[x]( # )"); + assert_eq!(s("x"), "x"); + assert_eq!( + s("x"), + "x" + ); + assert_eq!(s(""), "\\"); + assert_eq!(s(""), ""); + assert_eq!(s("[r]: file:///x \"t\"\n"), "[r]: # \"t\"\n"); + assert_eq!(s("[r]: https://ok.io\n"), "[r]: https://ok.io\n"); + } + + #[test] + fn code_is_left_exactly_as_written() { + let fenced = "```md\n![x](http://a/b.png)\n\n```\n![y](http://c/d.png)\n"; + assert_eq!( + s(fenced), + "```md\n![x](http://a/b.png)\n\n```\n[y](http://c/d.png)\n" + ); + assert_eq!( + s("use `![a](b)` for images, ![c](http://d)"), + "use `![a](b)` for images, [c](http://d)" + ); + assert_eq!( + s("~~~~\n[x](file:///y)\n~~~~\n"), + "~~~~\n[x](file:///y)\n~~~~\n" + ); + } + + #[test] + fn prose_with_angle_brackets_is_untouched() { + assert_eq!(s("a < b and c > d"), "a < b and c > d"); + assert_eq!(s("Vec is fine"), "Vec is fine"); + assert_eq!(s("unicode ✓ → ok"), "unicode ✓ → ok"); + } + + #[test] + fn safe_targets_are_judged_after_stripping_whitespace() { + assert!(!is_safe_target(" jav\tascript:alert(1)")); + assert!(!is_safe_target("FILE:///x")); + assert!(is_safe_target("HTTPS://x.io")); + assert!(is_safe_target("./a:b")); + assert!(is_safe_target("/abs/path")); + } +} diff --git a/crates/tty7-core/src/core/github/mod.rs b/crates/tty7-core/src/core/github/mod.rs new file mode 100644 index 00000000..9062eeb6 --- /dev/null +++ b/crates/tty7-core/src/core/github/mod.rs @@ -0,0 +1,20 @@ +//! Read-only GitHub: which repository a working tree belongs to, whose token +//! to read it with, and the handful of REST calls the right panel's GitHub tab +//! makes. +//! +//! Framework-free like the rest of this crate. The HTTPS half ([`http`]) is +//! behind the `github` feature, which only the GUI turns on — the headless +//! server never talks to GitHub. + +pub mod api; +#[cfg(feature = "github")] +pub mod http; +pub mod markdown; +pub mod model; +pub mod remote; +pub mod token; + +pub use api::{ApiError, ListPage, ListQuery, Reply, Transport}; +pub use model::{Comment, Detail, Item, ItemState, Kind, Label, PrFile, PullInfo, StateFilter}; +pub use remote::{GitHubRemote, RepoSlug}; +pub use token::{Token, TokenSource}; diff --git a/crates/tty7-core/src/core/github/model.rs b/crates/tty7-core/src/core/github/model.rs new file mode 100644 index 00000000..10f00ac9 --- /dev/null +++ b/crates/tty7-core/src/core/github/model.rs @@ -0,0 +1,525 @@ +//! What the panel shows, decoded out of GitHub's REST JSON. +//! +//! The wire structs (`Raw*`) mirror only the fields read; everything else in +//! GitHub's (large) payloads is ignored, so a field GitHub adds or drops later +//! cannot break decoding. The public types are what the UI draws from, already +//! reduced to the decisions it needs — one [`ItemState`] rather than the three +//! fields (`state`, `state_reason`, `merged_at`/`draft`) GitHub spreads it over. + +use serde::Deserialize; + +use crate::core::git::diff::{DiffBudget, DiffParser, FileDiff, FileStatus}; + +/// Issues or pull requests. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum Kind { + #[default] + Issues, + Pulls, +} + +/// The list's open/closed switch. "Closed" includes merged pull requests, as +/// it does on GitHub. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum StateFilter { + #[default] + Open, + Closed, +} + +impl StateFilter { + pub fn as_query(self) -> &'static str { + match self { + StateFilter::Open => "open", + StateFilter::Closed => "closed", + } + } +} + +/// Where an issue or a pull request stands. Each one gets its own glyph +/// *shape*, not only its own colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ItemState { + Open, + /// A pull request marked as a draft (and still open). + Draft, + /// An issue closed as completed, or a pull request closed unmerged. + Closed, + /// An issue closed as "not planned" / duplicate. + NotPlanned, + Merged, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Label { + pub name: String, + /// `0xRRGGBB`, when GitHub sent a usable colour. + pub color: Option, +} + +/// One row of the list. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + pub number: u64, + pub title: String, + pub state: ItemState, + pub is_pr: bool, + pub author: String, + pub labels: Vec