From 11a4f193ecdb8f774e231498559bf181e61bd223 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Mon, 27 Jul 2026 12:06:53 +0300 Subject: [PATCH] feat(app): add PR inbox with LLM summaries --- app/.changes/260727-1204.md | 5 + app/src-tauri/src/docs/lib.md | 4 +- app/src-tauri/src/docs/pull_requests.md | 25 ++ app/src-tauri/src/lib.rs | 5 +- app/src-tauri/src/pull_requests.rs | 336 ++++++++++++++++++ app/src/components/PullRequestsDialog.vue | 206 +++++++++++ app/src/components/TaskGraph.vue | 4 + app/src/components/docs/PullRequestsDialog.md | 25 ++ app/src/components/docs/TaskGraph.md | 4 +- app/src/tests/pull-requests-dialog.test.js | 104 ++++++ 10 files changed, 713 insertions(+), 5 deletions(-) create mode 100644 app/.changes/260727-1204.md create mode 100644 app/src-tauri/src/docs/pull_requests.md create mode 100644 app/src-tauri/src/pull_requests.rs create mode 100644 app/src/components/PullRequestsDialog.vue create mode 100644 app/src/components/docs/PullRequestsDialog.md create mode 100644 app/src/tests/pull-requests-dialog.test.js diff --git a/app/.changes/260727-1204.md b/app/.changes/260727-1204.md new file mode 100644 index 0000000..4f3609e --- /dev/null +++ b/app/.changes/260727-1204.md @@ -0,0 +1,5 @@ +--- +bump: patch +section: Added +--- +PR Inbox: перегляд відкритих pull requests через локальний GitHub CLI з групами «потрібна моя дія», «потрібен мій review» і «очікує інших», а також LLM-резюме read-only контексту PR. diff --git a/app/src-tauri/src/docs/lib.md b/app/src-tauri/src/docs/lib.md index 9f97889..602cf4d 100644 --- a/app/src-tauri/src/docs/lib.md +++ b/app/src-tauri/src/docs/lib.md @@ -3,13 +3,13 @@ type: Rust Module title: lib.rs resource: app/src-tauri/src/lib.rs docgen: - crc: 55f3026f + crc: 1ae09483 score: 100 --- ## Огляд -Файл надає інструменти для роботи з завданнями. Функції включають `scan_tasks`, `find_tasks_dir`, `find_all_tasks_dirs`, `read_task` та `run`. +Файл реєструє Tauri-команди застосунку для task-графів, CI, PR Inbox, агента та локального стану. ## Поведінка diff --git a/app/src-tauri/src/docs/pull_requests.md b/app/src-tauri/src/docs/pull_requests.md new file mode 100644 index 0000000..e7f8777 --- /dev/null +++ b/app/src-tauri/src/docs/pull_requests.md @@ -0,0 +1,25 @@ +--- +type: Rust Module +title: pull_requests.rs +resource: app/src-tauri/src/pull_requests.rs +docgen: + crc: 26b30968 +--- + +## Огляд + +Модуль формує глобальний PR Inbox із відкритих GitHub pull requests для поточного користувача. Він використовує тільки локально авторизований CLI `gh` і не зберігає токени чи вміст приватних змін. + +## Поведінка + +- Шукає відкриті PR, де користувач є автором, запитаним reviewer або assignee, та прибирає дублікати між цими вибірками. +- Для авторських PR визначає наступну дію за GitHub-фактами: draft, `Changes requested`, конфлікт, провалений CI або очікування reviewer/мейнтейнера. +- Для PR на review і призначених PR повертає окремі категорії, щоб UI міг показати їх без евристик. +- Повертає лише метадані, посилання, перелік запитаних reviewer та провалені checks. +- За запитом UI отримує окремий read-only контекст одного PR для LLM-резюме: body, файли, reviews, comments і CI status. + +## Гарантії поведінки + +- Усі GitHub-запити read-only. +- Дані PR передаються в LLM тільки після явного запиту користувача в UI. +- Помилка `gh` або невалідна відповідь повертається в UI як помилка завантаження, без частково вигаданих даних. diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d251106..d10e4bd 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ use tauri::{Emitter, Manager}; mod config; mod git_util; mod pipeline; +mod pull_requests; #[tauri::command] fn scan_tasks(tasks_dir: String) -> Result, String> { @@ -435,7 +436,9 @@ pub fn run() { run_node, run_auto, pipeline::list_pipeline_runs, - pipeline::pipeline_run_details + pipeline::pipeline_run_details, + pull_requests::list_pull_requests, + pull_requests::pull_request_context ]); #[cfg(desktop)] diff --git a/app/src-tauri/src/pull_requests.rs b/app/src-tauri/src/pull_requests.rs new file mode 100644 index 0000000..a724b7e --- /dev/null +++ b/app/src-tauri/src/pull_requests.rs @@ -0,0 +1,336 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::process::Command; + +/// Стисла read-модель GitHub PR для глобального inbox застосунку. Дані +/// отримуються лише через локально авторизований `gh`, без збереження токенів. +#[derive(serde::Serialize, Clone)] +pub struct PullRequestSummary { + pub number: i64, + pub repository: String, + pub title: String, + pub url: String, + pub updated_at: String, + pub is_draft: bool, + pub role: String, + pub category: String, + pub action: String, + pub reviewers: Vec, + pub failed_checks: Vec, + pub merge_state: String, +} + +#[derive(serde::Deserialize)] +struct SearchResponse { + data: SearchData, +} + +#[derive(serde::Deserialize)] +struct SearchData { + search: SearchResult, +} + +#[derive(serde::Deserialize)] +struct SearchResult { + nodes: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PullRequestNode { + number: i64, + title: String, + url: String, + updated_at: String, + is_draft: bool, + review_decision: Option, + merge_state_status: String, + repository: Repository, + review_requests: ReviewRequests, + status_check_rollup: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Repository { + name_with_owner: String, +} + +#[derive(serde::Deserialize, Default)] +struct ReviewRequests { + nodes: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReviewRequest { + requested_reviewer: Option, +} + +#[derive(serde::Deserialize)] +struct Reviewer { + #[serde(rename = "__typename")] + typename: String, + login: Option, + name: Option, +} + +#[derive(serde::Deserialize, Default)] +struct StatusCheckRollup { + contexts: CheckContexts, +} + +#[derive(serde::Deserialize, Default)] +struct CheckContexts { + nodes: Vec, +} + +#[derive(serde::Deserialize)] +struct CheckContext { + name: Option, + context: Option, + conclusion: Option, + state: Option, +} + +const QUERY: &str = r#" +query PullRequests($search: String!) { + search(query: $search, type: ISSUE, first: 100) { + nodes { + ... on PullRequest { + number title url updatedAt isDraft reviewDecision mergeStateStatus + repository { nameWithOwner } + reviewRequests(first: 20) { + nodes { + requestedReviewer { + __typename + ... on User { login } + ... on Team { name } + } + } + } + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { name conclusion status } + ... on StatusContext { context state } + } + } + } + } + } + } +} +"#; + +/// Повертає відкриті PR, де користувач є автором, assignee або запитаним +/// reviewer. Кожна категорія має одну наступну дію, виведену лише з GitHub +/// фактів: review decision, CI та merge state. +#[tauri::command] +pub fn list_pull_requests() -> Result, String> { + let mut entries: HashMap<(String, i64), (PullRequestNode, HashSet<&str>)> = HashMap::new(); + for (search, role) in [ + ("is:pr is:open author:@me", "author"), + ("is:pr is:open review-requested:@me", "reviewer"), + ("is:pr is:open assignee:@me", "assignee"), + ] { + for pr in search_pull_requests(search)? { + let key = (pr.repository.name_with_owner.clone(), pr.number); + let entry = entries.entry(key).or_insert_with(|| (pr, HashSet::new())); + entry.1.insert(role); + } + } + + let mut result: Vec<_> = entries + .into_values() + .map(|(pr, roles)| summarize(pr, roles)) + .collect(); + result.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(result) +} + +/// Повертає read-only контекст одного PR для LLM-резюме. Аргументи передаються +/// в `Command` окремо, тому назва репозиторію або номер не інтерпретуються shell. +#[tauri::command] +pub fn pull_request_context(repository: String, number: i64) -> Result { + if repository.is_empty() || number <= 0 { + return Err("invalid pull request reference".to_string()); + } + let number = number.to_string(); + let output = Command::new("gh") + .args([ + "pr", + "view", + &number, + "--repo", + &repository, + "--json", + "title,body,url,files,comments,reviews,statusCheckRollup,mergeStateStatus,reviewDecision", + ]) + .output() + .map_err(|error| format!("gh: {error}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("GitHub returned invalid PR context: {error}")) +} + +fn search_pull_requests(search: &str) -> Result, String> { + let output = Command::new("gh") + .args([ + "api", + "graphql", + "-f", + &format!("query={QUERY}"), + "-F", + &format!("search={search}"), + ]) + .current_dir(Path::new(".")) + .output() + .map_err(|error| format!("gh: {error}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + serde_json::from_slice::(&output.stdout) + .map(|response| response.data.search.nodes) + .map_err(|error| format!("GitHub returned invalid PR data: {error}")) +} + +fn summarize(pr: PullRequestNode, roles: HashSet<&str>) -> PullRequestSummary { + let failed_checks: Vec = pr + .status_check_rollup + .as_ref() + .map(|rollup| { + rollup + .contexts + .nodes + .iter() + .filter(|check| { + check.conclusion.as_deref() == Some("FAILURE") + || check.state.as_deref() == Some("FAILURE") + }) + .filter_map(|check| check.name.clone().or_else(|| check.context.clone())) + .collect::>() + }) + .unwrap_or_default(); + let reviewers = pr + .review_requests + .nodes + .iter() + .filter_map(|request| request.requested_reviewer.as_ref()) + .filter_map(|reviewer| match reviewer.typename.as_str() { + "User" => reviewer.login.clone(), + "Team" => reviewer.name.as_ref().map(|name| format!("@{name}")), + _ => None, + }) + .collect::>(); + let role = if roles.contains("author") { + "author" + } else if roles.contains("reviewer") { + "reviewer" + } else { + "assignee" + }; + let (category, action) = if roles.contains("author") { + author_action(&pr, &failed_checks, &reviewers) + } else if roles.contains("reviewer") { + ("needs_my_review", "Потрібен ваш review") + } else { + ("assigned_to_me", "PR призначений вам") + }; + + PullRequestSummary { + number: pr.number, + repository: pr.repository.name_with_owner, + title: pr.title, + url: pr.url, + updated_at: pr.updated_at, + is_draft: pr.is_draft, + role: role.to_string(), + category: category.to_string(), + action: action.to_string(), + reviewers, + failed_checks, + merge_state: pr.merge_state_status, + } +} + +fn author_action( + pr: &PullRequestNode, + failed_checks: &[String], + reviewers: &[String], +) -> (&'static str, &'static str) { + if pr.is_draft { + return ( + "needs_my_action", + "Завершіть чернетку або позначте PR ready for review", + ); + } + if pr.review_decision.as_deref() == Some("CHANGES_REQUESTED") { + return ("needs_my_action", "Внесіть зміни за результатами review"); + } + if pr.merge_state_status == "DIRTY" { + return ("needs_my_action", "Усуньте конфлікти з базовою гілкою"); + } + if !failed_checks.is_empty() { + return ( + "needs_my_action", + "Перевірте та виправте провалені CI checks", + ); + } + if !reviewers.is_empty() { + return ("waiting_for_others", "Очікує review від призначених осіб"); + } + ("waiting_for_others", "Очікує рішення мейнтейнера") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pr() -> PullRequestNode { + PullRequestNode { + number: 1, + title: "Test".to_string(), + url: "https://example.test/pr/1".to_string(), + updated_at: "2026-07-27T00:00:00Z".to_string(), + is_draft: false, + review_decision: None, + merge_state_status: "CLEAN".to_string(), + repository: Repository { + name_with_owner: "owner/repo".to_string(), + }, + review_requests: ReviewRequests::default(), + status_check_rollup: None, + } + } + + #[test] + fn author_with_failed_ci_needs_action() { + let summary = summarize(pr(), HashSet::from(["author"])); + assert_eq!(summary.category, "waiting_for_others"); + + let mut failing = pr(); + failing.status_check_rollup = Some(StatusCheckRollup { + contexts: CheckContexts { + nodes: vec![CheckContext { + name: Some("lint".to_string()), + context: None, + conclusion: Some("FAILURE".to_string()), + state: None, + }], + }, + }); + let summary = summarize(failing, HashSet::from(["author"])); + assert_eq!(summary.category, "needs_my_action"); + assert_eq!(summary.failed_checks, ["lint"]); + } + + #[test] + fn requested_reviewer_gets_review_action() { + let summary = summarize(pr(), HashSet::from(["reviewer"])); + assert_eq!(summary.category, "needs_my_review"); + } +} diff --git a/app/src/components/PullRequestsDialog.vue b/app/src/components/PullRequestsDialog.vue new file mode 100644 index 0000000..dbdda47 --- /dev/null +++ b/app/src/components/PullRequestsDialog.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/app/src/components/TaskGraph.vue b/app/src/components/TaskGraph.vue index ffce118..e772db3 100644 --- a/app/src/components/TaskGraph.vue +++ b/app/src/components/TaskGraph.vue @@ -3,6 +3,7 @@
Tasks + @@ -87,6 +88,7 @@ + Promise.resolve(PULL_REQUESTS)) +const openUrl = vi.fn() + +vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) })) +vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: (...args) => openUrl(...args) })) + +const { default: PullRequestsDialog } = await import('../components/PullRequestsDialog.vue') +const body = () => new DOMWrapper(document.body) + +describe('PullRequestsDialog', () => { + it('показує авторські дії за замовчуванням і фільтрує review', async () => { + const wrapper = mountQuasar(PullRequestsDialog, { props: { modelValue: false }, attachTo: document.body }) + await wrapper.setProps({ modelValue: true }) + await flushPromises() + + expect(invoke).toHaveBeenCalledWith('list_pull_requests') + expect(document.body.textContent).toContain('Fix failed workflow') + expect(document.body.textContent).not.toContain('Review requested') + + const reviewFilter = body() + .findAll('button') + .find(button => button.text() === 'My review') + await reviewFilter.trigger('click') + await flushPromises() + expect(document.body.textContent).toContain('Review requested') + expect(document.body.textContent).not.toContain('Fix failed workflow') + wrapper.unmount() + }) + + it('відкриває PR у браузері', async () => { + const wrapper = mountQuasar(PullRequestsDialog, { props: { modelValue: false }, attachTo: document.body }) + await wrapper.setProps({ modelValue: true }) + await flushPromises() + await body().find('.pr-row').trigger('click') + expect(openUrl).toHaveBeenCalledWith('https://github.com/nitra/task/pull/10') + wrapper.unmount() + }) + + it('передає локальний gh-контекст у агента та показує його резюме', async () => { + const agent = { + loadEnv: vi.fn(() => Promise.resolve()), + request: vi.fn((_prompt, { onChunk }) => { + onChunk({ text: 'Поточний стан: виправити CI.' }) + return Promise.resolve({ status: 'done', summary: 'Поточний стан: виправити CI.' }) + }) + } + invoke.mockImplementation(command => { + if (command === 'pull_request_context') return Promise.resolve({ title: 'Fix failed workflow', files: [] }) + return Promise.resolve(PULL_REQUESTS) + }) + const wrapper = mountQuasar(PullRequestsDialog, { + props: { modelValue: false, agent }, + attachTo: document.body + }) + await wrapper.setProps({ modelValue: true }) + await flushPromises() + const summaryButton = body() + .findAll('button') + .find(button => button.text() === 'LLM summary') + await summaryButton.trigger('click') + await flushPromises() + + expect(invoke).toHaveBeenCalledWith('pull_request_context', { repository: 'nitra/task', number: 10 }) + expect(agent.loadEnv).toHaveBeenCalledOnce() + expect(agent.request).toHaveBeenCalledOnce() + expect(document.body.textContent).toContain('Поточний стан: виправити CI.') + wrapper.unmount() + }) +})