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