mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
feat(github): add Codex/OpenCode model support for issue validation
- Support Codex and OpenCode models in issue validation - Add reasoningEffort parameter for Codex model configuration - Update validation logic to use structured output for Claude/Codex - Update UI hooks and types for multi-provider model selection
This commit is contained in:
@@ -5,6 +5,43 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
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 {
|
export interface GitHubRemoteStatus {
|
||||||
hasGitHubRemote: boolean;
|
hasGitHubRemote: boolean;
|
||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
@@ -21,19 +58,38 @@ export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemo
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the remote URL (origin by default)
|
let remoteUrl = '';
|
||||||
const { stdout } = await execAsync('git remote get-url origin', {
|
try {
|
||||||
cwd: projectPath,
|
// Get the remote URL (origin by default)
|
||||||
env: execEnv,
|
const { stdout } = await execAsync(GIT_REMOTE_ORIGIN_COMMAND, {
|
||||||
});
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
remoteUrl = stdout.trim();
|
||||||
|
status.remoteUrl = remoteUrl || null;
|
||||||
|
} catch {
|
||||||
|
// Ignore missing origin remote
|
||||||
|
}
|
||||||
|
|
||||||
const remoteUrl = stdout.trim();
|
const ghRepo = await resolveRepoFromGh(projectPath);
|
||||||
status.remoteUrl = remoteUrl;
|
if (ghRepo) {
|
||||||
|
status.hasGitHubRemote = true;
|
||||||
|
status.owner = ghRepo.owner;
|
||||||
|
status.repo = ghRepo.repo;
|
||||||
|
if (!status.remoteUrl) {
|
||||||
|
status.remoteUrl = `${GITHUB_REPO_URL_PREFIX}${ghRepo.owner}/${ghRepo.repo}`;
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a GitHub URL
|
// Check if it's a GitHub URL
|
||||||
// Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git
|
// Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git
|
||||||
const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)/);
|
if (!remoteUrl) {
|
||||||
const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^/.]+)/);
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpsMatch = remoteUrl.match(GITHUB_HTTPS_REMOTE_REGEX);
|
||||||
|
const sshMatch = remoteUrl.match(GITHUB_SSH_REMOTE_REGEX);
|
||||||
|
|
||||||
const match = httpsMatch || sshMatch;
|
const match = httpsMatch || sshMatch;
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
@@ -25,19 +25,24 @@ interface GraphQLComment {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GraphQLCommentConnection {
|
||||||
|
totalCount: number;
|
||||||
|
pageInfo: {
|
||||||
|
hasNextPage: boolean;
|
||||||
|
endCursor: string | null;
|
||||||
|
};
|
||||||
|
nodes: GraphQLComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphQLIssueOrPullRequest {
|
||||||
|
__typename: 'Issue' | 'PullRequest';
|
||||||
|
comments: GraphQLCommentConnection;
|
||||||
|
}
|
||||||
|
|
||||||
interface GraphQLResponse {
|
interface GraphQLResponse {
|
||||||
data?: {
|
data?: {
|
||||||
repository?: {
|
repository?: {
|
||||||
issue?: {
|
issueOrPullRequest?: GraphQLIssueOrPullRequest | null;
|
||||||
comments: {
|
|
||||||
totalCount: number;
|
|
||||||
pageInfo: {
|
|
||||||
hasNextPage: boolean;
|
|
||||||
endCursor: string | null;
|
|
||||||
};
|
|
||||||
nodes: GraphQLComment[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
errors?: Array<{ message: string }>;
|
errors?: Array<{ message: string }>;
|
||||||
@@ -45,6 +50,7 @@ interface GraphQLResponse {
|
|||||||
|
|
||||||
/** Timeout for GitHub API requests in milliseconds */
|
/** Timeout for GitHub API requests in milliseconds */
|
||||||
const GITHUB_API_TIMEOUT_MS = 30000;
|
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||||
|
const COMMENTS_PAGE_SIZE = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate cursor format (GraphQL cursors are typically base64 strings)
|
* 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(
|
async function fetchIssueComments(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -70,24 +76,52 @@ async function fetchIssueComments(
|
|||||||
|
|
||||||
// Use GraphQL variables instead of string interpolation for safety
|
// Use GraphQL variables instead of string interpolation for safety
|
||||||
const query = `
|
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) {
|
repository(owner: $owner, name: $repo) {
|
||||||
issue(number: $issueNumber) {
|
issueOrPullRequest(number: $issueNumber) {
|
||||||
comments(first: 50, after: $cursor) {
|
__typename
|
||||||
totalCount
|
... on Issue {
|
||||||
pageInfo {
|
comments(first: $pageSize, after: $cursor) {
|
||||||
hasNextPage
|
totalCount
|
||||||
endCursor
|
pageInfo {
|
||||||
}
|
hasNextPage
|
||||||
nodes {
|
endCursor
|
||||||
id
|
}
|
||||||
author {
|
nodes {
|
||||||
login
|
id
|
||||||
avatarUrl
|
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,
|
repo,
|
||||||
issueNumber,
|
issueNumber,
|
||||||
cursor: cursor || null,
|
cursor: cursor || null,
|
||||||
|
pageSize: COMMENTS_PAGE_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestBody = JSON.stringify({ query, variables });
|
const requestBody = JSON.stringify({ query, variables });
|
||||||
@@ -140,10 +175,10 @@ async function fetchIssueComments(
|
|||||||
throw new Error(response.errors[0].message);
|
throw new Error(response.errors[0].message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentsData = response.data?.repository?.issue?.comments;
|
const commentsData = response.data?.repository?.issueOrPullRequest?.comments;
|
||||||
|
|
||||||
if (!commentsData) {
|
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) => ({
|
const comments: GitHubComment[] = commentsData.nodes.map((node) => ({
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ import { checkGitHubRemote } from './check-github-remote.js';
|
|||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('ListIssues');
|
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 {
|
export interface GitHubLabel {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -69,34 +80,68 @@ async function fetchLinkedPRs(
|
|||||||
|
|
||||||
// Build GraphQL query for batch fetching linked PRs
|
// Build GraphQL query for batch fetching linked PRs
|
||||||
// We fetch up to 20 issues at a time to avoid query limits
|
// We fetch up to 20 issues at a time to avoid query limits
|
||||||
const batchSize = 20;
|
for (let i = 0; i < issueNumbers.length; i += LINKED_PRS_BATCH_SIZE) {
|
||||||
for (let i = 0; i < issueNumbers.length; i += batchSize) {
|
const batch = issueNumbers.slice(i, i + LINKED_PRS_BATCH_SIZE);
|
||||||
const batch = issueNumbers.slice(i, i + batchSize);
|
|
||||||
|
|
||||||
const issueQueries = batch
|
const issueQueries = batch
|
||||||
.map(
|
.map(
|
||||||
(num, idx) => `
|
(num, idx) => `
|
||||||
issue${idx}: issue(number: ${num}) {
|
issue${idx}: issueOrPullRequest(number: ${num}) {
|
||||||
number
|
... on Issue {
|
||||||
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
|
number
|
||||||
nodes {
|
timelineItems(
|
||||||
... on CrossReferencedEvent {
|
first: ${LINKED_PRS_TIMELINE_ITEMS}
|
||||||
source {
|
itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]
|
||||||
... on PullRequest {
|
) {
|
||||||
number
|
nodes {
|
||||||
title
|
... on CrossReferencedEvent {
|
||||||
state
|
source {
|
||||||
url
|
... on PullRequest {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
state
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ConnectedEvent {
|
||||||
|
subject {
|
||||||
|
... on PullRequest {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
state
|
||||||
|
url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
... on ConnectedEvent {
|
}
|
||||||
subject {
|
}
|
||||||
... on PullRequest {
|
... on PullRequest {
|
||||||
number
|
number
|
||||||
title
|
timelineItems(
|
||||||
state
|
first: ${LINKED_PRS_TIMELINE_ITEMS}
|
||||||
url
|
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)
|
// 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([
|
const [openResult, closedResult] = await Promise.all([
|
||||||
execAsync(
|
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,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
execAsync(
|
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,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ import type { Request, Response } from 'express';
|
|||||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
import { checkGitHubRemote } from './check-github-remote.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 {
|
export interface GitHubLabel {
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -57,16 +68,36 @@ export function createListPRsHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const repoQualifier =
|
||||||
|
remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : '';
|
||||||
|
const repoFlag = repoQualifier ? `-R ${repoQualifier}` : '';
|
||||||
|
|
||||||
const [openResult, mergedResult] = await Promise.all([
|
const [openResult, mergedResult] = await Promise.all([
|
||||||
execAsync(
|
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,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
execAsync(
|
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,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
|
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
|
||||||
* Runs asynchronously and emits events for progress and completion.
|
* 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';
|
import type { Request, Response } from 'express';
|
||||||
@@ -11,13 +11,19 @@ import type { EventEmitter } from '../../../lib/events.js';
|
|||||||
import type {
|
import type {
|
||||||
IssueValidationResult,
|
IssueValidationResult,
|
||||||
IssueValidationEvent,
|
IssueValidationEvent,
|
||||||
ModelAlias,
|
ModelId,
|
||||||
CursorModelId,
|
|
||||||
GitHubComment,
|
GitHubComment,
|
||||||
LinkedPRInfo,
|
LinkedPRInfo,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
|
ReasoningEffort,
|
||||||
|
} from '@automaker/types';
|
||||||
|
import {
|
||||||
|
DEFAULT_PHASE_MODELS,
|
||||||
|
isClaudeModel,
|
||||||
|
isCodexModel,
|
||||||
|
isCursorModel,
|
||||||
|
isOpencodeModel,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types';
|
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { extractJson } from '../../../lib/json-extractor.js';
|
import { extractJson } from '../../../lib/json-extractor.js';
|
||||||
import { writeValidation } from '../../../lib/validation-storage.js';
|
import { writeValidation } from '../../../lib/validation-storage.js';
|
||||||
@@ -39,9 +45,6 @@ import {
|
|||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.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
|
* Request body for issue validation
|
||||||
*/
|
*/
|
||||||
@@ -51,10 +54,12 @@ interface ValidateIssueRequestBody {
|
|||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
issueBody: string;
|
issueBody: string;
|
||||||
issueLabels?: string[];
|
issueLabels?: string[];
|
||||||
/** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */
|
/** Model to use for validation (Claude alias or provider model ID) */
|
||||||
model?: ModelAlias | CursorModelId;
|
model?: ModelId;
|
||||||
/** Thinking level for Claude models (ignored for Cursor models) */
|
/** Thinking level for Claude models (ignored for non-Claude models) */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
/** Reasoning effort for Codex models (ignored for non-Codex models) */
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
/** Comments to include in validation analysis */
|
/** Comments to include in validation analysis */
|
||||||
comments?: GitHubComment[];
|
comments?: GitHubComment[];
|
||||||
/** Linked pull requests for this issue */
|
/** Linked pull requests for this issue */
|
||||||
@@ -66,7 +71,7 @@ interface ValidateIssueRequestBody {
|
|||||||
*
|
*
|
||||||
* Emits events for start, progress, complete, and error.
|
* Emits events for start, progress, complete, and error.
|
||||||
* Stores result on completion.
|
* 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(
|
async function runValidation(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -74,13 +79,14 @@ async function runValidation(
|
|||||||
issueTitle: string,
|
issueTitle: string,
|
||||||
issueBody: string,
|
issueBody: string,
|
||||||
issueLabels: string[] | undefined,
|
issueLabels: string[] | undefined,
|
||||||
model: ModelAlias | CursorModelId,
|
model: ModelId,
|
||||||
events: EventEmitter,
|
events: EventEmitter,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
settingsService?: SettingsService,
|
settingsService?: SettingsService,
|
||||||
comments?: ValidationComment[],
|
comments?: ValidationComment[],
|
||||||
linkedPRs?: ValidationLinkedPR[],
|
linkedPRs?: ValidationLinkedPR[],
|
||||||
thinkingLevel?: ThinkingLevel
|
thinkingLevel?: ThinkingLevel,
|
||||||
|
reasoningEffort?: ReasoningEffort
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Emit start event
|
// Emit start event
|
||||||
const startEvent: IssueValidationEvent = {
|
const startEvent: IssueValidationEvent = {
|
||||||
@@ -111,8 +117,8 @@ async function runValidation(
|
|||||||
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
|
|
||||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
|
||||||
const useStructuredOutput = !isCursorModel(model);
|
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
||||||
|
|
||||||
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
|
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
|
||||||
let finalPrompt = basePrompt;
|
let finalPrompt = basePrompt;
|
||||||
@@ -138,14 +144,20 @@ ${basePrompt}`;
|
|||||||
'[ValidateIssue]'
|
'[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;
|
let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel;
|
||||||
if (!effectiveThinkingLevel) {
|
let effectiveReasoningEffort: ReasoningEffort | undefined = reasoningEffort;
|
||||||
|
if (!effectiveThinkingLevel || !effectiveReasoningEffort) {
|
||||||
const settings = await settingsService?.getGlobalSettings();
|
const settings = await settingsService?.getGlobalSettings();
|
||||||
const phaseModelEntry =
|
const phaseModelEntry =
|
||||||
settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel;
|
settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel;
|
||||||
const resolved = resolvePhaseModel(phaseModelEntry);
|
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}`);
|
logger.info(`Using model: ${model}`);
|
||||||
@@ -158,6 +170,7 @@ ${basePrompt}`;
|
|||||||
systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined,
|
systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined,
|
||||||
abortController,
|
abortController,
|
||||||
thinkingLevel: effectiveThinkingLevel,
|
thinkingLevel: effectiveThinkingLevel,
|
||||||
|
reasoningEffort: effectiveReasoningEffort,
|
||||||
readOnly: true, // Issue validation only reads code, doesn't write
|
readOnly: true, // Issue validation only reads code, doesn't write
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||||
outputFormat: useStructuredOutput
|
outputFormat: useStructuredOutput
|
||||||
@@ -262,6 +275,7 @@ export function createValidateIssueHandler(
|
|||||||
issueLabels,
|
issueLabels,
|
||||||
model = 'opus',
|
model = 'opus',
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
reasoningEffort,
|
||||||
comments: rawComments,
|
comments: rawComments,
|
||||||
linkedPRs: rawLinkedPRs,
|
linkedPRs: rawLinkedPRs,
|
||||||
} = req.body as ValidateIssueRequestBody;
|
} = req.body as ValidateIssueRequestBody;
|
||||||
@@ -309,14 +323,17 @@ export function createValidateIssueHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate model parameter at runtime - accept Claude models or Cursor models
|
// Validate model parameter at runtime - accept any supported provider model
|
||||||
const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias);
|
const isValidModel =
|
||||||
const isValidCursorModel = isCursorModel(model);
|
isClaudeModel(model) ||
|
||||||
|
isCursorModel(model) ||
|
||||||
|
isCodexModel(model) ||
|
||||||
|
isOpencodeModel(model);
|
||||||
|
|
||||||
if (!isValidClaudeModel && !isValidCursorModel) {
|
if (!isValidModel) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -347,7 +364,8 @@ export function createValidateIssueHandler(
|
|||||||
settingsService,
|
settingsService,
|
||||||
validationComments,
|
validationComments,
|
||||||
validationLinkedPRs,
|
validationLinkedPRs,
|
||||||
thinkingLevel
|
thinkingLevel,
|
||||||
|
reasoningEffort
|
||||||
)
|
)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Error is already handled inside runValidation (event emitted)
|
// Error is already handled inside runValidation (event emitted)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
IssueValidationEvent,
|
IssueValidationEvent,
|
||||||
StoredValidation,
|
StoredValidation,
|
||||||
} from '@/lib/electron';
|
} 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 { useAppStore } from '@/store/app-store';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { isValidationStale } from '../utils';
|
import { isValidationStale } from '../utils';
|
||||||
@@ -19,12 +19,10 @@ const logger = createLogger('IssueValidation');
|
|||||||
/**
|
/**
|
||||||
* Extract model string from PhaseModelEntry or string (handles both formats)
|
* Extract model string from PhaseModelEntry or string (handles both formats)
|
||||||
*/
|
*/
|
||||||
function extractModel(
|
function extractModel(entry: PhaseModelEntry | string | undefined): ModelId | undefined {
|
||||||
entry: PhaseModelEntry | string | undefined
|
|
||||||
): ModelAlias | CursorModelId | undefined {
|
|
||||||
if (!entry) return undefined;
|
if (!entry) return undefined;
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
return entry as ModelAlias | CursorModelId;
|
return entry as ModelId;
|
||||||
}
|
}
|
||||||
return entry.model;
|
return entry.model;
|
||||||
}
|
}
|
||||||
@@ -228,8 +226,8 @@ export function useIssueValidation({
|
|||||||
issue: GitHubIssue,
|
issue: GitHubIssue,
|
||||||
options: {
|
options: {
|
||||||
forceRevalidate?: boolean;
|
forceRevalidate?: boolean;
|
||||||
model?: string | PhaseModelEntry; // Accept either string (backward compat) or PhaseModelEntry
|
model?: ModelId | PhaseModelEntry; // Accept either model ID (backward compat) or PhaseModelEntry
|
||||||
modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking level
|
modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking/reasoning
|
||||||
comments?: GitHubComment[];
|
comments?: GitHubComment[];
|
||||||
linkedPRs?: LinkedPRInfo[];
|
linkedPRs?: LinkedPRInfo[];
|
||||||
} = {}
|
} = {}
|
||||||
@@ -267,15 +265,16 @@ export function useIssueValidation({
|
|||||||
? modelEntry
|
? modelEntry
|
||||||
: model
|
: model
|
||||||
? typeof model === 'string'
|
? typeof model === 'string'
|
||||||
? { model: model as ModelAlias | CursorModelId }
|
? { model: model as ModelId }
|
||||||
: model
|
: model
|
||||||
: phaseModels.validationModel;
|
: phaseModels.validationModel;
|
||||||
const normalizedEntry =
|
const normalizedEntry =
|
||||||
typeof effectiveModelEntry === 'string'
|
typeof effectiveModelEntry === 'string'
|
||||||
? { model: effectiveModelEntry as ModelAlias | CursorModelId }
|
? { model: effectiveModelEntry as ModelId }
|
||||||
: effectiveModelEntry;
|
: effectiveModelEntry;
|
||||||
const modelToUse = normalizedEntry.model;
|
const modelToUse = normalizedEntry.model;
|
||||||
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
|
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
|
||||||
|
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -292,7 +291,8 @@ export function useIssueValidation({
|
|||||||
currentProject.path,
|
currentProject.path,
|
||||||
validationInput,
|
validationInput,
|
||||||
modelToUse,
|
modelToUse,
|
||||||
thinkingLevelToUse
|
thinkingLevelToUse,
|
||||||
|
reasoningEffortToUse
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
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 {
|
export interface IssueRowProps {
|
||||||
issue: GitHubIssue;
|
issue: GitHubIssue;
|
||||||
@@ -37,7 +37,7 @@ export interface IssueDetailPanelProps {
|
|||||||
/** Model override state */
|
/** Model override state */
|
||||||
modelOverride: {
|
modelOverride: {
|
||||||
effectiveModelEntry: PhaseModelEntry;
|
effectiveModelEntry: PhaseModelEntry;
|
||||||
effectiveModel: ModelAlias | CursorModelId;
|
effectiveModel: ModelId;
|
||||||
isOverridden: boolean;
|
isOverridden: boolean;
|
||||||
setOverride: (entry: PhaseModelEntry | null) => void;
|
setOverride: (entry: PhaseModelEntry | null) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Types for validating GitHub issues against the codebase using Claude SDK.
|
* 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
|
* Verdict from issue validation
|
||||||
@@ -137,8 +137,8 @@ export type IssueValidationEvent =
|
|||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
result: IssueValidationResult;
|
result: IssueValidationResult;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
/** Model used for validation (opus, sonnet, haiku) */
|
/** Model used for validation */
|
||||||
model: ModelAlias;
|
model: ModelId;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'issue_validation_error';
|
type: 'issue_validation_error';
|
||||||
@@ -162,8 +162,8 @@ export interface StoredValidation {
|
|||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
/** ISO timestamp when validation was performed */
|
/** ISO timestamp when validation was performed */
|
||||||
validatedAt: string;
|
validatedAt: string;
|
||||||
/** Model used for validation (opus, sonnet, haiku) */
|
/** Model used for validation */
|
||||||
model: ModelAlias;
|
model: ModelId;
|
||||||
/** The validation result */
|
/** The validation result */
|
||||||
result: IssueValidationResult;
|
result: IssueValidationResult;
|
||||||
/** ISO timestamp when user viewed this validation (undefined = not yet viewed) */
|
/** ISO timestamp when user viewed this validation (undefined = not yet viewed) */
|
||||||
|
|||||||
Reference in New Issue
Block a user