From c156b3b1390a0d99bf3d39574b93055683ce7a34 Mon Sep 17 00:00:00 2001 From: nimrodkor Date: Tue, 7 Apr 2026 23:21:48 +0200 Subject: [PATCH 1/2] feat: Add support for review of local changes --- .env.example | 21 +++ src/flows/LocalReview/LocalDiffPrompt.tsx | 125 +++++++++++++++ .../LocalReview/LocalPullRequestReview.tsx | 146 ++++++++++++++++++ src/flows/Review/Review.tsx | 99 +++++++++++- src/index.ts | 5 +- src/lib/git.ts | 103 ++++++++++++ src/models/chat.ts | 15 +- src/pages/PRChat/PRChat.tsx | 20 ++- src/pages/PRSelector/PullRequestSelector.tsx | 62 +++++++- .../PullRequestSelectorContainer.tsx | 19 ++- 10 files changed, 596 insertions(+), 19 deletions(-) create mode 100644 src/flows/LocalReview/LocalDiffPrompt.tsx create mode 100644 src/flows/LocalReview/LocalPullRequestReview.tsx create mode 100644 src/lib/git.ts diff --git a/.env.example b/.env.example index 81832d4..2cfdc92 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,23 @@ +# App mode: set both GH_TOKEN and ANTHROPIC_TOKEN for "tokens" mode. +# Leave both unset to use "baz" mode (OAuth authentication). +GH_TOKEN= +ANTHROPIC_TOKEN= + +# Baz service URL +BAZ_BASE_URL=https://baz.co + +# Descope authentication +DESCOPE_BASE_URL=https://api.descope.com DESCOPE_PROJECT_ID= DESCOPE_CLIENT_ID= + +# OAuth callback port for local auth flow +OAUTH_CALLBACK_PORT=8020 + +# Integration client IDs +JIRA_CLIENT_ID= +LINEAR_CLIENT_ID= + +# Environment +NODE_ENV=production +LOG_LEVEL=warn diff --git a/src/flows/LocalReview/LocalDiffPrompt.tsx b/src/flows/LocalReview/LocalDiffPrompt.tsx new file mode 100644 index 0000000..6848313 --- /dev/null +++ b/src/flows/LocalReview/LocalDiffPrompt.tsx @@ -0,0 +1,125 @@ +import React, { useState, useEffect } from "react"; +import { Box, Text } from "ink"; +import SelectInput from "ink-select-input"; +import { ITEM_SELECTION_GAP, ITEM_SELECTOR } from "../../theme/symbols.js"; +import { + hasStagedChanges, + getUnstagedFiles, + getStagedDiff, + getAllDiff, +} from "../../lib/git.js"; + +type DiffChoice = "staged" | "all"; + +interface SelectItem { + label: string; + value: DiffChoice; +} + +interface LocalDiffPromptProps { + onReady: (diffText: string) => void; + onError: (message: string) => void; +} + +const LocalDiffPrompt: React.FC = ({ + onReady, + onError, +}) => { + const [unstagedFiles, setUnstagedFiles] = useState([]); + const [hasStaged, setHasStaged] = useState(false); + const [ready, setReady] = useState(false); + + useEffect(() => { + try { + const staged = hasStagedChanges(); + const unstaged = getUnstagedFiles(); + + if (!staged && unstaged.length === 0) { + onError( + "No changes detected. Stage some changes with `git add` and try again.", + ); + return; + } + + setHasStaged(staged); + setUnstagedFiles(unstaged); + + // Auto-proceed if no unstaged files + if (unstaged.length === 0) { + onReady(getStagedDiff()); + return; + } + + // If no staged changes but there are unstaged, auto-select all + if (!staged) { + onReady(getAllDiff()); + return; + } + + setReady(true); + } catch (err) { + onError(err instanceof Error ? err.message : "Failed to read git state"); + } + }, []); + + if (!ready) { + return null; + } + + const items: SelectItem[] = [ + { label: "Review only staged changes", value: "staged" }, + { label: "Review all changes (staged + unstaged)", value: "all" }, + ]; + + const handleSelect = (item: SelectItem) => { + if (item.value === "staged") { + onReady(getStagedDiff()); + } else { + onReady(getAllDiff()); + } + }; + + return ( + + + + The following files have unstaged changes: + + {unstagedFiles.map((file) => ( + + {" "}- {file} + + ))} + + + {hasStaged && ( + <> + + How would you like to proceed? + + + ( + + {isSelected ? ITEM_SELECTOR : ITEM_SELECTION_GAP} + + )} + itemComponent={({ isSelected, label }) => ( + {label} + )} + /> + + + + Use ↑↓ arrows and Enter to select + + + + )} + + ); +}; + +export default LocalDiffPrompt; diff --git a/src/flows/LocalReview/LocalPullRequestReview.tsx b/src/flows/LocalReview/LocalPullRequestReview.tsx new file mode 100644 index 0000000..12db14e --- /dev/null +++ b/src/flows/LocalReview/LocalPullRequestReview.tsx @@ -0,0 +1,146 @@ +import React, { useState, useCallback } from "react"; +import ReviewMenu, { + ReviewMenuAction, + CompletedSteps, +} from "../Review/ReviewMenu.js"; +import PRChat from "../../pages/PRChat/PRChat.js"; +import { IssueType, CheckoutChatRequest } from "../../models/chat.js"; + +const LOCAL_WALKTHROUGH_PROMPT = + "Please walk me through these changes. Start by showing me a very short description of what the changes do, followed by a brief summary of the sections. Do not include any section yet in your answer"; + +interface LocalPullRequestReviewProps { + diffText: string; + repoName: string; + onComplete: () => void; + onBack: () => void; +} + +type State = + | ({ step: "menu" } & MenuStateData) + | ({ step: "prWalkthrough" } & MenuStateData) + | ({ step: "prChat" } & MenuStateData & { chatInput?: string }) + | { step: "complete" }; + +interface MenuStateData { + completedSteps: CompletedSteps; +} + +const LOCAL_PR_ID = "local"; +const LOCAL_PR_NUMBER = 0; + +const LocalPullRequestReview: React.FC = ({ + diffText, + repoName, + onComplete, + onBack, +}) => { + const initialCompletedSteps: CompletedSteps = { + unmetRequirements: false, + metRequirements: false, + comments: false, + prWalkthrough: false, + }; + + const [state, setState] = useState({ + step: "menu", + completedSteps: initialCompletedSteps, + }); + + const buildLocalChatRequest = useCallback( + (freeText: string, conversationId?: string): CheckoutChatRequest => { + return { + mode: "local", + repoName, + diff: Buffer.from(diffText).toString("base64"), + diffEncoding: "base64", + issue: { + type: IssueType.PR_WALKTHROUGH, + data: { id: LOCAL_PR_ID }, + }, + freeText, + conversationId, + }; + }, + [repoName, diffText], + ); + + const handleMenuAction = (action: ReviewMenuAction, input?: string) => { + if (state.step !== "menu") return; + + switch (action) { + case "prWalkthrough": + setState({ ...state, step: "prWalkthrough" }); + break; + case "prChat": + setState({ ...state, chatInput: input, step: "prChat" }); + break; + case "finish": + setState({ step: "complete" }); + onComplete(); + break; + } + }; + + const handleBackFromWalkthrough = () => { + if (state.step !== "prWalkthrough") return; + setState({ + step: "menu", + completedSteps: { ...state.completedSteps, prWalkthrough: true }, + }); + }; + + const handleBackFromChat = () => { + if (state.step !== "prChat") return; + setState({ ...state, step: "menu" }); + }; + + const handleBackFromMenu = () => { + onBack(); + }; + + switch (state.step) { + case "menu": + return ( + + ); + case "prWalkthrough": + return ( + + ); + case "prChat": + return ( + + ); + case "complete": + return null; + } +}; + +export default LocalPullRequestReview; diff --git a/src/flows/Review/Review.tsx b/src/flows/Review/Review.tsx index 438097e..c692aac 100644 --- a/src/flows/Review/Review.tsx +++ b/src/flows/Review/Review.tsx @@ -7,6 +7,9 @@ import IntegrationsCheck from "../Integration/IntegrationsCheck.js"; import PostReviewPrompt, { PostReviewAction } from "./PostReviewPrompt.js"; import { logger } from "../../lib/logger.js"; import PullRequestReview from "../../components/PullRequestReview.js"; +import LocalDiffPrompt from "../LocalReview/LocalDiffPrompt.js"; +import LocalPullRequestReview from "../LocalReview/LocalPullRequestReview.js"; +import { getRepoName } from "../../lib/git.js"; import { MAIN_COLOR } from "../../theme/colors.js"; import { REVIEW_COMPLETE_TEXT } from "../../theme/banners.js"; import { useAppMode } from "../../lib/config/index.js"; @@ -45,12 +48,26 @@ type FlowState = step: "complete"; selectedPR: PullRequest; skippedIntegration?: boolean; + } + | { + step: "localDiffPrompt"; + } + | { + step: "localReview"; + diffText: string; + } + | { + step: "localReviewComplete"; }; -const InternalReviewFlow: React.FC = () => { - const [flowState, setFlowState] = useState({ - step: "handlePRSelect", - }); +interface InternalReviewFlowProps { + isLocal?: boolean; +} + +const InternalReviewFlow: React.FC = ({ isLocal }) => { + const [flowState, setFlowState] = useState( + isLocal ? { step: "localDiffPrompt" } : { step: "handlePRSelect" }, + ); const [hasIntegration, setHasIntegration] = useState(null); const appMode = useAppMode(); @@ -163,12 +180,42 @@ const InternalReviewFlow: React.FC = () => { }); }; + // Local review handlers + const handleLocalSelect = () => { + setFlowState({ step: "localDiffPrompt" }); + }; + + const handleLocalDiffReady = (diffText: string) => { + setFlowState({ step: "localReview", diffText }); + }; + + const handleLocalDiffError = (message: string) => { + // Fall back to PR selector with error shown + logger.debug({ message }, "Local diff error"); + setFlowState({ step: "handlePRSelect" }); + }; + + const handleLocalReviewComplete = () => { + setFlowState({ step: "localReviewComplete" }); + }; + + const handleLocalReviewBack = () => { + if (isLocal) { + // If launched with --local, go back to diff prompt + setFlowState({ step: "localDiffPrompt" }); + } else { + // If entered from PR selector, go back to selector + setFlowState({ step: "handlePRSelect" }); + } + }; + switch (flowState.step) { case "handlePRSelect": return ( @@ -209,6 +256,42 @@ const InternalReviewFlow: React.FC = () => { case "complete": return ; + case "localDiffPrompt": + return ( + + + + ); + + case "localReview": + return ( + + + ✓ Reviewing local changes + [{getRepoName()}] + + + + ); + + case "localReviewComplete": + return ( + + + {REVIEW_COMPLETE_TEXT} + Local review completed + + + ); + default: return Unknown step; } @@ -242,11 +325,15 @@ const CompleteMessage: React.FC<{ ); }; -const ReviewFlow = () => ( +interface ReviewFlowProps { + isLocal?: boolean; +} + +const ReviewFlow: React.FC = ({ isLocal }) => ( <> - + ); export default ReviewFlow; diff --git a/src/index.ts b/src/index.ts index a7c8dad..75435b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,8 @@ program.name("Baz CLI").version(VERSION); program .command("review", { isDefault: true }) .description("Start a review") - .action(async () => { + .option("--local", "Review local git changes instead of a GitHub PR") + .action(async (options: { local?: boolean }) => { try { getAppConfig(); // Validate early before rendering } catch (e) { @@ -44,7 +45,7 @@ program React.createElement( AppModeProvider, null, - React.createElement(ReviewFlow), + React.createElement(ReviewFlow, { isLocal: options.local }), ), ); }); diff --git a/src/lib/git.ts b/src/lib/git.ts new file mode 100644 index 0000000..b0cdb93 --- /dev/null +++ b/src/lib/git.ts @@ -0,0 +1,103 @@ +import { execSync } from "child_process"; +import path from "path"; + +const EXEC_OPTIONS = { encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024 }; + +function gitExec(args: string): string { + try { + return execSync(`git ${args}`, EXEC_OPTIONS).trim(); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("not a git repository") + ) { + throw new Error( + "Not a git repository. Run this command from within a git project.", + ); + } + throw error; + } +} + +export function getStagedDiff(): string { + return gitExec("diff --cached"); +} + +export function getUnstagedFiles(): string[] { + const output = gitExec("diff --name-only"); + return output ? output.split("\n") : []; +} + +export function getAllDiff(): string { + return gitExec("diff HEAD"); +} + +export function getRepoName(): string { + try { + const remoteUrl = gitExec("remote get-url origin"); + + // SSH format: git@github.com:org/repo.git + const sshMatch = remoteUrl.match(/git@[^:]+:(.+?)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]; + + // HTTPS format: https://github.com/org/repo.git + const httpsMatch = remoteUrl.match(/https?:\/\/[^/]+\/(.+?)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]; + + return remoteUrl; + } catch { + return path.basename(process.cwd()); + } +} + +export function hasStagedChanges(): boolean { + return getStagedDiff().length > 0; +} + +export interface ChangeSummary { + modified: number; + added: number; + deleted: number; +} + +export function getChangeSummary(): ChangeSummary | null { + const staged = gitExec("diff --cached --name-status"); + const unstaged = gitExec("diff --name-status"); + + // Combine and deduplicate by filename (staged takes precedence) + const fileStatuses = new Map(); + for (const line of [...unstaged.split("\n"), ...staged.split("\n")]) { + if (!line.trim()) continue; + const [status, ...fileParts] = line.split("\t"); + const file = fileParts.join("\t"); + if (status && file) { + fileStatuses.set(file, status.charAt(0)); + } + } + + if (fileStatuses.size === 0) return null; + + let modified = 0; + let added = 0; + let deleted = 0; + + for (const status of fileStatuses.values()) { + switch (status) { + case "A": + added++; + break; + case "D": + deleted++; + break; + default: + modified++; + break; + } + } + + return { modified, added, deleted }; +} + +export function hasAnyChanges(): boolean { + return getChangeSummary() !== null; +} diff --git a/src/models/chat.ts b/src/models/chat.ts index 9b87cdc..ee1b1ff 100644 --- a/src/models/chat.ts +++ b/src/models/chat.ts @@ -102,7 +102,20 @@ export interface TokensChatRequest { conversationId?: string; } -export type CheckoutChatRequest = BazChatRequest | TokensChatRequest; +export interface LocalChatRequest { + mode: "local"; + repoName: string; + diff: string; + diffEncoding: "base64"; + issue: ChatIssue; + freeText: string; + conversationId?: string; +} + +export type CheckoutChatRequest = + | BazChatRequest + | TokensChatRequest + | LocalChatRequest; export interface MessageStart { type: "message_start"; diff --git a/src/pages/PRChat/PRChat.tsx b/src/pages/PRChat/PRChat.tsx index aa16306..575f348 100644 --- a/src/pages/PRChat/PRChat.tsx +++ b/src/pages/PRChat/PRChat.tsx @@ -8,7 +8,7 @@ import { } from "../../models/chat.js"; import { processStream, StreamAbortError } from "../../lib/chat-stream.js"; import { MAIN_COLOR } from "../../theme/colors.js"; -import { useAppMode } from "../../lib/config/AppModeContext.js"; +import { useAppMode } from "../../lib/config/index.js"; interface PRChatProps { prId: string; @@ -22,6 +22,10 @@ interface PRChatProps { issueType: IssueType.PR_CHAT | IssueType.PR_WALKTHROUGH; existingMessages?: ChatMessage[]; existingConversationId?: string; + buildChatRequestOverride?: ( + freeText: string, + conversationId?: string, + ) => CheckoutChatRequest; onBack: () => void; } @@ -37,6 +41,7 @@ const PRChat: React.FC = ({ issueType, existingMessages, existingConversationId, + buildChatRequestOverride, onBack, }) => { const [conversationId, setConversationId] = useState( @@ -56,6 +61,10 @@ const PRChat: React.FC = ({ const buildChatRequest = useCallback( (freeText: string, convId?: string): CheckoutChatRequest => { + if (buildChatRequestOverride) { + return buildChatRequestOverride(freeText, convId); + } + const issue = { type: issueType, data: { id: prId }, @@ -80,7 +89,14 @@ const PRChat: React.FC = ({ conversationId: convId, }; }, - [appMode.mode.name, bazRepoId, prId, fullRepoName, prNumber], + [ + appMode.mode.name, + bazRepoId, + prId, + fullRepoName, + prNumber, + buildChatRequestOverride, + ], ); const runChatStream = useCallback( diff --git a/src/pages/PRSelector/PullRequestSelector.tsx b/src/pages/PRSelector/PullRequestSelector.tsx index 048681b..7707026 100644 --- a/src/pages/PRSelector/PullRequestSelector.tsx +++ b/src/pages/PRSelector/PullRequestSelector.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState, useRef } from "react"; import { Box, Text, useInput } from "ink"; import type { PullRequest } from "../../lib/providers/index.js"; +import type { ChangeSummary } from "../../lib/git.js"; import { updatedTimeAgo } from "../../lib/date.js"; import { PullRequestCard } from "./PullRequestCard.js"; import { useFetchUser } from "../../hooks/useFetchUser.js"; @@ -17,13 +18,19 @@ interface PullRequestSearchKeywords { interface PullRequestSelectorProps { pullRequests: PullRequest[]; onSelect: (pr: PullRequest) => void; + onLocalSelect?: () => void; + changeSummary?: ChangeSummary; initialPrId?: string; updateData: (updater: (prev: PullRequest[]) => PullRequest[]) => void; } +const LOCAL_CHANGES_INDEX = -1; + const PullRequestSelector: React.FC = ({ pullRequests, onSelect, + onLocalSelect, + changeSummary, initialPrId, updateData, }) => { @@ -44,12 +51,17 @@ const PullRequestSelector: React.FC = ({ updatedAt: updatedTimeAgo(pr.updatedAt), })); + const hasLocal = !!onLocalSelect; + const minIndex = hasLocal ? LOCAL_CHANGES_INDEX : 0; + const initialIndex = initialPrId ? sanitizedPRs.findIndex((pr) => pr.id === initialPrId) - : 0; + : hasLocal + ? LOCAL_CHANGES_INDEX + : 0; const [selectedIndex, setSelectedIndex] = useState( - initialIndex >= 0 ? initialIndex : 0, + initialIndex >= minIndex ? initialIndex : minIndex, ); const searchKeywords = extractSearchKeywords(searchQuery.toLowerCase()); @@ -85,7 +97,7 @@ const PullRequestSelector: React.FC = ({ isFirstRender.current = false; return; } - setSelectedIndex(0); + setSelectedIndex(searchQuery ? 0 : minIndex); }, [searchQuery]); useInput((input, key) => { @@ -94,7 +106,7 @@ const PullRequestSelector: React.FC = ({ } if (key.upArrow) { - setSelectedIndex((prev) => Math.max(0, prev - 1)); + setSelectedIndex((prev) => Math.max(minIndex, prev - 1)); } else if (key.downArrow) { setSelectedIndex((prev) => Math.min(filteredPRs.length - 1, prev + 1)); } else if (key.return) { @@ -112,7 +124,11 @@ const PullRequestSelector: React.FC = ({ }); const handleSubmit = () => { - if (filteredPRs.length > 0) { + if (selectedIndex === LOCAL_CHANGES_INDEX && onLocalSelect) { + onLocalSelect(); + return; + } + if (filteredPRs.length > 0 && selectedIndex >= 0) { onSelect(filteredPRs[selectedIndex]); } }; @@ -126,7 +142,8 @@ const PullRequestSelector: React.FC = ({ ); }; - const selectedPr = filteredPRs[selectedIndex]; + const selectedPr = + selectedIndex >= 0 ? filteredPRs[selectedIndex] : undefined; const canMergeSelectedPr = selectedPr ? canMergePR(selectedPr) : false; if (showMergePrompt && prToMerge) { @@ -179,6 +196,25 @@ const PullRequestSelector: React.FC = ({ No pull requests match your search. ) : ( <> + {hasLocal && ( + + + {selectedIndex === LOCAL_CHANGES_INDEX ? "❯ " : " "} + Review local changes + + {changeSummary && ( + + {" "} + ({formatChangeSummary(changeSummary)}) + + )} + + )} Found {filteredPRs.length}{" "} {filteredPRs.length === 1 ? "pull request" : "pull requests"}: @@ -286,4 +322,18 @@ function extractSearchKeywords(query: string): PullRequestSearchKeywords { return { author, authorNot, repo, repoNot, freetext: freeText.join(" ") }; } +function formatChangeSummary(summary: ChangeSummary): string { + const parts: string[] = []; + if (summary.modified > 0) { + parts.push(`${summary.modified} modified`); + } + if (summary.added > 0) { + parts.push(`${summary.added} added`); + } + if (summary.deleted > 0) { + parts.push(`${summary.deleted} deleted`); + } + return parts.join(", "); +} + export default PullRequestSelector; diff --git a/src/pages/PRSelector/PullRequestSelectorContainer.tsx b/src/pages/PRSelector/PullRequestSelectorContainer.tsx index 2ff527b..caec506 100644 --- a/src/pages/PRSelector/PullRequestSelectorContainer.tsx +++ b/src/pages/PRSelector/PullRequestSelectorContainer.tsx @@ -1,19 +1,32 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { Box, Text, useInput } from "ink"; import Spinner from "ink-spinner"; import type { PullRequest } from "../../lib/providers/index.js"; import { usePullRequests } from "../../hooks/usePullRequests.js"; +import { getChangeSummary, type ChangeSummary } from "../../lib/git.js"; import PullRequestSelector from "./PullRequestSelector.js"; interface PullRequestSelectorContainerProps { onSelect: (pr: PullRequest) => void; + onLocalSelect?: () => void; initialPrId?: string; } const PullRequestSelectorContainer: React.FC< PullRequestSelectorContainerProps -> = ({ onSelect, initialPrId }) => { +> = ({ onSelect, onLocalSelect, initialPrId }) => { const { data, loading, error, updateData } = usePullRequests(); + const [changeSummary, setChangeSummary] = useState( + null, + ); + + useEffect(() => { + try { + setChangeSummary(getChangeSummary()); + } catch { + // Not a git repo or git not available — don't show local option + } + }, []); if (loading) { return ( @@ -44,6 +57,8 @@ const PullRequestSelectorContainer: React.FC< From e9d268ad60a55318b99823e9300475b50efd80e4 Mon Sep 17 00:00:00 2001 From: nimrodkor Date: Tue, 7 Apr 2026 23:25:25 +0200 Subject: [PATCH 2/2] CICD --- src/lib/git.ts | 5 ++++- src/pages/PRSelector/PullRequestSelector.tsx | 5 +---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/git.ts b/src/lib/git.ts index b0cdb93..30dc981 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -1,7 +1,10 @@ import { execSync } from "child_process"; import path from "path"; -const EXEC_OPTIONS = { encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024 }; +const EXEC_OPTIONS = { + encoding: "utf-8" as const, + maxBuffer: 10 * 1024 * 1024, +}; function gitExec(args: string): string { try { diff --git a/src/pages/PRSelector/PullRequestSelector.tsx b/src/pages/PRSelector/PullRequestSelector.tsx index 7707026..7a417e5 100644 --- a/src/pages/PRSelector/PullRequestSelector.tsx +++ b/src/pages/PRSelector/PullRequestSelector.tsx @@ -208,10 +208,7 @@ const PullRequestSelector: React.FC = ({ Review local changes {changeSummary && ( - - {" "} - ({formatChangeSummary(changeSummary)}) - + ({formatChangeSummary(changeSummary)}) )} )}