diff --git a/apps/server/src/routes/github/routes/check-github-remote.ts b/apps/server/src/routes/github/routes/check-github-remote.ts index 34a07198..5efdb172 100644 --- a/apps/server/src/routes/github/routes/check-github-remote.ts +++ b/apps/server/src/routes/github/routes/check-github-remote.ts @@ -5,6 +5,43 @@ import type { Request, Response } from 'express'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; +const GIT_REMOTE_ORIGIN_COMMAND = 'git remote get-url origin'; +const GH_REPO_VIEW_COMMAND = 'gh repo view --json name,owner'; +const GITHUB_REPO_URL_PREFIX = 'https://github.com/'; +const GITHUB_HTTPS_REMOTE_REGEX = /https:\/\/github\.com\/([^/]+)\/([^/.]+)/; +const GITHUB_SSH_REMOTE_REGEX = /git@github\.com:([^/]+)\/([^/.]+)/; + +interface GhRepoViewResponse { + name?: string; + owner?: { + login?: string; + }; +} + +async function resolveRepoFromGh(projectPath: string): Promise<{ + owner: string; + repo: string; +} | null> { + try { + const { stdout } = await execAsync(GH_REPO_VIEW_COMMAND, { + cwd: projectPath, + env: execEnv, + }); + + const data = JSON.parse(stdout) as GhRepoViewResponse; + const owner = typeof data.owner?.login === 'string' ? data.owner.login : null; + const repo = typeof data.name === 'string' ? data.name : null; + + if (!owner || !repo) { + return null; + } + + return { owner, repo }; + } catch { + return null; + } +} + export interface GitHubRemoteStatus { hasGitHubRemote: boolean; remoteUrl: string | null; @@ -21,19 +58,38 @@ export async function checkGitHubRemote(projectPath: string): Promise; @@ -45,6 +50,7 @@ interface GraphQLResponse { /** Timeout for GitHub API requests in milliseconds */ const GITHUB_API_TIMEOUT_MS = 30000; +const COMMENTS_PAGE_SIZE = 50; /** * Validate cursor format (GraphQL cursors are typically base64 strings) @@ -54,7 +60,7 @@ function isValidCursor(cursor: string): boolean { } /** - * Fetch comments for a specific issue using GitHub GraphQL API + * Fetch comments for a specific issue or pull request using GitHub GraphQL API */ async function fetchIssueComments( projectPath: string, @@ -70,24 +76,52 @@ async function fetchIssueComments( // Use GraphQL variables instead of string interpolation for safety const query = ` - query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) { + query GetIssueComments( + $owner: String! + $repo: String! + $issueNumber: Int! + $cursor: String + $pageSize: Int! + ) { repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - comments(first: 50, after: $cursor) { - totalCount - pageInfo { - hasNextPage - endCursor - } - nodes { - id - author { - login - avatarUrl + issueOrPullRequest(number: $issueNumber) { + __typename + ... on Issue { + comments(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { + login + avatarUrl + } + body + createdAt + updatedAt + } + } + } + ... on PullRequest { + comments(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { + login + avatarUrl + } + body + createdAt + updatedAt } - body - createdAt - updatedAt } } } @@ -99,6 +133,7 @@ async function fetchIssueComments( repo, issueNumber, cursor: cursor || null, + pageSize: COMMENTS_PAGE_SIZE, }; const requestBody = JSON.stringify({ query, variables }); @@ -140,10 +175,10 @@ async function fetchIssueComments( throw new Error(response.errors[0].message); } - const commentsData = response.data?.repository?.issue?.comments; + const commentsData = response.data?.repository?.issueOrPullRequest?.comments; if (!commentsData) { - throw new Error('Issue not found or no comments data available'); + throw new Error('Issue or pull request not found or no comments data available'); } const comments: GitHubComment[] = commentsData.nodes.map((node) => ({ diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts index 9c0f8933..96c3c202 100644 --- a/apps/server/src/routes/github/routes/list-issues.ts +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -9,6 +9,17 @@ import { checkGitHubRemote } from './check-github-remote.js'; import { createLogger } from '@automaker/utils'; const logger = createLogger('ListIssues'); +const OPEN_ISSUES_LIMIT = 100; +const CLOSED_ISSUES_LIMIT = 50; +const ISSUE_LIST_FIELDS = 'number,title,state,author,createdAt,labels,url,body,assignees'; +const ISSUE_STATE_OPEN = 'open'; +const ISSUE_STATE_CLOSED = 'closed'; +const GH_ISSUE_LIST_COMMAND = 'gh issue list'; +const GH_STATE_FLAG = '--state'; +const GH_JSON_FLAG = '--json'; +const GH_LIMIT_FLAG = '--limit'; +const LINKED_PRS_BATCH_SIZE = 20; +const LINKED_PRS_TIMELINE_ITEMS = 10; export interface GitHubLabel { name: string; @@ -69,34 +80,68 @@ async function fetchLinkedPRs( // Build GraphQL query for batch fetching linked PRs // We fetch up to 20 issues at a time to avoid query limits - const batchSize = 20; - for (let i = 0; i < issueNumbers.length; i += batchSize) { - const batch = issueNumbers.slice(i, i + batchSize); + for (let i = 0; i < issueNumbers.length; i += LINKED_PRS_BATCH_SIZE) { + const batch = issueNumbers.slice(i, i + LINKED_PRS_BATCH_SIZE); const issueQueries = batch .map( (num, idx) => ` - issue${idx}: issue(number: ${num}) { - number - timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) { - nodes { - ... on CrossReferencedEvent { - source { - ... on PullRequest { - number - title - state - url + issue${idx}: issueOrPullRequest(number: ${num}) { + ... on Issue { + number + timelineItems( + first: ${LINKED_PRS_TIMELINE_ITEMS} + itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT] + ) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } } } } - ... on ConnectedEvent { - subject { - ... on PullRequest { - number - title - state - url + } + } + ... on PullRequest { + number + timelineItems( + first: ${LINKED_PRS_TIMELINE_ITEMS} + itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT] + ) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } } } } @@ -213,16 +258,35 @@ export function createListIssuesHandler() { } // Fetch open and closed issues in parallel (now including assignees) + const repoQualifier = + remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : ''; + const repoFlag = repoQualifier ? `-R ${repoQualifier}` : ''; const [openResult, closedResult] = await Promise.all([ execAsync( - 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100', + [ + GH_ISSUE_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${ISSUE_STATE_OPEN}`, + `${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${OPEN_ISSUES_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, } ), execAsync( - 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50', + [ + GH_ISSUE_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${ISSUE_STATE_CLOSED}`, + `${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${CLOSED_ISSUES_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, diff --git a/apps/server/src/routes/github/routes/list-prs.ts b/apps/server/src/routes/github/routes/list-prs.ts index 87f42a38..b273fc0a 100644 --- a/apps/server/src/routes/github/routes/list-prs.ts +++ b/apps/server/src/routes/github/routes/list-prs.ts @@ -6,6 +6,17 @@ import type { Request, Response } from 'express'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; import { checkGitHubRemote } from './check-github-remote.js'; +const OPEN_PRS_LIMIT = 100; +const MERGED_PRS_LIMIT = 50; +const PR_LIST_FIELDS = + 'number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body'; +const PR_STATE_OPEN = 'open'; +const PR_STATE_MERGED = 'merged'; +const GH_PR_LIST_COMMAND = 'gh pr list'; +const GH_STATE_FLAG = '--state'; +const GH_JSON_FLAG = '--json'; +const GH_LIMIT_FLAG = '--limit'; + export interface GitHubLabel { name: string; color: string; @@ -57,16 +68,36 @@ export function createListPRsHandler() { return; } + const repoQualifier = + remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : ''; + const repoFlag = repoQualifier ? `-R ${repoQualifier}` : ''; + const [openResult, mergedResult] = await Promise.all([ execAsync( - 'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100', + [ + GH_PR_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${PR_STATE_OPEN}`, + `${GH_JSON_FLAG} ${PR_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${OPEN_PRS_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, } ), execAsync( - 'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50', + [ + GH_PR_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${PR_STATE_MERGED}`, + `${GH_JSON_FLAG} ${PR_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${MERGED_PRS_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index aaa83c9a..14de437b 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -3,7 +3,7 @@ * * Scans the codebase to determine if an issue is valid, invalid, or needs clarification. * Runs asynchronously and emits events for progress and completion. - * Supports both Claude models and Cursor models. + * Supports Claude, Codex, Cursor, and OpenCode models. */ import type { Request, Response } from 'express'; @@ -11,13 +11,19 @@ import type { EventEmitter } from '../../../lib/events.js'; import type { IssueValidationResult, IssueValidationEvent, - ModelAlias, - CursorModelId, + ModelId, GitHubComment, LinkedPRInfo, ThinkingLevel, + ReasoningEffort, +} from '@automaker/types'; +import { + DEFAULT_PHASE_MODELS, + isClaudeModel, + isCodexModel, + isCursorModel, + isOpencodeModel, } from '@automaker/types'; -import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; import { extractJson } from '../../../lib/json-extractor.js'; import { writeValidation } from '../../../lib/validation-storage.js'; @@ -39,9 +45,6 @@ import { import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; -/** Valid Claude model values for validation */ -const VALID_CLAUDE_MODELS: readonly ModelAlias[] = ['opus', 'sonnet', 'haiku'] as const; - /** * Request body for issue validation */ @@ -51,10 +54,12 @@ interface ValidateIssueRequestBody { issueTitle: string; issueBody: string; issueLabels?: string[]; - /** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */ - model?: ModelAlias | CursorModelId; - /** Thinking level for Claude models (ignored for Cursor models) */ + /** Model to use for validation (Claude alias or provider model ID) */ + model?: ModelId; + /** Thinking level for Claude models (ignored for non-Claude models) */ thinkingLevel?: ThinkingLevel; + /** Reasoning effort for Codex models (ignored for non-Codex models) */ + reasoningEffort?: ReasoningEffort; /** Comments to include in validation analysis */ comments?: GitHubComment[]; /** Linked pull requests for this issue */ @@ -66,7 +71,7 @@ interface ValidateIssueRequestBody { * * Emits events for start, progress, complete, and error. * Stores result on completion. - * Supports both Claude models (with structured output) and Cursor models (with JSON parsing). + * Supports Claude/Codex models (structured output) and Cursor/OpenCode models (JSON parsing). */ async function runValidation( projectPath: string, @@ -74,13 +79,14 @@ async function runValidation( issueTitle: string, issueBody: string, issueLabels: string[] | undefined, - model: ModelAlias | CursorModelId, + model: ModelId, events: EventEmitter, abortController: AbortController, settingsService?: SettingsService, comments?: ValidationComment[], linkedPRs?: ValidationLinkedPR[], - thinkingLevel?: ThinkingLevel + thinkingLevel?: ThinkingLevel, + reasoningEffort?: ReasoningEffort ): Promise { // Emit start event const startEvent: IssueValidationEvent = { @@ -111,8 +117,8 @@ async function runValidation( let responseText = ''; - // Determine if we should use structured output (Claude supports it, Cursor doesn't) - const useStructuredOutput = !isCursorModel(model); + // Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't) + const useStructuredOutput = isClaudeModel(model) || isCodexModel(model); // Build the final prompt - for Cursor, include system prompt and JSON schema instructions let finalPrompt = basePrompt; @@ -138,14 +144,20 @@ ${basePrompt}`; '[ValidateIssue]' ); - // Use thinkingLevel from request if provided, otherwise fall back to settings + // Use request overrides if provided, otherwise fall back to settings let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel; - if (!effectiveThinkingLevel) { + let effectiveReasoningEffort: ReasoningEffort | undefined = reasoningEffort; + if (!effectiveThinkingLevel || !effectiveReasoningEffort) { const settings = await settingsService?.getGlobalSettings(); const phaseModelEntry = settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; const resolved = resolvePhaseModel(phaseModelEntry); - effectiveThinkingLevel = resolved.thinkingLevel; + if (!effectiveThinkingLevel) { + effectiveThinkingLevel = resolved.thinkingLevel; + } + if (!effectiveReasoningEffort && typeof phaseModelEntry !== 'string') { + effectiveReasoningEffort = phaseModelEntry.reasoningEffort; + } } logger.info(`Using model: ${model}`); @@ -158,6 +170,7 @@ ${basePrompt}`; systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined, abortController, thinkingLevel: effectiveThinkingLevel, + reasoningEffort: effectiveReasoningEffort, readOnly: true, // Issue validation only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, outputFormat: useStructuredOutput @@ -262,6 +275,7 @@ export function createValidateIssueHandler( issueLabels, model = 'opus', thinkingLevel, + reasoningEffort, comments: rawComments, linkedPRs: rawLinkedPRs, } = req.body as ValidateIssueRequestBody; @@ -309,14 +323,17 @@ export function createValidateIssueHandler( return; } - // Validate model parameter at runtime - accept Claude models or Cursor models - const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias); - const isValidCursorModel = isCursorModel(model); + // Validate model parameter at runtime - accept any supported provider model + const isValidModel = + isClaudeModel(model) || + isCursorModel(model) || + isCodexModel(model) || + isOpencodeModel(model); - if (!isValidClaudeModel && !isValidCursorModel) { + if (!isValidModel) { res.status(400).json({ success: false, - error: `Invalid model. Must be one of: ${VALID_CLAUDE_MODELS.join(', ')}, or a Cursor model ID`, + error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).', }); return; } @@ -347,7 +364,8 @@ export function createValidateIssueHandler( settingsService, validationComments, validationLinkedPRs, - thinkingLevel + thinkingLevel, + reasoningEffort ) .catch(() => { // Error is already handled inside runValidation (event emitted) diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts index 7978e9fe..c09baab0 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts @@ -9,7 +9,7 @@ import { IssueValidationEvent, StoredValidation, } from '@/lib/electron'; -import type { LinkedPRInfo, PhaseModelEntry, ModelAlias, CursorModelId } from '@automaker/types'; +import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { toast } from 'sonner'; import { isValidationStale } from '../utils'; @@ -19,12 +19,10 @@ const logger = createLogger('IssueValidation'); /** * Extract model string from PhaseModelEntry or string (handles both formats) */ -function extractModel( - entry: PhaseModelEntry | string | undefined -): ModelAlias | CursorModelId | undefined { +function extractModel(entry: PhaseModelEntry | string | undefined): ModelId | undefined { if (!entry) return undefined; if (typeof entry === 'string') { - return entry as ModelAlias | CursorModelId; + return entry as ModelId; } return entry.model; } @@ -228,8 +226,8 @@ export function useIssueValidation({ issue: GitHubIssue, options: { forceRevalidate?: boolean; - model?: string | PhaseModelEntry; // Accept either string (backward compat) or PhaseModelEntry - modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking level + model?: ModelId | PhaseModelEntry; // Accept either model ID (backward compat) or PhaseModelEntry + modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking/reasoning comments?: GitHubComment[]; linkedPRs?: LinkedPRInfo[]; } = {} @@ -267,15 +265,16 @@ export function useIssueValidation({ ? modelEntry : model ? typeof model === 'string' - ? { model: model as ModelAlias | CursorModelId } + ? { model: model as ModelId } : model : phaseModels.validationModel; const normalizedEntry = typeof effectiveModelEntry === 'string' - ? { model: effectiveModelEntry as ModelAlias | CursorModelId } + ? { model: effectiveModelEntry as ModelId } : effectiveModelEntry; const modelToUse = normalizedEntry.model; const thinkingLevelToUse = normalizedEntry.thinkingLevel; + const reasoningEffortToUse = normalizedEntry.reasoningEffort; try { const api = getElectronAPI(); @@ -292,7 +291,8 @@ export function useIssueValidation({ currentProject.path, validationInput, modelToUse, - thinkingLevelToUse + thinkingLevelToUse, + reasoningEffortToUse ); if (!result.success) { diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts index 466859a9..7ea799d9 100644 --- a/apps/ui/src/components/views/github-issues-view/types.ts +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -1,5 +1,5 @@ import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron'; -import type { ModelAlias, CursorModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types'; +import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types'; export interface IssueRowProps { issue: GitHubIssue; @@ -37,7 +37,7 @@ export interface IssueDetailPanelProps { /** Model override state */ modelOverride: { effectiveModelEntry: PhaseModelEntry; - effectiveModel: ModelAlias | CursorModelId; + effectiveModel: ModelId; isOverridden: boolean; setOverride: (entry: PhaseModelEntry | null) => void; }; diff --git a/libs/types/src/issue-validation.ts b/libs/types/src/issue-validation.ts index f5e5e976..ff720d5c 100644 --- a/libs/types/src/issue-validation.ts +++ b/libs/types/src/issue-validation.ts @@ -4,7 +4,7 @@ * Types for validating GitHub issues against the codebase using Claude SDK. */ -import type { ModelAlias } from './model.js'; +import type { ModelId } from './model.js'; /** * Verdict from issue validation @@ -137,8 +137,8 @@ export type IssueValidationEvent = issueTitle: string; result: IssueValidationResult; projectPath: string; - /** Model used for validation (opus, sonnet, haiku) */ - model: ModelAlias; + /** Model used for validation */ + model: ModelId; } | { type: 'issue_validation_error'; @@ -162,8 +162,8 @@ export interface StoredValidation { issueTitle: string; /** ISO timestamp when validation was performed */ validatedAt: string; - /** Model used for validation (opus, sonnet, haiku) */ - model: ModelAlias; + /** Model used for validation */ + model: ModelId; /** The validation result */ result: IssueValidationResult; /** ISO timestamp when user viewed this validation (undefined = not yet viewed) */