wip: pull request view & md rendering
This commit is contained in:
0
.zed/tasks.json
Normal file
0
.zed/tasks.json
Normal file
@@ -15,6 +15,8 @@ reqwest = { version = "0.13.2", features = ["form", "json", "query"] }
|
|||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time"] }
|
tokio = { version = "1.52.1", features = ["rt-multi-thread", "net", "time"] }
|
||||||
|
tree-sitter = "0.19.5"
|
||||||
|
tree-sitter-markdown = "0.7.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
|||||||
166
build.rs
166
build.rs
@@ -9,6 +9,12 @@ struct AssetFile {
|
|||||||
disk_path: PathBuf,
|
disk_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TimelineFixturePage {
|
||||||
|
json: String,
|
||||||
|
end_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let manifest_dir =
|
let manifest_dir =
|
||||||
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_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 repo_list = read_json_fixture(&fixture_root.join("repo.list.json"));
|
||||||
|
|
||||||
let mut issue_fixtures = BTreeMap::<(String, u32), String>::new();
|
let mut issue_fixtures = BTreeMap::<(String, u32), String>::new();
|
||||||
|
let mut pull_request_fixtures = BTreeMap::<String, String>::new();
|
||||||
|
let mut pull_request_timeline_fixtures =
|
||||||
|
BTreeMap::<String, BTreeMap<u32, TimelineFixturePage>>::new();
|
||||||
let mut entries = fs::read_dir(fixture_root)
|
let mut entries = fs::read_dir(fixture_root)
|
||||||
.unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture_root.display()))
|
.unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture_root.display()))
|
||||||
.map(|entry| entry.expect("failed to read github fixture entry"))
|
.map(|entry| entry.expect("failed to read github fixture entry"))
|
||||||
@@ -141,11 +150,24 @@ fn render_github_fixtures(fixture_root: &Path) -> String {
|
|||||||
.into_string()
|
.into_string()
|
||||||
.unwrap_or_else(|_| panic!("non-utf8 fixture name in {}", fixture_root.display()));
|
.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;
|
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();
|
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("}\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
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_json_fixture(path: &Path) -> String {
|
fn read_fixture_value(path: &Path) -> serde_json::Value {
|
||||||
let raw = fs::read_to_string(path)
|
let raw = fs::read_to_string(path)
|
||||||
.unwrap_or_else(|err| panic!("failed to read fixture {}: {err}", path.display()));
|
.unwrap_or_else(|err| panic!("failed to read fixture {}: {err}", path.display()));
|
||||||
let value: serde_json::Value = serde_json::from_str(&raw)
|
serde_json::from_str(&raw)
|
||||||
.unwrap_or_else(|err| panic!("invalid json fixture {}: {err}", path.display()));
|
.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)
|
serde_json::to_string(&value)
|
||||||
.unwrap_or_else(|err| panic!("failed to serialize fixture {}: {err}", path.display()))
|
.unwrap_or_else(|err| panic!("failed to serialize fixture {}: {err}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_issue_fixture(path: &Path) -> String {
|
fn read_issue_fixture(value: &serde_json::Value, 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()));
|
|
||||||
let issues = value
|
let issues = value
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap_or_else(|| panic!("issue fixture {} must be a json array", path.display()));
|
.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 {
|
fn map_issue_fixture(issue: &serde_json::Value) -> serde_json::Value {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
"id": issue_fixture_graphql_id(issue),
|
||||||
"title": required_string(issue, &["title"]),
|
"title": required_string(issue, &["title"]),
|
||||||
"state": issue_fixture_state(issue),
|
"state": issue_fixture_state(issue),
|
||||||
"is_draft": issue.get("draft").and_then(serde_json::Value::as_bool).unwrap_or(false),
|
"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)
|
.and_then(serde_json::Value::as_str)
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
return "Merged";
|
return "MERGED";
|
||||||
}
|
}
|
||||||
|
|
||||||
match required_string(issue, &["state"]) {
|
match required_string(issue, &["state"]) {
|
||||||
"open" => "Open",
|
"open" => "OPEN",
|
||||||
"closed" => "Closed",
|
"closed" => "CLOSED",
|
||||||
_ => "Unknown",
|
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<String> {
|
fn issue_fixture_cursor(issue: &serde_json::Value) -> Option<String> {
|
||||||
issue
|
Some(issue_fixture_graphql_id(issue).to_owned())
|
||||||
.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())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn required_string<'a>(value: &'a serde_json::Value, path: &[&str]) -> &'a str {
|
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))
|
Some((filter.to_owned(), page))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_pull_request_fixture_name(file_name: &str) -> Option<String> {
|
||||||
|
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::<u32>().ok()?;
|
||||||
|
Some((id.to_owned(), page))
|
||||||
|
}
|
||||||
|
|
||||||
fn string_literal(value: &str) -> String {
|
fn string_literal(value: &str) -> String {
|
||||||
format!("{value:?}")
|
format!("{value:?}")
|
||||||
}
|
}
|
||||||
|
|||||||
6
fixtures/github/issues.pull_request.PR_kwDOAgent47.json
Normal file
6
fixtures/github/issues.pull_request.PR_kwDOAgent47.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
6
fixtures/github/issues.pull_request.PR_kwDODesign31.json
Normal file
6
fixtures/github/issues.pull_request.PR_kwDODesign31.json
Normal file
@@ -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."
|
||||||
|
}
|
||||||
6
fixtures/github/issues.pull_request.PR_kwDOInfra19.json
Normal file
6
fixtures/github/issues.pull_request.PR_kwDOInfra19.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
6
fixtures/github/issues.pull_request.PR_kwDONovem84.json
Normal file
6
fixtures/github/issues.pull_request.PR_kwDONovem84.json
Normal file
@@ -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."
|
||||||
|
}
|
||||||
6
fixtures/github/issues.pull_request.PR_kwDONovem85.json
Normal file
6
fixtures/github/issues.pull_request.PR_kwDONovem85.json
Normal file
@@ -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."
|
||||||
|
}
|
||||||
6
fixtures/github/issues.pull_request.PR_kwDOSprint62.json
Normal file
6
fixtures/github/issues.pull_request.PR_kwDOSprint62.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(dashboard): hydrate issue pane from cached query state",
|
"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_text": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"state": "closed",
|
"state": "closed",
|
||||||
"state_reason": "completed",
|
"state_reason": "completed",
|
||||||
"title": "feat(calendar): ship release handoff checklist in weekly planner",
|
"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_text": "Adds the release checklist views and marks the handoff flow complete for the May rollout.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(repo): add cached repository query for titlebar picker",
|
"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_text": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -344,7 +344,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(prompts): split context loading from execution workers",
|
"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_text": "Separates prompt packing from worker orchestration to make delegation easier to reason about.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "chore(tokens): tighten dashboard spacing scale",
|
"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_text": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"state": "closed",
|
"state": "closed",
|
||||||
"state_reason": "not_planned",
|
"state_reason": "not_planned",
|
||||||
"title": "docs(deploy): document manual failover steps",
|
"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_text": null,
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "closed",
|
"state": "closed",
|
||||||
"state_reason": "completed",
|
"state_reason": "completed",
|
||||||
"title": "feat(calendar): ship release handoff checklist in weekly planner",
|
"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_text": "Adds the release checklist views and marks the handoff flow complete for the May rollout.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(prompts): split context loading from execution workers",
|
"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_text": "Separates prompt packing from worker orchestration to make delegation easier to reason about.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "chore(tokens): tighten dashboard spacing scale",
|
"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_text": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(repo): add cached repository query for titlebar picker",
|
"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_text": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(dashboard): hydrate issue pane from cached query state",
|
"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_text": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "closed",
|
"state": "closed",
|
||||||
"state_reason": "not_planned",
|
"state_reason": "not_planned",
|
||||||
"title": "docs(deploy): document manual failover steps",
|
"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_text": null,
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
2
rustfmt.toml
Normal file
2
rustfmt.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
match_arm_indent = false
|
||||||
|
match_arm_leading_pipes = "Always"
|
||||||
25
src/api.rs
25
src/api.rs
@@ -1,3 +1,4 @@
|
|||||||
|
use reqwest::Method;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::query;
|
use crate::query;
|
||||||
@@ -36,8 +37,9 @@ pub(crate) enum Error {
|
|||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
MissingMockFixture(String),
|
MissingMockFixture(String),
|
||||||
Github(GithubError),
|
Github(GithubError),
|
||||||
MalformedResponse(serde_json::Error),
|
MalformedResponse(String),
|
||||||
HttpError(reqwest::Error),
|
HttpError(reqwest::Error),
|
||||||
|
GraphQLError(Vec<graphql_client::Error>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -66,6 +68,16 @@ impl QueryContext {
|
|||||||
.header("User-Agent", "kennethnym")
|
.header("User-Agent", "kennethnym")
|
||||||
.bearer_auth(&auth.access_token))
|
.bearer_auth(&auth.access_token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn github_graphql_request<V>(
|
||||||
|
&self,
|
||||||
|
request: &graphql_client::QueryBody<V>,
|
||||||
|
) -> Result<reqwest::RequestBuilder, Error>
|
||||||
|
where
|
||||||
|
V: serde::Serialize,
|
||||||
|
{
|
||||||
|
Ok(self.github_request(Method::POST, "/graphql")?.json(request))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@@ -88,7 +100,7 @@ impl From<reqwest::Error> for Error {
|
|||||||
|
|
||||||
impl From<serde_json::Error> for Error {
|
impl From<serde_json::Error> for Error {
|
||||||
fn from(value: serde_json::Error) -> Self {
|
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<T>(
|
async fn parse_graphql_response<T>(
|
||||||
res: reqwest::Response,
|
res: reqwest::Response,
|
||||||
) -> Result<graphql_client::Response<T>, Error>
|
) -> Result<(graphql_client::Response<T>, T), Error>
|
||||||
where
|
where
|
||||||
T: serde::de::DeserializeOwned,
|
T: serde::de::DeserializeOwned,
|
||||||
{
|
{
|
||||||
let data: graphql_client::Response<T> = res.json().await?;
|
let mut body: graphql_client::Response<T> = res.json().await?;
|
||||||
Ok(data)
|
match body.data.take() {
|
||||||
|
None => Err(Error::GraphQLError(body.errors.unwrap_or_default())),
|
||||||
|
Some(data) => Ok((body, data)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/api/graphql/fetch_pull_request.graphql
Normal file
11
src/api/graphql/fetch_pull_request.graphql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
query PullRequestQuery($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
__typename
|
||||||
|
... on PullRequest {
|
||||||
|
title
|
||||||
|
body
|
||||||
|
state
|
||||||
|
isDraft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
267
src/api/graphql/fetch_pull_request_timeline.graphql
Normal file
267
src/api/graphql/fetch_pull_request_timeline.graphql
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
query PullRequestQuery($query: String!) {
|
query PullRequestPaginationQuery($query: String!) {
|
||||||
search(query: $query, first: 10, type: ISSUE) {
|
search(query: $query, first: 10, type: ISSUE) {
|
||||||
issueCount
|
issueCount
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
__typename
|
__typename
|
||||||
... on PullRequest {
|
... on PullRequest {
|
||||||
|
id
|
||||||
isDraft
|
isDraft
|
||||||
title
|
title
|
||||||
state
|
state
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
|
||||||
use graphql_client::{GraphQLQuery, Response};
|
use graphql_client::GraphQLQuery;
|
||||||
use reqwest::Method;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
self,
|
self,
|
||||||
issues::pull_request_query::{PullRequestQuerySearchEdgesNode, PullRequestState},
|
issues::{
|
||||||
|
pull_request_pagination_query::PullRequestPaginationQuerySearchEdgesNode,
|
||||||
|
pull_request_query::PullRequestQueryNode,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
query,
|
query,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DateTime = String;
|
||||||
|
type URI = String;
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
#[repr(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<String> for Id {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for String {
|
||||||
|
fn from(value: Id) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct PullRequestPaginatedResponse {
|
pub(crate) struct PullRequestPaginatedResponse {
|
||||||
pub(crate) items: Vec<PullRequest>,
|
pub(crate) items: Vec<PullRequest>,
|
||||||
@@ -34,18 +57,155 @@ pub(crate) struct PullRequestPaginatedResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct PullRequest {
|
pub(crate) struct PullRequest {
|
||||||
|
pub(crate) id: Id,
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
pub(crate) state: IssueState,
|
pub(crate) state: PullRequestState,
|
||||||
pub(crate) is_draft: bool,
|
pub(crate) is_draft: bool,
|
||||||
pub(crate) repo_slug: String,
|
pub(crate) repo_slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) enum IssueState {
|
pub(crate) struct DetailedPullRequest {
|
||||||
Open,
|
pub(crate) title: String,
|
||||||
Closed,
|
pub(crate) state: PullRequestState,
|
||||||
Merged,
|
pub(crate) is_draft: bool,
|
||||||
Unknown,
|
pub(crate) body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct PullRequestTimeline {
|
||||||
|
pub(crate) items: Vec<PullRequestTimelineItem>,
|
||||||
|
pub(crate) end_cursor: Option<String>,
|
||||||
|
pub(crate) has_next_page: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum PullRequestTimelineItem {
|
||||||
|
Assigned {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
assignee: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
Unassigned {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
assignee: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
Comment {
|
||||||
|
created_at: String,
|
||||||
|
author: Option<TimelineActor>,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
Commit {
|
||||||
|
committed_at: String,
|
||||||
|
abbreviated_oid: String,
|
||||||
|
message_headline: String,
|
||||||
|
},
|
||||||
|
Review {
|
||||||
|
created_at: String,
|
||||||
|
author: Option<TimelineActor>,
|
||||||
|
state: String,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
ReviewRequested {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
reviewer: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
ReviewRequestRemoved {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
reviewer: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
ReviewDismissed {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
Merged {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
Closed {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
Reopened {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
ConvertToDraft {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
ReadyForReview {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
HeadRefForcePushed {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
before_commit_oid: Option<String>,
|
||||||
|
after_commit_oid: Option<String>,
|
||||||
|
},
|
||||||
|
BaseRefChanged {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
Labeled {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
Unlabeled {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
Milestoned {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
milestone_title: String,
|
||||||
|
},
|
||||||
|
Demilestoned {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
milestone_title: String,
|
||||||
|
},
|
||||||
|
Referenced {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
CrossReferenced {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
AutoMergeEnabled {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
AutoMergeDisabled {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
AddedToMergeQueue {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
RemovedFromMergeQueue {
|
||||||
|
created_at: String,
|
||||||
|
actor: Option<TimelineActor>,
|
||||||
|
},
|
||||||
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for Id {
|
impl std::fmt::Display for Id {
|
||||||
@@ -54,24 +214,50 @@ impl std::fmt::Display for Id {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<PullRequestState> for IssueState {
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||||
fn from(state: PullRequestState) -> Self {
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
match state {
|
pub(crate) enum PullRequestState {
|
||||||
PullRequestState::OPEN => Self::Open,
|
Open,
|
||||||
PullRequestState::CLOSED => Self::Closed,
|
Closed,
|
||||||
PullRequestState::MERGED => Self::Merged,
|
Merged,
|
||||||
_ => Self::Unknown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(graphql_client::GraphQLQuery)]
|
#[derive(graphql_client::GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
schema_path = "src/api/graphql/schema.json",
|
schema_path = "src/api/graphql/schema.json",
|
||||||
query_path = "src/api/graphql/list_pull_requests.graphql"
|
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;
|
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)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct ListPullRequests {
|
pub(crate) struct ListPullRequests {
|
||||||
pub filter: Option<&'static str>,
|
pub filter: Option<&'static str>,
|
||||||
@@ -103,20 +289,15 @@ impl query::QueryFn for ListPullRequests {
|
|||||||
None => "is:pr archived:false sort:updated-desc".into(),
|
None => "is:pr archived:false sort:updated-desc".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let gql = PullRequestQuery::build_query(pull_request_query::Variables {
|
let gql =
|
||||||
query: query_string,
|
PullRequestPaginationQuery::build_query(pull_request_pagination_query::Variables {
|
||||||
});
|
query: query_string,
|
||||||
|
});
|
||||||
|
|
||||||
let res = c
|
let res = c.github_graphql_request(&gql)?.send().await?;
|
||||||
.github_request(Method::POST, "/graphql")?
|
|
||||||
.json(&gql)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let data = api::parse_graphql_response::<pull_request_query::ResponseData>(res)
|
let (_, data) =
|
||||||
.await?
|
api::parse_graphql_response::<pull_request_pagination_query::ResponseData>(res).await?;
|
||||||
.data
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
Ok(PullRequestPaginatedResponse {
|
Ok(PullRequestPaginatedResponse {
|
||||||
items: data
|
items: data
|
||||||
@@ -127,10 +308,11 @@ impl query::QueryFn for ListPullRequests {
|
|||||||
.flatten()
|
.flatten()
|
||||||
.filter_map(|edge| {
|
.filter_map(|edge| {
|
||||||
edge.node.and_then(|n| match n {
|
edge.node.and_then(|n| match n {
|
||||||
PullRequestQuerySearchEdgesNode::PullRequest(p) => {
|
PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
|
||||||
Some(PullRequest {
|
Some(PullRequest {
|
||||||
|
id: p.id.into(),
|
||||||
title: p.title,
|
title: p.title,
|
||||||
state: p.state.into(),
|
state: p.state,
|
||||||
is_draft: p.is_draft,
|
is_draft: p.is_draft,
|
||||||
repo_slug: format!(
|
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<Self::Data, Self::Error> {
|
||||||
|
#[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::<pull_request_query::ResponseData>(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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FetchPullRequestTimeline {
|
||||||
|
pub(crate) fn new(id: Id, first: i64, after: Option<String>) -> 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<Self::Data, Self::Error> {
|
||||||
|
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::<PullRequestTimelineResponse>(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::<PullRequestTimelineResponse>(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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
231
src/api/mock.rs
231
src/api/mock.rs
@@ -29,11 +29,56 @@ pub(crate) fn list_pull_requests(
|
|||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<issues::PullRequestPaginatedResponse, api::Error> {
|
) -> Result<issues::PullRequestPaginatedResponse, api::Error> {
|
||||||
let filter = filter.unwrap_or_default();
|
let filter = filter.unwrap_or_default();
|
||||||
let json = issues_pull_requests(filter, page).ok_or_else(|| {
|
let fixture_filter = issue_filter_fixture_key(filter);
|
||||||
api::Error::MissingMockFixture(format!("issues.pull_requests filter={filter} page={page}"))
|
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<issues::DetailedPullRequest, api::Error> {
|
||||||
|
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<issues::PullRequestTimelineResponse, api::Error> {
|
||||||
|
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<T>(name: &str, json: &'static str) -> Result<T, api::Error>
|
fn parse_fixture<T>(name: &str, json: &'static str) -> Result<T, api::Error>
|
||||||
@@ -42,6 +87,184 @@ where
|
|||||||
{
|
{
|
||||||
serde_json::from_str(json).map_err(|err| {
|
serde_json::from_str(json).map_err(|err| {
|
||||||
println!("[mock fixture] failed to parse {name}: {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(_)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
502
src/component/markdown.rs
Normal file
502
src/component/markdown.rs
Normal file
@@ -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<ContentBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContentBlock {
|
||||||
|
Text {
|
||||||
|
decoration: Option<&'static str>,
|
||||||
|
text: gpui::SharedString,
|
||||||
|
highlights: Vec<(Range<usize>, gpui::HighlightStyle)>,
|
||||||
|
links: Vec<(Range<usize>, gpui::SharedString)>,
|
||||||
|
style: gpui::StyleRefinement,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(
|
||||||
|
content: gpui::SharedString,
|
||||||
|
cx: &mut gpui::Context<MarkdownText>,
|
||||||
|
) -> 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<Self>) {
|
||||||
|
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<Self>) {}
|
||||||
|
|
||||||
|
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<usize>, gpui::HighlightStyle)> = Vec::new();
|
||||||
|
let mut links: Vec<(Range<usize>, 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<ContentBlock>,
|
||||||
|
) {
|
||||||
|
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<ContentBlock>,
|
||||||
|
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<Self>,
|
||||||
|
) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub(crate) mod button;
|
pub(crate) mod button;
|
||||||
pub(crate) mod font_icon;
|
pub(crate) mod font_icon;
|
||||||
|
pub(crate) mod markdown;
|
||||||
pub(crate) mod text;
|
pub(crate) mod text;
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ impl gpui::RenderOnce for Text {
|
|||||||
div = div.text_color(theme.colors.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
|
div
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{
|
use crate::{
|
||||||
api::{self},
|
api::{self},
|
||||||
@@ -15,21 +20,21 @@ pub(crate) struct IssueList {
|
|||||||
|
|
||||||
list_state: gpui::ListState,
|
list_state: gpui::ListState,
|
||||||
list_items: Vec<IssueListItem>,
|
list_items: Vec<IssueListItem>,
|
||||||
|
selected_item: Option<(usize, gpui::SharedString)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
pub(crate) enum Event {
|
||||||
enum IssueStatus {
|
ItemSelected(api::issues::Id),
|
||||||
Draft,
|
|
||||||
Open,
|
|
||||||
Closed,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(gpui::IntoElement, Clone)]
|
#[derive(gpui::IntoElement, Clone)]
|
||||||
struct IssueListItem {
|
pub(crate) struct IssueListItem {
|
||||||
|
id: gpui::SharedString,
|
||||||
repo_name: Option<gpui::SharedString>,
|
repo_name: Option<gpui::SharedString>,
|
||||||
title: gpui::SharedString,
|
title: gpui::SharedString,
|
||||||
description: Option<gpui::SharedString>,
|
description: Option<gpui::SharedString>,
|
||||||
status: api::issues::IssueState,
|
status: api::issues::PullRequestState,
|
||||||
|
is_selected: bool,
|
||||||
is_last: bool,
|
is_last: bool,
|
||||||
is_draft: bool,
|
is_draft: bool,
|
||||||
}
|
}
|
||||||
@@ -46,6 +51,7 @@ pub(crate) fn new(cx: &mut gpui::Context<IssueList>) -> IssueList {
|
|||||||
|
|
||||||
list_state: gpui::ListState::new(0, gpui::ListAlignment::Top, px(100.)),
|
list_state: gpui::ListState::new(0, gpui::ListAlignment::Top, px(100.)),
|
||||||
list_items: Vec::new(),
|
list_items: Vec::new(),
|
||||||
|
selected_item: None,
|
||||||
};
|
};
|
||||||
list.on_create(cx);
|
list.on_create(cx);
|
||||||
list
|
list
|
||||||
@@ -60,10 +66,16 @@ impl IssueList {
|
|||||||
let new_len = res.items.len();
|
let new_len = res.items.len();
|
||||||
|
|
||||||
let new_items = res.items.iter().enumerate().map(|(i, it)| IssueListItem {
|
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())),
|
repo_name: Some(gpui::SharedString::new(it.repo_slug.as_str())),
|
||||||
title: gpui::SharedString::new(it.title.as_str()),
|
title: gpui::SharedString::new(it.title.as_str()),
|
||||||
description: None,
|
description: None,
|
||||||
status: it.state,
|
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_last: i == new_len - 1,
|
||||||
is_draft: it.is_draft,
|
is_draft: it.is_draft,
|
||||||
});
|
});
|
||||||
@@ -74,6 +86,17 @@ impl IssueList {
|
|||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_item_click(&mut self, i: usize, cx: &mut gpui::Context<Self>) {
|
||||||
|
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 {
|
impl gpui::Render for IssueList {
|
||||||
@@ -82,15 +105,31 @@ impl gpui::Render for IssueList {
|
|||||||
_window: &mut gpui::Window,
|
_window: &mut gpui::Window,
|
||||||
cx: &mut gpui::Context<Self>,
|
cx: &mut gpui::Context<Self>,
|
||||||
) -> impl gpui::IntoElement {
|
) -> impl gpui::IntoElement {
|
||||||
let this = cx.entity();
|
let weak = cx.entity();
|
||||||
|
|
||||||
list(self.list_state.clone(), move |i, _, cx| {
|
list(self.list_state.clone(), move |i, _, cx| {
|
||||||
let this = this.read(cx);
|
let item = {
|
||||||
this.list_items[i].clone().into_any_element()
|
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()
|
.size_full()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl gpui::EventEmitter<Event> for IssueList {}
|
||||||
|
|
||||||
impl gpui::RenderOnce for IssueListItem {
|
impl gpui::RenderOnce for IssueListItem {
|
||||||
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||||
let theme = app::current_theme(cx);
|
let theme = app::current_theme(cx);
|
||||||
@@ -108,10 +147,10 @@ impl gpui::RenderOnce for IssueListItem {
|
|||||||
.opacity(0.5)
|
.opacity(0.5)
|
||||||
} else {
|
} else {
|
||||||
match self.status {
|
match self.status {
|
||||||
api::issues::IssueState::Closed => {
|
api::issues::PullRequestState::Closed => {
|
||||||
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger)
|
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::PullRequestClosed).text_color(theme.colors.success)
|
||||||
}
|
}
|
||||||
_ => font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success),
|
_ => font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success),
|
||||||
@@ -127,7 +166,8 @@ impl gpui::RenderOnce for IssueListItem {
|
|||||||
|
|
||||||
div()
|
div()
|
||||||
.w_full()
|
.w_full()
|
||||||
.p_2()
|
.px_1p5()
|
||||||
|
.py_1()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_row()
|
.flex_row()
|
||||||
@@ -155,5 +195,12 @@ impl gpui::RenderOnce for IssueListItem {
|
|||||||
.when(!self.is_last, |it| {
|
.when(!self.is_last, |it| {
|
||||||
it.border_b_1().border_color(theme.colors.border)
|
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.))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod issue_list;
|
mod issue_list;
|
||||||
|
mod pull_request_view;
|
||||||
mod screen;
|
mod screen;
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
mod titlebar;
|
mod titlebar;
|
||||||
|
|||||||
156
src/screen/dashboard/pull_request_view.rs
Normal file
156
src/screen/dashboard/pull_request_view.rs
Normal file
@@ -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<gpui::Entity<MarkdownText>>,
|
||||||
|
|
||||||
|
pull_request_query: Option<query::Entity<api::issues::FetchPullRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(cx: &mut gpui::Context<PullRequestView>) -> 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<Self>,
|
||||||
|
) {
|
||||||
|
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<Self>,
|
||||||
|
) -> 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<Self>,
|
||||||
|
) -> 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
use gpui::{AppContext, BorrowAppContext, ParentElement, Styled, div};
|
use gpui::{AppContext, ParentElement, Styled, div};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app,
|
api, app,
|
||||||
screen::dashboard::{
|
screen::dashboard::{
|
||||||
issue_list::{self, IssueList},
|
issue_list::{self, IssueList},
|
||||||
|
pull_request_view::{self, PullRequestView},
|
||||||
sidebar::{self, Sidebar, SidebarItemValue},
|
sidebar::{self, Sidebar, SidebarItemValue},
|
||||||
titlebar::{self, TitleBar},
|
titlebar::{self, TitleBar},
|
||||||
},
|
},
|
||||||
@@ -13,6 +14,7 @@ pub(crate) struct Screen {
|
|||||||
titlebar: gpui::Entity<TitleBar>,
|
titlebar: gpui::Entity<TitleBar>,
|
||||||
issue_list: gpui::Entity<IssueList>,
|
issue_list: gpui::Entity<IssueList>,
|
||||||
sidebar: gpui::Entity<Sidebar>,
|
sidebar: gpui::Entity<Sidebar>,
|
||||||
|
pull_request_view: gpui::Entity<PullRequestView>,
|
||||||
|
|
||||||
issue_filter: Option<&'static str>,
|
issue_filter: Option<&'static str>,
|
||||||
}
|
}
|
||||||
@@ -22,6 +24,8 @@ pub(crate) fn new(cx: &mut gpui::Context<Screen>) -> Screen {
|
|||||||
titlebar: cx.new(titlebar::new),
|
titlebar: cx.new(titlebar::new),
|
||||||
issue_list: cx.new(issue_list::new),
|
issue_list: cx.new(issue_list::new),
|
||||||
sidebar: cx.new(|_| sidebar::new()),
|
sidebar: cx.new(|_| sidebar::new()),
|
||||||
|
pull_request_view: cx.new(pull_request_view::new),
|
||||||
|
|
||||||
issue_filter: None,
|
issue_filter: None,
|
||||||
};
|
};
|
||||||
screen.on_create(cx);
|
screen.on_create(cx);
|
||||||
@@ -36,6 +40,14 @@ impl Screen {
|
|||||||
self.sidebar.update(cx, |sidebar, _| {
|
self.sidebar.update(cx, |sidebar, _| {
|
||||||
sidebar.on_item_change(on_item_change);
|
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(
|
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<Self>,
|
||||||
|
) {
|
||||||
|
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 {
|
impl gpui::Render for Screen {
|
||||||
@@ -71,10 +96,17 @@ impl gpui::Render for Screen {
|
|||||||
.flex_row()
|
.flex_row()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.child(div().w_40().h_full().child(self.sidebar.clone()))
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.w_80()
|
.w_40()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.h_full()
|
||||||
|
.child(self.sidebar.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w_64()
|
||||||
|
.flex_shrink_0()
|
||||||
.h_full()
|
.h_full()
|
||||||
.bg(theme.colors.surface)
|
.bg(theme.colors.surface)
|
||||||
.border_x_1()
|
.border_x_1()
|
||||||
@@ -86,10 +118,12 @@ impl gpui::Render for Screen {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
.h_full()
|
.h_full()
|
||||||
.bg(theme.colors.surface)
|
.bg(theme.colors.surface)
|
||||||
.border_l_1()
|
.border_l_1()
|
||||||
.border_color(theme.colors.border),
|
.border_color(theme.colors.border)
|
||||||
|
.child(self.pull_request_view.clone()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user