diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 0000000..e69de29 diff --git a/Cargo.toml b/Cargo.toml index 692ac1d..c782d62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ reqwest = { version = "0.13.2", features = ["form", "json", "query"] } serde = "1.0.228" serde_json = "1.0.149" tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time"] } +tree-sitter = "0.19.5" +tree-sitter-markdown = "0.7.1" [build-dependencies] serde_json = "1.0.149" diff --git a/build.rs b/build.rs index 99131bc..d1af8ed 100644 --- a/build.rs +++ b/build.rs @@ -9,6 +9,12 @@ struct AssetFile { disk_path: PathBuf, } +#[derive(Debug)] +struct TimelineFixturePage { + json: String, + end_cursor: Option, +} + fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR")); @@ -129,6 +135,9 @@ fn render_github_fixtures(fixture_root: &Path) -> String { let repo_list = read_json_fixture(&fixture_root.join("repo.list.json")); let mut issue_fixtures = BTreeMap::<(String, u32), String>::new(); + let mut pull_request_fixtures = BTreeMap::::new(); + let mut pull_request_timeline_fixtures = + BTreeMap::>::new(); let mut entries = fs::read_dir(fixture_root) .unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture_root.display())) .map(|entry| entry.expect("failed to read github fixture entry")) @@ -141,11 +150,24 @@ fn render_github_fixtures(fixture_root: &Path) -> String { .into_string() .unwrap_or_else(|_| panic!("non-utf8 fixture name in {}", fixture_root.display())); - let Some((filter, page)) = parse_issue_fixture_name(&file_name) else { + if let Some((filter, page)) = parse_issue_fixture_name(&file_name) { + let value = read_fixture_value(&entry.path()); + issue_fixtures.insert((filter, page), read_issue_fixture(&value, &entry.path())); continue; - }; + } - issue_fixtures.insert((filter, page), read_issue_fixture(&entry.path())); + if let Some(id) = parse_pull_request_fixture_name(&file_name) { + pull_request_fixtures.insert(id, read_json_fixture(&entry.path())); + continue; + } + + if let Some((id, page)) = parse_pull_request_timeline_fixture_name(&file_name) { + let value = read_fixture_value(&entry.path()); + pull_request_timeline_fixtures + .entry(id) + .or_default() + .insert(page, read_timeline_fixture(&value, &entry.path())); + } } let mut output = String::new(); @@ -177,23 +199,68 @@ fn render_github_fixtures(fixture_root: &Path) -> String { output.push_str(" }\n"); output.push_str("}\n"); + output.push_str("\n"); + output.push_str("pub fn issues_pull_request(id: &str) -> Option<&'static str> {\n"); + output.push_str(" match id {\n"); + for (id, json) in pull_request_fixtures { + output.push_str(" "); + output.push_str(&string_literal(&id)); + output.push_str(" => Some("); + output.push_str(&string_literal(&json)); + output.push_str("),\n"); + } + output.push_str(" _ => None,\n"); + output.push_str(" }\n"); + output.push_str("}\n"); + + output.push_str("\n"); + output.push_str("pub fn issues_pull_request_timeline(id: &str, after: Option<&str>) -> Option<&'static str> {\n"); + output.push_str(" match (id, after) {\n"); + for (id, pages) in pull_request_timeline_fixtures { + let mut previous_page = 0; + let mut previous_end_cursor = None; + + for (page, fixture) in pages { + if page != previous_page + 1 { + panic!("missing pull request timeline fixture page {page} for {id}"); + } + + output.push_str(" ("); + output.push_str(&string_literal(&id)); + output.push_str(", "); + match previous_end_cursor.as_deref() { + Some(after) => output.push_str(&format!("Some({})", string_literal(after))), + None => output.push_str("None"), + } + output.push_str(") => Some("); + output.push_str(&string_literal(&fixture.json)); + output.push_str("),\n"); + + previous_page = page; + previous_end_cursor = fixture.end_cursor.clone(); + } + } + output.push_str(" _ => None,\n"); + output.push_str(" }\n"); + output.push_str("}\n"); + output } -fn read_json_fixture(path: &Path) -> String { +fn read_fixture_value(path: &Path) -> serde_json::Value { let raw = fs::read_to_string(path) .unwrap_or_else(|err| panic!("failed to read fixture {}: {err}", path.display())); - let value: serde_json::Value = serde_json::from_str(&raw) - .unwrap_or_else(|err| panic!("invalid json fixture {}: {err}", path.display())); + serde_json::from_str(&raw) + .unwrap_or_else(|err| panic!("invalid json fixture {}: {err}", path.display())) +} + +fn read_json_fixture(path: &Path) -> String { + let value = read_fixture_value(path); serde_json::to_string(&value) .unwrap_or_else(|err| panic!("failed to serialize fixture {}: {err}", path.display())) } -fn read_issue_fixture(path: &Path) -> String { - let raw = fs::read_to_string(path) - .unwrap_or_else(|err| panic!("failed to read fixture {}: {err}", path.display())); - let value: serde_json::Value = serde_json::from_str(&raw) - .unwrap_or_else(|err| panic!("invalid json fixture {}: {err}", path.display())); +fn read_issue_fixture(value: &serde_json::Value, path: &Path) -> String { let issues = value .as_array() .unwrap_or_else(|| panic!("issue fixture {} must be a json array", path.display())); @@ -215,8 +282,49 @@ fn read_issue_fixture(path: &Path) -> String { }) } +fn read_timeline_fixture(value: &serde_json::Value, path: &Path) -> TimelineFixturePage { + let page_info = value + .get("node") + .and_then(|node| node.get("timelineItems")) + .and_then(|timeline| timeline.get("pageInfo")) + .unwrap_or_else(|| { + panic!( + "timeline fixture {} must include node.timelineItems.pageInfo", + path.display() + ) + }); + + if !matches!( + value + .get("node") + .and_then(|node| node.get("timelineItems")) + .and_then(|timeline| timeline.get("nodes")), + Some(serde_json::Value::Array(_)) + ) { + panic!( + "timeline fixture {} must include node.timelineItems.nodes", + path.display() + ); + } + + let end_cursor = page_info + .get("endCursor") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + + let json = serde_json::to_string(value).unwrap_or_else(|err| { + panic!( + "failed to serialize mapped timeline fixture {}: {err}", + path.display() + ) + }); + + TimelineFixturePage { json, end_cursor } +} + fn map_issue_fixture(issue: &serde_json::Value) -> serde_json::Value { serde_json::json!({ + "id": issue_fixture_graphql_id(issue), "title": required_string(issue, &["title"]), "state": issue_fixture_state(issue), "is_draft": issue.get("draft").and_then(serde_json::Value::as_bool).unwrap_or(false), @@ -231,27 +339,22 @@ fn issue_fixture_state(issue: &serde_json::Value) -> &'static str { .and_then(serde_json::Value::as_str) .is_some() { - return "Merged"; + return "MERGED"; } match required_string(issue, &["state"]) { - "open" => "Open", - "closed" => "Closed", - _ => "Unknown", + "open" => "OPEN", + "closed" => "CLOSED", + state => panic!("unsupported pull request state in fixture: {state}"), } } +fn issue_fixture_graphql_id<'a>(issue: &'a serde_json::Value) -> &'a str { + required_string(issue, &["node_id"]) +} + fn issue_fixture_cursor(issue: &serde_json::Value) -> Option { - issue - .get("node_id") - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - .or_else(|| { - issue - .get("id") - .and_then(serde_json::Value::as_u64) - .map(|id| id.to_string()) - }) + Some(issue_fixture_graphql_id(issue).to_owned()) } fn required_string<'a>(value: &'a serde_json::Value, path: &[&str]) -> &'a str { @@ -276,6 +379,19 @@ fn parse_issue_fixture_name(file_name: &str) -> Option<(String, u32)> { Some((filter.to_owned(), page)) } +fn parse_pull_request_fixture_name(file_name: &str) -> Option { + let name = file_name.strip_suffix(".json")?; + name.strip_prefix("issues.pull_request.").map(str::to_owned) +} + +fn parse_pull_request_timeline_fixture_name(file_name: &str) -> Option<(String, u32)> { + let name = file_name.strip_suffix(".json")?; + let rest = name.strip_prefix("issues.pull_request_timeline.")?; + let (id, page) = rest.rsplit_once(".page")?; + let page = page.parse::().ok()?; + Some((id.to_owned(), page)) +} + fn string_literal(value: &str) -> String { format!("{value:?}") } diff --git a/fixtures/github/issues.pull_request.PR_kwDOAgent47.json b/fixtures/github/issues.pull_request.PR_kwDOAgent47.json new file mode 100644 index 0000000..e982fb2 --- /dev/null +++ b/fixtures/github/issues.pull_request.PR_kwDOAgent47.json @@ -0,0 +1,6 @@ +{ + "title": "feat(prompts): split context loading from execution workers", + "state": "OPEN", + "is_draft": true, + "body": "## Goal\n\nSplit context loading from execution workers so delegation stays predictable while this pull request is still in draft.\n\n### Why\n- workers should receive a compact payload\n- prompt packing should be testable without spawning a worker\n- retry policy should stay in one place\n\n### Proposed flow\n1. Load repository context once.\n2. Normalize file excerpts and metadata.\n3. Hand workers a stable execution envelope.\n\n```text\nContextLoader -> PromptAssembler -> WorkerRunner\n```\n\n> Draft status stays until we decide whether token counts belong in the worker response.\n\n### Questions\n- Should `ContextLoader` expose cache hit metrics?\n- Should worker retries carry the same prompt hash?\n- [ ] Add a regression test for interrupted workers" +} diff --git a/fixtures/github/issues.pull_request.PR_kwDODesign31.json b/fixtures/github/issues.pull_request.PR_kwDODesign31.json new file mode 100644 index 0000000..64d8db8 --- /dev/null +++ b/fixtures/github/issues.pull_request.PR_kwDODesign31.json @@ -0,0 +1,6 @@ +{ + "title": "chore(tokens): tighten dashboard spacing scale", + "state": "OPEN", + "is_draft": false, + "body": "## Summary\n\nTightens the dashboard spacing scale before the next visual refresh.\n\n### Updated tokens\n- `space.3` for compact sidebar gaps\n- `space.5` for section rhythm\n- `space.8` for page-level separation\n\n| Surface | Before | After |\n| --- | --- | --- |\n| Sidebar section gap | `space.6` | `space.5` |\n| Filter row padding | `space.4` | `space.3` |\n| Dashboard gutter | `space.7` | `space.6` |\n\n### Review notes\n- verify heading baselines still align with list content\n- compare 1280px and 1440px screenshots side by side\n- [ ] revisit compact mode once the nav collapse lands\n\n**Design intent:** make dense screens feel more deliberate without looking cramped." +} diff --git a/fixtures/github/issues.pull_request.PR_kwDOInfra19.json b/fixtures/github/issues.pull_request.PR_kwDOInfra19.json new file mode 100644 index 0000000..07a8698 --- /dev/null +++ b/fixtures/github/issues.pull_request.PR_kwDOInfra19.json @@ -0,0 +1,6 @@ +{ + "title": "docs(deploy): document manual failover steps", + "state": "CLOSED", + "is_draft": false, + "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated recovery path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers.\n4. Warm the cache before reopening traffic.\n\n```bash\n./scripts/failover promote-standby --env staging\n./scripts/failover repoint-workers --env staging\n./scripts/failover verify --env staging\n```\n\n> This pull request was closed because the final DNS validation steps were still changing underneath the runbook.\n\n### Remaining gaps\n- secrets rotation is still manual\n- rollback screenshots are missing\n- [ ] add the final post-cutover checklist" +} diff --git a/fixtures/github/issues.pull_request.PR_kwDONovem84.json b/fixtures/github/issues.pull_request.PR_kwDONovem84.json new file mode 100644 index 0000000..ccd972c --- /dev/null +++ b/fixtures/github/issues.pull_request.PR_kwDONovem84.json @@ -0,0 +1,6 @@ +{ + "title": "feat(dashboard): hydrate issue pane from cached query state", + "state": "OPEN", + "is_draft": false, + "body": "## Summary\n\nHydrates the dashboard issue pane from cached query state so selection and scroll position stay stable during refetches.\n\n### Rendering coverage\n- [x] headings\n- [x] bullet lists\n- [x] task list items\n- [x] inline code like `use_query`\n- [x] tables\n\n### Implementation sketch\n```rust\nlet cached = query_store.read(key);\nlet selection = cached.and_then(|data| data.selected_issue_id.clone());\n```\n\n| Case | Expected behavior |\n| --- | --- |\n| Cache hit | Keep the current selection pinned |\n| Cache miss | Fall back to the first visible item |\n| Refetch in flight | Preserve scroll position |\n\n### Follow-up\n- [ ] mirror the same cache behavior in the pull request detail pane\n- [ ] add a smoke test around keyboard navigation during refetch\n\nSee also the [query store](src/query.rs) integration notes." +} diff --git a/fixtures/github/issues.pull_request.PR_kwDONovem85.json b/fixtures/github/issues.pull_request.PR_kwDONovem85.json new file mode 100644 index 0000000..02fa998 --- /dev/null +++ b/fixtures/github/issues.pull_request.PR_kwDONovem85.json @@ -0,0 +1,6 @@ +{ + "title": "feat(repo): add cached repository query for titlebar picker", + "state": "OPEN", + "is_draft": false, + "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories visible during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Cache rules\n- explicit refresh invalidates the cached list\n- fresh network data still wins when available\n- empty responses should not overwrite a warm cache\n\n```text\nopen picker -> read cache -> render immediately -> refresh in background\n```\n\n### Follow-up\n1. Measure cache hit rate in debug builds.\n2. Add eviction telemetry.\n3. [ ] Consider persisting the last successful repository list across launches." +} diff --git a/fixtures/github/issues.pull_request.PR_kwDOSprint62.json b/fixtures/github/issues.pull_request.PR_kwDOSprint62.json new file mode 100644 index 0000000..a3939e6 --- /dev/null +++ b/fixtures/github/issues.pull_request.PR_kwDOSprint62.json @@ -0,0 +1,6 @@ +{ + "title": "feat(calendar): ship release handoff checklist in weekly planner", + "state": "MERGED", + "is_draft": false, + "body": "## Release handoff checklist\n\nAdds the release checklist views and closes the loop for the May rollout.\n\n### Included\n- launch readiness checklist for QA, docs, and release engineering\n- handoff status badges in the weekly planner\n- empty-state copy for weeks without a scheduled release\n\n| Stage | Owner | Status |\n| --- | --- | --- |\n| QA sign-off | `@mariahops` | Done |\n| Docs publish | `@rorycraft` | Done |\n| Release window confirm | `@kennethnym` | Done |\n\n### Verification\n1. Open a release week and confirm checklist sections render in order.\n2. Mark each handoff item complete and confirm the summary badge updates.\n3. Review the planner on a narrow viewport.\n\n> The merged version intentionally keeps the checklist readable even when one section has no pending items.\n\n- [x] QA sign-off state is visible\n- [x] Docs handoff state is visible\n- [ ] Add screenshot coverage for the compact layout" +} diff --git a/fixtures/github/issues.pull_request_timeline.PR_kwDOAgent47.page1.json b/fixtures/github/issues.pull_request_timeline.PR_kwDOAgent47.page1.json new file mode 100644 index 0000000..33e71ee --- /dev/null +++ b/fixtures/github/issues.pull_request_timeline.PR_kwDOAgent47.page1.json @@ -0,0 +1,101 @@ +{ + "node": { + "__typename": "PullRequest", + "timelineItems": { + "pageInfo": { + "endCursor": "timeline:PR_kwDOAgent47:page1", + "hasNextPage": false + }, + "nodes": [ + { + "__typename": "AssignedEvent", + "createdAt": "2026-04-30T14:30:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "assignee": { + "__typename": "Mannequin", + "login": "legacy-ci-owner", + "avatarUrl": "https://avatars.githubusercontent.com/u/90001?v=4" + } + }, + { + "__typename": "ReadyForReviewEvent", + "createdAt": "2026-05-01T01:05:00Z", + "actor": { + "__typename": "User", + "login": "leaferiksen", + "avatarUrl": "https://avatars.githubusercontent.com/u/5151?v=4" + } + }, + { + "__typename": "ReviewRequestedEvent", + "createdAt": "2026-05-01T01:10:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "requestedReviewer": { + "__typename": "Team", + "name": "prompting" + } + }, + { + "__typename": "PullRequestCommit", + "commit": { + "committedDate": "2026-05-01T03:00:00Z", + "abbreviatedOid": "4a8df12", + "messageHeadline": "Split prompt packing from worker execution" + } + }, + { + "__typename": "IssueComment", + "createdAt": "2026-05-01T03:20:00Z", + "author": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "body": "Let us keep this in draft until telemetry lands." + }, + { + "__typename": "ConvertToDraftEvent", + "createdAt": "2026-05-01T04:00:00Z", + "actor": { + "__typename": "User", + "login": "leaferiksen", + "avatarUrl": "https://avatars.githubusercontent.com/u/5151?v=4" + } + }, + { + "__typename": "UnassignedEvent", + "createdAt": "2026-05-01T04:10:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "assignee": { + "__typename": "Mannequin", + "login": "legacy-ci-owner", + "avatarUrl": "https://avatars.githubusercontent.com/u/90001?v=4" + } + }, + { + "__typename": "PullRequestReview", + "createdAt": "2026-05-02T00:15:00Z", + "author": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "state": "COMMENTED", + "body": "Split looks good; leaving draft until telemetry is in." + } + ] + } + } +} diff --git a/fixtures/github/issues.pull_request_timeline.PR_kwDODesign31.page1.json b/fixtures/github/issues.pull_request_timeline.PR_kwDODesign31.page1.json new file mode 100644 index 0000000..c9609e7 --- /dev/null +++ b/fixtures/github/issues.pull_request_timeline.PR_kwDODesign31.page1.json @@ -0,0 +1,66 @@ +{ + "node": { + "__typename": "PullRequest", + "timelineItems": { + "pageInfo": { + "endCursor": "timeline:PR_kwDODesign31:page1", + "hasNextPage": false + }, + "nodes": [ + { + "__typename": "AssignedEvent", + "createdAt": "2026-05-02T11:12:00Z", + "actor": { + "__typename": "User", + "login": "mariahops", + "avatarUrl": "https://avatars.githubusercontent.com/u/6161?v=4" + }, + "assignee": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "PullRequestReview", + "createdAt": "2026-05-02T15:40:00Z", + "author": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "state": "CHANGES_REQUESTED", + "body": "The 12px sidebar gutter still feels cramped." + }, + { + "__typename": "BaseRefChangedEvent", + "createdAt": "2026-05-02T18:05:00Z", + "actor": { + "__typename": "User", + "login": "mariahops", + "avatarUrl": "https://avatars.githubusercontent.com/u/6161?v=4" + } + }, + { + "__typename": "IssueComment", + "createdAt": "2026-05-02T18:18:00Z", + "author": { + "__typename": "User", + "login": "mariahops", + "avatarUrl": "https://avatars.githubusercontent.com/u/6161?v=4" + }, + "body": "Updated the spacing tokens to align with the latest mock." + }, + { + "__typename": "AutoMergeEnabledEvent", + "createdAt": "2026-05-03T09:00:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + } + ] + } + } +} diff --git a/fixtures/github/issues.pull_request_timeline.PR_kwDOInfra19.page1.json b/fixtures/github/issues.pull_request_timeline.PR_kwDOInfra19.page1.json new file mode 100644 index 0000000..f457c06 --- /dev/null +++ b/fixtures/github/issues.pull_request_timeline.PR_kwDOInfra19.page1.json @@ -0,0 +1,50 @@ +{ + "node": { + "__typename": "PullRequest", + "timelineItems": { + "pageInfo": { + "endCursor": "timeline:PR_kwDOInfra19:page1", + "hasNextPage": false + }, + "nodes": [ + { + "__typename": "IssueComment", + "createdAt": "2026-04-29T07:10:00Z", + "author": { + "__typename": "User", + "login": "piperlane", + "avatarUrl": "https://avatars.githubusercontent.com/u/8181?v=4" + }, + "body": "Closing this until the failover runbook stabilizes." + }, + { + "__typename": "ReferencedEvent", + "createdAt": "2026-04-30T06:15:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "ReopenedEvent", + "createdAt": "2026-05-01T09:45:00Z", + "actor": { + "__typename": "User", + "login": "piperlane", + "avatarUrl": "https://avatars.githubusercontent.com/u/8181?v=4" + } + }, + { + "__typename": "ClosedEvent", + "createdAt": "2026-05-02T12:05:00Z", + "actor": { + "__typename": "User", + "login": "piperlane", + "avatarUrl": "https://avatars.githubusercontent.com/u/8181?v=4" + } + } + ] + } + } +} diff --git a/fixtures/github/issues.pull_request_timeline.PR_kwDONovem84.page1.json b/fixtures/github/issues.pull_request_timeline.PR_kwDONovem84.page1.json new file mode 100644 index 0000000..3070b3f --- /dev/null +++ b/fixtures/github/issues.pull_request_timeline.PR_kwDONovem84.page1.json @@ -0,0 +1,68 @@ +{ + "node": { + "__typename": "PullRequest", + "timelineItems": { + "pageInfo": { + "endCursor": "timeline:PR_kwDONovem84:page1", + "hasNextPage": false + }, + "nodes": [ + { + "__typename": "AssignedEvent", + "createdAt": "2026-05-01T09:20:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "assignee": { + "__typename": "Bot", + "login": "triage-bot", + "avatarUrl": "https://avatars.githubusercontent.com/in/98765?v=4" + } + }, + { + "__typename": "PullRequestCommit", + "commit": { + "committedDate": "2026-05-01T10:05:00Z", + "abbreviatedOid": "2bc41de", + "messageHeadline": "Hydrate issue pane from cached dashboard state" + } + }, + { + "__typename": "CrossReferencedEvent", + "createdAt": "2026-05-01T12:00:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "ReviewRequestedEvent", + "createdAt": "2026-05-01T12:05:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "requestedReviewer": { + "__typename": "Bot", + "login": "novem-ci", + "avatarUrl": "https://avatars.githubusercontent.com/in/54321?v=4" + } + }, + { + "__typename": "IssueComment", + "createdAt": "2026-05-01T12:20:00Z", + "author": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "body": "Cache hydration is now stable across refetches." + } + ] + } + } +} diff --git a/fixtures/github/issues.pull_request_timeline.PR_kwDONovem85.page1.json b/fixtures/github/issues.pull_request_timeline.PR_kwDONovem85.page1.json new file mode 100644 index 0000000..0076465 --- /dev/null +++ b/fixtures/github/issues.pull_request_timeline.PR_kwDONovem85.page1.json @@ -0,0 +1,77 @@ +{ + "node": { + "__typename": "PullRequest", + "timelineItems": { + "pageInfo": { + "endCursor": "timeline:PR_kwDONovem85:page1", + "hasNextPage": false + }, + "nodes": [ + { + "__typename": "BaseRefChangedEvent", + "createdAt": "2026-05-03T07:50:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "AssignedEvent", + "createdAt": "2026-05-03T08:00:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "assignee": { + "__typename": "User", + "login": "leaferiksen", + "avatarUrl": "https://avatars.githubusercontent.com/u/5151?v=4" + } + }, + { + "__typename": "PullRequestCommit", + "commit": { + "committedDate": "2026-05-03T08:25:00Z", + "abbreviatedOid": "13af7d0", + "messageHeadline": "Cache repository list for titlebar context switcher" + } + }, + { + "__typename": "ReviewRequestedEvent", + "createdAt": "2026-05-03T08:40:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "requestedReviewer": { + "__typename": "Bot", + "login": "novem-ci", + "avatarUrl": "https://avatars.githubusercontent.com/in/54321?v=4" + } + }, + { + "__typename": "IssueComment", + "createdAt": "2026-05-03T09:05:00Z", + "author": { + "__typename": "User", + "login": "leaferiksen", + "avatarUrl": "https://avatars.githubusercontent.com/u/5151?v=4" + }, + "body": "The cached picker feels much faster under repo churn." + }, + { + "__typename": "AutoMergeEnabledEvent", + "createdAt": "2026-05-04T06:30:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + } + ] + } + } +} diff --git a/fixtures/github/issues.pull_request_timeline.PR_kwDOSprint62.page1.json b/fixtures/github/issues.pull_request_timeline.PR_kwDOSprint62.page1.json new file mode 100644 index 0000000..c8238a5 --- /dev/null +++ b/fixtures/github/issues.pull_request_timeline.PR_kwDOSprint62.page1.json @@ -0,0 +1,82 @@ +{ + "node": { + "__typename": "PullRequest", + "timelineItems": { + "pageInfo": { + "endCursor": "timeline:PR_kwDOSprint62:page1", + "hasNextPage": true + }, + "nodes": [ + { + "__typename": "AssignedEvent", + "createdAt": "2026-04-28T10:25:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "assignee": { + "__typename": "User", + "login": "mariahops", + "avatarUrl": "https://avatars.githubusercontent.com/u/6161?v=4" + } + }, + { + "__typename": "PullRequestCommit", + "commit": { + "committedDate": "2026-04-29T08:14:00Z", + "abbreviatedOid": "9f3c2ab", + "messageHeadline": "Add release handoff checklist panel" + } + }, + { + "__typename": "IssueComment", + "createdAt": "2026-04-29T11:00:00Z", + "author": { + "__typename": "User", + "login": "rorycraft", + "avatarUrl": "https://avatars.githubusercontent.com/u/7171?v=4" + }, + "body": "Release checklist is ready for QA." + }, + { + "__typename": "ReviewRequestedEvent", + "createdAt": "2026-04-30T09:20:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "requestedReviewer": { + "__typename": "User", + "login": "leaferiksen", + "avatarUrl": "https://avatars.githubusercontent.com/u/5151?v=4" + } + }, + { + "__typename": "PullRequestReview", + "createdAt": "2026-05-01T03:42:00Z", + "author": { + "__typename": "User", + "login": "leaferiksen", + "avatarUrl": "https://avatars.githubusercontent.com/u/5151?v=4" + }, + "state": "APPROVED", + "body": "Planner handoff flow looks good." + }, + { + "__typename": "LabeledEvent", + "createdAt": "2026-05-01T04:10:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "label": { + "name": "release-blocker" + } + } + ] + } + } +} diff --git a/fixtures/github/issues.pull_request_timeline.PR_kwDOSprint62.page2.json b/fixtures/github/issues.pull_request_timeline.PR_kwDOSprint62.page2.json new file mode 100644 index 0000000..354f1af --- /dev/null +++ b/fixtures/github/issues.pull_request_timeline.PR_kwDOSprint62.page2.json @@ -0,0 +1,91 @@ +{ + "node": { + "__typename": "PullRequest", + "timelineItems": { + "pageInfo": { + "endCursor": "timeline:PR_kwDOSprint62:page2", + "hasNextPage": true + }, + "nodes": [ + { + "__typename": "ReviewRequestRemovedEvent", + "createdAt": "2026-05-01T04:20:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "requestedReviewer": { + "__typename": "Team", + "name": "release-engineering" + } + }, + { + "__typename": "HeadRefForcePushedEvent", + "createdAt": "2026-05-01T06:10:00Z", + "actor": { + "__typename": "User", + "login": "rorycraft", + "avatarUrl": "https://avatars.githubusercontent.com/u/7171?v=4" + }, + "beforeCommit": { + "abbreviatedOid": "9f3c2ab" + }, + "afterCommit": { + "abbreviatedOid": "be7a811" + } + }, + { + "__typename": "MilestonedEvent", + "createdAt": "2026-05-01T06:35:00Z", + "actor": { + "__typename": "User", + "login": "mariahops", + "avatarUrl": "https://avatars.githubusercontent.com/u/6161?v=4" + }, + "milestoneTitle": "May Release" + }, + { + "__typename": "UnlabeledEvent", + "createdAt": "2026-05-01T07:05:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "label": { + "name": "tests" + } + }, + { + "__typename": "AutoMergeEnabledEvent", + "createdAt": "2026-05-02T02:15:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "AutoMergeDisabledEvent", + "createdAt": "2026-05-02T02:18:00Z", + "actor": { + "__typename": "User", + "login": "mariahops", + "avatarUrl": "https://avatars.githubusercontent.com/u/6161?v=4" + }, + "reason": "Branch protection rules changed" + }, + { + "__typename": "CrossReferencedEvent", + "createdAt": "2026-05-02T08:40:00Z", + "actor": { + "__typename": "User", + "login": "piperlane", + "avatarUrl": "https://avatars.githubusercontent.com/u/8181?v=4" + } + } + ] + } + } +} diff --git a/fixtures/github/issues.pull_request_timeline.PR_kwDOSprint62.page3.json b/fixtures/github/issues.pull_request_timeline.PR_kwDOSprint62.page3.json new file mode 100644 index 0000000..9490c27 --- /dev/null +++ b/fixtures/github/issues.pull_request_timeline.PR_kwDOSprint62.page3.json @@ -0,0 +1,86 @@ +{ + "node": { + "__typename": "PullRequest", + "timelineItems": { + "pageInfo": { + "endCursor": "timeline:PR_kwDOSprint62:page3", + "hasNextPage": false + }, + "nodes": [ + { + "__typename": "BaseRefChangedEvent", + "createdAt": "2026-05-02T10:00:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "AddedToMergeQueueEvent", + "createdAt": "2026-05-03T05:12:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "RemovedFromMergeQueueEvent", + "createdAt": "2026-05-03T05:18:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "ReferencedEvent", + "createdAt": "2026-05-03T12:05:00Z", + "actor": { + "__typename": "User", + "login": "rorycraft", + "avatarUrl": "https://avatars.githubusercontent.com/u/7171?v=4" + } + }, + { + "__typename": "ReviewDismissedEvent", + "createdAt": "2026-05-03T12:50:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "DemilestonedEvent", + "createdAt": "2026-05-04T07:20:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + }, + "milestoneTitle": "May Release" + }, + { + "__typename": "MergedEvent", + "createdAt": "2026-05-04T18:10:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + }, + { + "__typename": "ClosedEvent", + "createdAt": "2026-05-04T18:15:00Z", + "actor": { + "__typename": "User", + "login": "kennethnym", + "avatarUrl": "https://avatars.githubusercontent.com/u/4242?v=4" + } + } + ] + } + } +} diff --git a/fixtures/github/issues.pull_requests.all.page1.json b/fixtures/github/issues.pull_requests.all.page1.json index f1c1b9e..fc9786e 100644 --- a/fixtures/github/issues.pull_requests.all.page1.json +++ b/fixtures/github/issues.pull_requests.all.page1.json @@ -12,7 +12,7 @@ "state": "open", "state_reason": null, "title": "feat(dashboard): hydrate issue pane from cached query state", - "body": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.", + "body": "## Summary\n\nHydrates the dashboard issue pane from cached query state so selection and scroll position stay stable during refetches.\n\n### Changes\n- reuse the cached query result before the network request resolves\n- keep the selected issue id pinned across list refreshes\n- fall back to the first visible item when the cached selection disappears\n\n### Follow-up\n- [ ] mirror the same cache behavior in the pull request detail pane\n- [ ] add a smoke test for refetch during keyboard navigation", "body_text": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.", "body_html": null, "user": { @@ -101,7 +101,7 @@ "state": "closed", "state_reason": "completed", "title": "feat(calendar): ship release handoff checklist in weekly planner", - "body": "Adds the release checklist views and marks the handoff flow complete for the May rollout.", + "body": "## Release handoff checklist\n\nAdds the release checklist views and closes the loop for the May rollout.\n\n### Included\n- launch readiness checklist for QA, docs, and release engineering\n- handoff status badges in the weekly planner\n- empty-state copy for weeks without a scheduled release\n\n### Verification\n1. Open a release week and confirm checklist sections render in order.\n2. Mark each handoff item complete and confirm the summary badge updates.\n3. Review the planner on a narrow viewport.\n\n```text\nrelease_week -> handoff_panel -> checklist_sections\n```", "body_text": "Adds the release checklist views and marks the handoff flow complete for the May rollout.", "body_html": null, "user": { @@ -230,7 +230,7 @@ "state": "open", "state_reason": null, "title": "feat(repo): add cached repository query for titlebar picker", - "body": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.", + "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories available during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Notes\n- cache invalidates on explicit refresh\n- fresh network data still wins when available\n- [ ] follow up with eviction metrics", "body_text": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.", "body_html": null, "user": { @@ -344,7 +344,7 @@ "state": "open", "state_reason": null, "title": "feat(prompts): split context loading from execution workers", - "body": "Separates prompt packing from worker orchestration to make delegation easier to reason about.", + "body": "## Goal\n\nSeparates prompt packing from worker orchestration to make delegation easier to reason about.\n\n### What changed\n- `ContextLoader` now owns repository and file hydration\n- workers receive a normalized prompt payload\n- retry handling stays at the orchestration layer\n\n### Open questions\n- should prompt packing expose token counts in debug builds?\n- should draft workers emit a dry-run preview?\n\n> Keeps the worker boundary small enough to test in isolation.", "body_text": "Separates prompt packing from worker orchestration to make delegation easier to reason about.", "body_html": null, "user": { diff --git a/fixtures/github/issues.pull_requests.all.page2.json b/fixtures/github/issues.pull_requests.all.page2.json index d3f25ca..f4007ab 100644 --- a/fixtures/github/issues.pull_requests.all.page2.json +++ b/fixtures/github/issues.pull_requests.all.page2.json @@ -12,7 +12,7 @@ "state": "open", "state_reason": null, "title": "chore(tokens): tighten dashboard spacing scale", - "body": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.", + "body": "## Summary\n\nNormalizes horizontal gutters and sidebar section padding before the visual refresh.\n\n### Token updates\n- `space.3` now anchors compact sidebar gaps\n- `space.5` is used for section-to-section rhythm\n- `space.8` stays reserved for page-level breaks\n\n### Review notes\n- compare the dashboard at 1280px and 1440px\n- verify headings still align with list rows\n- [ ] revisit mobile spacing once nav collapse lands", "body_text": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.", "body_html": null, "user": { @@ -117,7 +117,7 @@ "state": "closed", "state_reason": "not_planned", "title": "docs(deploy): document manual failover steps", - "body": null, + "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers and warm the cache.\n4. Verify health checks before reopening traffic.\n\n### Risks\n- secrets rotation is still manual\n- rollback steps need screenshots\n- [ ] add the final DNS validation command", "body_text": null, "body_html": null, "user": { diff --git a/fixtures/github/issues.pull_requests.assigned.page1.json b/fixtures/github/issues.pull_requests.assigned.page1.json index d7392ae..ba61e04 100644 --- a/fixtures/github/issues.pull_requests.assigned.page1.json +++ b/fixtures/github/issues.pull_requests.assigned.page1.json @@ -12,7 +12,7 @@ "state": "closed", "state_reason": "completed", "title": "feat(calendar): ship release handoff checklist in weekly planner", - "body": "Adds the release checklist views and marks the handoff flow complete for the May rollout.", + "body": "## Release handoff checklist\n\nAdds the release checklist views and closes the loop for the May rollout.\n\n### Included\n- launch readiness checklist for QA, docs, and release engineering\n- handoff status badges in the weekly planner\n- empty-state copy for weeks without a scheduled release\n\n### Verification\n1. Open a release week and confirm checklist sections render in order.\n2. Mark each handoff item complete and confirm the summary badge updates.\n3. Review the planner on a narrow viewport.\n\n```text\nrelease_week -> handoff_panel -> checklist_sections\n```", "body_text": "Adds the release checklist views and marks the handoff flow complete for the May rollout.", "body_html": null, "user": { @@ -141,7 +141,7 @@ "state": "open", "state_reason": null, "title": "feat(prompts): split context loading from execution workers", - "body": "Separates prompt packing from worker orchestration to make delegation easier to reason about.", + "body": "## Goal\n\nSeparates prompt packing from worker orchestration to make delegation easier to reason about.\n\n### What changed\n- `ContextLoader` now owns repository and file hydration\n- workers receive a normalized prompt payload\n- retry handling stays at the orchestration layer\n\n### Open questions\n- should prompt packing expose token counts in debug builds?\n- should draft workers emit a dry-run preview?\n\n> Keeps the worker boundary small enough to test in isolation.", "body_text": "Separates prompt packing from worker orchestration to make delegation easier to reason about.", "body_html": null, "user": { @@ -246,7 +246,7 @@ "state": "open", "state_reason": null, "title": "chore(tokens): tighten dashboard spacing scale", - "body": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.", + "body": "## Summary\n\nNormalizes horizontal gutters and sidebar section padding before the visual refresh.\n\n### Token updates\n- `space.3` now anchors compact sidebar gaps\n- `space.5` is used for section-to-section rhythm\n- `space.8` stays reserved for page-level breaks\n\n### Review notes\n- compare the dashboard at 1280px and 1440px\n- verify headings still align with list rows\n- [ ] revisit mobile spacing once nav collapse lands", "body_text": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.", "body_html": null, "user": { diff --git a/fixtures/github/issues.pull_requests.created.page1.json b/fixtures/github/issues.pull_requests.created.page1.json index e9575ad..de5c19a 100644 --- a/fixtures/github/issues.pull_requests.created.page1.json +++ b/fixtures/github/issues.pull_requests.created.page1.json @@ -12,7 +12,7 @@ "state": "open", "state_reason": null, "title": "feat(repo): add cached repository query for titlebar picker", - "body": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.", + "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories available during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Notes\n- cache invalidates on explicit refresh\n- fresh network data still wins when available\n- [ ] follow up with eviction metrics", "body_text": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.", "body_html": null, "user": { @@ -126,7 +126,7 @@ "state": "open", "state_reason": null, "title": "feat(dashboard): hydrate issue pane from cached query state", - "body": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.", + "body": "## Summary\n\nHydrates the dashboard issue pane from cached query state so selection and scroll position stay stable during refetches.\n\n### Changes\n- reuse the cached query result before the network request resolves\n- keep the selected issue id pinned across list refreshes\n- fall back to the first visible item when the cached selection disappears\n\n### Follow-up\n- [ ] mirror the same cache behavior in the pull request detail pane\n- [ ] add a smoke test for refetch during keyboard navigation", "body_text": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.", "body_html": null, "user": { diff --git a/fixtures/github/issues.pull_requests.created.page2.json b/fixtures/github/issues.pull_requests.created.page2.json index 6e73e3a..614594b 100644 --- a/fixtures/github/issues.pull_requests.created.page2.json +++ b/fixtures/github/issues.pull_requests.created.page2.json @@ -12,7 +12,7 @@ "state": "closed", "state_reason": "not_planned", "title": "docs(deploy): document manual failover steps", - "body": null, + "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers and warm the cache.\n4. Verify health checks before reopening traffic.\n\n### Risks\n- secrets rotation is still manual\n- rollback steps need screenshots\n- [ ] add the final DNS validation command", "body_text": null, "body_html": null, "user": { diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..55852a4 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +match_arm_indent = false +match_arm_leading_pipes = "Always" diff --git a/src/api.rs b/src/api.rs index 7d25d6c..143415f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,3 +1,4 @@ +use reqwest::Method; use serde::{Deserialize, Serialize}; use crate::query; @@ -36,8 +37,9 @@ pub(crate) enum Error { #[cfg(debug_assertions)] MissingMockFixture(String), Github(GithubError), - MalformedResponse(serde_json::Error), + MalformedResponse(String), HttpError(reqwest::Error), + GraphQLError(Vec), } #[derive(Debug, Deserialize)] @@ -66,6 +68,16 @@ impl QueryContext { .header("User-Agent", "kennethnym") .bearer_auth(&auth.access_token)) } + + fn github_graphql_request( + &self, + request: &graphql_client::QueryBody, + ) -> Result + where + V: serde::Serialize, + { + Ok(self.github_request(Method::POST, "/graphql")?.json(request)) + } } #[cfg(debug_assertions)] @@ -88,7 +100,7 @@ impl From for Error { impl From for Error { fn from(value: serde_json::Error) -> Self { - Self::MalformedResponse(value) + Self::MalformedResponse(value.to_string()) } } @@ -118,10 +130,13 @@ where async fn parse_graphql_response( res: reqwest::Response, -) -> Result, Error> +) -> Result<(graphql_client::Response, T), Error> where T: serde::de::DeserializeOwned, { - let data: graphql_client::Response = res.json().await?; - Ok(data) + let mut body: graphql_client::Response = res.json().await?; + match body.data.take() { + None => Err(Error::GraphQLError(body.errors.unwrap_or_default())), + Some(data) => Ok((body, data)), + } } diff --git a/src/api/graphql/fetch_pull_request.graphql b/src/api/graphql/fetch_pull_request.graphql new file mode 100644 index 0000000..8e814a9 --- /dev/null +++ b/src/api/graphql/fetch_pull_request.graphql @@ -0,0 +1,11 @@ +query PullRequestQuery($id: ID!) { + node(id: $id) { + __typename + ... on PullRequest { + title + body + state + isDraft + } + } +} diff --git a/src/api/graphql/fetch_pull_request_timeline.graphql b/src/api/graphql/fetch_pull_request_timeline.graphql new file mode 100644 index 0000000..c983d8c --- /dev/null +++ b/src/api/graphql/fetch_pull_request_timeline.graphql @@ -0,0 +1,267 @@ +query PullRequestTimelineQuery($id: ID!, $first: Int!, $after: String) { + node(id: $id) { + __typename + ... on PullRequest { + timelineItems(first: $first, after: $after) { + pageInfo { + endCursor + hasNextPage + } + nodes { + __typename + + ... on AssignedEvent { + createdAt + actor { + ...actorFields + } + assignee { + ...assigneeFields + } + } + + ... on UnassignedEvent { + createdAt + actor { + ...actorFields + } + assignee { + ...assigneeFields + } + } + + ... on IssueComment { + createdAt + author { + ...actorFields + } + body + } + + ... on PullRequestCommit { + commit { + committedDate + abbreviatedOid + messageHeadline + } + } + + ... on PullRequestReview { + createdAt + author { + ...actorFields + } + state + body + } + + ... on ReviewRequestedEvent { + createdAt + actor { + ...actorFields + } + requestedReviewer { + ...requestedReviewerFields + } + } + + ... on ReviewRequestRemovedEvent { + createdAt + actor { + ...actorFields + } + requestedReviewer { + ...requestedReviewerFields + } + } + + ... on ReviewDismissedEvent { + createdAt + actor { + ...actorFields + } + } + + ... on MergedEvent { + createdAt + actor { + ...actorFields + } + } + + ... on ClosedEvent { + createdAt + actor { + ...actorFields + } + } + + ... on ReopenedEvent { + createdAt + actor { + ...actorFields + } + } + + ... on ConvertToDraftEvent { + createdAt + actor { + ...actorFields + } + } + + ... on ReadyForReviewEvent { + createdAt + actor { + ...actorFields + } + } + + ... on HeadRefForcePushedEvent { + createdAt + actor { + ...actorFields + } + beforeCommit { + abbreviatedOid + } + afterCommit { + abbreviatedOid + } + } + + ... on BaseRefChangedEvent { + createdAt + actor { + ...actorFields + } + } + + ... on LabeledEvent { + createdAt + actor { + ...actorFields + } + label { + name + } + } + + ... on UnlabeledEvent { + createdAt + actor { + ...actorFields + } + label { + name + } + } + + ... on MilestonedEvent { + createdAt + actor { + ...actorFields + } + milestoneTitle + } + + ... on DemilestonedEvent { + createdAt + actor { + ...actorFields + } + milestoneTitle + } + + ... on ReferencedEvent { + createdAt + actor { + ...actorFields + } + } + + ... on CrossReferencedEvent { + createdAt + actor { + ...actorFields + } + } + + ... on AutoMergeEnabledEvent { + createdAt + actor { + ...actorFields + } + } + + ... on AutoMergeDisabledEvent { + createdAt + actor { + ...actorFields + } + reason + } + + ... on AddedToMergeQueueEvent { + createdAt + actor { + ...actorFields + } + } + + ... on RemovedFromMergeQueueEvent { + createdAt + actor { + ...actorFields + } + } + } + } + } + } +} + +fragment actorFields on Actor { + __typename + login + avatarUrl(size: 100) +} + +fragment assigneeFields on Assignee { + __typename + ... on Bot { + login + avatarUrl(size: 100) + } + ... on Mannequin { + login + avatarUrl(size: 100) + } + ... on Organization { + login + avatarUrl(size: 100) + } + ... on User { + login + avatarUrl(size: 100) + } +} + +fragment requestedReviewerFields on RequestedReviewer { + __typename + ... on Bot { + login + avatarUrl(size: 100) + } + ... on Mannequin { + login + avatarUrl(size: 100) + } + ... on Team { + name + } + ... on User { + login + avatarUrl(size: 100) + } +} diff --git a/src/api/graphql/list_pull_requests.graphql b/src/api/graphql/list_pull_requests.graphql index 3fed7df..27dc0cb 100644 --- a/src/api/graphql/list_pull_requests.graphql +++ b/src/api/graphql/list_pull_requests.graphql @@ -1,10 +1,11 @@ -query PullRequestQuery($query: String!) { +query PullRequestPaginationQuery($query: String!) { search(query: $query, first: 10, type: ISSUE) { issueCount edges { node { __typename ... on PullRequest { + id isDraft title state diff --git a/src/api/issues.rs b/src/api/issues.rs index 58a7ee5..090182a 100644 --- a/src/api/issues.rs +++ b/src/api/issues.rs @@ -1,17 +1,22 @@ use std::ops::Deref; -use graphql_client::{GraphQLQuery, Response}; -use reqwest::Method; +use graphql_client::GraphQLQuery; use serde::Deserialize; use crate::{ api::{ self, - issues::pull_request_query::{PullRequestQuerySearchEdgesNode, PullRequestState}, + issues::{ + pull_request_pagination_query::PullRequestPaginationQuerySearchEdgesNode, + pull_request_query::PullRequestQueryNode, + }, }, query, }; +type DateTime = String; +type URI = String; + #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)] #[serde(transparent)] #[repr(transparent)] @@ -25,6 +30,24 @@ impl Deref for Id { } } +impl From<&str> for Id { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } +} + +impl From for Id { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From for String { + fn from(value: Id) -> Self { + value.0 + } +} + #[derive(Debug, Deserialize)] pub(crate) struct PullRequestPaginatedResponse { pub(crate) items: Vec, @@ -34,18 +57,155 @@ pub(crate) struct PullRequestPaginatedResponse { #[derive(Debug, Deserialize)] pub(crate) struct PullRequest { + pub(crate) id: Id, pub(crate) title: String, - pub(crate) state: IssueState, + pub(crate) state: PullRequestState, pub(crate) is_draft: bool, pub(crate) repo_slug: String, } -#[derive(Debug, Clone, Copy, Deserialize)] -pub(crate) enum IssueState { - Open, - Closed, - Merged, - Unknown, +#[derive(Debug, Deserialize)] +pub(crate) struct DetailedPullRequest { + pub(crate) title: String, + pub(crate) state: PullRequestState, + pub(crate) is_draft: bool, + pub(crate) body: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PullRequestTimeline { + pub(crate) items: Vec, + pub(crate) end_cursor: Option, + pub(crate) has_next_page: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PullRequestTimelineItem { + Assigned { + created_at: String, + actor: Option, + assignee: Option, + }, + Unassigned { + created_at: String, + actor: Option, + assignee: Option, + }, + Comment { + created_at: String, + author: Option, + body: String, + }, + Commit { + committed_at: String, + abbreviated_oid: String, + message_headline: String, + }, + Review { + created_at: String, + author: Option, + state: String, + body: String, + }, + ReviewRequested { + created_at: String, + actor: Option, + reviewer: Option, + }, + ReviewRequestRemoved { + created_at: String, + actor: Option, + reviewer: Option, + }, + ReviewDismissed { + created_at: String, + actor: Option, + }, + Merged { + created_at: String, + actor: Option, + }, + Closed { + created_at: String, + actor: Option, + }, + Reopened { + created_at: String, + actor: Option, + }, + ConvertToDraft { + created_at: String, + actor: Option, + }, + ReadyForReview { + created_at: String, + actor: Option, + }, + HeadRefForcePushed { + created_at: String, + actor: Option, + before_commit_oid: Option, + after_commit_oid: Option, + }, + BaseRefChanged { + created_at: String, + actor: Option, + }, + Labeled { + created_at: String, + actor: Option, + label: String, + }, + Unlabeled { + created_at: String, + actor: Option, + label: String, + }, + Milestoned { + created_at: String, + actor: Option, + milestone_title: String, + }, + Demilestoned { + created_at: String, + actor: Option, + milestone_title: String, + }, + Referenced { + created_at: String, + actor: Option, + }, + CrossReferenced { + created_at: String, + actor: Option, + }, + AutoMergeEnabled { + created_at: String, + actor: Option, + }, + AutoMergeDisabled { + created_at: String, + actor: Option, + reason: String, + }, + AddedToMergeQueue { + created_at: String, + actor: Option, + }, + RemovedFromMergeQueue { + created_at: String, + actor: Option, + }, + Other { + typename: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TimelineActor { + pub(crate) kind: String, + pub(crate) name: String, + pub(crate) avatar_url: Option, } impl std::fmt::Display for Id { @@ -54,24 +214,50 @@ impl std::fmt::Display for Id { } } -impl From for IssueState { - fn from(state: PullRequestState) -> Self { - match state { - PullRequestState::OPEN => Self::Open, - PullRequestState::CLOSED => Self::Closed, - PullRequestState::MERGED => Self::Merged, - _ => Self::Unknown, - } - } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub(crate) enum PullRequestState { + Open, + Closed, + Merged, } #[derive(graphql_client::GraphQLQuery)] #[graphql( schema_path = "src/api/graphql/schema.json", query_path = "src/api/graphql/list_pull_requests.graphql" + extern_enums("PullRequestState") +)] +struct PullRequestPaginationQuery; + +#[derive(graphql_client::GraphQLQuery)] +#[graphql( + schema_path = "src/api/graphql/schema.json", + query_path = "src/api/graphql/fetch_pull_request.graphql" + extern_enums("PullRequestState") )] struct PullRequestQuery; +#[derive(graphql_client::GraphQLQuery)] +#[graphql( + schema_path = "src/api/graphql/schema.json", + query_path = "src/api/graphql/fetch_pull_request_timeline.graphql" +)] +struct PullRequestTimelineQuery; + +pub(super) type PullRequestTimelineResponse = pull_request_timeline_query::ResponseData; +#[cfg(test)] +pub(super) type PullRequestTimelineResponseNode = PullRequestTimelineQueryNode; +#[cfg(test)] +pub(super) type PullRequestTimelineNode = + pull_request_timeline_query::PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes; + +use self::pull_request_timeline_query::{ + PullRequestReviewState, PullRequestTimelineQueryNode, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes, actorFields, actorFieldsOn, + assigneeFields, requestedReviewerFields, +}; + #[derive(Clone)] pub(crate) struct ListPullRequests { pub filter: Option<&'static str>, @@ -103,20 +289,15 @@ impl query::QueryFn for ListPullRequests { None => "is:pr archived:false sort:updated-desc".into(), }; - let gql = PullRequestQuery::build_query(pull_request_query::Variables { - query: query_string, - }); + let gql = + PullRequestPaginationQuery::build_query(pull_request_pagination_query::Variables { + query: query_string, + }); - let res = c - .github_request(Method::POST, "/graphql")? - .json(&gql) - .send() - .await?; + let res = c.github_graphql_request(&gql)?.send().await?; - let data = api::parse_graphql_response::(res) - .await? - .data - .unwrap(); + let (_, data) = + api::parse_graphql_response::(res).await?; Ok(PullRequestPaginatedResponse { items: data @@ -127,10 +308,11 @@ impl query::QueryFn for ListPullRequests { .flatten() .filter_map(|edge| { edge.node.and_then(|n| match n { - PullRequestQuerySearchEdgesNode::PullRequest(p) => { + PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => { Some(PullRequest { + id: p.id.into(), title: p.title, - state: p.state.into(), + state: p.state, is_draft: p.is_draft, repo_slug: format!( "{}/{}", @@ -149,3 +331,399 @@ impl query::QueryFn for ListPullRequests { }) } } + +#[derive(Clone)] +pub(crate) struct FetchPullRequest { + pub(crate) id: Id, +} + +impl query::QueryFn for FetchPullRequest { + type Data = DetailedPullRequest; + type Error = api::Error; + type Context = api::QueryContext; + + fn key(&self) -> query::Key { + format!("issues/{}", self.id).into() + } + + async fn run(&self, c: &Self::Context) -> Result { + #[cfg(debug_assertions)] + if c.should_use_fixtures { + return super::mock::fetch_pull_request(&self.id); + } + + let gql = PullRequestQuery::build_query(pull_request_query::Variables { + id: self.id.clone().into(), + }); + + let res = c.github_graphql_request(&gql)?.send().await?; + let (_, data) = + api::parse_graphql_response::(res).await?; + + data.node + .ok_or(api::Error::MalformedResponse( + "missing 'node' field on PullRequestQuery response".into(), + )) + .and_then(|n| match n { + PullRequestQueryNode::PullRequest(p) => Ok(DetailedPullRequest { + title: p.title, + state: p.state, + is_draft: p.is_draft, + body: p.body, + }), + _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestQuery".into(), + )), + }) + } +} + +#[derive(Clone)] +pub(crate) struct FetchPullRequestTimeline { + pub(crate) id: Id, + pub(crate) first: i64, + pub(crate) after: Option, +} + +impl FetchPullRequestTimeline { + pub(crate) fn new(id: Id, first: i64, after: Option) -> Self { + Self { id, first, after } + } +} + +impl query::QueryFn for FetchPullRequestTimeline { + type Data = PullRequestTimeline; + type Error = api::Error; + type Context = api::QueryContext; + + fn key(&self) -> query::Key { + format!( + "issues/{}/timeline?first={}&after={}", + self.id, + self.first, + self.after.as_deref().unwrap_or_default() + ) + .into() + } + + async fn run(&self, c: &Self::Context) -> Result { + fn normalize_actor(actor: actorFields) -> TimelineActor { + let actorFields { + login, + avatar_url, + on, + } = actor; + + TimelineActor { + kind: match on { + actorFieldsOn::Bot => "Bot", + actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount", + actorFieldsOn::Mannequin => "Mannequin", + actorFieldsOn::Organization => "Organization", + actorFieldsOn::User => "User", + } + .into(), + name: login, + avatar_url: Some(avatar_url), + } + } + + fn normalize_assignee(actor: assigneeFields) -> TimelineActor { + match actor { + assigneeFields::Bot(actor) => TimelineActor { + kind: "Bot".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + assigneeFields::Mannequin(actor) => TimelineActor { + kind: "Mannequin".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + assigneeFields::Organization(actor) => TimelineActor { + kind: "Organization".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + assigneeFields::User(actor) => TimelineActor { + kind: "User".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + } + } + + fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor { + match actor { + requestedReviewerFields::Bot(actor) => TimelineActor { + kind: "Bot".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + requestedReviewerFields::Mannequin(actor) => TimelineActor { + kind: "Mannequin".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + requestedReviewerFields::Team(actor) => TimelineActor { + kind: "Team".into(), + name: actor.name, + avatar_url: None, + }, + requestedReviewerFields::User(actor) => TimelineActor { + kind: "User".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + } + } + + fn normalize_review_state(state: PullRequestReviewState) -> String { + match state { + PullRequestReviewState::PENDING => "PENDING", + PullRequestReviewState::COMMENTED => "COMMENTED", + PullRequestReviewState::APPROVED => "APPROVED", + PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED", + PullRequestReviewState::DISMISSED => "DISMISSED", + _ => "OTHER", + } + .into() + } + + fn normalize_item( + value: PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes, + ) -> PullRequestTimelineItem { + match value { + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AssignedEvent( + event, + ) => PullRequestTimelineItem::Assigned { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + assignee: event.assignee.map(normalize_assignee), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::UnassignedEvent( + event, + ) => PullRequestTimelineItem::Unassigned { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + assignee: event.assignee.map(normalize_assignee), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::IssueComment( + event, + ) => PullRequestTimelineItem::Comment { + created_at: event.created_at, + author: event.author.map(normalize_actor), + body: event.body, + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::PullRequestCommit( + event, + ) => PullRequestTimelineItem::Commit { + committed_at: event.commit.committed_date, + abbreviated_oid: event.commit.abbreviated_oid, + message_headline: event.commit.message_headline, + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::PullRequestReview( + event, + ) => PullRequestTimelineItem::Review { + created_at: event.created_at, + author: event.author.map(normalize_actor), + state: normalize_review_state(event.state), + body: event.body, + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReviewRequestedEvent( + event, + ) => PullRequestTimelineItem::ReviewRequested { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + reviewer: event.requested_reviewer.map(normalize_requested_reviewer), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReviewRequestRemovedEvent( + event, + ) => PullRequestTimelineItem::ReviewRequestRemoved { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + reviewer: event.requested_reviewer.map(normalize_requested_reviewer), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReviewDismissedEvent( + event, + ) => PullRequestTimelineItem::ReviewDismissed { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::MergedEvent( + event, + ) => PullRequestTimelineItem::Merged { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ClosedEvent( + event, + ) => PullRequestTimelineItem::Closed { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReopenedEvent( + event, + ) => PullRequestTimelineItem::Reopened { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ConvertToDraftEvent( + event, + ) => PullRequestTimelineItem::ConvertToDraft { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReadyForReviewEvent( + event, + ) => PullRequestTimelineItem::ReadyForReview { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::HeadRefForcePushedEvent( + event, + ) => PullRequestTimelineItem::HeadRefForcePushed { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + before_commit_oid: event.before_commit.map(|commit| commit.abbreviated_oid), + after_commit_oid: event.after_commit.map(|commit| commit.abbreviated_oid), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::BaseRefChangedEvent( + event, + ) => PullRequestTimelineItem::BaseRefChanged { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::LabeledEvent( + event, + ) => PullRequestTimelineItem::Labeled { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + label: event.label.name, + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::UnlabeledEvent( + event, + ) => PullRequestTimelineItem::Unlabeled { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + label: event.label.name, + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::MilestonedEvent( + event, + ) => PullRequestTimelineItem::Milestoned { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + milestone_title: event.milestone_title, + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::DemilestonedEvent( + event, + ) => PullRequestTimelineItem::Demilestoned { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + milestone_title: event.milestone_title, + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReferencedEvent( + event, + ) => PullRequestTimelineItem::Referenced { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::CrossReferencedEvent( + event, + ) => PullRequestTimelineItem::CrossReferenced { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AutoMergeEnabledEvent( + event, + ) => PullRequestTimelineItem::AutoMergeEnabled { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AutoMergeDisabledEvent( + event, + ) => PullRequestTimelineItem::AutoMergeDisabled { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + reason: event.reason.unwrap_or_default(), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AddedToMergeQueueEvent( + event, + ) => PullRequestTimelineItem::AddedToMergeQueue { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::RemovedFromMergeQueueEvent( + event, + ) => PullRequestTimelineItem::RemovedFromMergeQueue { + created_at: event.created_at, + actor: event.actor.map(normalize_actor), + }, + _ => PullRequestTimelineItem::Other { + typename: "Other".into(), + }, + } + } + + #[cfg(debug_assertions)] + let data = if c.should_use_fixtures { + super::mock::fetch_pull_request_timeline(&self.id, self.after.as_deref())? + } else { + let gql = + PullRequestTimelineQuery::build_query(pull_request_timeline_query::Variables { + id: self.id.clone().into(), + first: self.first, + after: self.after.clone(), + }); + + let res = c.github_graphql_request(&gql)?.send().await?; + + api::parse_graphql_response::(res) + .await? + .1 + }; + + #[cfg(not(debug_assertions))] + let data = { + let gql = + PullRequestTimelineQuery::build_query(pull_request_timeline_query::Variables { + id: self.id.clone().into(), + first: self.first, + after: self.after.clone(), + }); + + let res = c.github_graphql_request(&gql)?.send().await?; + + api::parse_graphql_response::(res) + .await? + .1 + }; + + let pull_request = data + .node + .ok_or(api::Error::MalformedResponse( + "missing 'node' field on PullRequestTimelineQuery response".into(), + )) + .and_then(|node| match node { + PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request), + _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestTimelineQuery".into(), + )), + })?; + + let timeline = pull_request.timeline_items; + let items = timeline + .nodes + .unwrap_or_default() + .into_iter() + .flatten() + .map(normalize_item) + .collect(); + + Ok(PullRequestTimeline { + items, + end_cursor: timeline.page_info.end_cursor, + has_next_page: timeline.page_info.has_next_page, + }) + } +} diff --git a/src/api/mock.rs b/src/api/mock.rs index ca3d053..23ea297 100644 --- a/src/api/mock.rs +++ b/src/api/mock.rs @@ -29,11 +29,56 @@ pub(crate) fn list_pull_requests( page: u32, ) -> Result { let filter = filter.unwrap_or_default(); - let json = issues_pull_requests(filter, page).ok_or_else(|| { - api::Error::MissingMockFixture(format!("issues.pull_requests filter={filter} page={page}")) + let fixture_filter = issue_filter_fixture_key(filter); + let json = issues_pull_requests(fixture_filter, page).ok_or_else(|| { + api::Error::MissingMockFixture(format!( + "issues.pull_requests filter={filter} fixture_filter={fixture_filter} page={page}" + )) })?; - parse_fixture(&format!("issues.pull_requests.{filter}.page{page}"), json) + parse_fixture( + &format!("issues.pull_requests.{fixture_filter}.page{page}"), + json, + ) +} + +pub(crate) fn fetch_pull_request( + id: &issues::Id, +) -> Result { + let id = id.to_string(); + let json = issues_pull_request(&id) + .ok_or_else(|| api::Error::MissingMockFixture(format!("issues.pull_request id={id}")))?; + + parse_fixture(&format!("issues.pull_request.{id}"), json) +} + +pub(crate) fn fetch_pull_request_timeline( + id: &issues::Id, + after: Option<&str>, +) -> Result { + let id = id.to_string(); + let json = issues_pull_request_timeline(&id, after).ok_or_else(|| { + api::Error::MissingMockFixture(format!( + "issues.pull_request_timeline id={id} after={}", + after.unwrap_or_default() + )) + })?; + + parse_fixture( + &format!( + "issues.pull_request_timeline.{id}.after.{}", + after.unwrap_or("start") + ), + json, + ) + .map_err(|err| { + println!( + "[mock fixture] failed to parse issues.pull_request_timeline.{id}.after.{}: {:?}", + after.unwrap_or("start"), + err + ); + err + }) } fn parse_fixture(name: &str, json: &'static str) -> Result @@ -42,6 +87,184 @@ where { serde_json::from_str(json).map_err(|err| { println!("[mock fixture] failed to parse {name}: {err}"); - api::Error::MalformedResponse(err) + api::Error::MalformedResponse(err.to_string()) }) } + +fn issue_filter_fixture_key(filter: &str) -> &str { + let filter = filter.trim(); + if filter.is_empty() { + return "all"; + } + + if filter.contains("author:@me") { + return "created"; + } + + if filter.contains("review-requested:@me") || filter.contains("assignee:@me") { + return "assigned"; + } + + filter +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_pull_request_fixtures_parse_with_current_filter_strings() { + let authored = list_pull_requests(Some("author:@me state:open"), 1) + .expect("authored fixture should parse"); + let assigned = list_pull_requests(Some("review-requested:@me"), 1) + .expect("assigned fixture should parse"); + let all = list_pull_requests(None, 1).expect("all fixture should parse"); + + assert_eq!(authored.items[0].id, issues::Id::from("PR_kwDONovem85")); + assert_eq!(assigned.items[0].state, issues::PullRequestState::Merged); + assert_eq!(all.items[0].state, issues::PullRequestState::Open); + } + + #[test] + fn pull_request_detail_fixtures_parse() { + let merged = fetch_pull_request(&issues::Id::from("PR_kwDOSprint62")) + .expect("merged pull request fixture should parse"); + let documented_failover = fetch_pull_request(&issues::Id::from("PR_kwDOInfra19")) + .expect("closed pull request fixture should parse"); + let dashboard_markdown = fetch_pull_request(&issues::Id::from("PR_kwDONovem84")) + .expect("dashboard pull request fixture should parse"); + + assert_eq!(merged.state, issues::PullRequestState::Merged); + assert!(merged.body.contains("| Stage | Owner | Status |")); + assert!( + documented_failover + .body + .contains("./scripts/failover promote-standby") + ); + assert!(dashboard_markdown.body.contains("```rust")); + } + + #[test] + fn pull_request_timeline_fixtures_parse() { + let first_page = fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), None) + .expect("first timeline fixture page should parse"); + + let first_page_json: serde_json::Value = + serde_json::from_str(issues_pull_request_timeline("PR_kwDOSprint62", None).unwrap()) + .expect("first timeline fixture json should parse"); + + let after = first_page_json + .get("node") + .and_then(|node| node.get("timelineItems")) + .and_then(|timeline| timeline.get("pageInfo")) + .and_then(|page_info| page_info.get("endCursor")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .expect("first timeline fixture page should provide an end cursor"); + + let second_page = + fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), Some(&after)) + .expect("second timeline fixture page should parse"); + + let second_page_json: serde_json::Value = serde_json::from_str( + issues_pull_request_timeline("PR_kwDOSprint62", Some(&after)).unwrap(), + ) + .expect("second timeline fixture json should parse"); + + let first_nodes = first_page_json + .get("node") + .and_then(|node| node.get("timelineItems")) + .and_then(|timeline| timeline.get("nodes")) + .and_then(serde_json::Value::as_array) + .map(Vec::len) + .expect("first timeline fixture page should contain nodes"); + + let second_has_next_page = second_page_json + .get("node") + .and_then(|node| node.get("timelineItems")) + .and_then(|timeline| timeline.get("pageInfo")) + .and_then(|page_info| page_info.get("hasNextPage")) + .and_then(serde_json::Value::as_bool) + .expect("second timeline fixture page should contain pagination data"); + + let third_cursor = second_page_json + .get("node") + .and_then(|node| node.get("timelineItems")) + .and_then(|timeline| timeline.get("pageInfo")) + .and_then(|page_info| page_info.get("endCursor")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .expect("second timeline fixture page should provide an end cursor"); + + let third_page = + fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), Some(&third_cursor)) + .expect("third timeline fixture page should parse"); + + let third_page_json: serde_json::Value = serde_json::from_str( + issues_pull_request_timeline("PR_kwDOSprint62", Some(&third_cursor)).unwrap(), + ) + .expect("third timeline fixture json should parse"); + + let first_page_nodes = match first_page.node.as_ref() { + Some(issues::PullRequestTimelineResponseNode::PullRequest(pull_request)) => { + pull_request + .timeline_items + .nodes + .as_ref() + .expect("first timeline fixture page should contain timeline nodes") + } + _ => panic!("first timeline fixture page should resolve to a pull request node"), + }; + + let second_page_nodes = match second_page.node.as_ref() { + Some(issues::PullRequestTimelineResponseNode::PullRequest(pull_request)) => { + pull_request + .timeline_items + .nodes + .as_ref() + .expect("second timeline fixture page should contain timeline nodes") + } + _ => panic!("second timeline fixture page should resolve to a pull request node"), + }; + + let third_page_nodes = match third_page.node.as_ref() { + Some(issues::PullRequestTimelineResponseNode::PullRequest(pull_request)) => { + pull_request + .timeline_items + .nodes + .as_ref() + .expect("third timeline fixture page should contain timeline nodes") + } + _ => panic!("third timeline fixture page should resolve to a pull request node"), + }; + + assert_eq!( + first_page_json["node"]["timelineItems"]["pageInfo"]["endCursor"], + "timeline:PR_kwDOSprint62:page1" + ); + assert!(first_nodes >= 5); + assert!(second_has_next_page); + assert_eq!( + second_page_json["node"]["timelineItems"]["pageInfo"]["hasNextPage"], + true + ); + assert_eq!( + third_page_json["node"]["timelineItems"]["pageInfo"]["hasNextPage"], + false + ); + assert!(matches!( + first_page_nodes.first(), + Some(Some(issues::PullRequestTimelineNode::AssignedEvent(_))) + )); + assert!(second_page_nodes.iter().flatten().any(|item| matches!( + item, + issues::PullRequestTimelineNode::AutoMergeDisabledEvent(_) + ))); + assert!( + third_page_nodes + .iter() + .flatten() + .any(|item| matches!(item, issues::PullRequestTimelineNode::MergedEvent(_))) + ); + } +} diff --git a/src/component/markdown.rs b/src/component/markdown.rs new file mode 100644 index 0000000..b3e74d7 --- /dev/null +++ b/src/component/markdown.rs @@ -0,0 +1,502 @@ +// markdown treesitter playground: https://ikatyang.github.io/tree-sitter-markdown/ + +use std::ops::Range; + +use gpui::{AppContext, ParentElement, Refineable, RenderOnce, Styled, div, px, rems}; +use tree_sitter::Node; + +use crate::{app, theme}; + +const MARKDOWN_KIND_ID_SETEXT_H1_UNDERLINE: u16 = 8; +const MARKDOWN_KIND_ID_SETEXT_H2_UNDERLINE: u16 = 9; +const MARKDOWN_KIND_ID_ATX_H1_MARKER: u16 = 11; +const MARKDOWN_KIND_ID_ATX_H2_MARKER: u16 = 12; +const MARKDOWN_KIND_ID_ATX_H3_MARKER: u16 = 13; +const MARKDOWN_KIND_ID_ATX_H4_MARKER: u16 = 14; +const MARKDOWN_KIND_ID_ATX_H5_MARKER: u16 = 15; +const MARKDOWN_KIND_ID_ATX_H6_MARKER: u16 = 16; +const MARKDOWN_KIND_ID_LIST_MARKER: u16 = 48; +const MARKDOWN_KIND_ID_BACKSLASH_ESCAPE: u16 = 56; +const MARKDOWN_KIND_ID_CHARACTER_REFERENCE: u16 = 57; +const MARKDOWN_KIND_ID_TABLE_COLUMN_ALIGNMENT: u16 = 107; +const MARKDOWN_KIND_ID_HARD_LINE_BREAK: u16 = 110; +const MARKDOWN_KIND_ID_SOFT_LINE_BREAK: u16 = 111; +const MARKDOWN_KIND_ID_HTML_TAG_NAME: u16 = 117; +const MARKDOWN_KIND_ID_VIRTUAL_SPACE: u16 = 118; +const MARKDOWN_KIND_ID_DOCUMENT: u16 = 119; +const MARKDOWN_KIND_ID_THEMATIC_BREAK: u16 = 122; +const MARKDOWN_KIND_ID_PARAGRAPH: u16 = 124; +const MARKDOWN_KIND_ID_LINK_REFERENCE_DEFINITION: u16 = 126; +const MARKDOWN_KIND_ID_SETEXT_HEADING: u16 = 129; +const MARKDOWN_KIND_ID_ATX_HEADING: u16 = 132; +const MARKDOWN_KIND_ID_INDENTED_CODE_BLOCK: u16 = 134; +const MARKDOWN_KIND_ID_FENCED_CODE_BLOCK: u16 = 136; +const MARKDOWN_KIND_ID_CODE_FENCE_CONTENT: u16 = 138; +const MARKDOWN_KIND_ID_HTML_BLOCK_SCRIPT: u16 = 140; +const MARKDOWN_KIND_ID_HTML_BLOCK_COMMENT: u16 = 142; +const MARKDOWN_KIND_ID_HTML_BLOCK_PROCESSING: u16 = 144; +const MARKDOWN_KIND_ID_HTML_BLOCK_DECLARATION: u16 = 146; +const MARKDOWN_KIND_ID_HTML_BLOCK_CDATA: u16 = 148; +const MARKDOWN_KIND_ID_HTML_BLOCK_DIV: u16 = 150; +const MARKDOWN_KIND_ID_HTML_BLOCK_CMP: u16 = 152; +const MARKDOWN_KIND_ID_BLOCK_QUOTE: u16 = 154; +const MARKDOWN_KIND_ID_TIGHT_LIST: u16 = 156; +const MARKDOWN_KIND_ID_LOOSE_LIST: u16 = 158; +const MARKDOWN_KIND_ID_LIST_ITEM_TIGHT: u16 = 160; +const MARKDOWN_KIND_ID_TASK_LIST_ITEM_TIGHT: u16 = 161; +const MARKDOWN_KIND_ID_LIST_ITEM_LOOSE: u16 = 163; +const MARKDOWN_KIND_ID_TASK_LIST_ITEM_LOOSE: u16 = 164; +const MARKDOWN_KIND_ID_PARAGRAPH_TASK_LIST: u16 = 166; +const MARKDOWN_KIND_ID_SETEXT_HEADING_TASK_LIST: u16 = 168; +const MARKDOWN_KIND_ID_HEADING_CONTENT: u16 = 169; +const MARKDOWN_KIND_ID_TABLE: u16 = 170; +const MARKDOWN_KIND_ID_TABLE_HEADER_ROW: u16 = 172; +const MARKDOWN_KIND_ID_TABLE_DELIMITER_ROW: u16 = 174; +const MARKDOWN_KIND_ID_TABLE_DATA_ROW: u16 = 176; +const MARKDOWN_KIND_ID_EMPHASIS: u16 = 181; +const MARKDOWN_KIND_ID_STRONG_EMPHASIS: u16 = 182; +const MARKDOWN_KIND_ID_STRIKETHROUGH: u16 = 183; +const MARKDOWN_KIND_ID_LINK: u16 = 184; +const MARKDOWN_KIND_ID_IMAGE: u16 = 185; +const MARKDOWN_KIND_ID_LINK_DESTINATION: u16 = 190; +const MARKDOWN_KIND_ID_LINK_TITLE: u16 = 191; +const MARKDOWN_KIND_ID_WWW_AUTOLINK: u16 = 192; +const MARKDOWN_KIND_ID_URI_AUTOLINK_EXTENDED: u16 = 194; +const MARKDOWN_KIND_ID_EMAIL_AUTOLINK_EXTENDED: u16 = 196; +const MARKDOWN_KIND_ID_URI_AUTOLINK_ANGLE: u16 = 198; +const MARKDOWN_KIND_ID_EMAIL_AUTOLINK_ANGLE: u16 = 199; +const MARKDOWN_KIND_ID_CODE_SPAN: u16 = 200; +const MARKDOWN_KIND_ID_HTML_OPEN_TAG: u16 = 201; +const MARKDOWN_KIND_ID_HTML_SELF_CLOSING_TAG: u16 = 202; +const MARKDOWN_KIND_ID_HTML_CLOSE_TAG: u16 = 204; +const MARKDOWN_KIND_ID_HTML_COMMENT: u16 = 205; +const MARKDOWN_KIND_ID_HTML_PROCESSING_INSTRUCTION: u16 = 206; +const MARKDOWN_KIND_ID_HTML_DECLARATION: u16 = 207; +const MARKDOWN_KIND_ID_HTML_CDATA_SECTION: u16 = 208; +const MARKDOWN_KIND_ID_HTML_ATTRRIBUTE: u16 = 209; +const MARKDOWN_KIND_ID_HTML_ATTRIBUTE_VALUE: u16 = 210; +const MARKDOWN_KIND_ID_TEXT: u16 = 211; +const MARKDOWN_KIND_ID_HTML_ATTRIBUTE_KEY: u16 = 228; +const MARKDOWN_KIND_ID_HTML_DECLARATION_NAME: u16 = 229; +const MARKDOWN_KIND_ID_IMAGE_DESCRIPTION: u16 = 230; +const MARKDOWN_KIND_ID_INFO_STRING: u16 = 231; +const MARKDOWN_KIND_ID_LINE_BREAK: u16 = 232; +const MARKDOWN_KIND_ID_LINK_LABEL: u16 = 233; +const MARKDOWN_KIND_ID_LINK_TEXT: u16 = 234; +const MARKDOWN_KIND_ID_TABLE_CELL: u16 = 235; +const MARKDOWN_KIND_ID_TASK_LIST_ITEM_MARKER: u16 = 236; + +pub(crate) struct MarkdownText { + content: gpui::SharedString, + blocks: Vec, +} + +enum ContentBlock { + Text { + decoration: Option<&'static str>, + text: gpui::SharedString, + highlights: Vec<(Range, gpui::HighlightStyle)>, + links: Vec<(Range, gpui::SharedString)>, + style: gpui::StyleRefinement, + }, +} + +pub(crate) fn new( + content: gpui::SharedString, + cx: &mut gpui::Context, +) -> MarkdownText { + let mut view = MarkdownText { + content, + blocks: Vec::new(), + }; + view.on_create(cx); + view +} + +impl Styled for ContentBlock { + fn style(&mut self) -> &mut gpui::StyleRefinement { + match self { + | ContentBlock::Text { style, .. } => style, + } + } +} + +impl MarkdownText { + fn on_create(&mut self, cx: &gpui::Context) { + let content = self.content.clone(); + let t = cx.background_spawn(async move { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(tree_sitter_markdown::language()) + .expect("tree-sitter-markdown language should load"); + parser.parse(content.as_str(), None) + }); + + cx.spawn(async |weak, cx| { + if let Some(tree) = t.await { + _ = weak.update(cx, |this, cx| { + let theme = app::current_theme(cx); + this.render_tree(&tree, &theme); + cx.notify(); + }); + }; + }) + .detach(); + } + + fn on_open_link(&self, _link: &str, _cx: &gpui::Context) {} + + fn render_tree(&mut self, tree: &tree_sitter::Tree, theme: &theme::Theme) { + let mut cursor = tree.walk(); + cursor.goto_first_child(); + + fn block_for_node( + cursor: &mut tree_sitter::TreeCursor, + content: &str, + // byte_offset is the number of bytes to offset the content start byte by + byte_offset: usize, + theme: &theme::Theme, + ) -> ContentBlock { + let node_start_byte = cursor.node().start_byte(); + + let mut highlights: Vec<(Range, gpui::HighlightStyle)> = Vec::new(); + let mut links: Vec<(Range, gpui::SharedString)> = Vec::new(); + + cursor.goto_first_child(); + + loop { + let node = cursor.node(); + + macro_rules! node_range { + () => { + (node.start_byte() - node_start_byte - byte_offset) + ..(node.end_byte() - node_start_byte - byte_offset) + }; + } + + match node.kind_id() { + | MARKDOWN_KIND_ID_EMPHASIS => { + highlights.push(( + node_range!(), + gpui::HighlightStyle { + font_style: Some(gpui::FontStyle::Italic), + ..Default::default() + }, + )); + } + | MARKDOWN_KIND_ID_STRONG_EMPHASIS => highlights.push(( + node_range!(), + gpui::HighlightStyle { + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + }, + )), + + | MARKDOWN_KIND_ID_LINK => { + if cursor.goto_first_child() { + highlights.push(( + node_range!(), + gpui::HighlightStyle { + color: Some(theme.colors.accent.into()), + underline: Some(gpui::UnderlineStyle { + color: Some(theme.colors.accent.into()), + thickness: px(1.), + wavy: false, + }), + ..Default::default() + }, + )); + + if cursor.goto_next_sibling() + && let Ok(src) = cursor.node().utf8_text(content.as_bytes()) + { + links + .push((node_range!(), gpui::SharedString::from(String::from(src)))); + } else { + // the link src is invalid, use an empty string as a fallback + // link on click handler will ignore empty string + links.push((node_range!(), "".into())) + } + } + } + + | _ => { + // extend here to support more markdown node stylings + } + }; + + if !cursor.goto_next_sibling() { + break; + } + } + + cursor.goto_parent(); + + ContentBlock::Text { + decoration: None, + text: gpui::SharedString::new( + &content[(node_start_byte + byte_offset)..cursor.node().end_byte()], + ), + highlights: highlights, + links: links, + style: gpui::StyleRefinement::default(), + } + } + + loop { + let current_node = cursor.node(); + + fn render_fallback_content( + cursor: &tree_sitter::TreeCursor, + content: &str, + blocks: &mut Vec, + ) { + blocks.push(ContentBlock::Text { + decoration: None, + text: gpui::SharedString::new(&content[cursor.node().byte_range()]), + highlights: Vec::new(), + links: Vec::new(), + style: gpui::StyleRefinement::default(), + }); + } + + fn render_list_node( + cursor: &mut tree_sitter::TreeCursor, + content: &str, + blocks: &mut Vec, + theme: &theme::Theme, + indentation: usize, + ) -> bool { + // expected tree shape for node pointed to by cursor: + // tight_list + // list_item + // list_marker + // paragraph + // tight_list <-- recursive point + + // go to list_item node + if !cursor.goto_first_child() { + render_fallback_content(&cursor, content, blocks); + return false; + } + + let mut list_index = 0; + + loop { + if cursor.node().kind_id() != MARKDOWN_KIND_ID_LIST_ITEM_TIGHT + // if is list_item node, dive into list_marker node + || !cursor.goto_first_child() + { + // encountered non lists item node under tight list node + // dont know what to do, so skipping this node + if !cursor.goto_next_sibling() { + return false; + } + continue; + } + + let marker_node = cursor.node(); + let marker_content = &content[marker_node.byte_range()]; + + match marker_content { + // unordered list item + | "-" | "+" | "*" => { + // go to paragraph sibling node + let block = if cursor.goto_next_sibling() { + let mut b = block_for_node(cursor, content, 0, theme); + match b { + | ContentBlock::Text { + ref mut decoration, .. + } => *decoration = Some("•"), + } + b + } else { + ContentBlock::Text { + decoration: Some("•"), + text: gpui::SharedString::default(), + highlights: Vec::new(), + links: Vec::new(), + style: gpui::StyleRefinement::default(), + } + } + .text_sm() + .text_color(theme.colors.text) + .p(rems(indentation as f32)); + + blocks.push(block); + + // if there is a nested tight_light after paragraph + // render it recursively + if cursor.goto_next_sibling() + && cursor.node().kind_id() == MARKDOWN_KIND_ID_TIGHT_LIST + { + render_list_node(cursor, content, blocks, theme, indentation + 1); + } + } + + | _ => { + render_fallback_content(&cursor, content, blocks); + return false; + } + } + + // go back to list_item node + cursor.goto_parent(); + + if !cursor.goto_next_sibling() { + // no more list_item in tight_list node + // go back up to tight_list node + cursor.goto_parent(); + return true; + } + } + } + + match current_node.kind_id() { + | MARKDOWN_KIND_ID_ATX_HEADING => { + if !cursor.goto_first_child() { + render_fallback_content(&cursor, &self.content, &mut self.blocks); + continue; + } + + let marker_node_kind = cursor.node().kind_id(); + + let block = if cursor.goto_next_sibling() + && cursor.node().kind_id() == MARKDOWN_KIND_ID_HEADING_CONTENT + { + // because HEADING_CONTENT node includes the space after the heading marker + // offset by 1 to exclude the space + block_for_node(&mut cursor, &self.content, 1, theme) + } else { + ContentBlock::Text { + decoration: None, + text: gpui::SharedString::new(&self.content[current_node.byte_range()]), + highlights: Vec::new(), + links: Vec::new(), + style: gpui::StyleRefinement::default(), + } + }; + + let block = match marker_node_kind { + | MARKDOWN_KIND_ID_ATX_H1_MARKER => block + .text_size(rems(2.25)) + .font_weight(gpui::FontWeight::EXTRA_BOLD) + .mb_8(), + | MARKDOWN_KIND_ID_ATX_H2_MARKER => block + .text_2xl() + .font_weight(gpui::FontWeight::BOLD) + .mt_12() + .mb_6(), + | MARKDOWN_KIND_ID_ATX_H3_MARKER => block + .text_xl() + .font_weight(gpui::FontWeight::SEMIBOLD) + .mt_8() + .mb_3(), + | MARKDOWN_KIND_ID_ATX_H4_MARKER => block + .text_base() + .font_weight(gpui::FontWeight::SEMIBOLD) + .mt_6() + .mb_2(), + | _ => block, + } + .text_color(theme.colors.text); + + cursor.goto_parent(); + + self.blocks.push(block); + } + + | MARKDOWN_KIND_ID_PARAGRAPH => { + let block = block_for_node(&mut cursor, &self.content, 0, theme) + .text_color(theme.colors.text) + .text_sm(); + + self.blocks.push(block); + } + + | MARKDOWN_KIND_ID_TIGHT_LIST => { + let is_rendered = + render_list_node(&mut cursor, &self.content, &mut self.blocks, theme, 0); + if !is_rendered { + continue; + } + } + + | _ => { + println!( + "[WARN] formatting not implemenetd for node type {:?}", + current_node.kind() + ); + + let block = block_for_node(&mut cursor, &self.content, 0, theme) + .text_color(theme.colors.text) + .text_sm(); + + self.blocks.push(block); + } + } + + if !cursor.goto_next_sibling() { + break; + } + } + } +} + +impl gpui::Render for MarkdownText { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::prelude::Context, + ) -> impl gpui::prelude::IntoElement { + let theme = app::current_theme(cx); + let children = self.blocks.iter().enumerate().map(|(i, block)| { + match block { + | ContentBlock::Text { + decoration, + text, + highlights, + links, + style, + } => { + let styled_text = + gpui::StyledText::new(text.clone()).with_highlights(highlights.clone()); + + let div = decoration + .map(|d| div().flex().flex_row().gap_2().items_start().child(d)) + .unwrap_or_else(|| div()); + + let mut div = if links.is_empty() { + // if no link in block, interactive text is not needed + div.child(styled_text) + } else { + // if link in block, interactive text is needed + // to handle link clicks + let (link_ranges, srcs): (Vec<_>, Vec<_>) = links.iter().cloned().unzip(); + + let weak = cx.entity(); + let t = gpui::InteractiveText::new(i, styled_text).on_click( + link_ranges, + move |i, _, cx| { + if let Some(src) = srcs.get(i) { + weak.update(cx, |this, cx| { + this.on_open_link(src, cx); + cx.notify(); + }) + } + }, + ); + + div.child(t) + }; + + div.style().refine(&style); + + div + } + } + }); + + div().flex().flex_col().children(children) + } +} diff --git a/src/component/mod.rs b/src/component/mod.rs index 330a3fa..778ae21 100644 --- a/src/component/mod.rs +++ b/src/component/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod button; pub(crate) mod font_icon; +pub(crate) mod markdown; pub(crate) mod text; diff --git a/src/component/text.rs b/src/component/text.rs index 4344973..7eb6f1b 100644 --- a/src/component/text.rs +++ b/src/component/text.rs @@ -100,6 +100,10 @@ impl gpui::RenderOnce for Text { div = div.text_color(theme.colors.text); } + if div.style().border_color.as_ref().is_none() { + div = div.border_color(theme.colors.border); + } + div } } diff --git a/src/screen/dashboard/issue_list.rs b/src/screen/dashboard/issue_list.rs index 3cfe505..9705651 100644 --- a/src/screen/dashboard/issue_list.rs +++ b/src/screen/dashboard/issue_list.rs @@ -1,4 +1,9 @@ -use gpui::{IntoElement, ParentElement, Styled, div, list, prelude::FluentBuilder, px}; +use std::ops::Deref; + +use gpui::{ + InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled, div, list, + prelude::FluentBuilder, px, +}; use crate::{ api::{self}, @@ -15,21 +20,21 @@ pub(crate) struct IssueList { list_state: gpui::ListState, list_items: Vec, + selected_item: Option<(usize, gpui::SharedString)>, } -#[derive(Clone)] -enum IssueStatus { - Draft, - Open, - Closed, +pub(crate) enum Event { + ItemSelected(api::issues::Id), } #[derive(gpui::IntoElement, Clone)] -struct IssueListItem { +pub(crate) struct IssueListItem { + id: gpui::SharedString, repo_name: Option, title: gpui::SharedString, description: Option, - status: api::issues::IssueState, + status: api::issues::PullRequestState, + is_selected: bool, is_last: bool, is_draft: bool, } @@ -46,6 +51,7 @@ pub(crate) fn new(cx: &mut gpui::Context) -> IssueList { list_state: gpui::ListState::new(0, gpui::ListAlignment::Top, px(100.)), list_items: Vec::new(), + selected_item: None, }; list.on_create(cx); list @@ -60,10 +66,16 @@ impl IssueList { let new_len = res.items.len(); let new_items = res.items.iter().enumerate().map(|(i, it)| IssueListItem { + id: gpui::SharedString::from(it.id.deref()), repo_name: Some(gpui::SharedString::new(it.repo_slug.as_str())), title: gpui::SharedString::new(it.title.as_str()), description: None, status: it.state, + is_selected: this + .selected_item + .as_ref() + .map(|(_, id)| id.as_str() == it.id.as_str()) + .unwrap_or(false), is_last: i == new_len - 1, is_draft: it.is_draft, }); @@ -74,6 +86,17 @@ impl IssueList { }) .detach(); } + + fn on_item_click(&mut self, i: usize, cx: &mut gpui::Context) { + let Some(item_id) = self.list_items.get(i).map(|item| item.id.clone()) else { + return; + }; + for (j, item) in self.list_items.iter_mut().enumerate() { + item.is_selected = i == j; + } + cx.notify(); + cx.emit(Event::ItemSelected(item_id.as_str().into())); + } } impl gpui::Render for IssueList { @@ -82,15 +105,31 @@ impl gpui::Render for IssueList { _window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - let this = cx.entity(); + let weak = cx.entity(); + list(self.list_state.clone(), move |i, _, cx| { - let this = this.read(cx); - this.list_items[i].clone().into_any_element() + let item = { + let this = weak.read(cx); + this.list_items[i].clone() + }; + let weak = weak.clone(); + + div() + .id(item.id.clone()) + .on_click(move |_, _, cx| { + _ = weak.update(cx, |this, cx| { + this.on_item_click(i, cx); + }) + }) + .child(item) + .into_any_element() }) .size_full() } } +impl gpui::EventEmitter for IssueList {} + impl gpui::RenderOnce for IssueListItem { fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { let theme = app::current_theme(cx); @@ -108,10 +147,10 @@ impl gpui::RenderOnce for IssueListItem { .opacity(0.5) } else { match self.status { - api::issues::IssueState::Closed => { + api::issues::PullRequestState::Closed => { font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger) } - api::issues::IssueState::Merged => { + api::issues::PullRequestState::Merged => { font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.success) } _ => font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success), @@ -127,7 +166,8 @@ impl gpui::RenderOnce for IssueListItem { div() .w_full() - .p_2() + .px_1p5() + .py_1() .gap_2() .flex() .flex_row() @@ -155,5 +195,12 @@ impl gpui::RenderOnce for IssueListItem { .when(!self.is_last, |it| { it.border_b_1().border_color(theme.colors.border) }) + .when(self.is_selected, |it| { + it.bg(theme.colors.surface_elevated) + .border_r_1() + .border_b_0() + .border_color(theme.colors.accent) + .pb(px(5.)) + }) } } diff --git a/src/screen/dashboard/mod.rs b/src/screen/dashboard/mod.rs index 0a37217..ed8834c 100644 --- a/src/screen/dashboard/mod.rs +++ b/src/screen/dashboard/mod.rs @@ -1,4 +1,5 @@ mod issue_list; +mod pull_request_view; mod screen; mod sidebar; mod titlebar; diff --git a/src/screen/dashboard/pull_request_view.rs b/src/screen/dashboard/pull_request_view.rs new file mode 100644 index 0000000..1d78e0b --- /dev/null +++ b/src/screen/dashboard/pull_request_view.rs @@ -0,0 +1,156 @@ +use gpui::{AppContext, IntoElement, ParentElement, Styled, div, prelude::FluentBuilder}; + +use crate::{ + api::{self, issues::PullRequest}, + app, + component::{ + font_icon::{FontIcon, font_icon}, + markdown::{self, MarkdownText}, + text::text, + }, + query::{self, QueryStatus, read_query, use_query}, +}; + +pub(crate) struct PullRequestView { + markdown_viewer: Option>, + + pull_request_query: Option>, +} + +pub fn new(cx: &mut gpui::Context) -> PullRequestView { + PullRequestView { + markdown_viewer: None, + pull_request_query: None, + } +} + +impl PullRequestView { + pub(crate) fn change_displayed_pull_request( + &mut self, + id: api::issues::Id, + cx: &mut gpui::Context, + ) { + let query = use_query(api::issues::FetchPullRequest { id }, cx); + + self.pull_request_query = Some(query.clone()); + + _ = cx + .observe(&query.clone(), move |this, _, cx| { + let maybe_content = { + let data = read_query(&query, cx); + if let QueryStatus::Loaded(pr) = data { + Some(gpui::SharedString::new(pr.body.as_str())) + } else { + None + } + }; + + this.markdown_viewer = + maybe_content.map(|content| cx.new(|cx| markdown::new(content, cx))) + }) + .detach(); + + cx.notify(); + } + + fn pr_content( + &self, + pr: &api::issues::DetailedPullRequest, + cx: &gpui::Context, + ) -> gpui::Div { + let theme = app::current_theme(cx); + + let mut status_pill = div() + .flex() + .flex_row() + .items_center() + .gap_1() + .px_2() + .rounded_full(); + + match pr.state { + | api::issues::PullRequestState::Open => { + status_pill = status_pill + .bg(theme.colors.success) + .child( + font_icon(FontIcon::PullRequestArrow) + .size_3() + .text_color(theme.colors.accent_text), + ) + .child(text("Open").text_color(theme.colors.accent_text).text_xs()); + } + | api::issues::PullRequestState::Closed => { + status_pill = status_pill + .bg(theme.colors.danger) + .child( + font_icon(FontIcon::PullRequestClosed) + .size_3() + .text_color(theme.colors.accent_text), + ) + .child( + text("Closed") + .text_color(theme.colors.accent_text) + .text_xs(), + ); + } + | api::issues::PullRequestState::Merged => { + status_pill = status_pill.bg(theme.colors.accent).child( + text("Merged") + .text_color(theme.colors.accent_text) + .text_xs(), + ); + } + } + + let author_pill = div() + .px_2() + .border_1() + .border_color(theme.colors.border) + .rounded_full() + .bg(theme.colors.surface_elevated) + .child(text("kennethnym").text_xs()); + + let row = div() + .flex() + .flex_row() + .gap_2() + .child(status_pill) + .child(author_pill); + + div() + .size_full() + .flex() + .flex_col() + .child( + div() + .w_full() + .px_3p5() + .py_3() + .border_b_1() + .border_color(theme.colors.border) + .child(text(pr.title.clone()).w_full().text_xl().mb_2()) + .child(row), + ) + .when_some(self.markdown_viewer.as_ref(), |it, viewer| { + it.child(div().h_full().p_3p5().child(viewer.clone())) + }) + } +} + +impl gpui::Render for PullRequestView { + fn render( + &mut self, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + match &self.pull_request_query { + | Some(q) => match read_query(q, cx) { + | QueryStatus::Loaded(pr) => self.pr_content(pr, cx), + | QueryStatus::Err(e) => div().child(format!("{:?}", e)), + | QueryStatus::Loading => div().child("loading pr content"), + }, + + | None => div().child("no pr selected"), + } + } +} diff --git a/src/screen/dashboard/screen.rs b/src/screen/dashboard/screen.rs index 34ed3ea..50703b9 100644 --- a/src/screen/dashboard/screen.rs +++ b/src/screen/dashboard/screen.rs @@ -1,9 +1,10 @@ -use gpui::{AppContext, BorrowAppContext, ParentElement, Styled, div}; +use gpui::{AppContext, ParentElement, Styled, div}; use crate::{ - app, + api, app, screen::dashboard::{ issue_list::{self, IssueList}, + pull_request_view::{self, PullRequestView}, sidebar::{self, Sidebar, SidebarItemValue}, titlebar::{self, TitleBar}, }, @@ -13,6 +14,7 @@ pub(crate) struct Screen { titlebar: gpui::Entity, issue_list: gpui::Entity, sidebar: gpui::Entity, + pull_request_view: gpui::Entity, issue_filter: Option<&'static str>, } @@ -22,6 +24,8 @@ pub(crate) fn new(cx: &mut gpui::Context) -> Screen { titlebar: cx.new(titlebar::new), issue_list: cx.new(issue_list::new), sidebar: cx.new(|_| sidebar::new()), + pull_request_view: cx.new(pull_request_view::new), + issue_filter: None, }; screen.on_create(cx); @@ -36,6 +40,14 @@ impl Screen { self.sidebar.update(cx, |sidebar, _| { sidebar.on_item_change(on_item_change); }); + + _ = cx + .subscribe(&self.issue_list, |this, _, event, cx| match event { + issue_list::Event::ItemSelected(pr_id) => { + this.handle_issue_list_item_selected(pr_id, cx); + } + }) + .detach(); } fn handle_sidebar_item_change( @@ -50,6 +62,19 @@ impl Screen { } } } + + fn handle_issue_list_item_selected( + &mut self, + id: &api::issues::Id, + cx: &mut gpui::Context, + ) { + println!("handle issue list item selected: {:?}", id); + self.pull_request_view.update(cx, |view, cx| { + view.change_displayed_pull_request(id.clone(), cx); + println!("change displayed pull request: {:?}", id); + cx.notify(); + }) + } } impl gpui::Render for Screen { @@ -71,10 +96,17 @@ impl gpui::Render for Screen { .flex_row() .flex_1() .w_full() - .child(div().w_40().h_full().child(self.sidebar.clone())) .child( div() - .w_80() + .w_40() + .flex_shrink_0() + .h_full() + .child(self.sidebar.clone()), + ) + .child( + div() + .w_64() + .flex_shrink_0() .h_full() .bg(theme.colors.surface) .border_x_1() @@ -86,10 +118,12 @@ impl gpui::Render for Screen { .child( div() .flex_1() + .min_w_0() .h_full() .bg(theme.colors.surface) .border_l_1() - .border_color(theme.colors.border), + .border_color(theme.colors.border) + .child(self.pull_request_view.clone()), ), ) }