From ff8769b0ad6825a2b8093cc4a4a7bef0c6f46a26 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Sat, 25 Apr 2026 00:31:20 +0800 Subject: [PATCH] feat: automated PR review poller and GitHub Actions trigger --- .github/workflows/dune-review-trigger.yml | 22 ++ src/electron/main.ts | 33 +++ src/electron/main/pr-reviewer/index.ts | 34 +++ src/electron/main/pr-reviewer/poller.ts | 298 ++++++++++++++++++++++ src/electron/main/pr-reviewer/types.ts | 16 ++ 5 files changed, 403 insertions(+) create mode 100644 .github/workflows/dune-review-trigger.yml create mode 100644 src/electron/main/pr-reviewer/index.ts create mode 100644 src/electron/main/pr-reviewer/poller.ts create mode 100644 src/electron/main/pr-reviewer/types.ts diff --git a/.github/workflows/dune-review-trigger.yml b/.github/workflows/dune-review-trigger.yml new file mode 100644 index 0000000..4ebfb86 --- /dev/null +++ b/.github/workflows/dune-review-trigger.yml @@ -0,0 +1,22 @@ +name: Dune Review Trigger + +on: + pull_request: + types: [opened, reopened] + +permissions: + pull-requests: write + contents: read + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Add dune-review-requested label + run: | + gh label create 'dune-review-requested' --color '0075ca' --description 'Awaiting Dune agent review' --force + gh label create 'dune-review-in-progress' --color 'e4e669' --description 'Dune agent review in progress' --force + gh pr edit ${{ github.event.pull_request.number }} --add-label 'dune-review-requested' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} diff --git a/src/electron/main.ts b/src/electron/main.ts index 014732b..e27d764 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -46,6 +46,7 @@ import { loadNetworkSettings } from '@/renderer/features/settings/model/network- import { ipcChannels } from '@/shared/electron/ipc-channels'; import { createDefaultTasks } from '@/shared/workflow/default-tasks'; import { createQuitCoordinator } from '@/electron/main/quit-coordinator'; +import { startPRReviewer } from '@/electron/main/pr-reviewer'; import { isPlainObject } from '@/shared/is-record'; import type { WorkflowEvent as StoredWorkflowEvent, @@ -85,10 +86,16 @@ let runtimeController: DesktopRuntimeController | null = null; let nudgeScheduled = false; let nudgeIntervalHandle: ReturnType | null = null; let taskSweepIntervalHandle: ReturnType | null = null; +let stopPRReviewer: (() => void) | null = null; let powerBlockerId: number | null = null; let telegramReconnectPromise: Promise | null = null; const NUDGE_INTERVAL_MS = 60_000; const TASK_SWEEP_INTERVAL_MS = 120_000; +const PR_REVIEWER_POLL_INTERVAL_MS = 5 * 60_000; +const PR_REVIEW_REPO_CONFIGS = [ + { owner: 'polygala-ai', repo: 'dune', reviewerAgentId: 'Mg_8MMfk' }, + { owner: 'boxlite-ai', repo: 'agentlite', reviewerAgentId: 'IaAuvT2t' }, +]; /** Returns whether Telegram polling or setup observers should stay alive. */ function hasActiveTelegramChannels(snapshot: AgentServiceSnapshot) { @@ -292,6 +299,10 @@ const quitCoordinator = createQuitCoordinator({ clearInterval(taskSweepIntervalHandle); taskSweepIntervalHandle = null; } + if (stopPRReviewer) { + stopPRReviewer(); + stopPRReviewer = null; + } stopPowerBlocker(); await runtimeController?.shutdown(); }, @@ -843,6 +854,28 @@ void app.whenReady().then(async () => { void sweepItemAssignmentTasks(); }, TASK_SWEEP_INTERVAL_MS); void sweepItemAssignmentTasks(); + + if (process.env.GITHUB_TOKEN) { + stopPRReviewer = startPRReviewer( + { + githubToken: process.env.GITHUB_TOKEN, + pollIntervalMs: PR_REVIEWER_POLL_INTERVAL_MS, + repos: PR_REVIEW_REPO_CONFIGS, + stateFilePath: path.join(userDataDir, 'pr-reviewer-state.json'), + }, + { + getRuntimeController: requireRuntimeController, + onWorkflowChanged: () => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(ipcChannels.workflowChanged); + } + }, + workflowStore, + }, + ); + } else { + console.info('Automated PR reviewer poller disabled: GITHUB_TOKEN is not set.'); + } }).catch((error) => { console.error('Failed to bootstrap the Dune runtime.', error); throw error; diff --git a/src/electron/main/pr-reviewer/index.ts b/src/electron/main/pr-reviewer/index.ts new file mode 100644 index 0000000..b8edc2b --- /dev/null +++ b/src/electron/main/pr-reviewer/index.ts @@ -0,0 +1,34 @@ +import type { ToolServices } from '@/electron/main/agent-actions/handlers/types'; + +import { pollPRReviewer } from './poller'; +import type { PRReviewerConfig } from './types'; + +/** Starts the automated PR reviewer poller. */ +export function startPRReviewer( + config: PRReviewerConfig, + services: Omit, +): () => void { + let isPolling = false; + + const run = () => { + if (isPolling) { + return; + } + + isPolling = true; + void pollPRReviewer(config, services) + .catch((error) => { + console.error('Automated PR reviewer poll failed.', error); + }) + .finally(() => { + isPolling = false; + }); + }; + + run(); + const intervalHandle = setInterval(run, config.pollIntervalMs); + + return () => { + clearInterval(intervalHandle); + }; +} diff --git a/src/electron/main/pr-reviewer/poller.ts b/src/electron/main/pr-reviewer/poller.ts new file mode 100644 index 0000000..3e70cb4 --- /dev/null +++ b/src/electron/main/pr-reviewer/poller.ts @@ -0,0 +1,298 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { itemTools } from '@/electron/main/agent-actions/handlers/items'; +import type { RegisteredTool, ToolServices } from '@/electron/main/agent-actions/handlers/types'; + +import type { PRReviewerConfig, PRReviewerState, RepoPRConfig } from './types'; + +const REQUESTED_LABEL = 'dune-review-requested'; +const IN_PROGRESS_LABEL = 'dune-review-in-progress'; +const DIFF_LIMIT = 8000; +const BODY_LIMIT = 500; +const PROJECT_ID = '2bqWpDY6'; +const USER_AGENT = 'dune-pr-reviewer'; + +interface GitHubUser { + login: string; +} + +interface GitHubIssuePR { + html_url: string; +} + +interface GitHubIssue { + body: string | null; + html_url: string; + number: number; + pull_request?: GitHubIssuePR; + title: string; + user: GitHubUser | null; +} + +interface CreateWorkItemResult { + itemId?: string; +} + +const createItemTool = requireItemTool('workflow.items.create'); +const updateItemTool = requireItemTool('workflow.items.update'); + +/** Polls every configured repository once. */ +export async function pollPRReviewer( + config: PRReviewerConfig, + services: Omit, +): Promise { + const state = await readState(config.stateFilePath); + + for (const repoConfig of config.repos) { + await pollRepo(config, services, state, repoConfig); + } + + await writeState(config.stateFilePath, state); +} + +async function pollRepo( + config: PRReviewerConfig, + services: Omit, + state: PRReviewerState, + repoConfig: RepoPRConfig, +): Promise { + const repoKey = createRepoKey(repoConfig); + const processed = new Set(state.processedPrs[repoKey] ?? []); + const issues = await listRequestedPRIssues(config.githubToken, repoConfig); + + for (const issue of issues) { + if (!issue.pull_request || processed.has(issue.number)) { + continue; + } + + try { + const diff = await fetchPRDiff(config.githubToken, repoConfig, issue.number); + await createReviewWorkItem(services, repoConfig, issue, diff); + processed.add(issue.number); + state.processedPrs[repoKey] = [...processed].sort((left, right) => left - right); + await writeState(config.stateFilePath, state); + await markReviewInProgress(config.githubToken, repoConfig, issue.number); + } catch (error) { + console.error(`Failed to create Dune PR review item for ${repoKey}#${issue.number}.`, error); + } + } +} + +async function createReviewWorkItem( + services: Omit, + repoConfig: RepoPRConfig, + issue: GitHubIssue, + diff: string, +): Promise { + const agentContext = { + agentId: 'dune-pr-reviewer', + agentName: 'Dune PR Reviewer', + ipcContainerDir: '', + ipcHostDir: '', + projectId: PROJECT_ID, + }; + const toolServices: ToolServices = { ...services, agentContext }; + const title = `[Review] ${repoConfig.owner}/${repoConfig.repo}#${issue.number}: ${issue.title}`; + const brief = createReviewBrief(repoConfig, issue, diff); + const created = await createItemTool.handler(toolServices, { + brief, + projectId: PROJECT_ID, + status: 'ready', + title, + }) as CreateWorkItemResult; + + if (!created.itemId) { + throw new Error('workflow.items.create did not return an itemId.'); + } + + await updateItemTool.handler(toolServices, { + itemId: created.itemId, + primaryAgentId: repoConfig.reviewerAgentId, + }); +} + +function createReviewBrief(repoConfig: RepoPRConfig, issue: GitHubIssue, diff: string): string { + const repoName = `${repoConfig.owner}/${repoConfig.repo}`; + const author = issue.user?.login ?? 'unknown'; + const body = truncate(issue.body ?? '', BODY_LIMIT); + + return [ + `PR: ${issue.html_url}`, + `Author: ${author}`, + `Repo: ${repoName}`, + '', + '## Description', + body, + '', + `## Diff (truncated to ${DIFF_LIMIT} chars)`, + truncate(diff, DIFF_LIMIT), + '', + '---', + 'Review this PR. Use Codex to read the full diff if needed.', + 'Post a structured review comment on the GitHub PR via:', + ` gh pr review ${issue.number} --repo ${repoName} --comment --body "{summary}"`, + 'Move this work item to "review" when done.', + ].join('\n'); +} + +async function listRequestedPRIssues( + githubToken: string, + repoConfig: RepoPRConfig, +): Promise { + const url = githubApiUrl( + `/repos/${repoConfig.owner}/${repoConfig.repo}/issues`, + { + labels: REQUESTED_LABEL, + per_page: '100', + state: 'open', + }, + ); + const response = await githubFetch(githubToken, url); + + return await response.json() as GitHubIssue[]; +} + +async function fetchPRDiff( + githubToken: string, + repoConfig: RepoPRConfig, + prNumber: number, +): Promise { + const url = githubApiUrl(`/repos/${repoConfig.owner}/${repoConfig.repo}/pulls/${prNumber}`); + const response = await githubFetch(githubToken, url, { + headers: { + Accept: 'application/vnd.github.v3.diff', + }, + }); + + return response.text(); +} + +async function markReviewInProgress( + githubToken: string, + repoConfig: RepoPRConfig, + prNumber: number, +): Promise { + await githubFetch( + githubToken, + githubApiUrl( + `/repos/${repoConfig.owner}/${repoConfig.repo}/issues/${prNumber}/labels/${encodeURIComponent(REQUESTED_LABEL)}`, + ), + { method: 'DELETE' }, + { allowNotFound: true }, + ); + await githubFetch( + githubToken, + githubApiUrl(`/repos/${repoConfig.owner}/${repoConfig.repo}/issues/${prNumber}/labels`), + { + body: JSON.stringify({ labels: [IN_PROGRESS_LABEL] }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }, + ); +} + +async function githubFetch( + githubToken: string, + url: string, + init: RequestInit = {}, + options: { allowNotFound?: boolean } = {}, +): Promise { + const headers = new Headers(init.headers); + headers.set('Authorization', `Bearer ${githubToken}`); + headers.set('User-Agent', USER_AGENT); + headers.set('X-GitHub-Api-Version', '2022-11-28'); + + if (!headers.has('Accept')) { + headers.set('Accept', 'application/vnd.github+json'); + } + + const response = await fetch(url, { ...init, headers }); + + if (options.allowNotFound && response.status === 404) { + return response; + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`GitHub API request failed: ${response.status} ${response.statusText} ${body}`); + } + + return response; +} + +async function readState(stateFilePath: string): Promise { + try { + const raw = await fs.readFile(stateFilePath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + + if (!parsed.processedPrs || typeof parsed.processedPrs !== 'object') { + return createEmptyState(); + } + + return { + processedPrs: Object.fromEntries( + Object.entries(parsed.processedPrs).map(([repoKey, values]) => [ + repoKey, + Array.isArray(values) + ? values.filter((value): value is number => Number.isInteger(value)) + : [], + ]), + ), + }; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + await writeState(stateFilePath, createEmptyState()); + return createEmptyState(); + } + + throw error; + } +} + +async function writeState(stateFilePath: string, state: PRReviewerState): Promise { + await fs.mkdir(path.dirname(stateFilePath), { recursive: true }); + await fs.writeFile(stateFilePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); +} + +function createEmptyState(): PRReviewerState { + return { processedPrs: {} }; +} + +function createRepoKey(repoConfig: RepoPRConfig): string { + return `${repoConfig.owner}/${repoConfig.repo}`; +} + +function githubApiUrl(route: string, searchParams?: Record): string { + const url = new URL(`https://api.github.com${route}`); + + for (const [key, value] of Object.entries(searchParams ?? {})) { + url.searchParams.set(key, value); + } + + return url.toString(); +} + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return value.slice(0, maxLength); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +function requireItemTool(name: string): RegisteredTool { + const tool = itemTools.find((candidate) => candidate.definition.name === name); + + if (!tool) { + throw new Error(`PR reviewer could not find ${name} action handler.`); + } + + return tool; +} diff --git a/src/electron/main/pr-reviewer/types.ts b/src/electron/main/pr-reviewer/types.ts new file mode 100644 index 0000000..1a9d6e3 --- /dev/null +++ b/src/electron/main/pr-reviewer/types.ts @@ -0,0 +1,16 @@ +export interface RepoPRConfig { + owner: string; + repo: string; + reviewerAgentId: string; +} + +export interface PRReviewerConfig { + repos: RepoPRConfig[]; + githubToken: string; + stateFilePath: string; + pollIntervalMs: number; +} + +export interface PRReviewerState { + processedPrs: Record; +}