diff --git a/README.md b/README.md index bf43c8b5..614f99e8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![GitHub Marketplace](https://img.shields.io/badge/marketplace/actions/copilot-github-with-super-powers?logo=github)](https://github.com/marketplace/actions/copilot-github-with-super-powers) +[![GitHub Marketplace](https://img.shields.io/badge/Copilot-Github_with_super_powers-white)](https://github.com/marketplace/actions/copilot-github-with-super-powers) [![codecov](https://codecov.io/gh/vypdev/copilot/branch/master/graph/badge.svg)](https://codecov.io/gh/vypdev/copilot) ![Build](https://github.com/vypdev/copilot/actions/workflows/ci_check.yml/badge.svg) ![License](https://img.shields.io/github/license/vypdev/copilot) diff --git a/build/cli/index.js b/build/cli/index.js index 6fcbed7a..1eb6a8da 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -47253,8 +47253,9 @@ const local_action_1 = __nccwpck_require__(7002); const issue_repository_1 = __nccwpck_require__(57); const constants_1 = __nccwpck_require__(8593); const logger_1 = __nccwpck_require__(8836); -const opencode_project_context_instruction_1 = __nccwpck_require__(7381); +const prompts_1 = __nccwpck_require__(5554); const ai_1 = __nccwpck_require__(4470); +const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const ai_repository_1 = __nccwpck_require__(8307); // Load environment variables from .env file dotenv.config(); @@ -47418,7 +47419,10 @@ program try { const ai = new ai_1.Ai(serverUrl, model, false, false, [], false, 'low', 20); const aiRepository = new ai_repository_1.AiRepository(); - const fullPrompt = `${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION}\n\n${prompt}`; + const fullPrompt = (0, prompts_1.getCliDoPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + userPrompt: prompt, + }); const result = await aiRepository.copilotMessage(ai, fullPrompt); if (!result) { console.error('❌ Request failed (check OpenCode server and model).'); @@ -49596,7 +49600,7 @@ exports.AiRepository = AiRepository; /***/ }), -/***/ 7701: +/***/ 8224: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -49635,91 +49639,200 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.BranchRepository = void 0; -const core = __importStar(__nccwpck_require__(2186)); -const exec = __importStar(__nccwpck_require__(1514)); +exports.BranchCompareRepository = void 0; const github = __importStar(__nccwpck_require__(5438)); const logger_1 = __nccwpck_require__(8836); -const version_utils_1 = __nccwpck_require__(9887); -const result_1 = __nccwpck_require__(7305); -class BranchRepository { +/** + * Repository for comparing branches and computing size categories. + * Isolated to allow unit tests with mocked Octokit and pure size logic. + */ +class BranchCompareRepository { constructor() { - this.fetchRemoteBranches = async () => { - try { - (0, logger_1.logDebugInfo)('Fetching tags and forcing fetch...'); - await exec.exec('git', ['fetch', '--tags', '--force']); - (0, logger_1.logDebugInfo)('Fetching all remote branches with verbose output...'); - await exec.exec('git', ['fetch', '--all', '-v']); - (0, logger_1.logDebugInfo)('Successfully fetched all remote branches.'); - } - catch (error) { - core.setFailed(`Error fetching remote branches: ${error}`); - } - }; - this.getLatestTag = async () => { + this.getChanges = async (owner, repository, head, base, token) => { try { - (0, logger_1.logDebugInfo)('Fetching the latest tag...'); - await exec.exec('git', ['fetch', '--tags']); - const tags = []; - await exec.exec('git', ['tag', '--sort=-creatordate'], { - listeners: { - stdout: (data) => { - tags.push(...data.toString().split('\n').map((v) => { - return v.replace('v', ''); - })); - }, - }, - }); - const validTags = tags.filter(tag => /\d+\.\d+\.\d+$/.test(tag)); - if (validTags.length > 0) { - const latestTag = (0, version_utils_1.getLatestVersion)(validTags); - (0, logger_1.logDebugInfo)(`Latest tag: ${latestTag}`); - return latestTag; + const octokit = github.getOctokit(token); + (0, logger_1.logDebugInfo)(`Comparing branches: ${head} with ${base}`); + let headRef = `heads/${head}`; + if (head.indexOf('tags/') > -1) { + headRef = head; } - else { - (0, logger_1.logDebugInfo)('No valid tags found.'); - return undefined; + let baseRef = `heads/${base}`; + if (base.indexOf('tags/') > -1) { + baseRef = base; } + const { data: comparison } = await octokit.rest.repos.compareCommits({ + owner: owner, + repo: repository, + base: baseRef, + head: headRef, + }); + return { + aheadBy: comparison.ahead_by, + behindBy: comparison.behind_by, + totalCommits: comparison.total_commits, + files: (comparison.files || []).map(file => ({ + filename: file.filename, + status: file.status, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + changes: file.changes ?? 0, + blobUrl: file.blob_url, + rawUrl: file.raw_url, + contentsUrl: file.contents_url, + patch: file.patch, + })), + commits: comparison.commits.map(commit => { + const author = commit.commit.author; + return { + sha: commit.sha, + message: commit.commit.message, + author: { + name: author?.name ?? 'Unknown', + email: author?.email ?? 'unknown@example.com', + date: author?.date ?? new Date().toISOString(), + }, + date: author?.date ?? new Date().toISOString(), + }; + }), + }; } catch (error) { - core.setFailed(`Error fetching the latest tag: ${error}`); - return undefined; + (0, logger_1.logError)(`Error comparing branches: ${error}`); + throw error; } }; - this.getCommitTag = async (latestTag) => { + this.getSizeCategoryAndReason = async (owner, repository, head, base, sizeThresholds, labels, token) => { try { - if (!latestTag) { - core.setFailed('No LATEST_TAG found in the environment'); - return; + const headBranchChanges = await this.getChanges(owner, repository, head, base, token); + const totalChanges = headBranchChanges.files.reduce((sum, file) => sum + file.changes, 0); + const totalFiles = headBranchChanges.files.length; + const totalCommits = headBranchChanges.totalCommits; + let sizeCategory; + let githubSize; + let sizeReason; + if (totalChanges > sizeThresholds.xxl.lines || totalFiles > sizeThresholds.xxl.files || totalCommits > sizeThresholds.xxl.commits) { + sizeCategory = labels.sizeXxl; + githubSize = `XL`; + sizeReason = totalChanges > sizeThresholds.xxl.lines ? `More than ${sizeThresholds.xxl.lines} lines changed` : + totalFiles > sizeThresholds.xxl.files ? `More than ${sizeThresholds.xxl.files} files modified` : + `More than ${sizeThresholds.xxl.commits} commits`; } - let tagVersion; - if (latestTag.startsWith('v')) { - tagVersion = latestTag; + else if (totalChanges > sizeThresholds.xl.lines || totalFiles > sizeThresholds.xl.files || totalCommits > sizeThresholds.xl.commits) { + sizeCategory = labels.sizeXl; + githubSize = `XL`; + sizeReason = totalChanges > sizeThresholds.xl.lines ? `More than ${sizeThresholds.xl.lines} lines changed` : + totalFiles > sizeThresholds.xl.files ? `More than ${sizeThresholds.xl.files} files modified` : + `More than ${sizeThresholds.xl.commits} commits`; } - else { - tagVersion = `v${latestTag}`; + else if (totalChanges > sizeThresholds.l.lines || totalFiles > sizeThresholds.l.files || totalCommits > sizeThresholds.l.commits) { + sizeCategory = labels.sizeL; + githubSize = `L`; + sizeReason = totalChanges > sizeThresholds.l.lines ? `More than ${sizeThresholds.l.lines} lines changed` : + totalFiles > sizeThresholds.l.files ? `More than ${sizeThresholds.l.files} files modified` : + `More than ${sizeThresholds.l.commits} commits`; } - (0, logger_1.logDebugInfo)(`Fetching commit hash for the tag: ${tagVersion}`); - let commitOid = ''; - await exec.exec('git', ['rev-list', '-n', '1', tagVersion], { - listeners: { - stdout: (data) => { - commitOid = data.toString().trim(); - }, - }, - }); - if (commitOid) { - (0, logger_1.logDebugInfo)(`Commit tag: ${commitOid}`); - return commitOid; + else if (totalChanges > sizeThresholds.m.lines || totalFiles > sizeThresholds.m.files || totalCommits > sizeThresholds.m.commits) { + sizeCategory = labels.sizeM; + githubSize = `M`; + sizeReason = totalChanges > sizeThresholds.m.lines ? `More than ${sizeThresholds.m.lines} lines changed` : + totalFiles > sizeThresholds.m.files ? `More than ${sizeThresholds.m.files} files modified` : + `More than ${sizeThresholds.m.commits} commits`; + } + else if (totalChanges > sizeThresholds.s.lines || totalFiles > sizeThresholds.s.files || totalCommits > sizeThresholds.s.commits) { + sizeCategory = labels.sizeS; + githubSize = `S`; + sizeReason = totalChanges > sizeThresholds.s.lines ? `More than ${sizeThresholds.s.lines} lines changed` : + totalFiles > sizeThresholds.s.files ? `More than ${sizeThresholds.s.files} files modified` : + `More than ${sizeThresholds.s.commits} commits`; } else { - core.setFailed('No commit found for the tag'); + sizeCategory = labels.sizeXs; + githubSize = `XS`; + sizeReason = `Small changes (${totalChanges} lines, ${totalFiles} files)`; } + return { + size: sizeCategory, + githubSize: githubSize, + reason: sizeReason, + }; } catch (error) { - core.setFailed(`Error fetching the commit hash: ${error}`); + (0, logger_1.logError)(`Error comparing branches: ${error}`); + throw error; } - return undefined; + }; + } +} +exports.BranchCompareRepository = BranchCompareRepository; + + +/***/ }), + +/***/ 7701: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.BranchRepository = void 0; +const github = __importStar(__nccwpck_require__(5438)); +const logger_1 = __nccwpck_require__(8836); +const result_1 = __nccwpck_require__(7305); +const branch_compare_repository_1 = __nccwpck_require__(8224); +const git_cli_repository_1 = __nccwpck_require__(5617); +const merge_repository_1 = __nccwpck_require__(4015); +const workflow_repository_1 = __nccwpck_require__(779); +/** + * Facade for branch-related operations. Delegates to focused repositories + * (GitCli, Workflow, Merge, BranchCompare) for testability. + */ +class BranchRepository { + constructor() { + this.gitCliRepository = new git_cli_repository_1.GitCliRepository(); + this.workflowRepository = new workflow_repository_1.WorkflowRepository(); + this.mergeRepository = new merge_repository_1.MergeRepository(); + this.branchCompareRepository = new branch_compare_repository_1.BranchCompareRepository(); + this.fetchRemoteBranches = async () => { + return this.gitCliRepository.fetchRemoteBranches(); + }; + this.getLatestTag = async () => { + return this.gitCliRepository.getLatestTag(); + }; + this.getCommitTag = async (latestTag) => { + return this.gitCliRepository.getCommitTag(latestTag); }; /** * Returns replaced branch (if any). @@ -49996,297 +50109,25 @@ class BranchRepository { return allBranches; }; this.executeWorkflow = async (owner, repository, branch, workflow, inputs, token) => { - const octokit = github.getOctokit(token); - return octokit.rest.actions.createWorkflowDispatch({ - owner: owner, - repo: repository, - workflow_id: workflow, - ref: branch, - inputs: inputs - }); + return this.workflowRepository.executeWorkflow(owner, repository, branch, workflow, inputs, token); }; this.mergeBranch = async (owner, repository, head, base, timeout, token) => { - const result = []; - try { - const octokit = github.getOctokit(token); - (0, logger_1.logDebugInfo)(`Creating merge from ${head} into ${base}`); - // Build PR body with commit list - const prBody = `🚀 Automated Merge - -This PR merges **${head}** into **${base}**. - -**Commits included:**`; - // We need PAT for creating PR to ensure it can trigger workflows - const { data: pullRequest } = await octokit.rest.pulls.create({ - owner: owner, - repo: repository, - head: head, - base: base, - title: `Merge ${head} into ${base}`, - body: prBody, - }); - (0, logger_1.logDebugInfo)(`Pull request #${pullRequest.number} created, getting commits...`); - // Get all commits in the PR - const { data: commits } = await octokit.rest.pulls.listCommits({ - owner: owner, - repo: repository, - pull_number: pullRequest.number - }); - const commitMessages = commits.map(commit => commit.commit.message); - (0, logger_1.logDebugInfo)(`Found ${commitMessages.length} commits in PR`); - // Update PR with commit list and footer - await octokit.rest.pulls.update({ - owner: owner, - repo: repository, - pull_number: pullRequest.number, - body: prBody + '\n' + commitMessages.map(msg => `- ${msg}`).join('\n') + - '\n\nThis PR was automatically created by [`copilot`](https://github.com/vypdev/copilot).' - }); - const iteration = 10; - if (timeout > iteration) { - // Wait for checks to complete - can use regular token for reading checks - let checksCompleted = false; - let attempts = 0; - const maxAttempts = timeout > iteration ? Math.floor(timeout / iteration) : iteration; - while (!checksCompleted && attempts < maxAttempts) { - const { data: checkRuns } = await octokit.rest.checks.listForRef({ - owner: owner, - repo: repository, - ref: head, - }); - // Get commit status checks for the PR head commit - const { data: commitStatus } = await octokit.rest.repos.getCombinedStatusForRef({ - owner: owner, - repo: repository, - ref: head - }); - (0, logger_1.logDebugInfo)(`Combined status state: ${commitStatus.state}`); - (0, logger_1.logDebugInfo)(`Number of check runs: ${checkRuns.check_runs.length}`); - // If there are check runs, prioritize those over status checks - if (checkRuns.check_runs.length > 0) { - const pendingCheckRuns = checkRuns.check_runs.filter(check => check.status !== 'completed'); - if (pendingCheckRuns.length === 0) { - checksCompleted = true; - (0, logger_1.logDebugInfo)('All check runs have completed.'); - // Verify if all checks passed - const failedChecks = checkRuns.check_runs.filter(check => check.conclusion === 'failure'); - if (failedChecks.length > 0) { - throw new Error(`Checks failed: ${failedChecks.map(check => check.name).join(', ')}`); - } - } - else { - (0, logger_1.logDebugInfo)(`Waiting for ${pendingCheckRuns.length} check runs to complete:`); - pendingCheckRuns.forEach(check => { - (0, logger_1.logDebugInfo)(` - ${check.name} (Status: ${check.status})`); - }); - await new Promise(resolve => setTimeout(resolve, iteration * 1000)); - attempts++; - continue; - } - } - else { - // Fall back to status checks if no check runs exist - const pendingChecks = commitStatus.statuses.filter(status => { - (0, logger_1.logDebugInfo)(`Status check: ${status.context} (State: ${status.state})`); - return status.state === 'pending'; - }); - if (pendingChecks.length === 0) { - checksCompleted = true; - (0, logger_1.logDebugInfo)('All status checks have completed.'); - } - else { - (0, logger_1.logDebugInfo)(`Waiting for ${pendingChecks.length} status checks to complete:`); - pendingChecks.forEach(check => { - (0, logger_1.logDebugInfo)(` - ${check.context} (State: ${check.state})`); - }); - await new Promise(resolve => setTimeout(resolve, iteration * 1000)); - attempts++; - } - } - } - if (!checksCompleted) { - throw new Error('Timed out waiting for checks to complete'); - } - } - // Need PAT for merging to ensure it can trigger subsequent workflows - await octokit.rest.pulls.merge({ - owner: owner, - repo: repository, - pull_number: pullRequest.number, - merge_method: 'merge', - commit_title: `Merge ${head} into ${base}. Forced merge with PAT token.`, - }); - result.push(new result_1.Result({ - id: 'branch_repository', - success: true, - executed: true, - steps: [ - `The branch \`${head}\` was merged into \`${base}\`.`, - ], - })); - } - catch (error) { - (0, logger_1.logError)(`Error in PR workflow: ${error}`); - // If the PR workflow fails, we try to merge directly - need PAT for direct merge to ensure it can trigger workflows - try { - const octokit = github.getOctokit(token); - await octokit.rest.repos.merge({ - owner: owner, - repo: repository, - base: base, - head: head, - commit_message: `Forced merge of ${head} into ${base}. Automated merge with PAT token.`, - }); - result.push(new result_1.Result({ - id: 'branch_repository', - success: true, - executed: true, - steps: [ - `The branch \`${head}\` was merged into \`${base}\` using direct merge.`, - ], - })); - return result; - } - catch (directMergeError) { - (0, logger_1.logError)(`Error in direct merge attempt: ${directMergeError}`); - result.push(new result_1.Result({ - id: 'branch_repository', - success: false, - executed: true, - steps: [ - `Failed to merge branch \`${head}\` into \`${base}\`.`, - ], - })); - result.push(new result_1.Result({ - id: 'branch_repository', - success: false, - executed: true, - error: error, - })); - result.push(new result_1.Result({ - id: 'branch_repository', - success: false, - executed: true, - error: directMergeError, - })); - } - } - return result; - }; - this.getChanges = async (owner, repository, head, base, token) => { - const octokit = github.getOctokit(token); - try { - (0, logger_1.logDebugInfo)(`Comparing branches: ${head} with ${base}`); - let headRef = `heads/${head}`; - if (head.indexOf('tags/') > -1) { - headRef = head; - } - let baseRef = `heads/${base}`; - if (base.indexOf('tags/') > -1) { - baseRef = base; - } - const { data: comparison } = await octokit.rest.repos.compareCommits({ - owner: owner, - repo: repository, - base: baseRef, - head: headRef, - }); - return { - aheadBy: comparison.ahead_by, - behindBy: comparison.behind_by, - totalCommits: comparison.total_commits, - files: (comparison.files || []).map(file => ({ - filename: file.filename, - status: file.status, - additions: file.additions, - deletions: file.deletions, - changes: file.changes, - blobUrl: file.blob_url, - rawUrl: file.raw_url, - contentsUrl: file.contents_url, - patch: file.patch - })), - commits: comparison.commits.map(commit => ({ - sha: commit.sha, - message: commit.commit.message, - author: commit.commit.author || { name: 'Unknown', email: 'unknown@example.com', date: new Date().toISOString() }, - date: commit.commit.author?.date || new Date().toISOString() - })) - }; - } - catch (error) { - (0, logger_1.logError)(`Error comparing branches: ${error}`); - throw error; - } - }; - this.getSizeCategoryAndReason = async (owner, repository, head, base, sizeThresholds, labels, token) => { - try { - const headBranchChanges = await this.getChanges(owner, repository, head, base, token); - const totalChanges = headBranchChanges.files.reduce((sum, file) => sum + file.changes, 0); - const totalFiles = headBranchChanges.files.length; - const totalCommits = headBranchChanges.totalCommits; - let sizeCategory; - let githubSize; - let sizeReason; - if (totalChanges > sizeThresholds.xxl.lines || totalFiles > sizeThresholds.xxl.files || totalCommits > sizeThresholds.xxl.commits) { - sizeCategory = labels.sizeXxl; - githubSize = `XL`; - sizeReason = totalChanges > sizeThresholds.xxl.lines ? `More than ${sizeThresholds.xxl.lines} lines changed` : - totalFiles > sizeThresholds.xxl.files ? `More than ${sizeThresholds.xxl.files} files modified` : - `More than ${sizeThresholds.xxl.commits} commits`; - } - else if (totalChanges > sizeThresholds.xl.lines || totalFiles > sizeThresholds.xl.files || totalCommits > sizeThresholds.xl.commits) { - sizeCategory = labels.sizeXl; - githubSize = `XL`; - sizeReason = totalChanges > sizeThresholds.xl.lines ? `More than ${sizeThresholds.xl.lines} lines changed` : - totalFiles > sizeThresholds.xl.files ? `More than ${sizeThresholds.xl.files} files modified` : - `More than ${sizeThresholds.xl.commits} commits`; - } - else if (totalChanges > sizeThresholds.l.lines || totalFiles > sizeThresholds.l.files || totalCommits > sizeThresholds.l.commits) { - sizeCategory = labels.sizeL; - githubSize = `L`; - sizeReason = totalChanges > sizeThresholds.l.lines ? `More than ${sizeThresholds.l.lines} lines changed` : - totalFiles > sizeThresholds.l.files ? `More than ${sizeThresholds.l.files} files modified` : - `More than ${sizeThresholds.l.commits} commits`; - } - else if (totalChanges > sizeThresholds.m.lines || totalFiles > sizeThresholds.m.files || totalCommits > sizeThresholds.m.commits) { - sizeCategory = labels.sizeM; - githubSize = `M`; - sizeReason = totalChanges > sizeThresholds.m.lines ? `More than ${sizeThresholds.m.lines} lines changed` : - totalFiles > sizeThresholds.m.files ? `More than ${sizeThresholds.m.files} files modified` : - `More than ${sizeThresholds.m.commits} commits`; - } - else if (totalChanges > sizeThresholds.s.lines || totalFiles > sizeThresholds.s.files || totalCommits > sizeThresholds.s.commits) { - sizeCategory = labels.sizeS; - githubSize = `S`; - sizeReason = totalChanges > sizeThresholds.s.lines ? `More than ${sizeThresholds.s.lines} lines changed` : - totalFiles > sizeThresholds.s.files ? `More than ${sizeThresholds.s.files} files modified` : - `More than ${sizeThresholds.s.commits} commits`; - } - else { - sizeCategory = labels.sizeXs; - githubSize = `XS`; - sizeReason = `Small changes (${totalChanges} lines, ${totalFiles} files)`; - } - return { - size: sizeCategory, - githubSize: githubSize, - reason: sizeReason - }; - } - catch (error) { - (0, logger_1.logError)(`Error comparing branches: ${error}`); - throw error; - } - }; - } -} -exports.BranchRepository = BranchRepository; + return this.mergeRepository.mergeBranch(owner, repository, head, base, timeout, token); + }; + this.getChanges = async (owner, repository, head, base, token) => { + return this.branchCompareRepository.getChanges(owner, repository, head, base, token); + }; + this.getSizeCategoryAndReason = async (owner, repository, head, base, sizeThresholds, labels, token) => { + return this.branchCompareRepository.getSizeCategoryAndReason(owner, repository, head, base, sizeThresholds, labels, token); + }; + } +} +exports.BranchRepository = BranchRepository; /***/ }), -/***/ 57: +/***/ 5617: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -50325,28 +50166,162 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.IssueRepository = exports.PROGRESS_LABEL_PATTERN = void 0; +exports.GitCliRepository = void 0; const core = __importStar(__nccwpck_require__(2186)); -const github = __importStar(__nccwpck_require__(5438)); +const exec = __importStar(__nccwpck_require__(1514)); const logger_1 = __nccwpck_require__(8836); -const milestone_1 = __nccwpck_require__(2298); -/** Matches labels that are progress percentages (e.g. "0%", "85%"). Used for setProgressLabel and syncing. */ -exports.PROGRESS_LABEL_PATTERN = /^\d+%$/; -class IssueRepository { +const version_utils_1 = __nccwpck_require__(9887); +/** + * Repository for Git operations executed via CLI (exec). + * Isolated to allow unit tests with mocked @actions/exec and @actions/core. + */ +class GitCliRepository { constructor() { - this.updateTitleIssueFormat = async (owner, repository, version, issueTitle, issueNumber, branchManagementAlways, branchManagementEmoji, labels, token) => { + this.fetchRemoteBranches = async () => { try { - const octokit = github.getOctokit(token); - let emoji = '🤖'; - const branched = branchManagementAlways || labels.containsBranchedLabel; - if (labels.isHotfix && branched) { - emoji = `🔥${branchManagementEmoji}`; - } - else if (labels.isRelease && branched) { - emoji = `🚀${branchManagementEmoji}`; - } - else if ((labels.isBugfix || labels.isBug) && branched) { - emoji = `🐛${branchManagementEmoji}`; + (0, logger_1.logDebugInfo)('Fetching tags and forcing fetch...'); + await exec.exec('git', ['fetch', '--tags', '--force']); + (0, logger_1.logDebugInfo)('Fetching all remote branches with verbose output...'); + await exec.exec('git', ['fetch', '--all', '-v']); + (0, logger_1.logDebugInfo)('Successfully fetched all remote branches.'); + } + catch (error) { + core.setFailed(`Error fetching remote branches: ${error}`); + } + }; + this.getLatestTag = async () => { + try { + (0, logger_1.logDebugInfo)('Fetching the latest tag...'); + await exec.exec('git', ['fetch', '--tags']); + const tags = []; + await exec.exec('git', ['tag', '--sort=-creatordate'], { + listeners: { + stdout: (data) => { + tags.push(...data.toString().split('\n').map((v) => { + return v.replace('v', ''); + })); + }, + }, + }); + const validTags = tags.filter(tag => /\d+\.\d+\.\d+$/.test(tag)); + if (validTags.length > 0) { + const latestTag = (0, version_utils_1.getLatestVersion)(validTags); + (0, logger_1.logDebugInfo)(`Latest tag: ${latestTag}`); + return latestTag; + } + else { + (0, logger_1.logDebugInfo)('No valid tags found.'); + return undefined; + } + } + catch (error) { + core.setFailed(`Error fetching the latest tag: ${error}`); + return undefined; + } + }; + this.getCommitTag = async (latestTag) => { + try { + if (!latestTag) { + core.setFailed('No LATEST_TAG found in the environment'); + return undefined; + } + let tagVersion; + if (latestTag.startsWith('v')) { + tagVersion = latestTag; + } + else { + tagVersion = `v${latestTag}`; + } + (0, logger_1.logDebugInfo)(`Fetching commit hash for the tag: ${tagVersion}`); + let commitOid = ''; + await exec.exec('git', ['rev-list', '-n', '1', tagVersion], { + listeners: { + stdout: (data) => { + commitOid = data.toString().trim(); + }, + }, + }); + if (commitOid) { + (0, logger_1.logDebugInfo)(`Commit tag: ${commitOid}`); + return commitOid; + } + else { + core.setFailed('No commit found for the tag'); + } + } + catch (error) { + core.setFailed(`Error fetching the commit hash: ${error}`); + } + return undefined; + }; + } +} +exports.GitCliRepository = GitCliRepository; + + +/***/ }), + +/***/ 57: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.IssueRepository = exports.PROGRESS_LABEL_PATTERN = void 0; +const core = __importStar(__nccwpck_require__(2186)); +const github = __importStar(__nccwpck_require__(5438)); +const logger_1 = __nccwpck_require__(8836); +const milestone_1 = __nccwpck_require__(2298); +/** Matches labels that are progress percentages (e.g. "0%", "85%"). Used for setProgressLabel and syncing. */ +exports.PROGRESS_LABEL_PATTERN = /^\d+%$/; +class IssueRepository { + constructor() { + this.updateTitleIssueFormat = async (owner, repository, version, issueTitle, issueNumber, branchManagementAlways, branchManagementEmoji, labels, token) => { + try { + const octokit = github.getOctokit(token); + let emoji = '🤖'; + const branched = branchManagementAlways || labels.containsBranchedLabel; + if (labels.isHotfix && branched) { + emoji = `🔥${branchManagementEmoji}`; + } + else if (labels.isRelease && branched) { + emoji = `🚀${branchManagementEmoji}`; + } + else if ((labels.isBugfix || labels.isBug) && branched) { + emoji = `🐛${branchManagementEmoji}`; } else if ((labels.isFeature || labels.isEnhancement) && branched) { emoji = `✨${branchManagementEmoji}`; @@ -51225,7 +51200,7 @@ IssueRepository.PROGRESS_LABEL_PERCENTS = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, /***/ }), -/***/ 7917: +/***/ 4015: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -51264,93 +51239,317 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.ProjectRepository = void 0; +exports.MergeRepository = void 0; const github = __importStar(__nccwpck_require__(5438)); const logger_1 = __nccwpck_require__(8836); -const project_detail_1 = __nccwpck_require__(3765); -class ProjectRepository { +const result_1 = __nccwpck_require__(7305); +/** + * Repository for merging branches (via PR or direct merge). + * Isolated to allow unit tests with mocked Octokit. + */ +class MergeRepository { constructor() { - this.priorityLabel = "Priority"; - this.sizeLabel = "Size"; - this.statusLabel = "Status"; - /** - * Retrieves detailed information about a GitHub project - * @param projectId - The project number/ID - * @param token - GitHub authentication token - * @returns Promise - The project details - * @throws {Error} If the project is not found or if there are authentication/network issues - */ - this.getProjectDetail = async (projectId, token) => { + this.mergeBranch = async (owner, repository, head, base, timeout, token) => { + const result = []; try { - // Validate projectId is a valid number - const projectNumber = parseInt(projectId, 10); - if (isNaN(projectNumber)) { - throw new Error(`Invalid project ID: ${projectId}. Must be a valid number.`); - } const octokit = github.getOctokit(token); - const { data: owner } = await octokit.rest.users.getByUsername({ - username: github.context.repo.owner - }).catch(error => { - throw new Error(`Failed to get owner information: ${error.message}`); + (0, logger_1.logDebugInfo)(`Creating merge from ${head} into ${base}`); + // Build PR body with commit list + const prBody = `🚀 Automated Merge + +This PR merges **${head}** into **${base}**. + +**Commits included:**`; + // We need PAT for creating PR to ensure it can trigger workflows + const { data: pullRequest } = await octokit.rest.pulls.create({ + owner: owner, + repo: repository, + head: head, + base: base, + title: `Merge ${head} into ${base}`, + body: prBody, }); - const ownerType = owner.type === 'Organization' ? 'orgs' : 'users'; - const projectUrl = `https://github.com/${ownerType}/${github.context.repo.owner}/projects/${projectId}`; - const ownerQueryField = ownerType === 'orgs' ? 'organization' : 'user'; - const queryProject = ` - query($ownerName: String!, $projectNumber: Int!) { - ${ownerQueryField}(login: $ownerName) { - projectV2(number: $projectNumber) { - id - title - url - } - } - } - `; - const projectResult = await octokit.graphql(queryProject, { - ownerName: github.context.repo.owner, - projectNumber: projectNumber, - }).catch(error => { - throw new Error(`Failed to fetch project data: ${error.message}`); + (0, logger_1.logDebugInfo)(`Pull request #${pullRequest.number} created, getting commits...`); + // Get all commits in the PR + const { data: commits } = await octokit.rest.pulls.listCommits({ + owner: owner, + repo: repository, + pull_number: pullRequest.number, }); - const projectData = projectResult[ownerQueryField]?.projectV2; - if (!projectData) { - throw new Error(`Project not found: ${projectUrl}`); - } - (0, logger_1.logDebugInfo)(`Project ID: ${projectData.id}`); - (0, logger_1.logDebugInfo)(`Project Title: ${projectData.title}`); - (0, logger_1.logDebugInfo)(`Project URL: ${projectData.url}`); - return new project_detail_1.ProjectDetail({ - id: projectData.id, - title: projectData.title, - url: projectData.url, - type: ownerQueryField, - owner: github.context.repo.owner, - number: projectNumber, + const commitMessages = commits.map(commit => commit.commit.message); + (0, logger_1.logDebugInfo)(`Found ${commitMessages.length} commits in PR`); + // Update PR with commit list and footer + await octokit.rest.pulls.update({ + owner: owner, + repo: repository, + pull_number: pullRequest.number, + body: prBody + '\n' + commitMessages.map(msg => `- ${msg}`).join('\n') + + '\n\nThis PR was automatically created by [`copilot`](https://github.com/vypdev/copilot).', }); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - (0, logger_1.logError)(`Error in getProjectDetail: ${errorMessage}`); - throw error; - } - }; - this.getContentId = async (project, owner, repo, issueOrPullRequestNumber, token) => { - const octokit = github.getOctokit(token); - // Search for the issue or pull request ID in the repository - const issueOrPrQuery = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issueOrPullRequest: issueOrPullRequest(number: $number) { - ... on Issue { - id - } - ... on PullRequest { - id - } - } - } - }`; + const iteration = 10; + if (timeout > iteration) { + // Wait for checks to complete - can use regular token for reading checks + let checksCompleted = false; + let attempts = 0; + const maxAttempts = timeout > iteration ? Math.floor(timeout / iteration) : iteration; + while (!checksCompleted && attempts < maxAttempts) { + const { data: checkRuns } = await octokit.rest.checks.listForRef({ + owner: owner, + repo: repository, + ref: head, + }); + // Get commit status checks for the PR head commit + const { data: commitStatus } = await octokit.rest.repos.getCombinedStatusForRef({ + owner: owner, + repo: repository, + ref: head, + }); + (0, logger_1.logDebugInfo)(`Combined status state: ${commitStatus.state}`); + (0, logger_1.logDebugInfo)(`Number of check runs: ${checkRuns.check_runs.length}`); + // If there are check runs, prioritize those over status checks + if (checkRuns.check_runs.length > 0) { + const pendingCheckRuns = checkRuns.check_runs.filter(check => check.status !== 'completed'); + if (pendingCheckRuns.length === 0) { + checksCompleted = true; + (0, logger_1.logDebugInfo)('All check runs have completed.'); + // Verify if all checks passed + const failedChecks = checkRuns.check_runs.filter(check => check.conclusion === 'failure'); + if (failedChecks.length > 0) { + throw new Error(`Checks failed: ${failedChecks.map(check => check.name).join(', ')}`); + } + } + else { + (0, logger_1.logDebugInfo)(`Waiting for ${pendingCheckRuns.length} check runs to complete:`); + pendingCheckRuns.forEach(check => { + (0, logger_1.logDebugInfo)(` - ${check.name} (Status: ${check.status})`); + }); + await new Promise(resolve => setTimeout(resolve, iteration * 1000)); + attempts++; + continue; + } + } + else { + // Fall back to status checks if no check runs exist + const pendingChecks = commitStatus.statuses.filter(status => { + (0, logger_1.logDebugInfo)(`Status check: ${status.context} (State: ${status.state})`); + return status.state === 'pending'; + }); + if (pendingChecks.length === 0) { + checksCompleted = true; + (0, logger_1.logDebugInfo)('All status checks have completed.'); + } + else { + (0, logger_1.logDebugInfo)(`Waiting for ${pendingChecks.length} status checks to complete:`); + pendingChecks.forEach(check => { + (0, logger_1.logDebugInfo)(` - ${check.context} (State: ${check.state})`); + }); + await new Promise(resolve => setTimeout(resolve, iteration * 1000)); + attempts++; + } + } + } + if (!checksCompleted) { + throw new Error('Timed out waiting for checks to complete'); + } + } + // Need PAT for merging to ensure it can trigger subsequent workflows + await octokit.rest.pulls.merge({ + owner: owner, + repo: repository, + pull_number: pullRequest.number, + merge_method: 'merge', + commit_title: `Merge ${head} into ${base}. Forced merge with PAT token.`, + }); + result.push(new result_1.Result({ + id: 'branch_repository', + success: true, + executed: true, + steps: [ + `The branch \`${head}\` was merged into \`${base}\`.`, + ], + })); + } + catch (error) { + (0, logger_1.logError)(`Error in PR workflow: ${error}`); + // If the PR workflow fails, we try to merge directly - need PAT for direct merge to ensure it can trigger workflows + try { + const octokit = github.getOctokit(token); + await octokit.rest.repos.merge({ + owner: owner, + repo: repository, + base: base, + head: head, + commit_message: `Forced merge of ${head} into ${base}. Automated merge with PAT token.`, + }); + result.push(new result_1.Result({ + id: 'branch_repository', + success: true, + executed: true, + steps: [ + `The branch \`${head}\` was merged into \`${base}\` using direct merge.`, + ], + })); + return result; + } + catch (directMergeError) { + (0, logger_1.logError)(`Error in direct merge attempt: ${directMergeError}`); + result.push(new result_1.Result({ + id: 'branch_repository', + success: false, + executed: true, + steps: [ + `Failed to merge branch \`${head}\` into \`${base}\`.`, + ], + })); + result.push(new result_1.Result({ + id: 'branch_repository', + success: false, + executed: true, + error: error, + })); + result.push(new result_1.Result({ + id: 'branch_repository', + success: false, + executed: true, + error: directMergeError, + })); + } + } + return result; + }; + } +} +exports.MergeRepository = MergeRepository; + + +/***/ }), + +/***/ 7917: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ProjectRepository = void 0; +const github = __importStar(__nccwpck_require__(5438)); +const logger_1 = __nccwpck_require__(8836); +const project_detail_1 = __nccwpck_require__(3765); +class ProjectRepository { + constructor() { + this.priorityLabel = "Priority"; + this.sizeLabel = "Size"; + this.statusLabel = "Status"; + /** + * Retrieves detailed information about a GitHub project + * @param projectId - The project number/ID + * @param token - GitHub authentication token + * @returns Promise - The project details + * @throws {Error} If the project is not found or if there are authentication/network issues + */ + this.getProjectDetail = async (projectId, token) => { + try { + // Validate projectId is a valid number + const projectNumber = parseInt(projectId, 10); + if (isNaN(projectNumber)) { + throw new Error(`Invalid project ID: ${projectId}. Must be a valid number.`); + } + const octokit = github.getOctokit(token); + const { data: owner } = await octokit.rest.users.getByUsername({ + username: github.context.repo.owner + }).catch(error => { + throw new Error(`Failed to get owner information: ${error.message}`); + }); + const ownerType = owner.type === 'Organization' ? 'orgs' : 'users'; + const projectUrl = `https://github.com/${ownerType}/${github.context.repo.owner}/projects/${projectId}`; + const ownerQueryField = ownerType === 'orgs' ? 'organization' : 'user'; + const queryProject = ` + query($ownerName: String!, $projectNumber: Int!) { + ${ownerQueryField}(login: $ownerName) { + projectV2(number: $projectNumber) { + id + title + url + } + } + } + `; + const projectResult = await octokit.graphql(queryProject, { + ownerName: github.context.repo.owner, + projectNumber: projectNumber, + }).catch(error => { + throw new Error(`Failed to fetch project data: ${error.message}`); + }); + const projectData = projectResult[ownerQueryField]?.projectV2; + if (!projectData) { + throw new Error(`Project not found: ${projectUrl}`); + } + (0, logger_1.logDebugInfo)(`Project ID: ${projectData.id}`); + (0, logger_1.logDebugInfo)(`Project Title: ${projectData.title}`); + (0, logger_1.logDebugInfo)(`Project URL: ${projectData.url}`); + return new project_detail_1.ProjectDetail({ + id: projectData.id, + title: projectData.title, + url: projectData.url, + type: ownerQueryField, + owner: github.context.repo.owner, + number: projectNumber, + }); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + (0, logger_1.logError)(`Error in getProjectDetail: ${errorMessage}`); + throw error; + } + }; + this.getContentId = async (project, owner, repo, issueOrPullRequestNumber, token) => { + const octokit = github.getOctokit(token); + // Search for the issue or pull request ID in the repository + const issueOrPrQuery = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issueOrPullRequest: issueOrPullRequest(number: $number) { + ... on Issue { + id + } + ... on PullRequest { + id + } + } + } + }`; const issueOrPrResult = await octokit.graphql(issueOrPrQuery, { owner, repo, @@ -51560,1109 +51759,1642 @@ class ProjectRepository { hasNextPage = fieldResult.node.items.pageInfo.hasNextPage; endCursor = fieldResult.node.items.pageInfo.endCursor; } - (0, logger_1.logDebugInfo)(`Target field ID: ${targetField.id}`); - (0, logger_1.logDebugInfo)(`Target option ID: ${targetOption.id}`); - const mutation = ` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue( - input: { - projectId: $projectId, - itemId: $itemId, - fieldId: $fieldId, - value: { singleSelectOptionId: $optionId } + (0, logger_1.logDebugInfo)(`Target field ID: ${targetField.id}`); + (0, logger_1.logDebugInfo)(`Target option ID: ${targetOption.id}`); + const mutation = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + } + ) { + projectV2Item { + id + } + } + }`; + const mutationResult = await octokit.graphql(mutation, { + projectId: project.id, + itemId: contentId, + fieldId: targetField.id, + optionId: targetOption.id + }); + return !!mutationResult.updateProjectV2ItemFieldValue?.projectV2Item; + }; + this.setTaskPriority = async (project, owner, repo, issueOrPullRequestNumber, priorityLabel, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.priorityLabel, priorityLabel, token); + this.setTaskSize = async (project, owner, repo, issueOrPullRequestNumber, sizeLabel, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.sizeLabel, sizeLabel, token); + this.moveIssueToColumn = async (project, owner, repo, issueOrPullRequestNumber, columnName, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.statusLabel, columnName, token); + this.getRandomMembers = async (organization, membersToAdd, currentMembers, token) => { + if (membersToAdd === 0) { + return []; + } + const octokit = github.getOctokit(token); + try { + const { data: teams } = await octokit.rest.teams.list({ + org: organization, + }); + if (teams.length === 0) { + (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); + return []; + } + const membersSet = new Set(); + for (const team of teams) { + (0, logger_1.logDebugInfo)(`Checking team: ${team.slug}`); + const { data: members } = await octokit.rest.teams.listMembersInOrg({ + org: organization, + team_slug: team.slug, + }); + (0, logger_1.logDebugInfo)(`Members: ${members.length}`); + members.forEach((member) => membersSet.add(member.login)); + } + const allMembers = Array.from(membersSet); + const availableMembers = allMembers.filter((member) => !currentMembers.includes(member)); + if (availableMembers.length === 0) { + (0, logger_1.logDebugInfo)(`No available members to assign for organization ${organization}.`); + return []; + } + if (membersToAdd >= availableMembers.length) { + (0, logger_1.logDebugInfo)(`Requested size (${membersToAdd}) exceeds available members (${availableMembers.length}). Returning all available members.`); + return availableMembers; + } + const shuffled = availableMembers.sort(() => Math.random() - 0.5); + return shuffled.slice(0, membersToAdd); + } + catch (error) { + (0, logger_1.logError)(`Error getting random members: ${error}.`); + } + return []; + }; + this.getAllMembers = async (organization, token) => { + const octokit = github.getOctokit(token); + try { + const { data: teams } = await octokit.rest.teams.list({ + org: organization, + }); + if (teams.length === 0) { + (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); + return []; + } + const membersSet = new Set(); + for (const team of teams) { + const { data: members } = await octokit.rest.teams.listMembersInOrg({ + org: organization, + team_slug: team.slug, + }); + members.forEach((member) => membersSet.add(member.login)); + } + return Array.from(membersSet); + } + catch (error) { + (0, logger_1.logError)(`Error getting all members: ${error}.`); + } + return []; + }; + this.getUserFromToken = async (token) => { + const octokit = github.getOctokit(token); + const { data: user } = await octokit.rest.users.getAuthenticated(); + return user.login; + }; + /** + * Returns true if the actor (user who triggered the event) is allowed to run use cases + * that ask OpenCode to modify files (e.g. bugbot autofix, generic user request). + * - When the repo owner is an Organization: actor must be a member of that organization. + * - When the repo owner is a User: actor must be the owner (same login). + */ + this.isActorAllowedToModifyFiles = async (owner, actor, token) => { + try { + const octokit = github.getOctokit(token); + const { data: ownerUser } = await octokit.rest.users.getByUsername({ username: owner }); + if (ownerUser.type === "Organization") { + try { + await octokit.rest.orgs.checkMembershipForUser({ + org: owner, + username: actor, + }); + return true; + } + catch (membershipErr) { + const status = membershipErr?.status; + if (status === 404) + return false; + (0, logger_1.logDebugInfo)(`checkMembershipForUser(${owner}, ${actor}): ${membershipErr instanceof Error ? membershipErr.message : String(membershipErr)}`); + return false; + } + } + return actor === owner; + } + catch (err) { + (0, logger_1.logDebugInfo)(`isActorAllowedToModifyFiles(${owner}, ${actor}): ${err instanceof Error ? err.message : String(err)}`); + return false; + } + }; + /** Name and email of the token user, for git commit author (e.g. bugbot autofix). */ + this.getTokenUserDetails = async (token) => { + const octokit = github.getOctokit(token); + const { data: user } = await octokit.rest.users.getAuthenticated(); + const name = (user.name ?? user.login ?? "GitHub Action").trim() || "GitHub Action"; + const email = (typeof user.email === "string" && user.email.trim().length > 0) + ? user.email.trim() + : `${user.login}@users.noreply.github.com`; + return { name, email }; + }; + this.findTag = async (owner, repo, tag, token) => { + const octokit = github.getOctokit(token); + try { + const { data: foundTag } = await octokit.rest.git.getRef({ + owner, + repo, + ref: `tags/${tag}`, + }); + return foundTag; + } + catch { + return undefined; + } + }; + this.getTagSHA = async (owner, repo, tag, token) => { + const foundTag = await this.findTag(owner, repo, tag, token); + if (!foundTag) { + (0, logger_1.logError)(`The '${tag}' tag does not exist in the remote repository`); + return undefined; + } + return foundTag.object.sha; + }; + this.updateTag = async (owner, repo, sourceTag, targetTag, token) => { + const sourceTagSHA = await this.getTagSHA(owner, repo, sourceTag, token); + if (!sourceTagSHA) { + (0, logger_1.logError)(`The '${sourceTag}' tag does not exist in the remote repository`); + return; + } + const foundTargetTag = await this.findTag(owner, repo, targetTag, token); + const refName = `tags/${targetTag}`; + const octokit = github.getOctokit(token); + if (foundTargetTag) { + (0, logger_1.logDebugInfo)(`Updating the '${targetTag}' tag to point to the '${sourceTag}' tag`); + await octokit.rest.git.updateRef({ + owner, + repo, + ref: refName, + sha: sourceTagSHA, + force: true, + }); + } + else { + (0, logger_1.logDebugInfo)(`Creating the '${targetTag}' tag from the '${sourceTag}' tag`); + await octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/${refName}`, + sha: sourceTagSHA, + }); + } + }; + this.updateRelease = async (owner, repo, sourceTag, targetTag, token) => { + // Get the release associated with sourceTag + const octokit = github.getOctokit(token); + const { data: sourceRelease } = await octokit.rest.repos.getReleaseByTag({ + owner, + repo, + tag: sourceTag, + }); + if (!sourceRelease.name || !sourceRelease.body) { + (0, logger_1.logError)(`The '${sourceTag}' tag does not exist in the remote repository`); + return undefined; + } + (0, logger_1.logDebugInfo)(`Found release for sourceTag '${sourceTag}': ${sourceRelease.name}`); + // Check if there is a release for targetTag + const { data: releases } = await octokit.rest.repos.listReleases({ + owner, + repo, + }); + const targetRelease = releases.find(r => r.tag_name === targetTag); + let targetReleaseId; + if (targetRelease) { + (0, logger_1.logDebugInfo)(`Updating release for targetTag '${targetTag}'`); + // Update the target release with the content from the source release + await octokit.rest.repos.updateRelease({ + owner, + repo, + release_id: targetRelease.id, + name: sourceRelease.name, + body: sourceRelease.body, + draft: sourceRelease.draft, + prerelease: sourceRelease.prerelease, + }); + targetReleaseId = targetRelease.id; + } + else { + console.log(`Creating new release for targetTag '${targetTag}'`); + // Create a new release for targetTag if it doesn't exist + const { data: newRelease } = await octokit.rest.repos.createRelease({ + owner, + repo, + tag_name: targetTag, + name: sourceRelease.name, + body: sourceRelease.body, + draft: sourceRelease.draft, + prerelease: sourceRelease.prerelease, + }); + targetReleaseId = newRelease.id; + } + (0, logger_1.logInfo)(`Updated release for targetTag '${targetTag}'`); + return targetReleaseId.toString(); + }; + this.createRelease = async (owner, repo, version, title, changelog, token) => { + try { + const octokit = github.getOctokit(token); + const { data: release } = await octokit.rest.repos.createRelease({ + owner, + repo, + tag_name: `v${version}`, + name: `v${version} - ${title}`, + body: changelog, + draft: false, + prerelease: false, + }); + return release.html_url; } - ) { - projectV2Item { - id + catch (error) { + (0, logger_1.logError)(`Error creating release: ${error}`); + return undefined; } - } - }`; - const mutationResult = await octokit.graphql(mutation, { - projectId: project.id, - itemId: contentId, - fieldId: targetField.id, - optionId: targetOption.id - }); - return !!mutationResult.updateProjectV2ItemFieldValue?.projectV2Item; }; - this.setTaskPriority = async (project, owner, repo, issueOrPullRequestNumber, priorityLabel, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.priorityLabel, priorityLabel, token); - this.setTaskSize = async (project, owner, repo, issueOrPullRequestNumber, sizeLabel, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.sizeLabel, sizeLabel, token); - this.moveIssueToColumn = async (project, owner, repo, issueOrPullRequestNumber, columnName, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.statusLabel, columnName, token); - this.getRandomMembers = async (organization, membersToAdd, currentMembers, token) => { - if (membersToAdd === 0) { - return []; + this.createTag = async (owner, repo, branch, tag, token) => { + const octokit = github.getOctokit(token); + try { + // Check if tag already exists + const existingTag = await this.findTag(owner, repo, tag, token); + if (existingTag) { + (0, logger_1.logInfo)(`Tag '${tag}' already exists in repository ${owner}/${repo}`); + return existingTag.object.sha; + } + // Get the latest commit SHA from the specified branch + const { data: ref } = await octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${branch}`, + }); + // Create the tag + await octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${tag}`, + sha: ref.object.sha, + }); + (0, logger_1.logInfo)(`Created tag '${tag}' in repository ${owner}/${repo} from branch '${branch}'`); + return ref.object.sha; } + catch (error) { + (0, logger_1.logError)(`Error creating tag '${tag}': ${JSON.stringify(error, null, 2)}`); + return undefined; + } + }; + } +} +exports.ProjectRepository = ProjectRepository; + + +/***/ }), + +/***/ 634: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PullRequestRepository = void 0; +const github = __importStar(__nccwpck_require__(5438)); +const logger_1 = __nccwpck_require__(8836); +class PullRequestRepository { + constructor() { + /** + * Returns the list of open pull request numbers whose head branch equals the given branch. + * Used to sync size/progress labels from the issue to PRs when they are updated on push. + */ + this.getOpenPullRequestNumbersByHeadBranch = async (owner, repository, headBranch, token) => { const octokit = github.getOctokit(token); try { - const { data: teams } = await octokit.rest.teams.list({ - org: organization, + const { data } = await octokit.rest.pulls.list({ + owner, + repo: repository, + state: 'open', + head: `${owner}:${headBranch}`, }); - if (teams.length === 0) { - (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); - return []; - } - const membersSet = new Set(); - for (const team of teams) { - (0, logger_1.logDebugInfo)(`Checking team: ${team.slug}`); - const { data: members } = await octokit.rest.teams.listMembersInOrg({ - org: organization, - team_slug: team.slug, - }); - (0, logger_1.logDebugInfo)(`Members: ${members.length}`); - members.forEach((member) => membersSet.add(member.login)); - } - const allMembers = Array.from(membersSet); - const availableMembers = allMembers.filter((member) => !currentMembers.includes(member)); - if (availableMembers.length === 0) { - (0, logger_1.logDebugInfo)(`No available members to assign for organization ${organization}.`); - return []; - } - if (membersToAdd >= availableMembers.length) { - (0, logger_1.logDebugInfo)(`Requested size (${membersToAdd}) exceeds available members (${availableMembers.length}). Returning all available members.`); - return availableMembers; - } - const shuffled = availableMembers.sort(() => Math.random() - 0.5); - return shuffled.slice(0, membersToAdd); + const numbers = (data || []).map((pr) => pr.number); + (0, logger_1.logDebugInfo)(`Found ${numbers.length} open PR(s) for head branch "${headBranch}": ${numbers.join(', ') || 'none'}`); + return numbers; } catch (error) { - (0, logger_1.logError)(`Error getting random members: ${error}.`); + (0, logger_1.logError)(`Error listing PRs for branch ${headBranch}: ${error}`); + return []; } - return []; }; - this.getAllMembers = async (organization, token) => { + /** + * Returns the head branch of the first open PR that references the given issue number + * (e.g. body contains "#123" or head ref contains "123" as in feature/123-...). + * Used for issue_comment events where commit.branch is empty. + * Uses bounded matching so #12 does not match #123 and branch "feature/1234-fix" does not match issue 123. + */ + this.getHeadBranchForIssue = async (owner, repository, issueNumber, token) => { const octokit = github.getOctokit(token); + const escaped = String(issueNumber).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const bodyRefRegex = new RegExp(`(?:^|[^\\d])#${escaped}(?:$|[^\\d])`); + const headRefRegex = new RegExp(`\\b${escaped}\\b`); try { - const { data: teams } = await octokit.rest.teams.list({ - org: organization, + const { data } = await octokit.rest.pulls.list({ + owner, + repo: repository, + state: 'open', + per_page: 100, }); - if (teams.length === 0) { - (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); - return []; - } - const membersSet = new Set(); - for (const team of teams) { - const { data: members } = await octokit.rest.teams.listMembersInOrg({ - org: organization, - team_slug: team.slug, - }); - members.forEach((member) => membersSet.add(member.login)); + for (const pr of data || []) { + const body = pr.body ?? ''; + const headRef = pr.head?.ref ?? ''; + if (bodyRefRegex.test(body) || headRefRegex.test(headRef)) { + (0, logger_1.logDebugInfo)(`Found head branch "${headRef}" for issue #${issueNumber} (PR #${pr.number}).`); + return headRef; + } } - return Array.from(membersSet); + (0, logger_1.logDebugInfo)(`No open PR referencing issue #${issueNumber} found.`); + return undefined; } catch (error) { - (0, logger_1.logError)(`Error getting all members: ${error}.`); + (0, logger_1.logError)(`Error getting head branch for issue #${issueNumber}: ${error}`); + return undefined; } - return []; }; - this.getUserFromToken = async (token) => { + this.isLinked = async (pullRequestUrl) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), PullRequestRepository.IS_LINKED_FETCH_TIMEOUT_MS); + try { + const res = await fetch(pullRequestUrl, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!res.ok) { + (0, logger_1.logDebugInfo)(`isLinked: non-2xx response ${res.status} for ${pullRequestUrl}`); + return false; + } + const htmlContent = await res.text(); + return !htmlContent.includes('has_github_issues=false'); + } + catch (err) { + clearTimeout(timeoutId); + const msg = err instanceof Error ? err.message : String(err); + (0, logger_1.logError)(`isLinked: fetch failed for ${pullRequestUrl}: ${msg}`); + return false; + } + }; + this.updateBaseBranch = async (owner, repository, pullRequestNumber, branch, token) => { const octokit = github.getOctokit(token); - const { data: user } = await octokit.rest.users.getAuthenticated(); - return user.login; + await octokit.rest.pulls.update({ + owner: owner, + repo: repository, + pull_number: pullRequestNumber, + base: branch, + }); + (0, logger_1.logDebugInfo)(`Changed base branch to ${branch}`); + }; + this.updateDescription = async (owner, repository, pullRequestNumber, description, token) => { + const octokit = github.getOctokit(token); + await octokit.rest.pulls.update({ + owner: owner, + repo: repository, + pull_number: pullRequestNumber, + body: description, + }); + (0, logger_1.logDebugInfo)(`Updated PR #${pullRequestNumber} description with: ${description}`); }; /** - * Returns true if the actor (user who triggered the event) is allowed to run use cases - * that ask OpenCode to modify files (e.g. bugbot autofix, generic user request). - * - When the repo owner is an Organization: actor must be a member of that organization. - * - When the repo owner is a User: actor must be the owner (same login). + * Returns all users involved in review: requested (pending) + those who already submitted a review. + * Used to avoid re-requesting someone who already reviewed when ensuring desired reviewer count. */ - this.isActorAllowedToModifyFiles = async (owner, actor, token) => { + this.getCurrentReviewers = async (owner, repository, pullNumber, token) => { + const octokit = github.getOctokit(token); try { - const octokit = github.getOctokit(token); - const { data: ownerUser } = await octokit.rest.users.getByUsername({ username: owner }); - if (ownerUser.type === "Organization") { - try { - await octokit.rest.orgs.checkMembershipForUser({ - org: owner, - username: actor, - }); - return true; - } - catch (membershipErr) { - const status = membershipErr?.status; - if (status === 404) - return false; - (0, logger_1.logDebugInfo)(`checkMembershipForUser(${owner}, ${actor}): ${membershipErr instanceof Error ? membershipErr.message : String(membershipErr)}`); - return false; + const [requestedRes, reviewsRes] = await Promise.all([ + octokit.rest.pulls.listRequestedReviewers({ + owner, + repo: repository, + pull_number: pullNumber, + }), + octokit.rest.pulls.listReviews({ + owner, + repo: repository, + pull_number: pullNumber, + }), + ]); + const logins = new Set(); + for (const user of requestedRes.data.users) { + logins.add(user.login); + } + for (const review of reviewsRes.data) { + if (review.user?.login) { + logins.add(review.user.login); } } - return actor === owner; + return Array.from(logins); } - catch (err) { - (0, logger_1.logDebugInfo)(`isActorAllowedToModifyFiles(${owner}, ${actor}): ${err instanceof Error ? err.message : String(err)}`); - return false; + catch (error) { + (0, logger_1.logError)(`Error getting reviewers of PR: ${error}.`); + return []; } }; - /** Name and email of the token user, for git commit author (e.g. bugbot autofix). */ - this.getTokenUserDetails = async (token) => { - const octokit = github.getOctokit(token); - const { data: user } = await octokit.rest.users.getAuthenticated(); - const name = (user.name ?? user.login ?? "GitHub Action").trim() || "GitHub Action"; - const email = (typeof user.email === "string" && user.email.trim().length > 0) - ? user.email.trim() - : `${user.login}@users.noreply.github.com`; - return { name, email }; - }; - this.findTag = async (owner, repo, tag, token) => { + this.addReviewersToPullRequest = async (owner, repository, pullNumber, reviewers, token) => { const octokit = github.getOctokit(token); try { - const { data: foundTag } = await octokit.rest.git.getRef({ + if (reviewers.length === 0) { + (0, logger_1.logDebugInfo)(`No reviewers provided for addition. Skipping operation.`); + return []; + } + const { data } = await octokit.rest.pulls.requestReviewers({ owner, - repo, - ref: `tags/${tag}`, + repo: repository, + pull_number: pullNumber, + reviewers: reviewers, }); - return foundTag; - } - catch { - return undefined; + const addedReviewers = data.requested_reviewers || []; + return addedReviewers.map((reviewer) => reviewer.login); } - }; - this.getTagSHA = async (owner, repo, tag, token) => { - const foundTag = await this.findTag(owner, repo, tag, token); - if (!foundTag) { - (0, logger_1.logError)(`The '${tag}' tag does not exist in the remote repository`); - return undefined; + catch (error) { + (0, logger_1.logError)(`Error adding reviewers to pull request: ${error}.`); + return []; } - return foundTag.object.sha; }; - this.updateTag = async (owner, repo, sourceTag, targetTag, token) => { - const sourceTagSHA = await this.getTagSHA(owner, repo, sourceTag, token); - if (!sourceTagSHA) { - (0, logger_1.logError)(`The '${sourceTag}' tag does not exist in the remote repository`); - return; - } - const foundTargetTag = await this.findTag(owner, repo, targetTag, token); - const refName = `tags/${targetTag}`; + this.getChangedFiles = async (owner, repository, pullNumber, token) => { const octokit = github.getOctokit(token); - if (foundTargetTag) { - (0, logger_1.logDebugInfo)(`Updating the '${targetTag}' tag to point to the '${sourceTag}' tag`); - await octokit.rest.git.updateRef({ + const all = []; + try { + for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listFiles, { owner, - repo, - ref: refName, - sha: sourceTagSHA, - force: true, - }); + repo: repository, + pull_number: pullNumber, + per_page: 100, + })) { + const data = response.data ?? []; + all.push(...data.map((file) => ({ + filename: file.filename, + status: file.status, + }))); + } + return all; } - else { - (0, logger_1.logDebugInfo)(`Creating the '${targetTag}' tag from the '${sourceTag}' tag`); - await octokit.rest.git.createRef({ - owner, - repo, - ref: `refs/${refName}`, - sha: sourceTagSHA, - }); + catch (error) { + (0, logger_1.logError)(`Error getting changed files from pull request: ${error}.`); + return []; } }; - this.updateRelease = async (owner, repo, sourceTag, targetTag, token) => { - // Get the release associated with sourceTag + /** + * Returns for each changed file the first line number that appears in the diff (right side). + * Used so review comments use a line that GitHub can resolve (avoids "line could not be resolved"). + */ + this.getFilesWithFirstDiffLine = async (owner, repository, pullNumber, token) => { const octokit = github.getOctokit(token); - const { data: sourceRelease } = await octokit.rest.repos.getReleaseByTag({ - owner, - repo, - tag: sourceTag, - }); - if (!sourceRelease.name || !sourceRelease.body) { - (0, logger_1.logError)(`The '${sourceTag}' tag does not exist in the remote repository`); - return undefined; - } - (0, logger_1.logDebugInfo)(`Found release for sourceTag '${sourceTag}': ${sourceRelease.name}`); - // Check if there is a release for targetTag - const { data: releases } = await octokit.rest.repos.listReleases({ - owner, - repo, - }); - const targetRelease = releases.find(r => r.tag_name === targetTag); - let targetReleaseId; - if (targetRelease) { - (0, logger_1.logDebugInfo)(`Updating release for targetTag '${targetTag}'`); - // Update the target release with the content from the source release - await octokit.rest.repos.updateRelease({ + try { + const { data } = await octokit.rest.pulls.listFiles({ owner, - repo, - release_id: targetRelease.id, - name: sourceRelease.name, - body: sourceRelease.body, - draft: sourceRelease.draft, - prerelease: sourceRelease.prerelease, + repo: repository, + pull_number: pullNumber, }); - targetReleaseId = targetRelease.id; - } - else { - console.log(`Creating new release for targetTag '${targetTag}'`); - // Create a new release for targetTag if it doesn't exist - const { data: newRelease } = await octokit.rest.repos.createRelease({ - owner, - repo, - tag_name: targetTag, - name: sourceRelease.name, - body: sourceRelease.body, - draft: sourceRelease.draft, - prerelease: sourceRelease.prerelease, + return (data || []) + .filter((f) => f.status !== 'removed' && (f.patch ?? '').length > 0) + .map((f) => { + const firstLine = PullRequestRepository.firstLineFromPatch(f.patch ?? ''); + return { path: f.filename, firstLine: firstLine ?? 1 }; }); - targetReleaseId = newRelease.id; } - (0, logger_1.logInfo)(`Updated release for targetTag '${targetTag}'`); - return targetReleaseId.toString(); + catch (error) { + (0, logger_1.logError)(`Error getting files with diff lines (owner=${owner}, repo=${repository}, pullNumber=${pullNumber}): ${error}.`); + return []; + } }; - this.createRelease = async (owner, repo, version, title, changelog, token) => { + this.getPullRequestChanges = async (owner, repository, pullNumber, token) => { + const octokit = github.getOctokit(token); + const allFiles = []; try { - const octokit = github.getOctokit(token); - const { data: release } = await octokit.rest.repos.createRelease({ + for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listFiles, { owner, - repo, - tag_name: `v${version}`, - name: `v${version} - ${title}`, - body: changelog, - draft: false, - prerelease: false, - }); - return release.html_url; + repo: repository, + pull_number: pullNumber, + per_page: 100 + })) { + const filesData = response.data; + allFiles.push(...filesData.map((file) => ({ + filename: file.filename, + status: file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch || '' + }))); + } + return allFiles; } catch (error) { - (0, logger_1.logError)(`Error creating release: ${error}`); - return undefined; + (0, logger_1.logError)(`Error getting pull request changes: ${error}.`); + return []; } }; - this.createTag = async (owner, repo, branch, tag, token) => { + /** Head commit SHA of the PR (for creating review). */ + this.getPullRequestHeadSha = async (owner, repository, pullNumber, token) => { const octokit = github.getOctokit(token); try { - // Check if tag already exists - const existingTag = await this.findTag(owner, repo, tag, token); - if (existingTag) { - (0, logger_1.logInfo)(`Tag '${tag}' already exists in repository ${owner}/${repo}`); - return existingTag.object.sha; - } - // Get the latest commit SHA from the specified branch - const { data: ref } = await octokit.rest.git.getRef({ - owner, - repo, - ref: `heads/${branch}`, - }); - // Create the tag - await octokit.rest.git.createRef({ + const { data } = await octokit.rest.pulls.get({ owner, - repo, - ref: `refs/tags/${tag}`, - sha: ref.object.sha, - }); - (0, logger_1.logInfo)(`Created tag '${tag}' in repository ${owner}/${repo} from branch '${branch}'`); - return ref.object.sha; + repo: repository, + pull_number: pullNumber, + }); + return data.head?.sha; } catch (error) { - (0, logger_1.logError)(`Error creating tag '${tag}': ${JSON.stringify(error, null, 2)}`); + (0, logger_1.logError)(`Error getting PR head SHA: ${error}.`); return undefined; } }; - } -} -exports.ProjectRepository = ProjectRepository; - - -/***/ }), - -/***/ 634: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.PullRequestRepository = void 0; -const github = __importStar(__nccwpck_require__(5438)); -const logger_1 = __nccwpck_require__(8836); -class PullRequestRepository { - constructor() { /** - * Returns the list of open pull request numbers whose head branch equals the given branch. - * Used to sync size/progress labels from the issue to PRs when they are updated on push. + * List all review comments on a PR (for bugbot: find existing findings by marker). + * Uses pagination to fetch every comment (default API returns only 30 per page). + * Includes node_id for GraphQL (e.g. resolve review thread). */ - this.getOpenPullRequestNumbersByHeadBranch = async (owner, repository, headBranch, token) => { + this.listPullRequestReviewComments = async (owner, repository, pullNumber, token) => { const octokit = github.getOctokit(token); + const all = []; try { - const { data } = await octokit.rest.pulls.list({ + for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listReviewComments, { owner, repo: repository, - state: 'open', - head: `${owner}:${headBranch}`, - }); - const numbers = (data || []).map((pr) => pr.number); - (0, logger_1.logDebugInfo)(`Found ${numbers.length} open PR(s) for head branch "${headBranch}": ${numbers.join(', ') || 'none'}`); - return numbers; + pull_number: pullNumber, + per_page: 100, + })) { + const data = response.data || []; + all.push(...data.map((c) => ({ + id: c.id, + body: c.body ?? null, + path: c.path, + line: c.line ?? undefined, + node_id: c.node_id ?? undefined, + }))); + } + return all; } catch (error) { - (0, logger_1.logError)(`Error listing PRs for branch ${headBranch}: ${error}`); + (0, logger_1.logError)(`Error listing PR review comments (owner=${owner}, repo=${repository}, pullNumber=${pullNumber}): ${error}.`); return []; } }; /** - * Returns the head branch of the first open PR that references the given issue number - * (e.g. body contains "#123" or head ref contains "123" as in feature/123-...). - * Used for issue_comment events where commit.branch is empty. - * Uses bounded matching so #12 does not match #123 and branch "feature/1234-fix" does not match issue 123. + * Fetches a single PR review comment by id (e.g. parent comment when user replied in thread). + * Returns the comment body or null if not found. */ - this.getHeadBranchForIssue = async (owner, repository, issueNumber, token) => { + this.getPullRequestReviewCommentBody = async (owner, repository, _pullNumber, commentId, token) => { const octokit = github.getOctokit(token); - const escaped = String(issueNumber).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const bodyRefRegex = new RegExp(`(?:^|[^\\d])#${escaped}(?:$|[^\\d])`); - const headRefRegex = new RegExp(`\\b${escaped}\\b`); try { - const { data } = await octokit.rest.pulls.list({ + const { data } = await octokit.rest.pulls.getReviewComment({ owner, repo: repository, - state: 'open', - per_page: 100, + comment_id: commentId, }); - for (const pr of data || []) { - const body = pr.body ?? ''; - const headRef = pr.head?.ref ?? ''; - if (bodyRefRegex.test(body) || headRefRegex.test(headRef)) { - (0, logger_1.logDebugInfo)(`Found head branch "${headRef}" for issue #${issueNumber} (PR #${pr.number}).`); - return headRef; + return data.body ?? null; + } + catch (error) { + (0, logger_1.logError)(`Error getting PR review comment ${commentId}: ${error}`); + return null; + } + }; + /** + * Resolve a PR review thread (GraphQL only). Finds the thread that contains the given comment and marks it resolved. + * Uses repository.pullRequest.reviewThreads because the field pullRequestReviewThread on PullRequestReviewComment was removed from the API. + * Paginates through all threads and all comments in each thread so the comment is found regardless of PR size. + * No-op if thread is already resolved. Logs and does not throw on error. + */ + this.resolvePullRequestReviewThread = async (owner, repository, pullNumber, commentNodeId, token) => { + const octokit = github.getOctokit(token); + try { + let threadId = null; + let threadsCursor = null; + outer: do { + const threadsData = await octokit.graphql(`query ($owner: String!, $repo: String!, $prNumber: Int!, $threadsAfter: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + reviewThreads(first: 100, after: $threadsAfter) { + nodes { + id + comments(first: 100) { + nodes { id } + pageInfo { hasNextPage endCursor } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + }`, { owner, repo: repository, prNumber: pullNumber, threadsAfter: threadsCursor }); + const threads = threadsData?.repository?.pullRequest?.reviewThreads; + if (!threads?.nodes?.length) + break; + for (const thread of threads.nodes) { + let commentsCursor = null; + let commentNodes = thread.comments?.nodes ?? []; + let commentsPageInfo = thread.comments?.pageInfo; + do { + if (commentNodes.some((c) => c.id === commentNodeId)) { + threadId = thread.id; + break outer; + } + if (!commentsPageInfo?.hasNextPage || commentsPageInfo.endCursor == null) + break; + commentsCursor = commentsPageInfo.endCursor; + const nextComments = await octokit.graphql(`query ($threadId: ID!, $commentsAfter: String) { + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: 100, after: $commentsAfter) { + nodes { id } + pageInfo { hasNextPage endCursor } + } + } + } + }`, { threadId: thread.id, commentsAfter: commentsCursor }); + commentNodes = nextComments?.node?.comments?.nodes ?? []; + commentsPageInfo = nextComments?.node?.comments?.pageInfo ?? { hasNextPage: false, endCursor: null }; + } while (commentsPageInfo?.hasNextPage === true && commentsPageInfo?.endCursor != null); } + const pageInfo = threads.pageInfo; + if (threadId != null || !pageInfo?.hasNextPage) + break; + threadsCursor = pageInfo.endCursor ?? null; + } while (threadsCursor != null); + if (!threadId) { + (0, logger_1.logError)(`[Bugbot] No review thread found for comment node_id=${commentNodeId}.`); + return; } - (0, logger_1.logDebugInfo)(`No open PR referencing issue #${issueNumber} found.`); - return undefined; + await octokit.graphql(`mutation ($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { + thread { id } + } + }`, { threadId }); + (0, logger_1.logDebugInfo)(`Resolved PR review thread ${threadId}.`); } - catch (error) { - (0, logger_1.logError)(`Error getting head branch for issue #${issueNumber}: ${error}`); - return undefined; + catch (err) { + (0, logger_1.logError)(`[Bugbot] Error resolving PR review thread (commentNodeId=${commentNodeId}, owner=${owner}, repo=${repository}): ${err}`); } }; - this.isLinked = async (pullRequestUrl) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), PullRequestRepository.IS_LINKED_FETCH_TIMEOUT_MS); - try { - const res = await fetch(pullRequestUrl, { signal: controller.signal }); - clearTimeout(timeoutId); - if (!res.ok) { - (0, logger_1.logDebugInfo)(`isLinked: non-2xx response ${res.status} for ${pullRequestUrl}`); - return false; - } - const htmlContent = await res.text(); - return !htmlContent.includes('has_github_issues=false'); - } - catch (err) { - clearTimeout(timeoutId); - const msg = err instanceof Error ? err.message : String(err); - (0, logger_1.logError)(`isLinked: fetch failed for ${pullRequestUrl}: ${msg}`); - return false; - } + /** + * Create a review on the PR with one or more inline comments (bugbot findings). + * Each comment requires path and line (use first file and line 1 if not specified). + */ + this.createReviewWithComments = async (owner, repository, pullNumber, commitId, comments, token) => { + if (comments.length === 0) + return; + const octokit = github.getOctokit(token); + const results = await Promise.allSettled(comments.map((c) => octokit.rest.pulls.createReviewComment({ + owner, + repo: repository, + pull_number: pullNumber, + commit_id: commitId, + path: c.path, + line: c.line, + side: 'RIGHT', + body: c.body, + }))); + let created = 0; + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + created += 1; + } + else { + const c = comments[i]; + (0, logger_1.logError)(`[Bugbot] Error creating PR review comment. path="${c.path}", line=${c.line}, prNumber=${pullNumber}, owner=${owner}, repo=${repository}: ${result.reason}`); + } + }); + if (created > 0) { + (0, logger_1.logDebugInfo)(`Created ${created} review comment(s) on PR #${pullNumber}.`); + } + }; + /** Update an existing PR review comment (e.g. to mark finding as resolved in body). */ + this.updatePullRequestReviewComment = async (owner, repository, commentId, body, token) => { + const octokit = github.getOctokit(token); + await octokit.rest.pulls.updateReviewComment({ + owner, + repo: repository, + comment_id: commentId, + body, + }); + (0, logger_1.logDebugInfo)(`Updated review comment ${commentId}.`); + }; + } + /** First line (right side) of the first hunk per file, for valid review comment placement. */ + static firstLineFromPatch(patch) { + const match = patch.match(/^@@ -\d+,\d+ \+(\d+),\d+ @@/m); + return match ? parseInt(match[1], 10) : undefined; + } +} +exports.PullRequestRepository = PullRequestRepository; +/** Default timeout (ms) for isLinked fetch. */ +PullRequestRepository.IS_LINKED_FETCH_TIMEOUT_MS = 10000; + + +/***/ }), + +/***/ 779: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.WorkflowRepository = void 0; +const github = __importStar(__nccwpck_require__(5438)); +const workflow_run_1 = __nccwpck_require__(6845); +const constants_1 = __nccwpck_require__(8593); +class WorkflowRepository { + constructor() { + this.getWorkflows = async (params) => { + const octokit = github.getOctokit(params.tokens.token); + const workflows = await octokit.rest.actions.listWorkflowRunsForRepo({ + owner: params.owner, + repo: params.repo, + }); + return workflows.data.workflow_runs.map(w => new workflow_run_1.WorkflowRun({ + id: w.id, + name: w.name ?? 'unknown', + head_branch: w.head_branch, + head_sha: w.head_sha, + run_number: w.run_number, + event: w.event, + status: w.status ?? 'unknown', + conclusion: w.conclusion ?? null, + created_at: w.created_at, + updated_at: w.updated_at, + url: w.url, + html_url: w.html_url, + })); }; - this.updateBaseBranch = async (owner, repository, pullRequestNumber, branch, token) => { - const octokit = github.getOctokit(token); - await octokit.rest.pulls.update({ - owner: owner, - repo: repository, - pull_number: pullRequestNumber, - base: branch, + this.getActivePreviousRuns = async (params) => { + const workflows = await this.getWorkflows(params); + const runId = parseInt(process.env.GITHUB_RUN_ID, 10); + const workflowName = process.env.GITHUB_WORKFLOW; + return workflows.filter((run) => { + const isSameWorkflow = run.name === workflowName; + const isPrevious = run.id < runId; + const isActive = constants_1.WORKFLOW_ACTIVE_STATUSES.includes(run.status); + return isSameWorkflow && isPrevious && isActive; }); - (0, logger_1.logDebugInfo)(`Changed base branch to ${branch}`); }; - this.updateDescription = async (owner, repository, pullRequestNumber, description, token) => { + this.executeWorkflow = async (owner, repository, branch, workflow, inputs, token) => { const octokit = github.getOctokit(token); - await octokit.rest.pulls.update({ + return octokit.rest.actions.createWorkflowDispatch({ owner: owner, repo: repository, - pull_number: pullRequestNumber, - body: description, + workflow_id: workflow, + ref: branch, + inputs: inputs, }); - (0, logger_1.logDebugInfo)(`Updated PR #${pullRequestNumber} description with: ${description}`); - }; - /** - * Returns all users involved in review: requested (pending) + those who already submitted a review. - * Used to avoid re-requesting someone who already reviewed when ensuring desired reviewer count. - */ - this.getCurrentReviewers = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - try { - const [requestedRes, reviewsRes] = await Promise.all([ - octokit.rest.pulls.listRequestedReviewers({ - owner, - repo: repository, - pull_number: pullNumber, - }), - octokit.rest.pulls.listReviews({ - owner, - repo: repository, - pull_number: pullNumber, - }), - ]); - const logins = new Set(); - for (const user of requestedRes.data.users) { - logins.add(user.login); - } - for (const review of reviewsRes.data) { - if (review.user?.login) { - logins.add(review.user.login); - } - } - return Array.from(logins); - } - catch (error) { - (0, logger_1.logError)(`Error getting reviewers of PR: ${error}.`); - return []; - } }; - this.addReviewersToPullRequest = async (owner, repository, pullNumber, reviewers, token) => { - const octokit = github.getOctokit(token); + } +} +exports.WorkflowRepository = WorkflowRepository; + + +/***/ }), + +/***/ 6365: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ContentInterface = void 0; +const logger_1 = __nccwpck_require__(8836); +class ContentInterface { + constructor() { + this.getContent = (description) => { try { - if (reviewers.length === 0) { - (0, logger_1.logDebugInfo)(`No reviewers provided for addition. Skipping operation.`); - return []; + if (description === undefined) { + return undefined; } - const { data } = await octokit.rest.pulls.requestReviewers({ - owner, - repo: repository, - pull_number: pullNumber, - reviewers: reviewers, - }); - const addedReviewers = data.requested_reviewers || []; - return addedReviewers.map((reviewer) => reviewer.login); - } - catch (error) { - (0, logger_1.logError)(`Error adding reviewers to pull request: ${error}.`); - return []; - } - }; - this.getChangedFiles = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - const all = []; - try { - for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listFiles, { - owner, - repo: repository, - pull_number: pullNumber, - per_page: 100, - })) { - const data = response.data ?? []; - all.push(...data.map((file) => ({ - filename: file.filename, - status: file.status, - }))); + if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + return undefined; } - return all; + return description.split(this.startPattern)[1].split(this.endPattern)[0]; } catch (error) { - (0, logger_1.logError)(`Error getting changed files from pull request: ${error}.`); - return []; + (0, logger_1.logError)(`Error reading issue configuration: ${error}`); + throw error; } }; - /** - * Returns for each changed file the first line number that appears in the diff (right side). - * Used so review comments use a line that GitHub can resolve (avoids "line could not be resolved"). - */ - this.getFilesWithFirstDiffLine = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - try { - const { data } = await octokit.rest.pulls.listFiles({ - owner, - repo: repository, - pull_number: pullNumber, - }); - return (data || []) - .filter((f) => f.status !== 'removed' && (f.patch ?? '').length > 0) - .map((f) => { - const firstLine = PullRequestRepository.firstLineFromPatch(f.patch ?? ''); - return { path: f.filename, firstLine: firstLine ?? 1 }; - }); + this._addContent = (description, content) => { + if (description.indexOf(this.startPattern) === -1 && description.indexOf(this.endPattern) === -1) { + const newContent = `${this.startPattern}\n${content}\n${this.endPattern}`; + return `${description}\n\n${newContent}`; } - catch (error) { - (0, logger_1.logError)(`Error getting files with diff lines (owner=${owner}, repo=${repository}, pullNumber=${pullNumber}): ${error}.`); - return []; + else { + return undefined; } }; - this.getPullRequestChanges = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - const allFiles = []; - try { - for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listFiles, { - owner, - repo: repository, - pull_number: pullNumber, - per_page: 100 - })) { - const filesData = response.data; - allFiles.push(...filesData.map((file) => ({ - filename: file.filename, - status: file.status, - additions: file.additions, - deletions: file.deletions, - patch: file.patch || '' - }))); - } - return allFiles; - } - catch (error) { - (0, logger_1.logError)(`Error getting pull request changes: ${error}.`); - return []; + this._updateContent = (description, content) => { + if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + (0, logger_1.logError)(`The content has a problem with open-close tags: ${this.startPattern} / ${this.endPattern}`); + return undefined; } + const start = description.split(this.startPattern)[0]; + const mid = `${this.startPattern}\n${content}\n${this.endPattern}`; + const end = description.split(this.endPattern)[1]; + return `${start}${mid}${end}`; }; - /** Head commit SHA of the PR (for creating review). */ - this.getPullRequestHeadSha = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); + this.updateContent = (description, content) => { try { - const { data } = await octokit.rest.pulls.get({ - owner, - repo: repository, - pull_number: pullNumber, - }); - return data.head?.sha; + if (description === undefined || content === undefined) { + return undefined; + } + const addedContent = this._addContent(description, content); + if (addedContent !== undefined) { + return addedContent; + } + return this._updateContent(description, content); } catch (error) { - (0, logger_1.logError)(`Error getting PR head SHA: ${error}.`); + (0, logger_1.logError)(`Error updating issue description: ${error}`); return undefined; } }; - /** - * List all review comments on a PR (for bugbot: find existing findings by marker). - * Uses pagination to fetch every comment (default API returns only 30 per page). - * Includes node_id for GraphQL (e.g. resolve review thread). - */ - this.listPullRequestReviewComments = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - const all = []; + } + get _id() { + return `copilot-${this.id}`; + } + get startPattern() { + if (this.visibleContent) { + return ``; + } + return ``; + } + return `${this._id}-end -->`; + } +} +exports.ContentInterface = ContentInterface; + + +/***/ }), + +/***/ 9913: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.IssueContentInterface = void 0; +const issue_repository_1 = __nccwpck_require__(57); +const logger_1 = __nccwpck_require__(8836); +const content_interface_1 = __nccwpck_require__(6365); +class IssueContentInterface extends content_interface_1.ContentInterface { + constructor() { + super(...arguments); + this.issueRepository = new issue_repository_1.IssueRepository(); + this.internalGetter = async (execution) => { try { - for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listReviewComments, { - owner, - repo: repository, - pull_number: pullNumber, - per_page: 100, - })) { - const data = response.data || []; - all.push(...data.map((c) => ({ - id: c.id, - body: c.body ?? null, - path: c.path, - line: c.line ?? undefined, - node_id: c.node_id ?? undefined, - }))); + let number = -1; + if (execution.isSingleAction) { + number = execution.issueNumber; } - return all; + else if (execution.isIssue) { + number = execution.issue.number; + } + else if (execution.isPullRequest) { + number = execution.pullRequest.number; + } + else if (execution.isPush) { + number = execution.issueNumber; + } + else { + return undefined; + } + const description = await this.issueRepository.getDescription(execution.owner, execution.repo, number, execution.tokens.token); + return this.getContent(description); } catch (error) { - (0, logger_1.logError)(`Error listing PR review comments (owner=${owner}, repo=${repository}, pullNumber=${pullNumber}): ${error}.`); - return []; + (0, logger_1.logError)(`Error reading issue configuration: ${error}`); + throw error; } }; - /** - * Fetches a single PR review comment by id (e.g. parent comment when user replied in thread). - * Returns the comment body or null if not found. - */ - this.getPullRequestReviewCommentBody = async (owner, repository, _pullNumber, commentId, token) => { - const octokit = github.getOctokit(token); + this.internalUpdate = async (execution, content) => { try { - const { data } = await octokit.rest.pulls.getReviewComment({ - owner, - repo: repository, - comment_id: commentId, - }); - return data.body ?? null; + let number = -1; + if (execution.isSingleAction) { + if (execution.isIssue) { + number = execution.issue.number; + } + else if (execution.isPullRequest) { + number = execution.pullRequest.number; + } + else if (execution.isPush) { + number = execution.issueNumber; + } + else { + number = execution.singleAction.issue; + } + } + else if (execution.isIssue) { + number = execution.issue.number; + } + else if (execution.isPullRequest) { + number = execution.pullRequest.number; + } + else if (execution.isPush) { + number = execution.issueNumber; + } + else { + return undefined; + } + const description = await this.issueRepository.getDescription(execution.owner, execution.repo, number, execution.tokens.token); + const updated = this.updateContent(description, content); + if (updated === undefined) { + return undefined; + } + await this.issueRepository.updateDescription(execution.owner, execution.repo, number, updated, execution.tokens.token); + return updated; } catch (error) { - (0, logger_1.logError)(`Error getting PR review comment ${commentId}: ${error}`); - return null; + (0, logger_1.logError)(`Error reading issue configuration: ${error}`); + throw error; } }; - /** - * Resolve a PR review thread (GraphQL only). Finds the thread that contains the given comment and marks it resolved. - * Uses repository.pullRequest.reviewThreads because the field pullRequestReviewThread on PullRequestReviewComment was removed from the API. - * Paginates through all threads and all comments in each thread so the comment is found regardless of PR size. - * No-op if thread is already resolved. Logs and does not throw on error. - */ - this.resolvePullRequestReviewThread = async (owner, repository, pullNumber, commentNodeId, token) => { - const octokit = github.getOctokit(token); + } +} +exports.IssueContentInterface = IssueContentInterface; + + +/***/ }), + +/***/ 4509: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ConfigurationHandler = void 0; +const config_1 = __nccwpck_require__(1106); +const logger_1 = __nccwpck_require__(8836); +const issue_content_interface_1 = __nccwpck_require__(9913); +/** Keys that must be preserved from stored config when current has undefined (e.g. when branch already existed). */ +const CONFIG_KEYS_TO_PRESERVE = [ + 'parentBranch', + 'workingBranch', + 'releaseBranch', + 'hotfixBranch', + 'hotfixOriginBranch', + 'branchType', +]; +class ConfigurationHandler extends issue_content_interface_1.IssueContentInterface { + constructor() { + super(...arguments); + this.update = async (execution) => { try { - let threadId = null; - let threadsCursor = null; - outer: do { - const threadsData = await octokit.graphql(`query ($owner: String!, $repo: String!, $prNumber: Int!, $threadsAfter: String) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $prNumber) { - reviewThreads(first: 100, after: $threadsAfter) { - nodes { - id - comments(first: 100) { - nodes { id } - pageInfo { hasNextPage endCursor } - } - } - pageInfo { hasNextPage endCursor } - } - } - } - }`, { owner, repo: repository, prNumber: pullNumber, threadsAfter: threadsCursor }); - const threads = threadsData?.repository?.pullRequest?.reviewThreads; - if (!threads?.nodes?.length) - break; - for (const thread of threads.nodes) { - let commentsCursor = null; - let commentNodes = thread.comments?.nodes ?? []; - let commentsPageInfo = thread.comments?.pageInfo; - do { - if (commentNodes.some((c) => c.id === commentNodeId)) { - threadId = thread.id; - break outer; - } - if (!commentsPageInfo?.hasNextPage || commentsPageInfo.endCursor == null) - break; - commentsCursor = commentsPageInfo.endCursor; - const nextComments = await octokit.graphql(`query ($threadId: ID!, $commentsAfter: String) { - node(id: $threadId) { - ... on PullRequestReviewThread { - comments(first: 100, after: $commentsAfter) { - nodes { id } - pageInfo { hasNextPage endCursor } - } - } - } - }`, { threadId: thread.id, commentsAfter: commentsCursor }); - commentNodes = nextComments?.node?.comments?.nodes ?? []; - commentsPageInfo = nextComments?.node?.comments?.pageInfo ?? { hasNextPage: false, endCursor: null }; - } while (commentsPageInfo?.hasNextPage === true && commentsPageInfo?.endCursor != null); + const current = execution.currentConfiguration; + const payload = { + branchType: current.branchType, + releaseBranch: current.releaseBranch, + workingBranch: current.workingBranch, + parentBranch: current.parentBranch, + hotfixOriginBranch: current.hotfixOriginBranch, + hotfixBranch: current.hotfixBranch, + results: current.results, + branchConfiguration: current.branchConfiguration, + }; + const storedRaw = await this.internalGetter(execution); + if (storedRaw != null && storedRaw.trim().length > 0) { + try { + const stored = JSON.parse(storedRaw); + for (const key of CONFIG_KEYS_TO_PRESERVE) { + if (payload[key] === undefined && stored[key] !== undefined) { + payload[key] = stored[key]; + } + } } - const pageInfo = threads.pageInfo; - if (threadId != null || !pageInfo?.hasNextPage) - break; - threadsCursor = pageInfo.endCursor ?? null; - } while (threadsCursor != null); - if (!threadId) { - (0, logger_1.logError)(`[Bugbot] No review thread found for comment node_id=${commentNodeId}.`); - return; - } - await octokit.graphql(`mutation ($threadId: ID!) { - resolveReviewThread(input: { threadId: $threadId }) { - thread { id } + catch { + /* ignore parse errors, save current as-is */ } - }`, { threadId }); - (0, logger_1.logDebugInfo)(`Resolved PR review thread ${threadId}.`); + } + return await this.internalUpdate(execution, JSON.stringify(payload, null, 4)); } - catch (err) { - (0, logger_1.logError)(`[Bugbot] Error resolving PR review thread (commentNodeId=${commentNodeId}, owner=${owner}, repo=${repository}): ${err}`); + catch (error) { + (0, logger_1.logError)(`Error updating issue description: ${error}`); + return undefined; } }; - /** - * Create a review on the PR with one or more inline comments (bugbot findings). - * Each comment requires path and line (use first file and line 1 if not specified). - */ - this.createReviewWithComments = async (owner, repository, pullNumber, commitId, comments, token) => { - if (comments.length === 0) - return; - const octokit = github.getOctokit(token); - const results = await Promise.allSettled(comments.map((c) => octokit.rest.pulls.createReviewComment({ - owner, - repo: repository, - pull_number: pullNumber, - commit_id: commitId, - path: c.path, - line: c.line, - side: 'RIGHT', - body: c.body, - }))); - let created = 0; - results.forEach((result, i) => { - if (result.status === 'fulfilled') { - created += 1; - } - else { - const c = comments[i]; - (0, logger_1.logError)(`[Bugbot] Error creating PR review comment. path="${c.path}", line=${c.line}, prNumber=${pullNumber}, owner=${owner}, repo=${repository}: ${result.reason}`); + this.get = async (execution) => { + try { + const config = await this.internalGetter(execution); + if (config === undefined) { + return undefined; } - }); - if (created > 0) { - (0, logger_1.logDebugInfo)(`Created ${created} review comment(s) on PR #${pullNumber}.`); + const branchConfig = JSON.parse(config); + return new config_1.Config(branchConfig); + } + catch (error) { + (0, logger_1.logError)(`Error reading issue configuration: ${error}`); + throw error; } - }; - /** Update an existing PR review comment (e.g. to mark finding as resolved in body). */ - this.updatePullRequestReviewComment = async (owner, repository, commentId, body, token) => { - const octokit = github.getOctokit(token); - await octokit.rest.pulls.updateReviewComment({ - owner, - repo: repository, - comment_id: commentId, - body, - }); - (0, logger_1.logDebugInfo)(`Updated review comment ${commentId}.`); }; } - /** First line (right side) of the first hunk per file, for valid review comment placement. */ - static firstLineFromPatch(patch) { - const match = patch.match(/^@@ -\d+,\d+ \+(\d+),\d+ @@/m); - return match ? parseInt(match[1], 10) : undefined; + get id() { + return 'configuration'; } + get visibleContent() { + return false; + } +} +exports.ConfigurationHandler = ConfigurationHandler; + + +/***/ }), + +/***/ 7879: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getAnswerIssueHelpPrompt = getAnswerIssueHelpPrompt; +/** + * Prompt for the initial reply when a user opens a question/help issue. + * Filled by the prompt provider; use getAnswerIssueHelpPrompt(). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. + +**Answer in this single response:** Give a complete, direct answer. Do not reply that you need to explore the repository, read documentation first, or gather more information—use the project (README, docs/, code, .cursor/rules) to answer now. For "how do I…" or tutorial-style questions (e.g. how to implement or configure this project), provide concrete steps or guidance based on the project's actual documentation and structure. + +{{projectContextInstruction}} + +**Issue description (user's question or request):** +""" +{{description}} +""" + +Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; +function getAnswerIssueHelpPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + description: params.description, + projectContextInstruction: params.projectContextInstruction, + }); +} + + +/***/ }), + +/***/ 1118: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getBugbotPrompt = getBugbotPrompt; +/** + * Prompt for Bugbot detection (detect potential problems on push). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are analyzing the latest code changes for potential bugs and issues. + +{{projectContextInstruction}} + +**Repository context:** +- Owner: {{owner}} +- Repository: {{repo}} +- Branch (head): {{headBranch}} +- Base branch: {{baseBranch}} +- Issue number: {{issueNumber}} +{{ignoreBlock}} + +**Your task 1 (new/current problems):** Determine what has changed in the branch "{{headBranch}}" compared to "{{baseBranch}}" (you must compute or obtain the diff yourself using the repository context above). Then identify potential bugs, logic errors, security issues, and code quality problems. Be strict and descriptive. One finding per distinct problem. Return them in the \`findings\` array (each with id, title, description; optionally file, line, severity, suggestion). Only include findings in files that are not in the ignore list above. +{{previousBlock}} + +**Output:** Return a JSON object with: "findings" (array of new/current problems from task 1), and if we gave you previously reported issues above, "resolved_finding_ids" (array of those ids that are now fixed or no longer apply, as per task 2).`; +function getBugbotPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + ...params, + issueNumber: String(params.issueNumber), + }); +} + + +/***/ }), + +/***/ 9673: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getBugbotFixPrompt = getBugbotFixPrompt; +/** + * Prompt for Bugbot autofix (fix selected findings in workspace). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are in the repository workspace. Your task is to fix the reported code findings (bugs, vulnerabilities, or quality issues) listed below, and only those. The user has explicitly requested these fixes. + +{{projectContextInstruction}} + +**Repository context:** +- Owner: {{owner}} +- Repository: {{repo}} +- Branch (head): {{headBranch}} +- Base branch: {{baseBranch}} +- Issue number: {{issueNumber}} +{{prNumberLine}} + +**Findings to fix (do not change code unrelated to these):** +{{findingsBlock}} + +**User request:** +""" +{{userComment}} +""" + +**Rules:** +1. Fix only the problems described in the findings above. Do not refactor or change other code except as strictly necessary for the fix. +2. You may add or update tests only to validate that the fix is correct. +3. After applying changes, run the verify commands (or standard build/test/lint) and ensure they all pass. If they fail, adjust the fix until they pass. +4. Apply all changes directly in the workspace (edit files, run commands). Do not output diffs for someone else to apply. +{{verifyBlock}} + +Once the fixes are applied and the verify commands pass, reply briefly confirming what was fixed and that checks passed.`; +function getBugbotFixPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + ...params, + issueNumber: String(params.issueNumber), + }); +} + + +/***/ }), + +/***/ 3975: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getBugbotFixIntentPrompt = getBugbotFixIntentPrompt; +/** + * Prompt for detecting if user comment is a fix request and which finding ids to target. + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are analyzing a user comment on an issue or pull request to decide whether they are asking to fix one or more reported code findings (bugs, vulnerabilities, or quality issues). + +{{projectContextInstruction}} + +**List of unresolved findings (id, title, and optional file/line/description):** +{{findingsBlock}} +{{parentBlock}} +**User comment:** +""" +{{userComment}} +""" + +**Your task:** Decide: +1. Is this comment clearly a request to fix one or more of the findings above? (e.g. "fix it", "arreglalo", "fix this", "fix all", "fix vulnerability X", "corrige", "fix the bug in src/foo.ts"). If the user is asking a question, discussing something else, or the intent is ambiguous, set \`is_fix_request\` to false. +2. If it is a fix request, which finding ids should be fixed? Return their exact ids in \`target_finding_ids\`. If the user says "fix all" or equivalent, include every id from the list above. If they refer to a specific finding (e.g. by replying to a comment that contains one finding), return only that finding's id. Use only ids that appear in the list above. +3. Is the user asking to perform some other change or task in the repo? (e.g. "add a test for X", "refactor this", "implement feature Y", "haz que Z"). If yes, set \`is_do_request\` to true. Set false for pure questions or when the only intent is to fix the listed findings. + +Respond with a JSON object: \`is_fix_request\` (boolean), \`target_finding_ids\` (array of strings; empty when \`is_fix_request\` is false), and \`is_do_request\` (boolean).`; +function getBugbotFixIntentPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, params); +} + + +/***/ }), + +/***/ 6320: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getCheckCommentLanguagePrompt = getCheckCommentLanguagePrompt; +exports.getTranslateCommentPrompt = getTranslateCommentPrompt; +/** + * Prompts for checking if a comment is in the target locale and for translating it. + * Used by CheckIssueCommentLanguageUseCase and CheckPullRequestCommentLanguageUseCase. + */ +const fill_1 = __nccwpck_require__(5269); +const CHECK_TEMPLATE = ` + You are a helpful assistant that checks if the text is written in {{locale}}. + + Instructions: + 1. Analyze the provided text + 2. If the text is written in {{locale}}, respond with exactly "done" + 3. If the text is written in any other language, respond with exactly "must_translate" + 4. Do not provide any explanation or additional text + + The text is: {{commentBody}} + `; +const TRANSLATE_TEMPLATE = ` +You are a helpful assistant that translates the text to {{locale}}. + +Instructions: +1. Translate the text to {{locale}} +2. Put the translated text in the translatedText field +3. If you cannot translate (e.g. ambiguous or invalid input), set translatedText to empty string and explain in reason + +The text to translate is: {{commentBody}} + `; +function getCheckCommentLanguagePrompt(params) { + return (0, fill_1.fillTemplate)(CHECK_TEMPLATE.trim(), { + locale: params.locale, + commentBody: params.commentBody, + }); +} +function getTranslateCommentPrompt(params) { + return (0, fill_1.fillTemplate)(TRANSLATE_TEMPLATE.trim(), { + locale: params.locale, + commentBody: params.commentBody, + }); +} + + +/***/ }), + +/***/ 7553: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getCheckProgressPrompt = getCheckProgressPrompt; +/** + * Prompt for assessing issue progress from branch diff (CheckProgressUseCase). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are in the repository workspace. Assess the progress of issue #{{issueNumber}} using the full diff between the base (parent) branch and the current branch. + +{{projectContextInstruction}} + +**Branches:** +- **Base (parent) branch:** \`{{baseBranch}}\` +- **Current branch:** \`{{currentBranch}}\` + +**Instructions:** +1. Get the full diff by running: \`git diff {{baseBranch}}..{{currentBranch}}\` (or \`git diff {{baseBranch}}...{{currentBranch}}\` for merge-base). If you cannot run shell commands, use whatever workspace tools you have to inspect changes between these branches. +2. Optionally confirm the current branch with \`git branch --show-current\` if needed. +3. Based on the full diff and the issue description below, assess completion progress (0-100%) and write a short summary. +4. If progress is below 100%, add a "remaining" field with a short description of what is left to do to complete the task (e.g. missing implementation, tests, docs). Omit "remaining" or leave empty when progress is 100%. + +**Issue description:** +{{issueDescription}} + +Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" }.`; +function getCheckProgressPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + issueNumber: String(params.issueNumber), + baseBranch: params.baseBranch, + currentBranch: params.currentBranch, + issueDescription: params.issueDescription, + }); +} + + +/***/ }), + +/***/ 7663: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getCliDoPrompt = getCliDoPrompt; +/** + * Prompt for CLI "copilot do" command: project context + user prompt. + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `{{projectContextInstruction}} + +{{userPrompt}}`; +function getCliDoPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, params); +} + + +/***/ }), + +/***/ 5269: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.fillTemplate = fillTemplate; +/** + * Replaces {{paramName}} placeholders in a template with values from params. + * Missing keys are left as {{paramName}}. + */ +function fillTemplate(template, params) { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => params[key] ?? `{{${key}}}`); } -exports.PullRequestRepository = PullRequestRepository; -/** Default timeout (ms) for isLinked fetch. */ -PullRequestRepository.IS_LINKED_FETCH_TIMEOUT_MS = 10000; /***/ }), -/***/ 779: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { +/***/ 5554: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.WorkflowRepository = void 0; -const github = __importStar(__nccwpck_require__(5438)); -const workflow_run_1 = __nccwpck_require__(6845); -const constants_1 = __nccwpck_require__(8593); -class WorkflowRepository { - constructor() { - this.getWorkflows = async (params) => { - const octokit = github.getOctokit(params.tokens.token); - const workflows = await octokit.rest.actions.listWorkflowRunsForRepo({ - owner: params.owner, - repo: params.repo, - }); - return workflows.data.workflow_runs.map(w => new workflow_run_1.WorkflowRun({ - id: w.id, - name: w.name ?? 'unknown', - head_branch: w.head_branch, - head_sha: w.head_sha, - run_number: w.run_number, - event: w.event, - status: w.status ?? 'unknown', - conclusion: w.conclusion ?? null, - created_at: w.created_at, - updated_at: w.updated_at, - url: w.url, - html_url: w.html_url, - })); - }; - this.getActivePreviousRuns = async (params) => { - const workflows = await this.getWorkflows(params); - const runId = parseInt(process.env.GITHUB_RUN_ID, 10); - const workflowName = process.env.GITHUB_WORKFLOW; - return workflows.filter((run) => { - const isSameWorkflow = run.name === workflowName; - const isPrevious = run.id < runId; - const isActive = constants_1.WORKFLOW_ACTIVE_STATUSES.includes(run.status); - return isSameWorkflow && isPrevious && isActive; - }); - }; +exports.PROMPT_NAMES = exports.getBugbotFixIntentPrompt = exports.getBugbotFixPrompt = exports.getBugbotPrompt = exports.getCliDoPrompt = exports.getTranslateCommentPrompt = exports.getCheckCommentLanguagePrompt = exports.getCheckProgressPrompt = exports.getRecommendStepsPrompt = exports.getUserRequestPrompt = exports.getUpdatePullRequestDescriptionPrompt = exports.getThinkPrompt = exports.getAnswerIssueHelpPrompt = exports.fillTemplate = void 0; +exports.getPrompt = getPrompt; +/** + * Prompt provider: one file per prompt, each exports a getter that fills the template with params. + * Use getPrompt(name, params) for a generic call or import the typed getter (e.g. getAnswerIssueHelpPrompt). + */ +const answer_issue_help_1 = __nccwpck_require__(7879); +const think_1 = __nccwpck_require__(5725); +const update_pull_request_description_1 = __nccwpck_require__(1482); +const user_request_1 = __nccwpck_require__(1762); +const recommend_steps_1 = __nccwpck_require__(2041); +const check_progress_1 = __nccwpck_require__(7553); +const check_comment_language_1 = __nccwpck_require__(6320); +const cli_do_1 = __nccwpck_require__(7663); +const bugbot_1 = __nccwpck_require__(1118); +const bugbot_fix_1 = __nccwpck_require__(9673); +const bugbot_fix_intent_1 = __nccwpck_require__(3975); +var fill_1 = __nccwpck_require__(5269); +Object.defineProperty(exports, "fillTemplate", ({ enumerable: true, get: function () { return fill_1.fillTemplate; } })); +var answer_issue_help_2 = __nccwpck_require__(7879); +Object.defineProperty(exports, "getAnswerIssueHelpPrompt", ({ enumerable: true, get: function () { return answer_issue_help_2.getAnswerIssueHelpPrompt; } })); +var think_2 = __nccwpck_require__(5725); +Object.defineProperty(exports, "getThinkPrompt", ({ enumerable: true, get: function () { return think_2.getThinkPrompt; } })); +var update_pull_request_description_2 = __nccwpck_require__(1482); +Object.defineProperty(exports, "getUpdatePullRequestDescriptionPrompt", ({ enumerable: true, get: function () { return update_pull_request_description_2.getUpdatePullRequestDescriptionPrompt; } })); +var user_request_2 = __nccwpck_require__(1762); +Object.defineProperty(exports, "getUserRequestPrompt", ({ enumerable: true, get: function () { return user_request_2.getUserRequestPrompt; } })); +var recommend_steps_2 = __nccwpck_require__(2041); +Object.defineProperty(exports, "getRecommendStepsPrompt", ({ enumerable: true, get: function () { return recommend_steps_2.getRecommendStepsPrompt; } })); +var check_progress_2 = __nccwpck_require__(7553); +Object.defineProperty(exports, "getCheckProgressPrompt", ({ enumerable: true, get: function () { return check_progress_2.getCheckProgressPrompt; } })); +var check_comment_language_2 = __nccwpck_require__(6320); +Object.defineProperty(exports, "getCheckCommentLanguagePrompt", ({ enumerable: true, get: function () { return check_comment_language_2.getCheckCommentLanguagePrompt; } })); +Object.defineProperty(exports, "getTranslateCommentPrompt", ({ enumerable: true, get: function () { return check_comment_language_2.getTranslateCommentPrompt; } })); +var cli_do_2 = __nccwpck_require__(7663); +Object.defineProperty(exports, "getCliDoPrompt", ({ enumerable: true, get: function () { return cli_do_2.getCliDoPrompt; } })); +var bugbot_2 = __nccwpck_require__(1118); +Object.defineProperty(exports, "getBugbotPrompt", ({ enumerable: true, get: function () { return bugbot_2.getBugbotPrompt; } })); +var bugbot_fix_2 = __nccwpck_require__(9673); +Object.defineProperty(exports, "getBugbotFixPrompt", ({ enumerable: true, get: function () { return bugbot_fix_2.getBugbotFixPrompt; } })); +var bugbot_fix_intent_2 = __nccwpck_require__(3975); +Object.defineProperty(exports, "getBugbotFixIntentPrompt", ({ enumerable: true, get: function () { return bugbot_fix_intent_2.getBugbotFixIntentPrompt; } })); +/** Known prompt names for getPrompt() */ +exports.PROMPT_NAMES = { + ANSWER_ISSUE_HELP: 'answer_issue_help', + THINK: 'think', + UPDATE_PULL_REQUEST_DESCRIPTION: 'update_pull_request_description', + USER_REQUEST: 'user_request', + RECOMMEND_STEPS: 'recommend_steps', + CHECK_PROGRESS: 'check_progress', + CHECK_COMMENT_LANGUAGE: 'check_comment_language', + TRANSLATE_COMMENT: 'translate_comment', + CLI_DO: 'cli_do', + BUGBOT: 'bugbot', + BUGBOT_FIX: 'bugbot_fix', + BUGBOT_FIX_INTENT: 'bugbot_fix_intent', +}; +const registry = { + [exports.PROMPT_NAMES.ANSWER_ISSUE_HELP]: (p) => (0, answer_issue_help_1.getAnswerIssueHelpPrompt)(p), + [exports.PROMPT_NAMES.THINK]: (p) => (0, think_1.getThinkPrompt)(p), + [exports.PROMPT_NAMES.UPDATE_PULL_REQUEST_DESCRIPTION]: (p) => (0, update_pull_request_description_1.getUpdatePullRequestDescriptionPrompt)(p), + [exports.PROMPT_NAMES.USER_REQUEST]: (p) => (0, user_request_1.getUserRequestPrompt)(p), + [exports.PROMPT_NAMES.RECOMMEND_STEPS]: (p) => (0, recommend_steps_1.getRecommendStepsPrompt)(p), + [exports.PROMPT_NAMES.CHECK_PROGRESS]: (p) => (0, check_progress_1.getCheckProgressPrompt)(p), + [exports.PROMPT_NAMES.CHECK_COMMENT_LANGUAGE]: (p) => (0, check_comment_language_1.getCheckCommentLanguagePrompt)(p), + [exports.PROMPT_NAMES.TRANSLATE_COMMENT]: (p) => (0, check_comment_language_1.getTranslateCommentPrompt)(p), + [exports.PROMPT_NAMES.CLI_DO]: (p) => (0, cli_do_1.getCliDoPrompt)(p), + [exports.PROMPT_NAMES.BUGBOT]: (p) => (0, bugbot_1.getBugbotPrompt)(p), + [exports.PROMPT_NAMES.BUGBOT_FIX]: (p) => (0, bugbot_fix_1.getBugbotFixPrompt)(p), + [exports.PROMPT_NAMES.BUGBOT_FIX_INTENT]: (p) => (0, bugbot_fix_intent_1.getBugbotFixIntentPrompt)(p), +}; +/** + * Returns a filled prompt by name. Params must match the prompt's expected keys. + */ +function getPrompt(name, params) { + const fn = registry[name]; + if (!fn) { + throw new Error(`Unknown prompt: ${name}`); } + return fn(params); } -exports.WorkflowRepository = WorkflowRepository; /***/ }), -/***/ 6365: +/***/ 2041: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.ContentInterface = void 0; -const logger_1 = __nccwpck_require__(8836); -class ContentInterface { - constructor() { - this.getContent = (description) => { - try { - if (description === undefined) { - return undefined; - } - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { - return undefined; - } - return description.split(this.startPattern)[1].split(this.endPattern)[0]; - } - catch (error) { - (0, logger_1.logError)(`Error reading issue configuration: ${error}`); - throw error; - } - }; - this._addContent = (description, content) => { - if (description.indexOf(this.startPattern) === -1 && description.indexOf(this.endPattern) === -1) { - const newContent = `${this.startPattern}\n${content}\n${this.endPattern}`; - return `${description}\n\n${newContent}`; - } - else { - return undefined; - } - }; - this._updateContent = (description, content) => { - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { - (0, logger_1.logError)(`The content has a problem with open-close tags: ${this.startPattern} / ${this.endPattern}`); - return undefined; - } - const start = description.split(this.startPattern)[0]; - const mid = `${this.startPattern}\n${content}\n${this.endPattern}`; - const end = description.split(this.endPattern)[1]; - return `${start}${mid}${end}`; - }; - this.updateContent = (description, content) => { - try { - if (description === undefined || content === undefined) { - return undefined; - } - const addedContent = this._addContent(description, content); - if (addedContent !== undefined) { - return addedContent; - } - return this._updateContent(description, content); - } - catch (error) { - (0, logger_1.logError)(`Error updating issue description: ${error}`); - return undefined; - } - }; - } - get _id() { - return `copilot-${this.id}`; - } - get startPattern() { - if (this.visibleContent) { - return ``; - } - return ``; - } - return `${this._id}-end -->`; - } +exports.getRecommendStepsPrompt = getRecommendStepsPrompt; +/** + * Prompt for recommending implementation steps from an issue (RecommendStepsUseCase). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. + +{{projectContextInstruction}} + +**Issue #{{issueNumber}} description:** +{{issueDescription}} + +Provide a numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed.`; +function getRecommendStepsPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + issueNumber: String(params.issueNumber), + issueDescription: params.issueDescription, + }); } -exports.ContentInterface = ContentInterface; /***/ }), -/***/ 9913: +/***/ 5725: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.IssueContentInterface = void 0; -const issue_repository_1 = __nccwpck_require__(57); -const logger_1 = __nccwpck_require__(8836); -const content_interface_1 = __nccwpck_require__(6365); -class IssueContentInterface extends content_interface_1.ContentInterface { - constructor() { - super(...arguments); - this.issueRepository = new issue_repository_1.IssueRepository(); - this.internalGetter = async (execution) => { - try { - let number = -1; - if (execution.isSingleAction) { - number = execution.issueNumber; - } - else if (execution.isIssue) { - number = execution.issue.number; - } - else if (execution.isPullRequest) { - number = execution.pullRequest.number; - } - else if (execution.isPush) { - number = execution.issueNumber; - } - else { - return undefined; - } - const description = await this.issueRepository.getDescription(execution.owner, execution.repo, number, execution.tokens.token); - return this.getContent(description); - } - catch (error) { - (0, logger_1.logError)(`Error reading issue configuration: ${error}`); - throw error; - } - }; - this.internalUpdate = async (execution, content) => { - try { - let number = -1; - if (execution.isSingleAction) { - if (execution.isIssue) { - number = execution.issue.number; - } - else if (execution.isPullRequest) { - number = execution.pullRequest.number; - } - else if (execution.isPush) { - number = execution.issueNumber; - } - else { - number = execution.singleAction.issue; - } - } - else if (execution.isIssue) { - number = execution.issue.number; - } - else if (execution.isPullRequest) { - number = execution.pullRequest.number; - } - else if (execution.isPush) { - number = execution.issueNumber; - } - else { - return undefined; - } - const description = await this.issueRepository.getDescription(execution.owner, execution.repo, number, execution.tokens.token); - const updated = this.updateContent(description, content); - if (updated === undefined) { - return undefined; - } - await this.issueRepository.updateDescription(execution.owner, execution.repo, number, updated, execution.tokens.token); - return updated; - } - catch (error) { - (0, logger_1.logError)(`Error reading issue configuration: ${error}`); - throw error; - } - }; - } +exports.getThinkPrompt = getThinkPrompt; +/** + * Prompt for the Think use case (answer to @mention in issue/PR comment). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are a helpful assistant. Answer the following question concisely, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. + +{{projectContextInstruction}} +{{contextBlock}}Question: {{question}}`; +function getThinkPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + contextBlock: params.contextBlock, + question: params.question, + }); +} + + +/***/ }), + +/***/ 1482: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getUpdatePullRequestDescriptionPrompt = getUpdatePullRequestDescriptionPrompt; +/** + * Prompt for generating PR description from issue and diff (UpdatePullRequestDescriptionUseCase). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. + +{{projectContextInstruction}} + +**Branches:** +- **Base (target) branch:** \`{{baseBranch}}\` +- **Head (source) branch:** \`{{headBranch}}\` + +**Instructions:** +1. Read the pull request template file: \`.github/pull_request_template.md\`. Use its structure (headings, bullet lists, separators) as the skeleton for your output. The checkboxes in the template are **indicative only**: you may check the ones that apply based on the project and the diff, define different or fewer checkboxes if that fits better, or omit a section entirely if it does not apply. +2. Get the full diff by running: \`git diff {{baseBranch}}..{{headBranch}}\` (or \`git diff {{baseBranch}}...{{headBranch}}\` for merge-base). Use the diff to understand what changed. +3. Use the issue description below for context and intent. +4. Fill each section of the template with concrete content derived from the diff and the issue. Keep the same markdown structure (headings, horizontal rules). For checkbox sections (e.g. Test Coverage, Deployment Notes, Security): use the template's options as guidance; check or add only the items that apply, or skip the section if it does not apply. + - **Summary:** brief explanation of what the PR does and why (intent, not implementation details). + - **Related Issues:** include \`Closes #{{issueNumber}}\` and "Related to #" only if relevant. + - **Scope of Changes:** use Added / Updated / Removed / Refactored with short bullet points (high level, not file-by-file). + - **Technical Details:** important decisions, trade-offs, or non-obvious aspects. + - **How to Test:** steps a reviewer can follow (infer from the changes when possible). + - **Test Coverage / Deployment / Security / Performance / Checklist:** treat checkboxes as indicative; check the ones that apply from the diff and project context, or omit the section if it does not apply. + - **Breaking Changes:** list any, or "None". + - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. +5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. + +**Issue description:** +{{issueDescription}} + +Output only the filled template content (the PR description body), starting with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks or add extra commentary.`; +function getUpdatePullRequestDescriptionPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + baseBranch: params.baseBranch, + headBranch: params.headBranch, + issueNumber: String(params.issueNumber), + issueDescription: params.issueDescription, + }); } -exports.IssueContentInterface = IssueContentInterface; /***/ }), -/***/ 4509: +/***/ 1762: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.ConfigurationHandler = void 0; -const config_1 = __nccwpck_require__(1106); -const logger_1 = __nccwpck_require__(8836); -const issue_content_interface_1 = __nccwpck_require__(9913); -/** Keys that must be preserved from stored config when current has undefined (e.g. when branch already existed). */ -const CONFIG_KEYS_TO_PRESERVE = [ - 'parentBranch', - 'workingBranch', - 'releaseBranch', - 'hotfixBranch', - 'hotfixOriginBranch', - 'branchType', -]; -class ConfigurationHandler extends issue_content_interface_1.IssueContentInterface { - constructor() { - super(...arguments); - this.update = async (execution) => { - try { - const current = execution.currentConfiguration; - const payload = { - branchType: current.branchType, - releaseBranch: current.releaseBranch, - workingBranch: current.workingBranch, - parentBranch: current.parentBranch, - hotfixOriginBranch: current.hotfixOriginBranch, - hotfixBranch: current.hotfixBranch, - results: current.results, - branchConfiguration: current.branchConfiguration, - }; - const storedRaw = await this.internalGetter(execution); - if (storedRaw != null && storedRaw.trim().length > 0) { - try { - const stored = JSON.parse(storedRaw); - for (const key of CONFIG_KEYS_TO_PRESERVE) { - if (payload[key] === undefined && stored[key] !== undefined) { - payload[key] = stored[key]; - } - } - } - catch { - /* ignore parse errors, save current as-is */ - } - } - return await this.internalUpdate(execution, JSON.stringify(payload, null, 4)); - } - catch (error) { - (0, logger_1.logError)(`Error updating issue description: ${error}`); - return undefined; - } - }; - this.get = async (execution) => { - try { - const config = await this.internalGetter(execution); - if (config === undefined) { - return undefined; - } - const branchConfig = JSON.parse(config); - return new config_1.Config(branchConfig); - } - catch (error) { - (0, logger_1.logError)(`Error reading issue configuration: ${error}`); - throw error; - } - }; - } - get id() { - return 'configuration'; - } - get visibleContent() { - return false; - } +exports.getUserRequestPrompt = getUserRequestPrompt; +/** + * Prompt for the Do user request use case (generic "do this" in repo). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are in the repository workspace. The user has asked you to do something. Perform their request by editing files and running commands directly in the workspace. Do not output diffs for someone else to apply. + +{{projectContextInstruction}} + +**Repository context:** +- Owner: {{owner}} +- Repository: {{repo}} +- Branch (head): {{headBranch}} +- Base branch: {{baseBranch}} +- Issue number: {{issueNumber}} + +**User request:** +""" +{{userComment}} +""" + +**Rules:** +1. Apply all changes directly in the workspace (edit files, run commands). +2. If the project has standard checks (build, test, lint), run them and ensure they pass when relevant. +3. Reply briefly confirming what you did.`; +function getUserRequestPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, params); } -exports.ConfigurationHandler = ConfigurationHandler; /***/ }), @@ -52681,6 +53413,7 @@ const issue_repository_1 = __nccwpck_require__(57); const branch_repository_1 = __nccwpck_require__(7701); const pull_request_repository_1 = __nccwpck_require__(634); const ai_repository_1 = __nccwpck_require__(8307); +const prompts_1 = __nccwpck_require__(5554); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const PROGRESS_RESPONSE_SCHEMA = { type: 'object', @@ -52786,7 +53519,13 @@ class CheckProgressUseCase { // Get development (parent) branch – we pass this so the OpenCode agent can compute the diff const developmentBranch = param.branches.development || 'develop'; (0, logger_1.logInfo)(`📦 Progress will be assessed from workspace diff: base branch "${developmentBranch}", current branch "${branch}" (OpenCode agent will run git diff).`); - const prompt = this.buildProgressPrompt(issueNumber, issueDescription, branch, developmentBranch); + const prompt = (0, prompts_1.getCheckProgressPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + issueNumber: String(issueNumber), + issueDescription, + baseBranch: developmentBranch, + currentBranch: branch, + }); (0, logger_1.logInfo)('🤖 Analyzing progress using OpenCode Plan agent...'); const attemptResult = await this.fetchProgressAttempt(param.ai, prompt); const progress = attemptResult.progress; @@ -52902,31 +53641,6 @@ class CheckProgressUseCase { : ''; return { progress, summary, reasoning, remaining }; } - /** - * Builds the progress prompt for the OpenCode agent. We do not send the diff from our side: - * we tell the agent the base (parent) branch and current branch so it can run `git diff` - * in the workspace and compute the full diff itself. - */ - buildProgressPrompt(issueNumber, issueDescription, currentBranch, baseBranch) { - return `You are in the repository workspace. Assess the progress of issue #${issueNumber} using the full diff between the base (parent) branch and the current branch. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Branches:** -- **Base (parent) branch:** \`${baseBranch}\` -- **Current branch:** \`${currentBranch}\` - -**Instructions:** -1. Get the full diff by running: \`git diff ${baseBranch}..${currentBranch}\` (or \`git diff ${baseBranch}...${currentBranch}\` for merge-base). If you cannot run shell commands, use whatever workspace tools you have to inspect changes between these branches. -2. Optionally confirm the current branch with \`git branch --show-current\` if needed. -3. Based on the full diff and the issue description below, assess completion progress (0-100%) and write a short summary. -4. If progress is below 100%, add a "remaining" field with a short description of what is left to do to complete the task (e.g. missing implementation, tests, docs). Omit "remaining" or leave empty when progress is 100%. - -**Issue description:** -${issueDescription} - -Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" }.`; - } /** * Returns true if the reasoning text looks truncated (e.g. ends with ":" or trailing spaces, * or no sentence-ending punctuation), so we can append a note in the comment. @@ -53500,11 +54214,12 @@ exports.PublishGithubActionUseCase = PublishGithubActionUseCase; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.RecommendStepsUseCase = void 0; const result_1 = __nccwpck_require__(7305); -const logger_1 = __nccwpck_require__(8836); -const task_emoji_1 = __nccwpck_require__(9785); -const issue_repository_1 = __nccwpck_require__(57); const ai_repository_1 = __nccwpck_require__(8307); +const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); +const logger_1 = __nccwpck_require__(8836); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); +const task_emoji_1 = __nccwpck_require__(9785); class RecommendStepsUseCase { constructor() { this.taskId = 'RecommendStepsUseCase'; @@ -53544,14 +54259,11 @@ class RecommendStepsUseCase { })); return results; } - const prompt = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Issue #${issueNumber} description:** -${issueDescription} - -Provide a numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed.`; + const prompt = (0, prompts_1.getRecommendStepsPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + issueNumber: String(issueNumber), + issueDescription, + }); (0, logger_1.logInfo)(`🤖 Recommending steps using OpenCode Plan agent...`); const response = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt); const steps = typeof response === 'string' @@ -54610,6 +55322,7 @@ function canRunDoUserRequest(payload) { */ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildBugbotFixIntentPrompt = buildBugbotFixIntentPrompt; +const prompts_1 = __nccwpck_require__(5554); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const sanitize_user_comment_for_prompt_1 = __nccwpck_require__(3514); const MAX_TITLE_LENGTH = 200; @@ -54635,24 +55348,12 @@ function buildBugbotFixIntentPrompt(userComment, unresolvedFindings, parentComme : ''; })() : ''; - return `You are analyzing a user comment on an issue or pull request to decide whether they are asking to fix one or more reported code findings (bugs, vulnerabilities, or quality issues). - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**List of unresolved findings (id, title, and optional file/line/description):** -${findingsBlock} -${parentBlock} -**User comment:** -""" -${(0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment)} -""" - -**Your task:** Decide: -1. Is this comment clearly a request to fix one or more of the findings above? (e.g. "fix it", "arreglalo", "fix this", "fix all", "fix vulnerability X", "corrige", "fix the bug in src/foo.ts"). If the user is asking a question, discussing something else, or the intent is ambiguous, set \`is_fix_request\` to false. -2. If it is a fix request, which finding ids should be fixed? Return their exact ids in \`target_finding_ids\`. If the user says "fix all" or equivalent, include every id from the list above. If they refer to a specific finding (e.g. by replying to a comment that contains one finding), return only that finding's id. Use only ids that appear in the list above. -3. Is the user asking to perform some other change or task in the repo? (e.g. "add a test for X", "refactor this", "implement feature Y", "haz que Z"). If yes, set \`is_do_request\` to true. Set false for pure questions or when the only intent is to fix the listed findings. - -Respond with a JSON object: \`is_fix_request\` (boolean), \`target_finding_ids\` (array of strings; empty when \`is_fix_request\` is false), and \`is_do_request\` (boolean).`; + return (0, prompts_1.getBugbotFixIntentPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + findingsBlock, + parentBlock, + userComment: (0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment), + }); } @@ -54667,6 +55368,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.MAX_FINDING_BODY_LENGTH = void 0; exports.truncateFindingBody = truncateFindingBody; exports.buildBugbotFixPrompt = buildBugbotFixPrompt; +const prompts_1 = __nccwpck_require__(5554); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const sanitize_user_comment_for_prompt_1 = __nccwpck_require__(3514); /** Maximum characters for a single finding's full comment body to avoid prompt bloat and token limits. */ @@ -54711,34 +55413,19 @@ function buildBugbotFixPrompt(param, context, targetFindingIds, userComment, ver const verifyBlock = verifyCommands.length > 0 ? `\n**Verify commands (run these in the workspace in order and only consider the fix successful if all pass):**\n${verifyCommands.map((c) => `- \`${String(c).replace(/`/g, "\\`")}\``).join("\n")}\n` : "\n**Verify:** Run any standard project checks (e.g. build, test, lint) that exist in this repo and confirm they pass.\n"; - return `You are in the repository workspace. Your task is to fix the reported code findings (bugs, vulnerabilities, or quality issues) listed below, and only those. The user has explicitly requested these fixes. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Repository context:** -- Owner: ${owner} -- Repository: ${repo} -- Branch (head): ${headBranch} -- Base branch: ${baseBranch} -- Issue number: ${issueNumber} -${prNumber != null ? `- Pull request number: ${prNumber}` : ""} - -**Findings to fix (do not change code unrelated to these):** -${findingsBlock} - -**User request:** -""" -${(0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment)} -""" - -**Rules:** -1. Fix only the problems described in the findings above. Do not refactor or change other code except as strictly necessary for the fix. -2. You may add or update tests only to validate that the fix is correct. -3. After applying changes, run the verify commands (or standard build/test/lint) and ensure they all pass. If they fail, adjust the fix until they pass. -4. Apply all changes directly in the workspace (edit files, run commands). Do not output diffs for someone else to apply. -${verifyBlock} - -Once the fixes are applied and the verify commands pass, reply briefly confirming what was fixed and that checks passed.`; + const prNumberLine = prNumber != null ? `- Pull request number: ${prNumber}` : ""; + return (0, prompts_1.getBugbotFixPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + owner, + repo, + headBranch, + baseBranch, + issueNumber: String(issueNumber), + prNumberLine, + findingsBlock, + userComment: (0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment), + verifyBlock, + }); } @@ -54757,13 +55444,14 @@ Once the fixes are applied and the verify commands pass, reply briefly confirmin */ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildBugbotPrompt = buildBugbotPrompt; +const prompts_1 = __nccwpck_require__(5554); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); +const MAX_IGNORE_BLOCK_LENGTH = 2000; function buildBugbotPrompt(param, context) { const headBranch = param.commit.branch; const baseBranch = param.currentConfiguration.parentBranch ?? param.branches.development ?? 'develop'; const previousBlock = context.previousFindingsBlock; const ignorePatterns = param.ai?.getAiIgnoreFiles?.() ?? []; - const MAX_IGNORE_BLOCK_LENGTH = 2000; const ignoreBlock = ignorePatterns.length > 0 ? (() => { const raw = ignorePatterns.join(", "); @@ -54773,22 +55461,16 @@ function buildBugbotPrompt(param, context) { return `\n**Files to ignore:** Do not report findings in files or paths matching these patterns: ${truncated}.`; })() : ""; - return `You are analyzing the latest code changes for potential bugs and issues. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Repository context:** -- Owner: ${param.owner} -- Repository: ${param.repo} -- Branch (head): ${headBranch} -- Base branch: ${baseBranch} -- Issue number: ${param.issueNumber} -${ignoreBlock} - -**Your task 1 (new/current problems):** Determine what has changed in the branch "${headBranch}" compared to "${baseBranch}" (you must compute or obtain the diff yourself using the repository context above). Then identify potential bugs, logic errors, security issues, and code quality problems. Be strict and descriptive. One finding per distinct problem. Return them in the \`findings\` array (each with id, title, description; optionally file, line, severity, suggestion). Only include findings in files that are not in the ignore list above. -${previousBlock} - -**Output:** Return a JSON object with: "findings" (array of new/current problems from task 1), and if we gave you previously reported issues above, "resolved_finding_ids" (array of those ids that are now fixed or no longer apply, as per task 2).`; + return (0, prompts_1.getBugbotPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + owner: param.owner, + repo: param.repo, + headBranch, + baseBranch, + issueNumber: String(param.issueNumber), + ignoreBlock, + previousBlock, + }); } @@ -56047,39 +56729,13 @@ exports.NotifyNewCommitOnIssueUseCase = NotifyNewCommitOnIssueUseCase; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.DoUserRequestUseCase = void 0; const ai_repository_1 = __nccwpck_require__(8307); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const task_emoji_1 = __nccwpck_require__(9785); const result_1 = __nccwpck_require__(7305); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const sanitize_user_comment_for_prompt_1 = __nccwpck_require__(3514); const TASK_ID = "DoUserRequestUseCase"; -function buildUserRequestPrompt(execution, userComment) { - const headBranch = execution.commit.branch; - const baseBranch = execution.currentConfiguration.parentBranch ?? execution.branches.development ?? "develop"; - const issueNumber = execution.issueNumber; - const owner = execution.owner; - const repo = execution.repo; - return `You are in the repository workspace. The user has asked you to do something. Perform their request by editing files and running commands directly in the workspace. Do not output diffs for someone else to apply. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Repository context:** -- Owner: ${owner} -- Repository: ${repo} -- Branch (head): ${headBranch} -- Base branch: ${baseBranch} -- Issue number: ${issueNumber} - -**User request:** -""" -${(0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment)} -""" - -**Rules:** -1. Apply all changes directly in the workspace (edit files, run commands). -2. If the project has standard checks (build, test, lint), run them and ensure they pass when relevant. -3. Reply briefly confirming what you did.`; -} class DoUserRequestUseCase { constructor() { this.taskId = TASK_ID; @@ -56098,7 +56754,16 @@ class DoUserRequestUseCase { (0, logger_1.logInfo)("No user comment; skipping user request."); return results; } - const prompt = buildUserRequestPrompt(execution, userComment); + const baseBranch = execution.currentConfiguration.parentBranch ?? execution.branches.development ?? "develop"; + const prompt = (0, prompts_1.getUserRequestPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + owner: execution.owner, + repo: execution.repo, + headBranch: execution.commit.branch, + baseBranch, + issueNumber: String(execution.issueNumber), + userComment: (0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment), + }); (0, logger_1.logInfo)("Running OpenCode build agent to perform user request (changes applied in workspace)."); const response = await this.aiRepository.copilotMessage(execution.ai, prompt); if (!response?.text) { @@ -56612,6 +57277,7 @@ exports.ThinkUseCase = void 0; const result_1 = __nccwpck_require__(7305); const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); class ThinkUseCase { @@ -56684,10 +57350,11 @@ class ThinkUseCase { const contextBlock = issueDescription ? `\n\nContext (issue #${issueNumberForContext} description):\n${issueDescription}\n\n` : '\n\n'; - const prompt = `You are a helpful assistant. Answer the following question concisely, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} -${contextBlock}Question: ${question}`; + const prompt = (0, prompts_1.getThinkPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + contextBlock, + question, + }); const response = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.THINK_RESPONSE_SCHEMA, @@ -56879,6 +57546,7 @@ exports.AnswerIssueHelpUseCase = void 0; const result_1 = __nccwpck_require__(7305); const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const task_emoji_1 = __nccwpck_require__(9785); @@ -56936,16 +57604,10 @@ class AnswerIssueHelpUseCase { return results; } (0, logger_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(this.taskId)} Posting initial help reply for question/help issue #${issueNumber}.`); - const prompt = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. Use the project context when relevant. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Issue description (user's question or request):** -""" -${description} -""" - -Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; + const prompt = (0, prompts_1.getAnswerIssueHelpPrompt)({ + description, + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + }); const response = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.THINK_RESPONSE_SCHEMA, @@ -58314,6 +58976,7 @@ exports.CheckIssueCommentLanguageUseCase = void 0; const result_1 = __nccwpck_require__(7305); const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const task_emoji_1 = __nccwpck_require__(9785); class CheckIssueCommentLanguageUseCase { @@ -58338,17 +59001,7 @@ If you'd like this comment to be translated again, please delete the entire comm return results; } const locale = param.locale.issue; - let prompt = ` - You are a helpful assistant that checks if the text is written in ${locale}. - - Instructions: - 1. Analyze the provided text - 2. If the text is written in ${locale}, respond with exactly "done" - 3. If the text is written in any other language, respond with exactly "must_translate" - 4. Do not provide any explanation or additional text - - The text is: ${commentBody} - `; + let prompt = (0, prompts_1.getCheckCommentLanguagePrompt)({ locale, commentBody }); const checkResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.LANGUAGE_CHECK_RESPONSE_SCHEMA, @@ -58367,16 +59020,7 @@ If you'd like this comment to be translated again, please delete the entire comm })); return results; } - prompt = ` -You are a helpful assistant that translates the text to ${locale}. - -Instructions: -1. Translate the text to ${locale} -2. Put the translated text in the translatedText field -3. If you cannot translate (e.g. ambiguous or invalid input), set translatedText to empty string and explain in reason - -The text to translate is: ${commentBody} - `; + prompt = (0, prompts_1.getTranslateCommentPrompt)({ locale, commentBody }); const translationResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.TRANSLATION_RESPONSE_SCHEMA, @@ -58803,6 +59447,7 @@ const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); const project_repository_1 = __nccwpck_require__(7917); const pull_request_repository_1 = __nccwpck_require__(634); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const task_emoji_1 = __nccwpck_require__(9785); @@ -58859,7 +59504,13 @@ class UpdatePullRequestDescriptionUseCase { })); return result; } - const prompt = this.buildPrDescriptionPrompt(param.issueNumber, issueDescription, headBranch, baseBranch); + const prompt = (0, prompts_1.getUpdatePullRequestDescriptionPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + baseBranch, + headBranch, + issueNumber: String(param.issueNumber), + issueDescription, + }); const agentResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt); const prBody = typeof agentResponse === 'string' ? agentResponse @@ -58892,41 +59543,6 @@ class UpdatePullRequestDescriptionUseCase { } return result; } - /** - * Builds the PR description prompt. We do not send the diff from our side: - * we pass the base and head branch so the OpenCode agent can run `git diff` - * in the workspace. The agent must read the repo's PR template and fill it - * with the same structure (sections, headings, checkboxes). - */ - buildPrDescriptionPrompt(issueNumber, issueDescription, headBranch, baseBranch) { - return `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Branches:** -- **Base (target) branch:** \`${baseBranch}\` -- **Head (source) branch:** \`${headBranch}\` - -**Instructions:** -1. Read the pull request template file: \`.github/pull_request_template.md\`. Use its structure (headings, bullet lists, separators) as the skeleton for your output. The checkboxes in the template are **indicative only**: you may check the ones that apply based on the project and the diff, define different or fewer checkboxes if that fits better, or omit a section entirely if it does not apply. -2. Get the full diff by running: \`git diff ${baseBranch}..${headBranch}\` (or \`git diff ${baseBranch}...${headBranch}\` for merge-base). Use the diff to understand what changed. -3. Use the issue description below for context and intent. -4. Fill each section of the template with concrete content derived from the diff and the issue. Keep the same markdown structure (headings, horizontal rules). For checkbox sections (e.g. Test Coverage, Deployment Notes, Security): use the template's options as guidance; check or add only the items that apply, or skip the section if not relevant. - - **Summary:** brief explanation of what the PR does and why (intent, not implementation details). - - **Related Issues:** include \`Closes #${issueNumber}\` and "Related to #" only if relevant. - - **Scope of Changes:** use Added / Updated / Removed / Refactored with short bullet points (high level, not file-by-file). - - **Technical Details:** important decisions, trade-offs, or non-obvious aspects. - - **How to Test:** steps a reviewer can follow (infer from the changes when possible). - - **Test Coverage / Deployment / Security / Performance / Checklist:** treat checkboxes as indicative; check the ones that apply from the diff and project context, or omit the section if it does not apply. - - **Breaking Changes:** list any, or "None". - - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. -5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. - -**Issue description:** -${issueDescription} - -Output only the filled template content (the PR description body), starting with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks or add extra commentary.`; - } } exports.UpdatePullRequestDescriptionUseCase = UpdatePullRequestDescriptionUseCase; @@ -58943,6 +59559,7 @@ exports.CheckPullRequestCommentLanguageUseCase = void 0; const result_1 = __nccwpck_require__(7305); const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const task_emoji_1 = __nccwpck_require__(9785); class CheckPullRequestCommentLanguageUseCase { @@ -58967,17 +59584,7 @@ If you'd like this comment to be translated again, please delete the entire comm return results; } const locale = param.locale.pullRequest; - let prompt = ` - You are a helpful assistant that checks if the text is written in ${locale}. - - Instructions: - 1. Analyze the provided text - 2. If the text is written in ${locale}, respond with exactly "done" - 3. If the text is written in any other language, respond with exactly "must_translate" - 4. Do not provide any explanation or additional text - - The text is: ${commentBody} - `; + let prompt = (0, prompts_1.getCheckCommentLanguagePrompt)({ locale, commentBody }); const checkResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.LANGUAGE_CHECK_RESPONSE_SCHEMA, @@ -58996,16 +59603,7 @@ If you'd like this comment to be translated again, please delete the entire comm })); return results; } - prompt = ` -You are a helpful assistant that translates the text to ${locale}. - -Instructions: -1. Translate the text to ${locale} -2. Put the translated text in the translatedText field -3. If you cannot translate (e.g. ambiguous or invalid input), set translatedText to empty string and explain in reason - -The text to translate is: ${commentBody} - `; + prompt = (0, prompts_1.getTranslateCommentPrompt)({ locale, commentBody }); const translationResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.TRANSLATION_RESPONSE_SCHEMA, diff --git a/build/cli/src/data/repository/branch_compare_repository.d.ts b/build/cli/src/data/repository/branch_compare_repository.d.ts new file mode 100644 index 00000000..b5c22f18 --- /dev/null +++ b/build/cli/src/data/repository/branch_compare_repository.d.ts @@ -0,0 +1,43 @@ +import { Labels } from '../model/labels'; +import { SizeThresholds } from '../model/size_thresholds'; +export interface BranchComparisonFile { + filename: string; + status: string; + additions: number; + deletions: number; + changes: number; + blobUrl: string; + rawUrl: string; + contentsUrl: string; + patch: string | undefined; +} +export interface BranchComparisonCommit { + sha: string; + message: string; + author: { + name: string; + email: string; + date: string; + }; + date: string; +} +export interface BranchComparison { + aheadBy: number; + behindBy: number; + totalCommits: number; + files: BranchComparisonFile[]; + commits: BranchComparisonCommit[]; +} +export interface SizeCategoryResult { + size: string; + githubSize: string; + reason: string; +} +/** + * Repository for comparing branches and computing size categories. + * Isolated to allow unit tests with mocked Octokit and pure size logic. + */ +export declare class BranchCompareRepository { + getChanges: (owner: string, repository: string, head: string, base: string, token: string) => Promise; + getSizeCategoryAndReason: (owner: string, repository: string, head: string, base: string, sizeThresholds: SizeThresholds, labels: Labels, token: string) => Promise; +} diff --git a/build/cli/src/data/repository/branch_repository.d.ts b/build/cli/src/data/repository/branch_repository.d.ts index e8965846..770debfb 100644 --- a/build/cli/src/data/repository/branch_repository.d.ts +++ b/build/cli/src/data/repository/branch_repository.d.ts @@ -1,8 +1,16 @@ -import { Execution } from "../model/execution"; +import { Execution } from '../model/execution'; import { Labels } from '../model/labels'; -import { Result } from "../model/result"; +import { Result } from '../model/result'; import { SizeThresholds } from '../model/size_thresholds'; +/** + * Facade for branch-related operations. Delegates to focused repositories + * (GitCli, Workflow, Merge, BranchCompare) for testability. + */ export declare class BranchRepository { + private readonly gitCliRepository; + private readonly workflowRepository; + private readonly mergeRepository; + private readonly branchCompareRepository; fetchRemoteBranches: () => Promise; getLatestTag: () => Promise; getCommitTag: (latestTag: string | undefined) => Promise; @@ -27,35 +35,6 @@ export declare class BranchRepository { getListOfBranches: (owner: string, repository: string, token: string) => Promise; executeWorkflow: (owner: string, repository: string, branch: string, workflow: string, inputs: Record, token: string) => Promise>; mergeBranch: (owner: string, repository: string, head: string, base: string, timeout: number, token: string) => Promise; - getChanges: (owner: string, repository: string, head: string, base: string, token: string) => Promise<{ - aheadBy: number; - behindBy: number; - totalCommits: number; - files: { - filename: string; - status: "modified" | "added" | "removed" | "renamed" | "copied" | "changed" | "unchanged"; - additions: number; - deletions: number; - changes: number; - blobUrl: string; - rawUrl: string; - contentsUrl: string; - patch: string | undefined; - }[]; - commits: { - sha: string; - message: string; - author: { - name?: string; - email?: string; - date?: string; - }; - date: string; - }[]; - }>; - getSizeCategoryAndReason: (owner: string, repository: string, head: string, base: string, sizeThresholds: SizeThresholds, labels: Labels, token: string) => Promise<{ - size: string; - githubSize: string; - reason: string; - }>; + getChanges: (owner: string, repository: string, head: string, base: string, token: string) => Promise; + getSizeCategoryAndReason: (owner: string, repository: string, head: string, base: string, sizeThresholds: SizeThresholds, labels: Labels, token: string) => Promise; } diff --git a/build/cli/src/data/repository/git_cli_repository.d.ts b/build/cli/src/data/repository/git_cli_repository.d.ts new file mode 100644 index 00000000..381035e0 --- /dev/null +++ b/build/cli/src/data/repository/git_cli_repository.d.ts @@ -0,0 +1,9 @@ +/** + * Repository for Git operations executed via CLI (exec). + * Isolated to allow unit tests with mocked @actions/exec and @actions/core. + */ +export declare class GitCliRepository { + fetchRemoteBranches: () => Promise; + getLatestTag: () => Promise; + getCommitTag: (latestTag: string | undefined) => Promise; +} diff --git a/build/cli/src/data/repository/merge_repository.d.ts b/build/cli/src/data/repository/merge_repository.d.ts new file mode 100644 index 00000000..a152b014 --- /dev/null +++ b/build/cli/src/data/repository/merge_repository.d.ts @@ -0,0 +1,8 @@ +import { Result } from '../model/result'; +/** + * Repository for merging branches (via PR or direct merge). + * Isolated to allow unit tests with mocked Octokit. + */ +export declare class MergeRepository { + mergeBranch: (owner: string, repository: string, head: string, base: string, timeout: number, token: string) => Promise; +} diff --git a/build/cli/src/data/repository/workflow_repository.d.ts b/build/cli/src/data/repository/workflow_repository.d.ts index aef0f4ba..68b41108 100644 --- a/build/cli/src/data/repository/workflow_repository.d.ts +++ b/build/cli/src/data/repository/workflow_repository.d.ts @@ -3,4 +3,5 @@ import { WorkflowRun } from "../model/workflow_run"; export declare class WorkflowRepository { getWorkflows: (params: Execution) => Promise; getActivePreviousRuns: (params: Execution) => Promise; + executeWorkflow: (owner: string, repository: string, branch: string, workflow: string, inputs: Record, token: string) => Promise>; } diff --git a/build/cli/src/prompts/answer_issue_help.d.ts b/build/cli/src/prompts/answer_issue_help.d.ts new file mode 100644 index 00000000..3ce4ee5b --- /dev/null +++ b/build/cli/src/prompts/answer_issue_help.d.ts @@ -0,0 +1,5 @@ +export type AnswerIssueHelpParams = { + description: string; + projectContextInstruction: string; +}; +export declare function getAnswerIssueHelpPrompt(params: AnswerIssueHelpParams): string; diff --git a/build/cli/src/prompts/bugbot.d.ts b/build/cli/src/prompts/bugbot.d.ts new file mode 100644 index 00000000..d6eae92e --- /dev/null +++ b/build/cli/src/prompts/bugbot.d.ts @@ -0,0 +1,11 @@ +export type BugbotParams = { + projectContextInstruction: string; + owner: string; + repo: string; + headBranch: string; + baseBranch: string; + issueNumber: string; + ignoreBlock: string; + previousBlock: string; +}; +export declare function getBugbotPrompt(params: BugbotParams): string; diff --git a/build/cli/src/prompts/bugbot_fix.d.ts b/build/cli/src/prompts/bugbot_fix.d.ts new file mode 100644 index 00000000..d316baaf --- /dev/null +++ b/build/cli/src/prompts/bugbot_fix.d.ts @@ -0,0 +1,13 @@ +export type BugbotFixParams = { + projectContextInstruction: string; + owner: string; + repo: string; + headBranch: string; + baseBranch: string; + issueNumber: string; + prNumberLine: string; + findingsBlock: string; + userComment: string; + verifyBlock: string; +}; +export declare function getBugbotFixPrompt(params: BugbotFixParams): string; diff --git a/build/cli/src/prompts/bugbot_fix_intent.d.ts b/build/cli/src/prompts/bugbot_fix_intent.d.ts new file mode 100644 index 00000000..8bd3c8fb --- /dev/null +++ b/build/cli/src/prompts/bugbot_fix_intent.d.ts @@ -0,0 +1,7 @@ +export type BugbotFixIntentParams = { + projectContextInstruction: string; + findingsBlock: string; + parentBlock: string; + userComment: string; +}; +export declare function getBugbotFixIntentPrompt(params: BugbotFixIntentParams): string; diff --git a/build/cli/src/prompts/check_comment_language.d.ts b/build/cli/src/prompts/check_comment_language.d.ts new file mode 100644 index 00000000..4f2949e8 --- /dev/null +++ b/build/cli/src/prompts/check_comment_language.d.ts @@ -0,0 +1,6 @@ +export type CheckCommentLanguageParams = { + locale: string; + commentBody: string; +}; +export declare function getCheckCommentLanguagePrompt(params: CheckCommentLanguageParams): string; +export declare function getTranslateCommentPrompt(params: CheckCommentLanguageParams): string; diff --git a/build/cli/src/prompts/check_progress.d.ts b/build/cli/src/prompts/check_progress.d.ts new file mode 100644 index 00000000..7fb5a036 --- /dev/null +++ b/build/cli/src/prompts/check_progress.d.ts @@ -0,0 +1,8 @@ +export type CheckProgressParams = { + projectContextInstruction: string; + issueNumber: string; + baseBranch: string; + currentBranch: string; + issueDescription: string; +}; +export declare function getCheckProgressPrompt(params: CheckProgressParams): string; diff --git a/build/cli/src/prompts/cli_do.d.ts b/build/cli/src/prompts/cli_do.d.ts new file mode 100644 index 00000000..d2cbd674 --- /dev/null +++ b/build/cli/src/prompts/cli_do.d.ts @@ -0,0 +1,5 @@ +export type CliDoParams = { + projectContextInstruction: string; + userPrompt: string; +}; +export declare function getCliDoPrompt(params: CliDoParams): string; diff --git a/build/cli/src/prompts/fill.d.ts b/build/cli/src/prompts/fill.d.ts new file mode 100644 index 00000000..311e1edc --- /dev/null +++ b/build/cli/src/prompts/fill.d.ts @@ -0,0 +1,5 @@ +/** + * Replaces {{paramName}} placeholders in a template with values from params. + * Missing keys are left as {{paramName}}. + */ +export declare function fillTemplate(template: string, params: Record): string; diff --git a/build/cli/src/prompts/index.d.ts b/build/cli/src/prompts/index.d.ts new file mode 100644 index 00000000..f6a6027b --- /dev/null +++ b/build/cli/src/prompts/index.d.ts @@ -0,0 +1,68 @@ +import type { AnswerIssueHelpParams } from './answer_issue_help'; +import type { ThinkParams } from './think'; +import type { UpdatePullRequestDescriptionParams } from './update_pull_request_description'; +import type { UserRequestParams } from './user_request'; +import type { RecommendStepsParams } from './recommend_steps'; +import type { CheckProgressParams } from './check_progress'; +import type { CheckCommentLanguageParams } from './check_comment_language'; +import type { CliDoParams } from './cli_do'; +import type { BugbotParams } from './bugbot'; +import type { BugbotFixParams } from './bugbot_fix'; +import type { BugbotFixIntentParams } from './bugbot_fix_intent'; +export { fillTemplate } from './fill'; +export { getAnswerIssueHelpPrompt } from './answer_issue_help'; +export type { AnswerIssueHelpParams } from './answer_issue_help'; +export { getThinkPrompt } from './think'; +export type { ThinkParams } from './think'; +export { getUpdatePullRequestDescriptionPrompt } from './update_pull_request_description'; +export type { UpdatePullRequestDescriptionParams } from './update_pull_request_description'; +export { getUserRequestPrompt } from './user_request'; +export type { UserRequestParams } from './user_request'; +export { getRecommendStepsPrompt } from './recommend_steps'; +export type { RecommendStepsParams } from './recommend_steps'; +export { getCheckProgressPrompt } from './check_progress'; +export type { CheckProgressParams } from './check_progress'; +export { getCheckCommentLanguagePrompt, getTranslateCommentPrompt, } from './check_comment_language'; +export type { CheckCommentLanguageParams } from './check_comment_language'; +export { getCliDoPrompt } from './cli_do'; +export type { CliDoParams } from './cli_do'; +export { getBugbotPrompt } from './bugbot'; +export type { BugbotParams } from './bugbot'; +export { getBugbotFixPrompt } from './bugbot_fix'; +export type { BugbotFixParams } from './bugbot_fix'; +export { getBugbotFixIntentPrompt } from './bugbot_fix_intent'; +export type { BugbotFixIntentParams } from './bugbot_fix_intent'; +/** Known prompt names for getPrompt() */ +export declare const PROMPT_NAMES: { + readonly ANSWER_ISSUE_HELP: "answer_issue_help"; + readonly THINK: "think"; + readonly UPDATE_PULL_REQUEST_DESCRIPTION: "update_pull_request_description"; + readonly USER_REQUEST: "user_request"; + readonly RECOMMEND_STEPS: "recommend_steps"; + readonly CHECK_PROGRESS: "check_progress"; + readonly CHECK_COMMENT_LANGUAGE: "check_comment_language"; + readonly TRANSLATE_COMMENT: "translate_comment"; + readonly CLI_DO: "cli_do"; + readonly BUGBOT: "bugbot"; + readonly BUGBOT_FIX: "bugbot_fix"; + readonly BUGBOT_FIX_INTENT: "bugbot_fix_intent"; +}; +export type PromptName = (typeof PROMPT_NAMES)[keyof typeof PROMPT_NAMES]; +type PromptParamsMap = { + [PROMPT_NAMES.ANSWER_ISSUE_HELP]: AnswerIssueHelpParams; + [PROMPT_NAMES.THINK]: ThinkParams; + [PROMPT_NAMES.UPDATE_PULL_REQUEST_DESCRIPTION]: UpdatePullRequestDescriptionParams; + [PROMPT_NAMES.USER_REQUEST]: UserRequestParams; + [PROMPT_NAMES.RECOMMEND_STEPS]: RecommendStepsParams; + [PROMPT_NAMES.CHECK_PROGRESS]: CheckProgressParams; + [PROMPT_NAMES.CHECK_COMMENT_LANGUAGE]: CheckCommentLanguageParams; + [PROMPT_NAMES.TRANSLATE_COMMENT]: CheckCommentLanguageParams; + [PROMPT_NAMES.CLI_DO]: CliDoParams; + [PROMPT_NAMES.BUGBOT]: BugbotParams; + [PROMPT_NAMES.BUGBOT_FIX]: BugbotFixParams; + [PROMPT_NAMES.BUGBOT_FIX_INTENT]: BugbotFixIntentParams; +}; +/** + * Returns a filled prompt by name. Params must match the prompt's expected keys. + */ +export declare function getPrompt(name: PromptName, params: PromptParamsMap[PromptName]): string; diff --git a/build/cli/src/prompts/recommend_steps.d.ts b/build/cli/src/prompts/recommend_steps.d.ts new file mode 100644 index 00000000..3a594ae0 --- /dev/null +++ b/build/cli/src/prompts/recommend_steps.d.ts @@ -0,0 +1,6 @@ +export type RecommendStepsParams = { + projectContextInstruction: string; + issueNumber: string; + issueDescription: string; +}; +export declare function getRecommendStepsPrompt(params: RecommendStepsParams): string; diff --git a/build/cli/src/prompts/think.d.ts b/build/cli/src/prompts/think.d.ts new file mode 100644 index 00000000..e7b2d14a --- /dev/null +++ b/build/cli/src/prompts/think.d.ts @@ -0,0 +1,6 @@ +export type ThinkParams = { + projectContextInstruction: string; + contextBlock: string; + question: string; +}; +export declare function getThinkPrompt(params: ThinkParams): string; diff --git a/build/cli/src/prompts/update_pull_request_description.d.ts b/build/cli/src/prompts/update_pull_request_description.d.ts new file mode 100644 index 00000000..536710ed --- /dev/null +++ b/build/cli/src/prompts/update_pull_request_description.d.ts @@ -0,0 +1,8 @@ +export type UpdatePullRequestDescriptionParams = { + projectContextInstruction: string; + baseBranch: string; + headBranch: string; + issueNumber: string; + issueDescription: string; +}; +export declare function getUpdatePullRequestDescriptionPrompt(params: UpdatePullRequestDescriptionParams): string; diff --git a/build/cli/src/prompts/user_request.d.ts b/build/cli/src/prompts/user_request.d.ts new file mode 100644 index 00000000..8e5600c7 --- /dev/null +++ b/build/cli/src/prompts/user_request.d.ts @@ -0,0 +1,10 @@ +export type UserRequestParams = { + projectContextInstruction: string; + owner: string; + repo: string; + headBranch: string; + baseBranch: string; + issueNumber: string; + userComment: string; +}; +export declare function getUserRequestPrompt(params: UserRequestParams): string; diff --git a/build/cli/src/usecase/actions/check_progress_use_case.d.ts b/build/cli/src/usecase/actions/check_progress_use_case.d.ts index 2c536409..671d3b84 100644 --- a/build/cli/src/usecase/actions/check_progress_use_case.d.ts +++ b/build/cli/src/usecase/actions/check_progress_use_case.d.ts @@ -13,12 +13,6 @@ export declare class CheckProgressUseCase implements ParamUseCase; - /** - * Builds the PR description prompt. We do not send the diff from our side: - * we pass the base and head branch so the OpenCode agent can run `git diff` - * in the workspace. The agent must read the repo's PR template and fill it - * with the same structure (sections, headings, checkboxes). - */ - private buildPrDescriptionPrompt; } diff --git a/build/github_action/index.js b/build/github_action/index.js index 267b2621..1f23b0f5 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -44684,7 +44684,7 @@ exports.AiRepository = AiRepository; /***/ }), -/***/ 7701: +/***/ 8224: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -44723,91 +44723,200 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.BranchRepository = void 0; -const core = __importStar(__nccwpck_require__(2186)); -const exec = __importStar(__nccwpck_require__(1514)); +exports.BranchCompareRepository = void 0; const github = __importStar(__nccwpck_require__(5438)); const logger_1 = __nccwpck_require__(8836); -const version_utils_1 = __nccwpck_require__(9887); -const result_1 = __nccwpck_require__(7305); -class BranchRepository { +/** + * Repository for comparing branches and computing size categories. + * Isolated to allow unit tests with mocked Octokit and pure size logic. + */ +class BranchCompareRepository { constructor() { - this.fetchRemoteBranches = async () => { - try { - (0, logger_1.logDebugInfo)('Fetching tags and forcing fetch...'); - await exec.exec('git', ['fetch', '--tags', '--force']); - (0, logger_1.logDebugInfo)('Fetching all remote branches with verbose output...'); - await exec.exec('git', ['fetch', '--all', '-v']); - (0, logger_1.logDebugInfo)('Successfully fetched all remote branches.'); - } - catch (error) { - core.setFailed(`Error fetching remote branches: ${error}`); - } - }; - this.getLatestTag = async () => { + this.getChanges = async (owner, repository, head, base, token) => { try { - (0, logger_1.logDebugInfo)('Fetching the latest tag...'); - await exec.exec('git', ['fetch', '--tags']); - const tags = []; - await exec.exec('git', ['tag', '--sort=-creatordate'], { - listeners: { - stdout: (data) => { - tags.push(...data.toString().split('\n').map((v) => { - return v.replace('v', ''); - })); - }, - }, - }); - const validTags = tags.filter(tag => /\d+\.\d+\.\d+$/.test(tag)); - if (validTags.length > 0) { - const latestTag = (0, version_utils_1.getLatestVersion)(validTags); - (0, logger_1.logDebugInfo)(`Latest tag: ${latestTag}`); - return latestTag; + const octokit = github.getOctokit(token); + (0, logger_1.logDebugInfo)(`Comparing branches: ${head} with ${base}`); + let headRef = `heads/${head}`; + if (head.indexOf('tags/') > -1) { + headRef = head; } - else { - (0, logger_1.logDebugInfo)('No valid tags found.'); - return undefined; + let baseRef = `heads/${base}`; + if (base.indexOf('tags/') > -1) { + baseRef = base; } + const { data: comparison } = await octokit.rest.repos.compareCommits({ + owner: owner, + repo: repository, + base: baseRef, + head: headRef, + }); + return { + aheadBy: comparison.ahead_by, + behindBy: comparison.behind_by, + totalCommits: comparison.total_commits, + files: (comparison.files || []).map(file => ({ + filename: file.filename, + status: file.status, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + changes: file.changes ?? 0, + blobUrl: file.blob_url, + rawUrl: file.raw_url, + contentsUrl: file.contents_url, + patch: file.patch, + })), + commits: comparison.commits.map(commit => { + const author = commit.commit.author; + return { + sha: commit.sha, + message: commit.commit.message, + author: { + name: author?.name ?? 'Unknown', + email: author?.email ?? 'unknown@example.com', + date: author?.date ?? new Date().toISOString(), + }, + date: author?.date ?? new Date().toISOString(), + }; + }), + }; } catch (error) { - core.setFailed(`Error fetching the latest tag: ${error}`); - return undefined; + (0, logger_1.logError)(`Error comparing branches: ${error}`); + throw error; } }; - this.getCommitTag = async (latestTag) => { + this.getSizeCategoryAndReason = async (owner, repository, head, base, sizeThresholds, labels, token) => { try { - if (!latestTag) { - core.setFailed('No LATEST_TAG found in the environment'); - return; + const headBranchChanges = await this.getChanges(owner, repository, head, base, token); + const totalChanges = headBranchChanges.files.reduce((sum, file) => sum + file.changes, 0); + const totalFiles = headBranchChanges.files.length; + const totalCommits = headBranchChanges.totalCommits; + let sizeCategory; + let githubSize; + let sizeReason; + if (totalChanges > sizeThresholds.xxl.lines || totalFiles > sizeThresholds.xxl.files || totalCommits > sizeThresholds.xxl.commits) { + sizeCategory = labels.sizeXxl; + githubSize = `XL`; + sizeReason = totalChanges > sizeThresholds.xxl.lines ? `More than ${sizeThresholds.xxl.lines} lines changed` : + totalFiles > sizeThresholds.xxl.files ? `More than ${sizeThresholds.xxl.files} files modified` : + `More than ${sizeThresholds.xxl.commits} commits`; } - let tagVersion; - if (latestTag.startsWith('v')) { - tagVersion = latestTag; + else if (totalChanges > sizeThresholds.xl.lines || totalFiles > sizeThresholds.xl.files || totalCommits > sizeThresholds.xl.commits) { + sizeCategory = labels.sizeXl; + githubSize = `XL`; + sizeReason = totalChanges > sizeThresholds.xl.lines ? `More than ${sizeThresholds.xl.lines} lines changed` : + totalFiles > sizeThresholds.xl.files ? `More than ${sizeThresholds.xl.files} files modified` : + `More than ${sizeThresholds.xl.commits} commits`; } - else { - tagVersion = `v${latestTag}`; + else if (totalChanges > sizeThresholds.l.lines || totalFiles > sizeThresholds.l.files || totalCommits > sizeThresholds.l.commits) { + sizeCategory = labels.sizeL; + githubSize = `L`; + sizeReason = totalChanges > sizeThresholds.l.lines ? `More than ${sizeThresholds.l.lines} lines changed` : + totalFiles > sizeThresholds.l.files ? `More than ${sizeThresholds.l.files} files modified` : + `More than ${sizeThresholds.l.commits} commits`; } - (0, logger_1.logDebugInfo)(`Fetching commit hash for the tag: ${tagVersion}`); - let commitOid = ''; - await exec.exec('git', ['rev-list', '-n', '1', tagVersion], { - listeners: { - stdout: (data) => { - commitOid = data.toString().trim(); - }, - }, - }); - if (commitOid) { - (0, logger_1.logDebugInfo)(`Commit tag: ${commitOid}`); - return commitOid; + else if (totalChanges > sizeThresholds.m.lines || totalFiles > sizeThresholds.m.files || totalCommits > sizeThresholds.m.commits) { + sizeCategory = labels.sizeM; + githubSize = `M`; + sizeReason = totalChanges > sizeThresholds.m.lines ? `More than ${sizeThresholds.m.lines} lines changed` : + totalFiles > sizeThresholds.m.files ? `More than ${sizeThresholds.m.files} files modified` : + `More than ${sizeThresholds.m.commits} commits`; + } + else if (totalChanges > sizeThresholds.s.lines || totalFiles > sizeThresholds.s.files || totalCommits > sizeThresholds.s.commits) { + sizeCategory = labels.sizeS; + githubSize = `S`; + sizeReason = totalChanges > sizeThresholds.s.lines ? `More than ${sizeThresholds.s.lines} lines changed` : + totalFiles > sizeThresholds.s.files ? `More than ${sizeThresholds.s.files} files modified` : + `More than ${sizeThresholds.s.commits} commits`; } else { - core.setFailed('No commit found for the tag'); + sizeCategory = labels.sizeXs; + githubSize = `XS`; + sizeReason = `Small changes (${totalChanges} lines, ${totalFiles} files)`; } + return { + size: sizeCategory, + githubSize: githubSize, + reason: sizeReason, + }; } catch (error) { - core.setFailed(`Error fetching the commit hash: ${error}`); + (0, logger_1.logError)(`Error comparing branches: ${error}`); + throw error; } - return undefined; + }; + } +} +exports.BranchCompareRepository = BranchCompareRepository; + + +/***/ }), + +/***/ 7701: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.BranchRepository = void 0; +const github = __importStar(__nccwpck_require__(5438)); +const logger_1 = __nccwpck_require__(8836); +const result_1 = __nccwpck_require__(7305); +const branch_compare_repository_1 = __nccwpck_require__(8224); +const git_cli_repository_1 = __nccwpck_require__(5617); +const merge_repository_1 = __nccwpck_require__(4015); +const workflow_repository_1 = __nccwpck_require__(779); +/** + * Facade for branch-related operations. Delegates to focused repositories + * (GitCli, Workflow, Merge, BranchCompare) for testability. + */ +class BranchRepository { + constructor() { + this.gitCliRepository = new git_cli_repository_1.GitCliRepository(); + this.workflowRepository = new workflow_repository_1.WorkflowRepository(); + this.mergeRepository = new merge_repository_1.MergeRepository(); + this.branchCompareRepository = new branch_compare_repository_1.BranchCompareRepository(); + this.fetchRemoteBranches = async () => { + return this.gitCliRepository.fetchRemoteBranches(); + }; + this.getLatestTag = async () => { + return this.gitCliRepository.getLatestTag(); + }; + this.getCommitTag = async (latestTag) => { + return this.gitCliRepository.getCommitTag(latestTag); }; /** * Returns replaced branch (if any). @@ -45084,297 +45193,25 @@ class BranchRepository { return allBranches; }; this.executeWorkflow = async (owner, repository, branch, workflow, inputs, token) => { - const octokit = github.getOctokit(token); - return octokit.rest.actions.createWorkflowDispatch({ - owner: owner, - repo: repository, - workflow_id: workflow, - ref: branch, - inputs: inputs - }); + return this.workflowRepository.executeWorkflow(owner, repository, branch, workflow, inputs, token); }; this.mergeBranch = async (owner, repository, head, base, timeout, token) => { - const result = []; - try { - const octokit = github.getOctokit(token); - (0, logger_1.logDebugInfo)(`Creating merge from ${head} into ${base}`); - // Build PR body with commit list - const prBody = `🚀 Automated Merge - -This PR merges **${head}** into **${base}**. - -**Commits included:**`; - // We need PAT for creating PR to ensure it can trigger workflows - const { data: pullRequest } = await octokit.rest.pulls.create({ - owner: owner, - repo: repository, - head: head, - base: base, - title: `Merge ${head} into ${base}`, - body: prBody, - }); - (0, logger_1.logDebugInfo)(`Pull request #${pullRequest.number} created, getting commits...`); - // Get all commits in the PR - const { data: commits } = await octokit.rest.pulls.listCommits({ - owner: owner, - repo: repository, - pull_number: pullRequest.number - }); - const commitMessages = commits.map(commit => commit.commit.message); - (0, logger_1.logDebugInfo)(`Found ${commitMessages.length} commits in PR`); - // Update PR with commit list and footer - await octokit.rest.pulls.update({ - owner: owner, - repo: repository, - pull_number: pullRequest.number, - body: prBody + '\n' + commitMessages.map(msg => `- ${msg}`).join('\n') + - '\n\nThis PR was automatically created by [`copilot`](https://github.com/vypdev/copilot).' - }); - const iteration = 10; - if (timeout > iteration) { - // Wait for checks to complete - can use regular token for reading checks - let checksCompleted = false; - let attempts = 0; - const maxAttempts = timeout > iteration ? Math.floor(timeout / iteration) : iteration; - while (!checksCompleted && attempts < maxAttempts) { - const { data: checkRuns } = await octokit.rest.checks.listForRef({ - owner: owner, - repo: repository, - ref: head, - }); - // Get commit status checks for the PR head commit - const { data: commitStatus } = await octokit.rest.repos.getCombinedStatusForRef({ - owner: owner, - repo: repository, - ref: head - }); - (0, logger_1.logDebugInfo)(`Combined status state: ${commitStatus.state}`); - (0, logger_1.logDebugInfo)(`Number of check runs: ${checkRuns.check_runs.length}`); - // If there are check runs, prioritize those over status checks - if (checkRuns.check_runs.length > 0) { - const pendingCheckRuns = checkRuns.check_runs.filter(check => check.status !== 'completed'); - if (pendingCheckRuns.length === 0) { - checksCompleted = true; - (0, logger_1.logDebugInfo)('All check runs have completed.'); - // Verify if all checks passed - const failedChecks = checkRuns.check_runs.filter(check => check.conclusion === 'failure'); - if (failedChecks.length > 0) { - throw new Error(`Checks failed: ${failedChecks.map(check => check.name).join(', ')}`); - } - } - else { - (0, logger_1.logDebugInfo)(`Waiting for ${pendingCheckRuns.length} check runs to complete:`); - pendingCheckRuns.forEach(check => { - (0, logger_1.logDebugInfo)(` - ${check.name} (Status: ${check.status})`); - }); - await new Promise(resolve => setTimeout(resolve, iteration * 1000)); - attempts++; - continue; - } - } - else { - // Fall back to status checks if no check runs exist - const pendingChecks = commitStatus.statuses.filter(status => { - (0, logger_1.logDebugInfo)(`Status check: ${status.context} (State: ${status.state})`); - return status.state === 'pending'; - }); - if (pendingChecks.length === 0) { - checksCompleted = true; - (0, logger_1.logDebugInfo)('All status checks have completed.'); - } - else { - (0, logger_1.logDebugInfo)(`Waiting for ${pendingChecks.length} status checks to complete:`); - pendingChecks.forEach(check => { - (0, logger_1.logDebugInfo)(` - ${check.context} (State: ${check.state})`); - }); - await new Promise(resolve => setTimeout(resolve, iteration * 1000)); - attempts++; - } - } - } - if (!checksCompleted) { - throw new Error('Timed out waiting for checks to complete'); - } - } - // Need PAT for merging to ensure it can trigger subsequent workflows - await octokit.rest.pulls.merge({ - owner: owner, - repo: repository, - pull_number: pullRequest.number, - merge_method: 'merge', - commit_title: `Merge ${head} into ${base}. Forced merge with PAT token.`, - }); - result.push(new result_1.Result({ - id: 'branch_repository', - success: true, - executed: true, - steps: [ - `The branch \`${head}\` was merged into \`${base}\`.`, - ], - })); - } - catch (error) { - (0, logger_1.logError)(`Error in PR workflow: ${error}`); - // If the PR workflow fails, we try to merge directly - need PAT for direct merge to ensure it can trigger workflows - try { - const octokit = github.getOctokit(token); - await octokit.rest.repos.merge({ - owner: owner, - repo: repository, - base: base, - head: head, - commit_message: `Forced merge of ${head} into ${base}. Automated merge with PAT token.`, - }); - result.push(new result_1.Result({ - id: 'branch_repository', - success: true, - executed: true, - steps: [ - `The branch \`${head}\` was merged into \`${base}\` using direct merge.`, - ], - })); - return result; - } - catch (directMergeError) { - (0, logger_1.logError)(`Error in direct merge attempt: ${directMergeError}`); - result.push(new result_1.Result({ - id: 'branch_repository', - success: false, - executed: true, - steps: [ - `Failed to merge branch \`${head}\` into \`${base}\`.`, - ], - })); - result.push(new result_1.Result({ - id: 'branch_repository', - success: false, - executed: true, - error: error, - })); - result.push(new result_1.Result({ - id: 'branch_repository', - success: false, - executed: true, - error: directMergeError, - })); - } - } - return result; - }; - this.getChanges = async (owner, repository, head, base, token) => { - const octokit = github.getOctokit(token); - try { - (0, logger_1.logDebugInfo)(`Comparing branches: ${head} with ${base}`); - let headRef = `heads/${head}`; - if (head.indexOf('tags/') > -1) { - headRef = head; - } - let baseRef = `heads/${base}`; - if (base.indexOf('tags/') > -1) { - baseRef = base; - } - const { data: comparison } = await octokit.rest.repos.compareCommits({ - owner: owner, - repo: repository, - base: baseRef, - head: headRef, - }); - return { - aheadBy: comparison.ahead_by, - behindBy: comparison.behind_by, - totalCommits: comparison.total_commits, - files: (comparison.files || []).map(file => ({ - filename: file.filename, - status: file.status, - additions: file.additions, - deletions: file.deletions, - changes: file.changes, - blobUrl: file.blob_url, - rawUrl: file.raw_url, - contentsUrl: file.contents_url, - patch: file.patch - })), - commits: comparison.commits.map(commit => ({ - sha: commit.sha, - message: commit.commit.message, - author: commit.commit.author || { name: 'Unknown', email: 'unknown@example.com', date: new Date().toISOString() }, - date: commit.commit.author?.date || new Date().toISOString() - })) - }; - } - catch (error) { - (0, logger_1.logError)(`Error comparing branches: ${error}`); - throw error; - } - }; - this.getSizeCategoryAndReason = async (owner, repository, head, base, sizeThresholds, labels, token) => { - try { - const headBranchChanges = await this.getChanges(owner, repository, head, base, token); - const totalChanges = headBranchChanges.files.reduce((sum, file) => sum + file.changes, 0); - const totalFiles = headBranchChanges.files.length; - const totalCommits = headBranchChanges.totalCommits; - let sizeCategory; - let githubSize; - let sizeReason; - if (totalChanges > sizeThresholds.xxl.lines || totalFiles > sizeThresholds.xxl.files || totalCommits > sizeThresholds.xxl.commits) { - sizeCategory = labels.sizeXxl; - githubSize = `XL`; - sizeReason = totalChanges > sizeThresholds.xxl.lines ? `More than ${sizeThresholds.xxl.lines} lines changed` : - totalFiles > sizeThresholds.xxl.files ? `More than ${sizeThresholds.xxl.files} files modified` : - `More than ${sizeThresholds.xxl.commits} commits`; - } - else if (totalChanges > sizeThresholds.xl.lines || totalFiles > sizeThresholds.xl.files || totalCommits > sizeThresholds.xl.commits) { - sizeCategory = labels.sizeXl; - githubSize = `XL`; - sizeReason = totalChanges > sizeThresholds.xl.lines ? `More than ${sizeThresholds.xl.lines} lines changed` : - totalFiles > sizeThresholds.xl.files ? `More than ${sizeThresholds.xl.files} files modified` : - `More than ${sizeThresholds.xl.commits} commits`; - } - else if (totalChanges > sizeThresholds.l.lines || totalFiles > sizeThresholds.l.files || totalCommits > sizeThresholds.l.commits) { - sizeCategory = labels.sizeL; - githubSize = `L`; - sizeReason = totalChanges > sizeThresholds.l.lines ? `More than ${sizeThresholds.l.lines} lines changed` : - totalFiles > sizeThresholds.l.files ? `More than ${sizeThresholds.l.files} files modified` : - `More than ${sizeThresholds.l.commits} commits`; - } - else if (totalChanges > sizeThresholds.m.lines || totalFiles > sizeThresholds.m.files || totalCommits > sizeThresholds.m.commits) { - sizeCategory = labels.sizeM; - githubSize = `M`; - sizeReason = totalChanges > sizeThresholds.m.lines ? `More than ${sizeThresholds.m.lines} lines changed` : - totalFiles > sizeThresholds.m.files ? `More than ${sizeThresholds.m.files} files modified` : - `More than ${sizeThresholds.m.commits} commits`; - } - else if (totalChanges > sizeThresholds.s.lines || totalFiles > sizeThresholds.s.files || totalCommits > sizeThresholds.s.commits) { - sizeCategory = labels.sizeS; - githubSize = `S`; - sizeReason = totalChanges > sizeThresholds.s.lines ? `More than ${sizeThresholds.s.lines} lines changed` : - totalFiles > sizeThresholds.s.files ? `More than ${sizeThresholds.s.files} files modified` : - `More than ${sizeThresholds.s.commits} commits`; - } - else { - sizeCategory = labels.sizeXs; - githubSize = `XS`; - sizeReason = `Small changes (${totalChanges} lines, ${totalFiles} files)`; - } - return { - size: sizeCategory, - githubSize: githubSize, - reason: sizeReason - }; - } - catch (error) { - (0, logger_1.logError)(`Error comparing branches: ${error}`); - throw error; - } - }; - } -} -exports.BranchRepository = BranchRepository; + return this.mergeRepository.mergeBranch(owner, repository, head, base, timeout, token); + }; + this.getChanges = async (owner, repository, head, base, token) => { + return this.branchCompareRepository.getChanges(owner, repository, head, base, token); + }; + this.getSizeCategoryAndReason = async (owner, repository, head, base, sizeThresholds, labels, token) => { + return this.branchCompareRepository.getSizeCategoryAndReason(owner, repository, head, base, sizeThresholds, labels, token); + }; + } +} +exports.BranchRepository = BranchRepository; /***/ }), -/***/ 57: +/***/ 5617: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -45413,31 +45250,165 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.IssueRepository = exports.PROGRESS_LABEL_PATTERN = void 0; +exports.GitCliRepository = void 0; const core = __importStar(__nccwpck_require__(2186)); -const github = __importStar(__nccwpck_require__(5438)); +const exec = __importStar(__nccwpck_require__(1514)); const logger_1 = __nccwpck_require__(8836); -const milestone_1 = __nccwpck_require__(2298); -/** Matches labels that are progress percentages (e.g. "0%", "85%"). Used for setProgressLabel and syncing. */ -exports.PROGRESS_LABEL_PATTERN = /^\d+%$/; -class IssueRepository { +const version_utils_1 = __nccwpck_require__(9887); +/** + * Repository for Git operations executed via CLI (exec). + * Isolated to allow unit tests with mocked @actions/exec and @actions/core. + */ +class GitCliRepository { constructor() { - this.updateTitleIssueFormat = async (owner, repository, version, issueTitle, issueNumber, branchManagementAlways, branchManagementEmoji, labels, token) => { + this.fetchRemoteBranches = async () => { try { - const octokit = github.getOctokit(token); - let emoji = '🤖'; - const branched = branchManagementAlways || labels.containsBranchedLabel; - if (labels.isHotfix && branched) { - emoji = `🔥${branchManagementEmoji}`; - } - else if (labels.isRelease && branched) { - emoji = `🚀${branchManagementEmoji}`; - } - else if ((labels.isBugfix || labels.isBug) && branched) { - emoji = `🐛${branchManagementEmoji}`; - } - else if ((labels.isFeature || labels.isEnhancement) && branched) { - emoji = `✨${branchManagementEmoji}`; + (0, logger_1.logDebugInfo)('Fetching tags and forcing fetch...'); + await exec.exec('git', ['fetch', '--tags', '--force']); + (0, logger_1.logDebugInfo)('Fetching all remote branches with verbose output...'); + await exec.exec('git', ['fetch', '--all', '-v']); + (0, logger_1.logDebugInfo)('Successfully fetched all remote branches.'); + } + catch (error) { + core.setFailed(`Error fetching remote branches: ${error}`); + } + }; + this.getLatestTag = async () => { + try { + (0, logger_1.logDebugInfo)('Fetching the latest tag...'); + await exec.exec('git', ['fetch', '--tags']); + const tags = []; + await exec.exec('git', ['tag', '--sort=-creatordate'], { + listeners: { + stdout: (data) => { + tags.push(...data.toString().split('\n').map((v) => { + return v.replace('v', ''); + })); + }, + }, + }); + const validTags = tags.filter(tag => /\d+\.\d+\.\d+$/.test(tag)); + if (validTags.length > 0) { + const latestTag = (0, version_utils_1.getLatestVersion)(validTags); + (0, logger_1.logDebugInfo)(`Latest tag: ${latestTag}`); + return latestTag; + } + else { + (0, logger_1.logDebugInfo)('No valid tags found.'); + return undefined; + } + } + catch (error) { + core.setFailed(`Error fetching the latest tag: ${error}`); + return undefined; + } + }; + this.getCommitTag = async (latestTag) => { + try { + if (!latestTag) { + core.setFailed('No LATEST_TAG found in the environment'); + return undefined; + } + let tagVersion; + if (latestTag.startsWith('v')) { + tagVersion = latestTag; + } + else { + tagVersion = `v${latestTag}`; + } + (0, logger_1.logDebugInfo)(`Fetching commit hash for the tag: ${tagVersion}`); + let commitOid = ''; + await exec.exec('git', ['rev-list', '-n', '1', tagVersion], { + listeners: { + stdout: (data) => { + commitOid = data.toString().trim(); + }, + }, + }); + if (commitOid) { + (0, logger_1.logDebugInfo)(`Commit tag: ${commitOid}`); + return commitOid; + } + else { + core.setFailed('No commit found for the tag'); + } + } + catch (error) { + core.setFailed(`Error fetching the commit hash: ${error}`); + } + return undefined; + }; + } +} +exports.GitCliRepository = GitCliRepository; + + +/***/ }), + +/***/ 57: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.IssueRepository = exports.PROGRESS_LABEL_PATTERN = void 0; +const core = __importStar(__nccwpck_require__(2186)); +const github = __importStar(__nccwpck_require__(5438)); +const logger_1 = __nccwpck_require__(8836); +const milestone_1 = __nccwpck_require__(2298); +/** Matches labels that are progress percentages (e.g. "0%", "85%"). Used for setProgressLabel and syncing. */ +exports.PROGRESS_LABEL_PATTERN = /^\d+%$/; +class IssueRepository { + constructor() { + this.updateTitleIssueFormat = async (owner, repository, version, issueTitle, issueNumber, branchManagementAlways, branchManagementEmoji, labels, token) => { + try { + const octokit = github.getOctokit(token); + let emoji = '🤖'; + const branched = branchManagementAlways || labels.containsBranchedLabel; + if (labels.isHotfix && branched) { + emoji = `🔥${branchManagementEmoji}`; + } + else if (labels.isRelease && branched) { + emoji = `🚀${branchManagementEmoji}`; + } + else if ((labels.isBugfix || labels.isBug) && branched) { + emoji = `🐛${branchManagementEmoji}`; + } + else if ((labels.isFeature || labels.isEnhancement) && branched) { + emoji = `✨${branchManagementEmoji}`; } else if ((labels.isDocs || labels.isDocumentation) && branched) { emoji = `📝${branchManagementEmoji}`; @@ -46313,7 +46284,7 @@ IssueRepository.PROGRESS_LABEL_PERCENTS = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, /***/ }), -/***/ 7917: +/***/ 4015: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -46352,90 +46323,314 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.ProjectRepository = void 0; +exports.MergeRepository = void 0; const github = __importStar(__nccwpck_require__(5438)); const logger_1 = __nccwpck_require__(8836); -const project_detail_1 = __nccwpck_require__(3765); -class ProjectRepository { +const result_1 = __nccwpck_require__(7305); +/** + * Repository for merging branches (via PR or direct merge). + * Isolated to allow unit tests with mocked Octokit. + */ +class MergeRepository { constructor() { - this.priorityLabel = "Priority"; - this.sizeLabel = "Size"; - this.statusLabel = "Status"; - /** - * Retrieves detailed information about a GitHub project - * @param projectId - The project number/ID - * @param token - GitHub authentication token - * @returns Promise - The project details - * @throws {Error} If the project is not found or if there are authentication/network issues - */ - this.getProjectDetail = async (projectId, token) => { + this.mergeBranch = async (owner, repository, head, base, timeout, token) => { + const result = []; try { - // Validate projectId is a valid number - const projectNumber = parseInt(projectId, 10); - if (isNaN(projectNumber)) { - throw new Error(`Invalid project ID: ${projectId}. Must be a valid number.`); - } const octokit = github.getOctokit(token); - const { data: owner } = await octokit.rest.users.getByUsername({ - username: github.context.repo.owner - }).catch(error => { - throw new Error(`Failed to get owner information: ${error.message}`); + (0, logger_1.logDebugInfo)(`Creating merge from ${head} into ${base}`); + // Build PR body with commit list + const prBody = `🚀 Automated Merge + +This PR merges **${head}** into **${base}**. + +**Commits included:**`; + // We need PAT for creating PR to ensure it can trigger workflows + const { data: pullRequest } = await octokit.rest.pulls.create({ + owner: owner, + repo: repository, + head: head, + base: base, + title: `Merge ${head} into ${base}`, + body: prBody, }); - const ownerType = owner.type === 'Organization' ? 'orgs' : 'users'; - const projectUrl = `https://github.com/${ownerType}/${github.context.repo.owner}/projects/${projectId}`; - const ownerQueryField = ownerType === 'orgs' ? 'organization' : 'user'; - const queryProject = ` - query($ownerName: String!, $projectNumber: Int!) { - ${ownerQueryField}(login: $ownerName) { - projectV2(number: $projectNumber) { - id - title - url - } - } - } - `; - const projectResult = await octokit.graphql(queryProject, { - ownerName: github.context.repo.owner, - projectNumber: projectNumber, - }).catch(error => { - throw new Error(`Failed to fetch project data: ${error.message}`); + (0, logger_1.logDebugInfo)(`Pull request #${pullRequest.number} created, getting commits...`); + // Get all commits in the PR + const { data: commits } = await octokit.rest.pulls.listCommits({ + owner: owner, + repo: repository, + pull_number: pullRequest.number, }); - const projectData = projectResult[ownerQueryField]?.projectV2; - if (!projectData) { - throw new Error(`Project not found: ${projectUrl}`); - } - (0, logger_1.logDebugInfo)(`Project ID: ${projectData.id}`); - (0, logger_1.logDebugInfo)(`Project Title: ${projectData.title}`); - (0, logger_1.logDebugInfo)(`Project URL: ${projectData.url}`); - return new project_detail_1.ProjectDetail({ - id: projectData.id, - title: projectData.title, - url: projectData.url, - type: ownerQueryField, - owner: github.context.repo.owner, - number: projectNumber, + const commitMessages = commits.map(commit => commit.commit.message); + (0, logger_1.logDebugInfo)(`Found ${commitMessages.length} commits in PR`); + // Update PR with commit list and footer + await octokit.rest.pulls.update({ + owner: owner, + repo: repository, + pull_number: pullRequest.number, + body: prBody + '\n' + commitMessages.map(msg => `- ${msg}`).join('\n') + + '\n\nThis PR was automatically created by [`copilot`](https://github.com/vypdev/copilot).', }); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - (0, logger_1.logError)(`Error in getProjectDetail: ${errorMessage}`); - throw error; - } - }; - this.getContentId = async (project, owner, repo, issueOrPullRequestNumber, token) => { - const octokit = github.getOctokit(token); - // Search for the issue or pull request ID in the repository - const issueOrPrQuery = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issueOrPullRequest: issueOrPullRequest(number: $number) { - ... on Issue { - id - } - ... on PullRequest { - id - } + const iteration = 10; + if (timeout > iteration) { + // Wait for checks to complete - can use regular token for reading checks + let checksCompleted = false; + let attempts = 0; + const maxAttempts = timeout > iteration ? Math.floor(timeout / iteration) : iteration; + while (!checksCompleted && attempts < maxAttempts) { + const { data: checkRuns } = await octokit.rest.checks.listForRef({ + owner: owner, + repo: repository, + ref: head, + }); + // Get commit status checks for the PR head commit + const { data: commitStatus } = await octokit.rest.repos.getCombinedStatusForRef({ + owner: owner, + repo: repository, + ref: head, + }); + (0, logger_1.logDebugInfo)(`Combined status state: ${commitStatus.state}`); + (0, logger_1.logDebugInfo)(`Number of check runs: ${checkRuns.check_runs.length}`); + // If there are check runs, prioritize those over status checks + if (checkRuns.check_runs.length > 0) { + const pendingCheckRuns = checkRuns.check_runs.filter(check => check.status !== 'completed'); + if (pendingCheckRuns.length === 0) { + checksCompleted = true; + (0, logger_1.logDebugInfo)('All check runs have completed.'); + // Verify if all checks passed + const failedChecks = checkRuns.check_runs.filter(check => check.conclusion === 'failure'); + if (failedChecks.length > 0) { + throw new Error(`Checks failed: ${failedChecks.map(check => check.name).join(', ')}`); + } + } + else { + (0, logger_1.logDebugInfo)(`Waiting for ${pendingCheckRuns.length} check runs to complete:`); + pendingCheckRuns.forEach(check => { + (0, logger_1.logDebugInfo)(` - ${check.name} (Status: ${check.status})`); + }); + await new Promise(resolve => setTimeout(resolve, iteration * 1000)); + attempts++; + continue; + } + } + else { + // Fall back to status checks if no check runs exist + const pendingChecks = commitStatus.statuses.filter(status => { + (0, logger_1.logDebugInfo)(`Status check: ${status.context} (State: ${status.state})`); + return status.state === 'pending'; + }); + if (pendingChecks.length === 0) { + checksCompleted = true; + (0, logger_1.logDebugInfo)('All status checks have completed.'); + } + else { + (0, logger_1.logDebugInfo)(`Waiting for ${pendingChecks.length} status checks to complete:`); + pendingChecks.forEach(check => { + (0, logger_1.logDebugInfo)(` - ${check.context} (State: ${check.state})`); + }); + await new Promise(resolve => setTimeout(resolve, iteration * 1000)); + attempts++; + } + } + } + if (!checksCompleted) { + throw new Error('Timed out waiting for checks to complete'); + } + } + // Need PAT for merging to ensure it can trigger subsequent workflows + await octokit.rest.pulls.merge({ + owner: owner, + repo: repository, + pull_number: pullRequest.number, + merge_method: 'merge', + commit_title: `Merge ${head} into ${base}. Forced merge with PAT token.`, + }); + result.push(new result_1.Result({ + id: 'branch_repository', + success: true, + executed: true, + steps: [ + `The branch \`${head}\` was merged into \`${base}\`.`, + ], + })); + } + catch (error) { + (0, logger_1.logError)(`Error in PR workflow: ${error}`); + // If the PR workflow fails, we try to merge directly - need PAT for direct merge to ensure it can trigger workflows + try { + const octokit = github.getOctokit(token); + await octokit.rest.repos.merge({ + owner: owner, + repo: repository, + base: base, + head: head, + commit_message: `Forced merge of ${head} into ${base}. Automated merge with PAT token.`, + }); + result.push(new result_1.Result({ + id: 'branch_repository', + success: true, + executed: true, + steps: [ + `The branch \`${head}\` was merged into \`${base}\` using direct merge.`, + ], + })); + return result; + } + catch (directMergeError) { + (0, logger_1.logError)(`Error in direct merge attempt: ${directMergeError}`); + result.push(new result_1.Result({ + id: 'branch_repository', + success: false, + executed: true, + steps: [ + `Failed to merge branch \`${head}\` into \`${base}\`.`, + ], + })); + result.push(new result_1.Result({ + id: 'branch_repository', + success: false, + executed: true, + error: error, + })); + result.push(new result_1.Result({ + id: 'branch_repository', + success: false, + executed: true, + error: directMergeError, + })); + } + } + return result; + }; + } +} +exports.MergeRepository = MergeRepository; + + +/***/ }), + +/***/ 7917: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ProjectRepository = void 0; +const github = __importStar(__nccwpck_require__(5438)); +const logger_1 = __nccwpck_require__(8836); +const project_detail_1 = __nccwpck_require__(3765); +class ProjectRepository { + constructor() { + this.priorityLabel = "Priority"; + this.sizeLabel = "Size"; + this.statusLabel = "Status"; + /** + * Retrieves detailed information about a GitHub project + * @param projectId - The project number/ID + * @param token - GitHub authentication token + * @returns Promise - The project details + * @throws {Error} If the project is not found or if there are authentication/network issues + */ + this.getProjectDetail = async (projectId, token) => { + try { + // Validate projectId is a valid number + const projectNumber = parseInt(projectId, 10); + if (isNaN(projectNumber)) { + throw new Error(`Invalid project ID: ${projectId}. Must be a valid number.`); + } + const octokit = github.getOctokit(token); + const { data: owner } = await octokit.rest.users.getByUsername({ + username: github.context.repo.owner + }).catch(error => { + throw new Error(`Failed to get owner information: ${error.message}`); + }); + const ownerType = owner.type === 'Organization' ? 'orgs' : 'users'; + const projectUrl = `https://github.com/${ownerType}/${github.context.repo.owner}/projects/${projectId}`; + const ownerQueryField = ownerType === 'orgs' ? 'organization' : 'user'; + const queryProject = ` + query($ownerName: String!, $projectNumber: Int!) { + ${ownerQueryField}(login: $ownerName) { + projectV2(number: $projectNumber) { + id + title + url + } + } + } + `; + const projectResult = await octokit.graphql(queryProject, { + ownerName: github.context.repo.owner, + projectNumber: projectNumber, + }).catch(error => { + throw new Error(`Failed to fetch project data: ${error.message}`); + }); + const projectData = projectResult[ownerQueryField]?.projectV2; + if (!projectData) { + throw new Error(`Project not found: ${projectUrl}`); + } + (0, logger_1.logDebugInfo)(`Project ID: ${projectData.id}`); + (0, logger_1.logDebugInfo)(`Project Title: ${projectData.title}`); + (0, logger_1.logDebugInfo)(`Project URL: ${projectData.url}`); + return new project_detail_1.ProjectDetail({ + id: projectData.id, + title: projectData.title, + url: projectData.url, + type: ownerQueryField, + owner: github.context.repo.owner, + number: projectNumber, + }); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + (0, logger_1.logError)(`Error in getProjectDetail: ${errorMessage}`); + throw error; + } + }; + this.getContentId = async (project, owner, repo, issueOrPullRequestNumber, token) => { + const octokit = github.getOctokit(token); + // Search for the issue or pull request ID in the repository + const issueOrPrQuery = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issueOrPullRequest: issueOrPullRequest(number: $number) { + ... on Issue { + id + } + ... on PullRequest { + id + } } } }`; @@ -46648,1109 +46843,1642 @@ class ProjectRepository { hasNextPage = fieldResult.node.items.pageInfo.hasNextPage; endCursor = fieldResult.node.items.pageInfo.endCursor; } - (0, logger_1.logDebugInfo)(`Target field ID: ${targetField.id}`); - (0, logger_1.logDebugInfo)(`Target option ID: ${targetOption.id}`); - const mutation = ` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue( - input: { - projectId: $projectId, - itemId: $itemId, - fieldId: $fieldId, - value: { singleSelectOptionId: $optionId } + (0, logger_1.logDebugInfo)(`Target field ID: ${targetField.id}`); + (0, logger_1.logDebugInfo)(`Target option ID: ${targetOption.id}`); + const mutation = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + } + ) { + projectV2Item { + id + } + } + }`; + const mutationResult = await octokit.graphql(mutation, { + projectId: project.id, + itemId: contentId, + fieldId: targetField.id, + optionId: targetOption.id + }); + return !!mutationResult.updateProjectV2ItemFieldValue?.projectV2Item; + }; + this.setTaskPriority = async (project, owner, repo, issueOrPullRequestNumber, priorityLabel, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.priorityLabel, priorityLabel, token); + this.setTaskSize = async (project, owner, repo, issueOrPullRequestNumber, sizeLabel, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.sizeLabel, sizeLabel, token); + this.moveIssueToColumn = async (project, owner, repo, issueOrPullRequestNumber, columnName, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.statusLabel, columnName, token); + this.getRandomMembers = async (organization, membersToAdd, currentMembers, token) => { + if (membersToAdd === 0) { + return []; + } + const octokit = github.getOctokit(token); + try { + const { data: teams } = await octokit.rest.teams.list({ + org: organization, + }); + if (teams.length === 0) { + (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); + return []; + } + const membersSet = new Set(); + for (const team of teams) { + (0, logger_1.logDebugInfo)(`Checking team: ${team.slug}`); + const { data: members } = await octokit.rest.teams.listMembersInOrg({ + org: organization, + team_slug: team.slug, + }); + (0, logger_1.logDebugInfo)(`Members: ${members.length}`); + members.forEach((member) => membersSet.add(member.login)); + } + const allMembers = Array.from(membersSet); + const availableMembers = allMembers.filter((member) => !currentMembers.includes(member)); + if (availableMembers.length === 0) { + (0, logger_1.logDebugInfo)(`No available members to assign for organization ${organization}.`); + return []; + } + if (membersToAdd >= availableMembers.length) { + (0, logger_1.logDebugInfo)(`Requested size (${membersToAdd}) exceeds available members (${availableMembers.length}). Returning all available members.`); + return availableMembers; + } + const shuffled = availableMembers.sort(() => Math.random() - 0.5); + return shuffled.slice(0, membersToAdd); + } + catch (error) { + (0, logger_1.logError)(`Error getting random members: ${error}.`); + } + return []; + }; + this.getAllMembers = async (organization, token) => { + const octokit = github.getOctokit(token); + try { + const { data: teams } = await octokit.rest.teams.list({ + org: organization, + }); + if (teams.length === 0) { + (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); + return []; + } + const membersSet = new Set(); + for (const team of teams) { + const { data: members } = await octokit.rest.teams.listMembersInOrg({ + org: organization, + team_slug: team.slug, + }); + members.forEach((member) => membersSet.add(member.login)); + } + return Array.from(membersSet); + } + catch (error) { + (0, logger_1.logError)(`Error getting all members: ${error}.`); + } + return []; + }; + this.getUserFromToken = async (token) => { + const octokit = github.getOctokit(token); + const { data: user } = await octokit.rest.users.getAuthenticated(); + return user.login; + }; + /** + * Returns true if the actor (user who triggered the event) is allowed to run use cases + * that ask OpenCode to modify files (e.g. bugbot autofix, generic user request). + * - When the repo owner is an Organization: actor must be a member of that organization. + * - When the repo owner is a User: actor must be the owner (same login). + */ + this.isActorAllowedToModifyFiles = async (owner, actor, token) => { + try { + const octokit = github.getOctokit(token); + const { data: ownerUser } = await octokit.rest.users.getByUsername({ username: owner }); + if (ownerUser.type === "Organization") { + try { + await octokit.rest.orgs.checkMembershipForUser({ + org: owner, + username: actor, + }); + return true; + } + catch (membershipErr) { + const status = membershipErr?.status; + if (status === 404) + return false; + (0, logger_1.logDebugInfo)(`checkMembershipForUser(${owner}, ${actor}): ${membershipErr instanceof Error ? membershipErr.message : String(membershipErr)}`); + return false; + } + } + return actor === owner; + } + catch (err) { + (0, logger_1.logDebugInfo)(`isActorAllowedToModifyFiles(${owner}, ${actor}): ${err instanceof Error ? err.message : String(err)}`); + return false; + } + }; + /** Name and email of the token user, for git commit author (e.g. bugbot autofix). */ + this.getTokenUserDetails = async (token) => { + const octokit = github.getOctokit(token); + const { data: user } = await octokit.rest.users.getAuthenticated(); + const name = (user.name ?? user.login ?? "GitHub Action").trim() || "GitHub Action"; + const email = (typeof user.email === "string" && user.email.trim().length > 0) + ? user.email.trim() + : `${user.login}@users.noreply.github.com`; + return { name, email }; + }; + this.findTag = async (owner, repo, tag, token) => { + const octokit = github.getOctokit(token); + try { + const { data: foundTag } = await octokit.rest.git.getRef({ + owner, + repo, + ref: `tags/${tag}`, + }); + return foundTag; + } + catch { + return undefined; + } + }; + this.getTagSHA = async (owner, repo, tag, token) => { + const foundTag = await this.findTag(owner, repo, tag, token); + if (!foundTag) { + (0, logger_1.logError)(`The '${tag}' tag does not exist in the remote repository`); + return undefined; + } + return foundTag.object.sha; + }; + this.updateTag = async (owner, repo, sourceTag, targetTag, token) => { + const sourceTagSHA = await this.getTagSHA(owner, repo, sourceTag, token); + if (!sourceTagSHA) { + (0, logger_1.logError)(`The '${sourceTag}' tag does not exist in the remote repository`); + return; + } + const foundTargetTag = await this.findTag(owner, repo, targetTag, token); + const refName = `tags/${targetTag}`; + const octokit = github.getOctokit(token); + if (foundTargetTag) { + (0, logger_1.logDebugInfo)(`Updating the '${targetTag}' tag to point to the '${sourceTag}' tag`); + await octokit.rest.git.updateRef({ + owner, + repo, + ref: refName, + sha: sourceTagSHA, + force: true, + }); + } + else { + (0, logger_1.logDebugInfo)(`Creating the '${targetTag}' tag from the '${sourceTag}' tag`); + await octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/${refName}`, + sha: sourceTagSHA, + }); + } + }; + this.updateRelease = async (owner, repo, sourceTag, targetTag, token) => { + // Get the release associated with sourceTag + const octokit = github.getOctokit(token); + const { data: sourceRelease } = await octokit.rest.repos.getReleaseByTag({ + owner, + repo, + tag: sourceTag, + }); + if (!sourceRelease.name || !sourceRelease.body) { + (0, logger_1.logError)(`The '${sourceTag}' tag does not exist in the remote repository`); + return undefined; + } + (0, logger_1.logDebugInfo)(`Found release for sourceTag '${sourceTag}': ${sourceRelease.name}`); + // Check if there is a release for targetTag + const { data: releases } = await octokit.rest.repos.listReleases({ + owner, + repo, + }); + const targetRelease = releases.find(r => r.tag_name === targetTag); + let targetReleaseId; + if (targetRelease) { + (0, logger_1.logDebugInfo)(`Updating release for targetTag '${targetTag}'`); + // Update the target release with the content from the source release + await octokit.rest.repos.updateRelease({ + owner, + repo, + release_id: targetRelease.id, + name: sourceRelease.name, + body: sourceRelease.body, + draft: sourceRelease.draft, + prerelease: sourceRelease.prerelease, + }); + targetReleaseId = targetRelease.id; + } + else { + console.log(`Creating new release for targetTag '${targetTag}'`); + // Create a new release for targetTag if it doesn't exist + const { data: newRelease } = await octokit.rest.repos.createRelease({ + owner, + repo, + tag_name: targetTag, + name: sourceRelease.name, + body: sourceRelease.body, + draft: sourceRelease.draft, + prerelease: sourceRelease.prerelease, + }); + targetReleaseId = newRelease.id; + } + (0, logger_1.logInfo)(`Updated release for targetTag '${targetTag}'`); + return targetReleaseId.toString(); + }; + this.createRelease = async (owner, repo, version, title, changelog, token) => { + try { + const octokit = github.getOctokit(token); + const { data: release } = await octokit.rest.repos.createRelease({ + owner, + repo, + tag_name: `v${version}`, + name: `v${version} - ${title}`, + body: changelog, + draft: false, + prerelease: false, + }); + return release.html_url; } - ) { - projectV2Item { - id + catch (error) { + (0, logger_1.logError)(`Error creating release: ${error}`); + return undefined; } - } - }`; - const mutationResult = await octokit.graphql(mutation, { - projectId: project.id, - itemId: contentId, - fieldId: targetField.id, - optionId: targetOption.id - }); - return !!mutationResult.updateProjectV2ItemFieldValue?.projectV2Item; }; - this.setTaskPriority = async (project, owner, repo, issueOrPullRequestNumber, priorityLabel, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.priorityLabel, priorityLabel, token); - this.setTaskSize = async (project, owner, repo, issueOrPullRequestNumber, sizeLabel, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.sizeLabel, sizeLabel, token); - this.moveIssueToColumn = async (project, owner, repo, issueOrPullRequestNumber, columnName, token) => this.setSingleSelectFieldValue(project, owner, repo, issueOrPullRequestNumber, this.statusLabel, columnName, token); - this.getRandomMembers = async (organization, membersToAdd, currentMembers, token) => { - if (membersToAdd === 0) { - return []; + this.createTag = async (owner, repo, branch, tag, token) => { + const octokit = github.getOctokit(token); + try { + // Check if tag already exists + const existingTag = await this.findTag(owner, repo, tag, token); + if (existingTag) { + (0, logger_1.logInfo)(`Tag '${tag}' already exists in repository ${owner}/${repo}`); + return existingTag.object.sha; + } + // Get the latest commit SHA from the specified branch + const { data: ref } = await octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${branch}`, + }); + // Create the tag + await octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${tag}`, + sha: ref.object.sha, + }); + (0, logger_1.logInfo)(`Created tag '${tag}' in repository ${owner}/${repo} from branch '${branch}'`); + return ref.object.sha; } + catch (error) { + (0, logger_1.logError)(`Error creating tag '${tag}': ${JSON.stringify(error, null, 2)}`); + return undefined; + } + }; + } +} +exports.ProjectRepository = ProjectRepository; + + +/***/ }), + +/***/ 634: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PullRequestRepository = void 0; +const github = __importStar(__nccwpck_require__(5438)); +const logger_1 = __nccwpck_require__(8836); +class PullRequestRepository { + constructor() { + /** + * Returns the list of open pull request numbers whose head branch equals the given branch. + * Used to sync size/progress labels from the issue to PRs when they are updated on push. + */ + this.getOpenPullRequestNumbersByHeadBranch = async (owner, repository, headBranch, token) => { const octokit = github.getOctokit(token); try { - const { data: teams } = await octokit.rest.teams.list({ - org: organization, + const { data } = await octokit.rest.pulls.list({ + owner, + repo: repository, + state: 'open', + head: `${owner}:${headBranch}`, }); - if (teams.length === 0) { - (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); - return []; - } - const membersSet = new Set(); - for (const team of teams) { - (0, logger_1.logDebugInfo)(`Checking team: ${team.slug}`); - const { data: members } = await octokit.rest.teams.listMembersInOrg({ - org: organization, - team_slug: team.slug, - }); - (0, logger_1.logDebugInfo)(`Members: ${members.length}`); - members.forEach((member) => membersSet.add(member.login)); - } - const allMembers = Array.from(membersSet); - const availableMembers = allMembers.filter((member) => !currentMembers.includes(member)); - if (availableMembers.length === 0) { - (0, logger_1.logDebugInfo)(`No available members to assign for organization ${organization}.`); - return []; - } - if (membersToAdd >= availableMembers.length) { - (0, logger_1.logDebugInfo)(`Requested size (${membersToAdd}) exceeds available members (${availableMembers.length}). Returning all available members.`); - return availableMembers; - } - const shuffled = availableMembers.sort(() => Math.random() - 0.5); - return shuffled.slice(0, membersToAdd); + const numbers = (data || []).map((pr) => pr.number); + (0, logger_1.logDebugInfo)(`Found ${numbers.length} open PR(s) for head branch "${headBranch}": ${numbers.join(', ') || 'none'}`); + return numbers; } catch (error) { - (0, logger_1.logError)(`Error getting random members: ${error}.`); + (0, logger_1.logError)(`Error listing PRs for branch ${headBranch}: ${error}`); + return []; } - return []; }; - this.getAllMembers = async (organization, token) => { + /** + * Returns the head branch of the first open PR that references the given issue number + * (e.g. body contains "#123" or head ref contains "123" as in feature/123-...). + * Used for issue_comment events where commit.branch is empty. + * Uses bounded matching so #12 does not match #123 and branch "feature/1234-fix" does not match issue 123. + */ + this.getHeadBranchForIssue = async (owner, repository, issueNumber, token) => { const octokit = github.getOctokit(token); + const escaped = String(issueNumber).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const bodyRefRegex = new RegExp(`(?:^|[^\\d])#${escaped}(?:$|[^\\d])`); + const headRefRegex = new RegExp(`\\b${escaped}\\b`); try { - const { data: teams } = await octokit.rest.teams.list({ - org: organization, + const { data } = await octokit.rest.pulls.list({ + owner, + repo: repository, + state: 'open', + per_page: 100, }); - if (teams.length === 0) { - (0, logger_1.logDebugInfo)(`${organization} doesn't have any team.`); - return []; - } - const membersSet = new Set(); - for (const team of teams) { - const { data: members } = await octokit.rest.teams.listMembersInOrg({ - org: organization, - team_slug: team.slug, - }); - members.forEach((member) => membersSet.add(member.login)); + for (const pr of data || []) { + const body = pr.body ?? ''; + const headRef = pr.head?.ref ?? ''; + if (bodyRefRegex.test(body) || headRefRegex.test(headRef)) { + (0, logger_1.logDebugInfo)(`Found head branch "${headRef}" for issue #${issueNumber} (PR #${pr.number}).`); + return headRef; + } } - return Array.from(membersSet); + (0, logger_1.logDebugInfo)(`No open PR referencing issue #${issueNumber} found.`); + return undefined; } catch (error) { - (0, logger_1.logError)(`Error getting all members: ${error}.`); + (0, logger_1.logError)(`Error getting head branch for issue #${issueNumber}: ${error}`); + return undefined; } - return []; }; - this.getUserFromToken = async (token) => { + this.isLinked = async (pullRequestUrl) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), PullRequestRepository.IS_LINKED_FETCH_TIMEOUT_MS); + try { + const res = await fetch(pullRequestUrl, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!res.ok) { + (0, logger_1.logDebugInfo)(`isLinked: non-2xx response ${res.status} for ${pullRequestUrl}`); + return false; + } + const htmlContent = await res.text(); + return !htmlContent.includes('has_github_issues=false'); + } + catch (err) { + clearTimeout(timeoutId); + const msg = err instanceof Error ? err.message : String(err); + (0, logger_1.logError)(`isLinked: fetch failed for ${pullRequestUrl}: ${msg}`); + return false; + } + }; + this.updateBaseBranch = async (owner, repository, pullRequestNumber, branch, token) => { const octokit = github.getOctokit(token); - const { data: user } = await octokit.rest.users.getAuthenticated(); - return user.login; + await octokit.rest.pulls.update({ + owner: owner, + repo: repository, + pull_number: pullRequestNumber, + base: branch, + }); + (0, logger_1.logDebugInfo)(`Changed base branch to ${branch}`); + }; + this.updateDescription = async (owner, repository, pullRequestNumber, description, token) => { + const octokit = github.getOctokit(token); + await octokit.rest.pulls.update({ + owner: owner, + repo: repository, + pull_number: pullRequestNumber, + body: description, + }); + (0, logger_1.logDebugInfo)(`Updated PR #${pullRequestNumber} description with: ${description}`); }; /** - * Returns true if the actor (user who triggered the event) is allowed to run use cases - * that ask OpenCode to modify files (e.g. bugbot autofix, generic user request). - * - When the repo owner is an Organization: actor must be a member of that organization. - * - When the repo owner is a User: actor must be the owner (same login). + * Returns all users involved in review: requested (pending) + those who already submitted a review. + * Used to avoid re-requesting someone who already reviewed when ensuring desired reviewer count. */ - this.isActorAllowedToModifyFiles = async (owner, actor, token) => { + this.getCurrentReviewers = async (owner, repository, pullNumber, token) => { + const octokit = github.getOctokit(token); try { - const octokit = github.getOctokit(token); - const { data: ownerUser } = await octokit.rest.users.getByUsername({ username: owner }); - if (ownerUser.type === "Organization") { - try { - await octokit.rest.orgs.checkMembershipForUser({ - org: owner, - username: actor, - }); - return true; - } - catch (membershipErr) { - const status = membershipErr?.status; - if (status === 404) - return false; - (0, logger_1.logDebugInfo)(`checkMembershipForUser(${owner}, ${actor}): ${membershipErr instanceof Error ? membershipErr.message : String(membershipErr)}`); - return false; + const [requestedRes, reviewsRes] = await Promise.all([ + octokit.rest.pulls.listRequestedReviewers({ + owner, + repo: repository, + pull_number: pullNumber, + }), + octokit.rest.pulls.listReviews({ + owner, + repo: repository, + pull_number: pullNumber, + }), + ]); + const logins = new Set(); + for (const user of requestedRes.data.users) { + logins.add(user.login); + } + for (const review of reviewsRes.data) { + if (review.user?.login) { + logins.add(review.user.login); } } - return actor === owner; + return Array.from(logins); } - catch (err) { - (0, logger_1.logDebugInfo)(`isActorAllowedToModifyFiles(${owner}, ${actor}): ${err instanceof Error ? err.message : String(err)}`); - return false; + catch (error) { + (0, logger_1.logError)(`Error getting reviewers of PR: ${error}.`); + return []; } }; - /** Name and email of the token user, for git commit author (e.g. bugbot autofix). */ - this.getTokenUserDetails = async (token) => { - const octokit = github.getOctokit(token); - const { data: user } = await octokit.rest.users.getAuthenticated(); - const name = (user.name ?? user.login ?? "GitHub Action").trim() || "GitHub Action"; - const email = (typeof user.email === "string" && user.email.trim().length > 0) - ? user.email.trim() - : `${user.login}@users.noreply.github.com`; - return { name, email }; - }; - this.findTag = async (owner, repo, tag, token) => { + this.addReviewersToPullRequest = async (owner, repository, pullNumber, reviewers, token) => { const octokit = github.getOctokit(token); try { - const { data: foundTag } = await octokit.rest.git.getRef({ + if (reviewers.length === 0) { + (0, logger_1.logDebugInfo)(`No reviewers provided for addition. Skipping operation.`); + return []; + } + const { data } = await octokit.rest.pulls.requestReviewers({ owner, - repo, - ref: `tags/${tag}`, + repo: repository, + pull_number: pullNumber, + reviewers: reviewers, }); - return foundTag; - } - catch { - return undefined; + const addedReviewers = data.requested_reviewers || []; + return addedReviewers.map((reviewer) => reviewer.login); } - }; - this.getTagSHA = async (owner, repo, tag, token) => { - const foundTag = await this.findTag(owner, repo, tag, token); - if (!foundTag) { - (0, logger_1.logError)(`The '${tag}' tag does not exist in the remote repository`); - return undefined; + catch (error) { + (0, logger_1.logError)(`Error adding reviewers to pull request: ${error}.`); + return []; } - return foundTag.object.sha; }; - this.updateTag = async (owner, repo, sourceTag, targetTag, token) => { - const sourceTagSHA = await this.getTagSHA(owner, repo, sourceTag, token); - if (!sourceTagSHA) { - (0, logger_1.logError)(`The '${sourceTag}' tag does not exist in the remote repository`); - return; - } - const foundTargetTag = await this.findTag(owner, repo, targetTag, token); - const refName = `tags/${targetTag}`; + this.getChangedFiles = async (owner, repository, pullNumber, token) => { const octokit = github.getOctokit(token); - if (foundTargetTag) { - (0, logger_1.logDebugInfo)(`Updating the '${targetTag}' tag to point to the '${sourceTag}' tag`); - await octokit.rest.git.updateRef({ + const all = []; + try { + for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listFiles, { owner, - repo, - ref: refName, - sha: sourceTagSHA, - force: true, - }); + repo: repository, + pull_number: pullNumber, + per_page: 100, + })) { + const data = response.data ?? []; + all.push(...data.map((file) => ({ + filename: file.filename, + status: file.status, + }))); + } + return all; } - else { - (0, logger_1.logDebugInfo)(`Creating the '${targetTag}' tag from the '${sourceTag}' tag`); - await octokit.rest.git.createRef({ - owner, - repo, - ref: `refs/${refName}`, - sha: sourceTagSHA, - }); + catch (error) { + (0, logger_1.logError)(`Error getting changed files from pull request: ${error}.`); + return []; } }; - this.updateRelease = async (owner, repo, sourceTag, targetTag, token) => { - // Get the release associated with sourceTag + /** + * Returns for each changed file the first line number that appears in the diff (right side). + * Used so review comments use a line that GitHub can resolve (avoids "line could not be resolved"). + */ + this.getFilesWithFirstDiffLine = async (owner, repository, pullNumber, token) => { const octokit = github.getOctokit(token); - const { data: sourceRelease } = await octokit.rest.repos.getReleaseByTag({ - owner, - repo, - tag: sourceTag, - }); - if (!sourceRelease.name || !sourceRelease.body) { - (0, logger_1.logError)(`The '${sourceTag}' tag does not exist in the remote repository`); - return undefined; - } - (0, logger_1.logDebugInfo)(`Found release for sourceTag '${sourceTag}': ${sourceRelease.name}`); - // Check if there is a release for targetTag - const { data: releases } = await octokit.rest.repos.listReleases({ - owner, - repo, - }); - const targetRelease = releases.find(r => r.tag_name === targetTag); - let targetReleaseId; - if (targetRelease) { - (0, logger_1.logDebugInfo)(`Updating release for targetTag '${targetTag}'`); - // Update the target release with the content from the source release - await octokit.rest.repos.updateRelease({ + try { + const { data } = await octokit.rest.pulls.listFiles({ owner, - repo, - release_id: targetRelease.id, - name: sourceRelease.name, - body: sourceRelease.body, - draft: sourceRelease.draft, - prerelease: sourceRelease.prerelease, + repo: repository, + pull_number: pullNumber, }); - targetReleaseId = targetRelease.id; - } - else { - console.log(`Creating new release for targetTag '${targetTag}'`); - // Create a new release for targetTag if it doesn't exist - const { data: newRelease } = await octokit.rest.repos.createRelease({ - owner, - repo, - tag_name: targetTag, - name: sourceRelease.name, - body: sourceRelease.body, - draft: sourceRelease.draft, - prerelease: sourceRelease.prerelease, + return (data || []) + .filter((f) => f.status !== 'removed' && (f.patch ?? '').length > 0) + .map((f) => { + const firstLine = PullRequestRepository.firstLineFromPatch(f.patch ?? ''); + return { path: f.filename, firstLine: firstLine ?? 1 }; }); - targetReleaseId = newRelease.id; } - (0, logger_1.logInfo)(`Updated release for targetTag '${targetTag}'`); - return targetReleaseId.toString(); + catch (error) { + (0, logger_1.logError)(`Error getting files with diff lines (owner=${owner}, repo=${repository}, pullNumber=${pullNumber}): ${error}.`); + return []; + } }; - this.createRelease = async (owner, repo, version, title, changelog, token) => { + this.getPullRequestChanges = async (owner, repository, pullNumber, token) => { + const octokit = github.getOctokit(token); + const allFiles = []; try { - const octokit = github.getOctokit(token); - const { data: release } = await octokit.rest.repos.createRelease({ + for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listFiles, { owner, - repo, - tag_name: `v${version}`, - name: `v${version} - ${title}`, - body: changelog, - draft: false, - prerelease: false, - }); - return release.html_url; + repo: repository, + pull_number: pullNumber, + per_page: 100 + })) { + const filesData = response.data; + allFiles.push(...filesData.map((file) => ({ + filename: file.filename, + status: file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch || '' + }))); + } + return allFiles; } catch (error) { - (0, logger_1.logError)(`Error creating release: ${error}`); - return undefined; + (0, logger_1.logError)(`Error getting pull request changes: ${error}.`); + return []; } }; - this.createTag = async (owner, repo, branch, tag, token) => { + /** Head commit SHA of the PR (for creating review). */ + this.getPullRequestHeadSha = async (owner, repository, pullNumber, token) => { const octokit = github.getOctokit(token); try { - // Check if tag already exists - const existingTag = await this.findTag(owner, repo, tag, token); - if (existingTag) { - (0, logger_1.logInfo)(`Tag '${tag}' already exists in repository ${owner}/${repo}`); - return existingTag.object.sha; - } - // Get the latest commit SHA from the specified branch - const { data: ref } = await octokit.rest.git.getRef({ - owner, - repo, - ref: `heads/${branch}`, - }); - // Create the tag - await octokit.rest.git.createRef({ + const { data } = await octokit.rest.pulls.get({ owner, - repo, - ref: `refs/tags/${tag}`, - sha: ref.object.sha, - }); - (0, logger_1.logInfo)(`Created tag '${tag}' in repository ${owner}/${repo} from branch '${branch}'`); - return ref.object.sha; + repo: repository, + pull_number: pullNumber, + }); + return data.head?.sha; } catch (error) { - (0, logger_1.logError)(`Error creating tag '${tag}': ${JSON.stringify(error, null, 2)}`); + (0, logger_1.logError)(`Error getting PR head SHA: ${error}.`); return undefined; } }; - } -} -exports.ProjectRepository = ProjectRepository; - - -/***/ }), - -/***/ 634: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.PullRequestRepository = void 0; -const github = __importStar(__nccwpck_require__(5438)); -const logger_1 = __nccwpck_require__(8836); -class PullRequestRepository { - constructor() { /** - * Returns the list of open pull request numbers whose head branch equals the given branch. - * Used to sync size/progress labels from the issue to PRs when they are updated on push. + * List all review comments on a PR (for bugbot: find existing findings by marker). + * Uses pagination to fetch every comment (default API returns only 30 per page). + * Includes node_id for GraphQL (e.g. resolve review thread). */ - this.getOpenPullRequestNumbersByHeadBranch = async (owner, repository, headBranch, token) => { + this.listPullRequestReviewComments = async (owner, repository, pullNumber, token) => { const octokit = github.getOctokit(token); + const all = []; try { - const { data } = await octokit.rest.pulls.list({ + for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listReviewComments, { owner, repo: repository, - state: 'open', - head: `${owner}:${headBranch}`, - }); - const numbers = (data || []).map((pr) => pr.number); - (0, logger_1.logDebugInfo)(`Found ${numbers.length} open PR(s) for head branch "${headBranch}": ${numbers.join(', ') || 'none'}`); - return numbers; + pull_number: pullNumber, + per_page: 100, + })) { + const data = response.data || []; + all.push(...data.map((c) => ({ + id: c.id, + body: c.body ?? null, + path: c.path, + line: c.line ?? undefined, + node_id: c.node_id ?? undefined, + }))); + } + return all; } catch (error) { - (0, logger_1.logError)(`Error listing PRs for branch ${headBranch}: ${error}`); + (0, logger_1.logError)(`Error listing PR review comments (owner=${owner}, repo=${repository}, pullNumber=${pullNumber}): ${error}.`); return []; } }; /** - * Returns the head branch of the first open PR that references the given issue number - * (e.g. body contains "#123" or head ref contains "123" as in feature/123-...). - * Used for issue_comment events where commit.branch is empty. - * Uses bounded matching so #12 does not match #123 and branch "feature/1234-fix" does not match issue 123. + * Fetches a single PR review comment by id (e.g. parent comment when user replied in thread). + * Returns the comment body or null if not found. */ - this.getHeadBranchForIssue = async (owner, repository, issueNumber, token) => { + this.getPullRequestReviewCommentBody = async (owner, repository, _pullNumber, commentId, token) => { const octokit = github.getOctokit(token); - const escaped = String(issueNumber).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const bodyRefRegex = new RegExp(`(?:^|[^\\d])#${escaped}(?:$|[^\\d])`); - const headRefRegex = new RegExp(`\\b${escaped}\\b`); try { - const { data } = await octokit.rest.pulls.list({ + const { data } = await octokit.rest.pulls.getReviewComment({ owner, repo: repository, - state: 'open', - per_page: 100, + comment_id: commentId, }); - for (const pr of data || []) { - const body = pr.body ?? ''; - const headRef = pr.head?.ref ?? ''; - if (bodyRefRegex.test(body) || headRefRegex.test(headRef)) { - (0, logger_1.logDebugInfo)(`Found head branch "${headRef}" for issue #${issueNumber} (PR #${pr.number}).`); - return headRef; + return data.body ?? null; + } + catch (error) { + (0, logger_1.logError)(`Error getting PR review comment ${commentId}: ${error}`); + return null; + } + }; + /** + * Resolve a PR review thread (GraphQL only). Finds the thread that contains the given comment and marks it resolved. + * Uses repository.pullRequest.reviewThreads because the field pullRequestReviewThread on PullRequestReviewComment was removed from the API. + * Paginates through all threads and all comments in each thread so the comment is found regardless of PR size. + * No-op if thread is already resolved. Logs and does not throw on error. + */ + this.resolvePullRequestReviewThread = async (owner, repository, pullNumber, commentNodeId, token) => { + const octokit = github.getOctokit(token); + try { + let threadId = null; + let threadsCursor = null; + outer: do { + const threadsData = await octokit.graphql(`query ($owner: String!, $repo: String!, $prNumber: Int!, $threadsAfter: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + reviewThreads(first: 100, after: $threadsAfter) { + nodes { + id + comments(first: 100) { + nodes { id } + pageInfo { hasNextPage endCursor } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + }`, { owner, repo: repository, prNumber: pullNumber, threadsAfter: threadsCursor }); + const threads = threadsData?.repository?.pullRequest?.reviewThreads; + if (!threads?.nodes?.length) + break; + for (const thread of threads.nodes) { + let commentsCursor = null; + let commentNodes = thread.comments?.nodes ?? []; + let commentsPageInfo = thread.comments?.pageInfo; + do { + if (commentNodes.some((c) => c.id === commentNodeId)) { + threadId = thread.id; + break outer; + } + if (!commentsPageInfo?.hasNextPage || commentsPageInfo.endCursor == null) + break; + commentsCursor = commentsPageInfo.endCursor; + const nextComments = await octokit.graphql(`query ($threadId: ID!, $commentsAfter: String) { + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: 100, after: $commentsAfter) { + nodes { id } + pageInfo { hasNextPage endCursor } + } + } + } + }`, { threadId: thread.id, commentsAfter: commentsCursor }); + commentNodes = nextComments?.node?.comments?.nodes ?? []; + commentsPageInfo = nextComments?.node?.comments?.pageInfo ?? { hasNextPage: false, endCursor: null }; + } while (commentsPageInfo?.hasNextPage === true && commentsPageInfo?.endCursor != null); } + const pageInfo = threads.pageInfo; + if (threadId != null || !pageInfo?.hasNextPage) + break; + threadsCursor = pageInfo.endCursor ?? null; + } while (threadsCursor != null); + if (!threadId) { + (0, logger_1.logError)(`[Bugbot] No review thread found for comment node_id=${commentNodeId}.`); + return; } - (0, logger_1.logDebugInfo)(`No open PR referencing issue #${issueNumber} found.`); - return undefined; + await octokit.graphql(`mutation ($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { + thread { id } + } + }`, { threadId }); + (0, logger_1.logDebugInfo)(`Resolved PR review thread ${threadId}.`); } - catch (error) { - (0, logger_1.logError)(`Error getting head branch for issue #${issueNumber}: ${error}`); - return undefined; + catch (err) { + (0, logger_1.logError)(`[Bugbot] Error resolving PR review thread (commentNodeId=${commentNodeId}, owner=${owner}, repo=${repository}): ${err}`); } }; - this.isLinked = async (pullRequestUrl) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), PullRequestRepository.IS_LINKED_FETCH_TIMEOUT_MS); - try { - const res = await fetch(pullRequestUrl, { signal: controller.signal }); - clearTimeout(timeoutId); - if (!res.ok) { - (0, logger_1.logDebugInfo)(`isLinked: non-2xx response ${res.status} for ${pullRequestUrl}`); - return false; - } - const htmlContent = await res.text(); - return !htmlContent.includes('has_github_issues=false'); - } - catch (err) { - clearTimeout(timeoutId); - const msg = err instanceof Error ? err.message : String(err); - (0, logger_1.logError)(`isLinked: fetch failed for ${pullRequestUrl}: ${msg}`); - return false; - } + /** + * Create a review on the PR with one or more inline comments (bugbot findings). + * Each comment requires path and line (use first file and line 1 if not specified). + */ + this.createReviewWithComments = async (owner, repository, pullNumber, commitId, comments, token) => { + if (comments.length === 0) + return; + const octokit = github.getOctokit(token); + const results = await Promise.allSettled(comments.map((c) => octokit.rest.pulls.createReviewComment({ + owner, + repo: repository, + pull_number: pullNumber, + commit_id: commitId, + path: c.path, + line: c.line, + side: 'RIGHT', + body: c.body, + }))); + let created = 0; + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + created += 1; + } + else { + const c = comments[i]; + (0, logger_1.logError)(`[Bugbot] Error creating PR review comment. path="${c.path}", line=${c.line}, prNumber=${pullNumber}, owner=${owner}, repo=${repository}: ${result.reason}`); + } + }); + if (created > 0) { + (0, logger_1.logDebugInfo)(`Created ${created} review comment(s) on PR #${pullNumber}.`); + } + }; + /** Update an existing PR review comment (e.g. to mark finding as resolved in body). */ + this.updatePullRequestReviewComment = async (owner, repository, commentId, body, token) => { + const octokit = github.getOctokit(token); + await octokit.rest.pulls.updateReviewComment({ + owner, + repo: repository, + comment_id: commentId, + body, + }); + (0, logger_1.logDebugInfo)(`Updated review comment ${commentId}.`); + }; + } + /** First line (right side) of the first hunk per file, for valid review comment placement. */ + static firstLineFromPatch(patch) { + const match = patch.match(/^@@ -\d+,\d+ \+(\d+),\d+ @@/m); + return match ? parseInt(match[1], 10) : undefined; + } +} +exports.PullRequestRepository = PullRequestRepository; +/** Default timeout (ms) for isLinked fetch. */ +PullRequestRepository.IS_LINKED_FETCH_TIMEOUT_MS = 10000; + + +/***/ }), + +/***/ 779: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.WorkflowRepository = void 0; +const github = __importStar(__nccwpck_require__(5438)); +const workflow_run_1 = __nccwpck_require__(6845); +const constants_1 = __nccwpck_require__(8593); +class WorkflowRepository { + constructor() { + this.getWorkflows = async (params) => { + const octokit = github.getOctokit(params.tokens.token); + const workflows = await octokit.rest.actions.listWorkflowRunsForRepo({ + owner: params.owner, + repo: params.repo, + }); + return workflows.data.workflow_runs.map(w => new workflow_run_1.WorkflowRun({ + id: w.id, + name: w.name ?? 'unknown', + head_branch: w.head_branch, + head_sha: w.head_sha, + run_number: w.run_number, + event: w.event, + status: w.status ?? 'unknown', + conclusion: w.conclusion ?? null, + created_at: w.created_at, + updated_at: w.updated_at, + url: w.url, + html_url: w.html_url, + })); }; - this.updateBaseBranch = async (owner, repository, pullRequestNumber, branch, token) => { - const octokit = github.getOctokit(token); - await octokit.rest.pulls.update({ - owner: owner, - repo: repository, - pull_number: pullRequestNumber, - base: branch, + this.getActivePreviousRuns = async (params) => { + const workflows = await this.getWorkflows(params); + const runId = parseInt(process.env.GITHUB_RUN_ID, 10); + const workflowName = process.env.GITHUB_WORKFLOW; + return workflows.filter((run) => { + const isSameWorkflow = run.name === workflowName; + const isPrevious = run.id < runId; + const isActive = constants_1.WORKFLOW_ACTIVE_STATUSES.includes(run.status); + return isSameWorkflow && isPrevious && isActive; }); - (0, logger_1.logDebugInfo)(`Changed base branch to ${branch}`); }; - this.updateDescription = async (owner, repository, pullRequestNumber, description, token) => { + this.executeWorkflow = async (owner, repository, branch, workflow, inputs, token) => { const octokit = github.getOctokit(token); - await octokit.rest.pulls.update({ + return octokit.rest.actions.createWorkflowDispatch({ owner: owner, repo: repository, - pull_number: pullRequestNumber, - body: description, + workflow_id: workflow, + ref: branch, + inputs: inputs, }); - (0, logger_1.logDebugInfo)(`Updated PR #${pullRequestNumber} description with: ${description}`); - }; - /** - * Returns all users involved in review: requested (pending) + those who already submitted a review. - * Used to avoid re-requesting someone who already reviewed when ensuring desired reviewer count. - */ - this.getCurrentReviewers = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - try { - const [requestedRes, reviewsRes] = await Promise.all([ - octokit.rest.pulls.listRequestedReviewers({ - owner, - repo: repository, - pull_number: pullNumber, - }), - octokit.rest.pulls.listReviews({ - owner, - repo: repository, - pull_number: pullNumber, - }), - ]); - const logins = new Set(); - for (const user of requestedRes.data.users) { - logins.add(user.login); - } - for (const review of reviewsRes.data) { - if (review.user?.login) { - logins.add(review.user.login); - } - } - return Array.from(logins); - } - catch (error) { - (0, logger_1.logError)(`Error getting reviewers of PR: ${error}.`); - return []; - } }; - this.addReviewersToPullRequest = async (owner, repository, pullNumber, reviewers, token) => { - const octokit = github.getOctokit(token); + } +} +exports.WorkflowRepository = WorkflowRepository; + + +/***/ }), + +/***/ 6365: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ContentInterface = void 0; +const logger_1 = __nccwpck_require__(8836); +class ContentInterface { + constructor() { + this.getContent = (description) => { try { - if (reviewers.length === 0) { - (0, logger_1.logDebugInfo)(`No reviewers provided for addition. Skipping operation.`); - return []; + if (description === undefined) { + return undefined; } - const { data } = await octokit.rest.pulls.requestReviewers({ - owner, - repo: repository, - pull_number: pullNumber, - reviewers: reviewers, - }); - const addedReviewers = data.requested_reviewers || []; - return addedReviewers.map((reviewer) => reviewer.login); - } - catch (error) { - (0, logger_1.logError)(`Error adding reviewers to pull request: ${error}.`); - return []; - } - }; - this.getChangedFiles = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - const all = []; - try { - for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listFiles, { - owner, - repo: repository, - pull_number: pullNumber, - per_page: 100, - })) { - const data = response.data ?? []; - all.push(...data.map((file) => ({ - filename: file.filename, - status: file.status, - }))); + if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + return undefined; } - return all; + return description.split(this.startPattern)[1].split(this.endPattern)[0]; } catch (error) { - (0, logger_1.logError)(`Error getting changed files from pull request: ${error}.`); - return []; + (0, logger_1.logError)(`Error reading issue configuration: ${error}`); + throw error; } }; - /** - * Returns for each changed file the first line number that appears in the diff (right side). - * Used so review comments use a line that GitHub can resolve (avoids "line could not be resolved"). - */ - this.getFilesWithFirstDiffLine = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - try { - const { data } = await octokit.rest.pulls.listFiles({ - owner, - repo: repository, - pull_number: pullNumber, - }); - return (data || []) - .filter((f) => f.status !== 'removed' && (f.patch ?? '').length > 0) - .map((f) => { - const firstLine = PullRequestRepository.firstLineFromPatch(f.patch ?? ''); - return { path: f.filename, firstLine: firstLine ?? 1 }; - }); + this._addContent = (description, content) => { + if (description.indexOf(this.startPattern) === -1 && description.indexOf(this.endPattern) === -1) { + const newContent = `${this.startPattern}\n${content}\n${this.endPattern}`; + return `${description}\n\n${newContent}`; } - catch (error) { - (0, logger_1.logError)(`Error getting files with diff lines (owner=${owner}, repo=${repository}, pullNumber=${pullNumber}): ${error}.`); - return []; + else { + return undefined; } }; - this.getPullRequestChanges = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - const allFiles = []; - try { - for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listFiles, { - owner, - repo: repository, - pull_number: pullNumber, - per_page: 100 - })) { - const filesData = response.data; - allFiles.push(...filesData.map((file) => ({ - filename: file.filename, - status: file.status, - additions: file.additions, - deletions: file.deletions, - patch: file.patch || '' - }))); - } - return allFiles; - } - catch (error) { - (0, logger_1.logError)(`Error getting pull request changes: ${error}.`); - return []; + this._updateContent = (description, content) => { + if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { + (0, logger_1.logError)(`The content has a problem with open-close tags: ${this.startPattern} / ${this.endPattern}`); + return undefined; } + const start = description.split(this.startPattern)[0]; + const mid = `${this.startPattern}\n${content}\n${this.endPattern}`; + const end = description.split(this.endPattern)[1]; + return `${start}${mid}${end}`; }; - /** Head commit SHA of the PR (for creating review). */ - this.getPullRequestHeadSha = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); + this.updateContent = (description, content) => { try { - const { data } = await octokit.rest.pulls.get({ - owner, - repo: repository, - pull_number: pullNumber, - }); - return data.head?.sha; + if (description === undefined || content === undefined) { + return undefined; + } + const addedContent = this._addContent(description, content); + if (addedContent !== undefined) { + return addedContent; + } + return this._updateContent(description, content); } catch (error) { - (0, logger_1.logError)(`Error getting PR head SHA: ${error}.`); + (0, logger_1.logError)(`Error updating issue description: ${error}`); return undefined; } }; - /** - * List all review comments on a PR (for bugbot: find existing findings by marker). - * Uses pagination to fetch every comment (default API returns only 30 per page). - * Includes node_id for GraphQL (e.g. resolve review thread). - */ - this.listPullRequestReviewComments = async (owner, repository, pullNumber, token) => { - const octokit = github.getOctokit(token); - const all = []; + } + get _id() { + return `copilot-${this.id}`; + } + get startPattern() { + if (this.visibleContent) { + return ``; + } + return ``; + } + return `${this._id}-end -->`; + } +} +exports.ContentInterface = ContentInterface; + + +/***/ }), + +/***/ 9913: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.IssueContentInterface = void 0; +const issue_repository_1 = __nccwpck_require__(57); +const logger_1 = __nccwpck_require__(8836); +const content_interface_1 = __nccwpck_require__(6365); +class IssueContentInterface extends content_interface_1.ContentInterface { + constructor() { + super(...arguments); + this.issueRepository = new issue_repository_1.IssueRepository(); + this.internalGetter = async (execution) => { try { - for await (const response of octokit.paginate.iterator(octokit.rest.pulls.listReviewComments, { - owner, - repo: repository, - pull_number: pullNumber, - per_page: 100, - })) { - const data = response.data || []; - all.push(...data.map((c) => ({ - id: c.id, - body: c.body ?? null, - path: c.path, - line: c.line ?? undefined, - node_id: c.node_id ?? undefined, - }))); + let number = -1; + if (execution.isSingleAction) { + number = execution.issueNumber; } - return all; + else if (execution.isIssue) { + number = execution.issue.number; + } + else if (execution.isPullRequest) { + number = execution.pullRequest.number; + } + else if (execution.isPush) { + number = execution.issueNumber; + } + else { + return undefined; + } + const description = await this.issueRepository.getDescription(execution.owner, execution.repo, number, execution.tokens.token); + return this.getContent(description); } catch (error) { - (0, logger_1.logError)(`Error listing PR review comments (owner=${owner}, repo=${repository}, pullNumber=${pullNumber}): ${error}.`); - return []; + (0, logger_1.logError)(`Error reading issue configuration: ${error}`); + throw error; } }; - /** - * Fetches a single PR review comment by id (e.g. parent comment when user replied in thread). - * Returns the comment body or null if not found. - */ - this.getPullRequestReviewCommentBody = async (owner, repository, _pullNumber, commentId, token) => { - const octokit = github.getOctokit(token); + this.internalUpdate = async (execution, content) => { try { - const { data } = await octokit.rest.pulls.getReviewComment({ - owner, - repo: repository, - comment_id: commentId, - }); - return data.body ?? null; + let number = -1; + if (execution.isSingleAction) { + if (execution.isIssue) { + number = execution.issue.number; + } + else if (execution.isPullRequest) { + number = execution.pullRequest.number; + } + else if (execution.isPush) { + number = execution.issueNumber; + } + else { + number = execution.singleAction.issue; + } + } + else if (execution.isIssue) { + number = execution.issue.number; + } + else if (execution.isPullRequest) { + number = execution.pullRequest.number; + } + else if (execution.isPush) { + number = execution.issueNumber; + } + else { + return undefined; + } + const description = await this.issueRepository.getDescription(execution.owner, execution.repo, number, execution.tokens.token); + const updated = this.updateContent(description, content); + if (updated === undefined) { + return undefined; + } + await this.issueRepository.updateDescription(execution.owner, execution.repo, number, updated, execution.tokens.token); + return updated; } catch (error) { - (0, logger_1.logError)(`Error getting PR review comment ${commentId}: ${error}`); - return null; + (0, logger_1.logError)(`Error reading issue configuration: ${error}`); + throw error; } }; - /** - * Resolve a PR review thread (GraphQL only). Finds the thread that contains the given comment and marks it resolved. - * Uses repository.pullRequest.reviewThreads because the field pullRequestReviewThread on PullRequestReviewComment was removed from the API. - * Paginates through all threads and all comments in each thread so the comment is found regardless of PR size. - * No-op if thread is already resolved. Logs and does not throw on error. - */ - this.resolvePullRequestReviewThread = async (owner, repository, pullNumber, commentNodeId, token) => { - const octokit = github.getOctokit(token); + } +} +exports.IssueContentInterface = IssueContentInterface; + + +/***/ }), + +/***/ 4509: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ConfigurationHandler = void 0; +const config_1 = __nccwpck_require__(1106); +const logger_1 = __nccwpck_require__(8836); +const issue_content_interface_1 = __nccwpck_require__(9913); +/** Keys that must be preserved from stored config when current has undefined (e.g. when branch already existed). */ +const CONFIG_KEYS_TO_PRESERVE = [ + 'parentBranch', + 'workingBranch', + 'releaseBranch', + 'hotfixBranch', + 'hotfixOriginBranch', + 'branchType', +]; +class ConfigurationHandler extends issue_content_interface_1.IssueContentInterface { + constructor() { + super(...arguments); + this.update = async (execution) => { try { - let threadId = null; - let threadsCursor = null; - outer: do { - const threadsData = await octokit.graphql(`query ($owner: String!, $repo: String!, $prNumber: Int!, $threadsAfter: String) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $prNumber) { - reviewThreads(first: 100, after: $threadsAfter) { - nodes { - id - comments(first: 100) { - nodes { id } - pageInfo { hasNextPage endCursor } - } - } - pageInfo { hasNextPage endCursor } - } - } - } - }`, { owner, repo: repository, prNumber: pullNumber, threadsAfter: threadsCursor }); - const threads = threadsData?.repository?.pullRequest?.reviewThreads; - if (!threads?.nodes?.length) - break; - for (const thread of threads.nodes) { - let commentsCursor = null; - let commentNodes = thread.comments?.nodes ?? []; - let commentsPageInfo = thread.comments?.pageInfo; - do { - if (commentNodes.some((c) => c.id === commentNodeId)) { - threadId = thread.id; - break outer; - } - if (!commentsPageInfo?.hasNextPage || commentsPageInfo.endCursor == null) - break; - commentsCursor = commentsPageInfo.endCursor; - const nextComments = await octokit.graphql(`query ($threadId: ID!, $commentsAfter: String) { - node(id: $threadId) { - ... on PullRequestReviewThread { - comments(first: 100, after: $commentsAfter) { - nodes { id } - pageInfo { hasNextPage endCursor } - } - } - } - }`, { threadId: thread.id, commentsAfter: commentsCursor }); - commentNodes = nextComments?.node?.comments?.nodes ?? []; - commentsPageInfo = nextComments?.node?.comments?.pageInfo ?? { hasNextPage: false, endCursor: null }; - } while (commentsPageInfo?.hasNextPage === true && commentsPageInfo?.endCursor != null); + const current = execution.currentConfiguration; + const payload = { + branchType: current.branchType, + releaseBranch: current.releaseBranch, + workingBranch: current.workingBranch, + parentBranch: current.parentBranch, + hotfixOriginBranch: current.hotfixOriginBranch, + hotfixBranch: current.hotfixBranch, + results: current.results, + branchConfiguration: current.branchConfiguration, + }; + const storedRaw = await this.internalGetter(execution); + if (storedRaw != null && storedRaw.trim().length > 0) { + try { + const stored = JSON.parse(storedRaw); + for (const key of CONFIG_KEYS_TO_PRESERVE) { + if (payload[key] === undefined && stored[key] !== undefined) { + payload[key] = stored[key]; + } + } } - const pageInfo = threads.pageInfo; - if (threadId != null || !pageInfo?.hasNextPage) - break; - threadsCursor = pageInfo.endCursor ?? null; - } while (threadsCursor != null); - if (!threadId) { - (0, logger_1.logError)(`[Bugbot] No review thread found for comment node_id=${commentNodeId}.`); - return; - } - await octokit.graphql(`mutation ($threadId: ID!) { - resolveReviewThread(input: { threadId: $threadId }) { - thread { id } + catch { + /* ignore parse errors, save current as-is */ } - }`, { threadId }); - (0, logger_1.logDebugInfo)(`Resolved PR review thread ${threadId}.`); + } + return await this.internalUpdate(execution, JSON.stringify(payload, null, 4)); } - catch (err) { - (0, logger_1.logError)(`[Bugbot] Error resolving PR review thread (commentNodeId=${commentNodeId}, owner=${owner}, repo=${repository}): ${err}`); + catch (error) { + (0, logger_1.logError)(`Error updating issue description: ${error}`); + return undefined; } }; - /** - * Create a review on the PR with one or more inline comments (bugbot findings). - * Each comment requires path and line (use first file and line 1 if not specified). - */ - this.createReviewWithComments = async (owner, repository, pullNumber, commitId, comments, token) => { - if (comments.length === 0) - return; - const octokit = github.getOctokit(token); - const results = await Promise.allSettled(comments.map((c) => octokit.rest.pulls.createReviewComment({ - owner, - repo: repository, - pull_number: pullNumber, - commit_id: commitId, - path: c.path, - line: c.line, - side: 'RIGHT', - body: c.body, - }))); - let created = 0; - results.forEach((result, i) => { - if (result.status === 'fulfilled') { - created += 1; - } - else { - const c = comments[i]; - (0, logger_1.logError)(`[Bugbot] Error creating PR review comment. path="${c.path}", line=${c.line}, prNumber=${pullNumber}, owner=${owner}, repo=${repository}: ${result.reason}`); + this.get = async (execution) => { + try { + const config = await this.internalGetter(execution); + if (config === undefined) { + return undefined; } - }); - if (created > 0) { - (0, logger_1.logDebugInfo)(`Created ${created} review comment(s) on PR #${pullNumber}.`); + const branchConfig = JSON.parse(config); + return new config_1.Config(branchConfig); + } + catch (error) { + (0, logger_1.logError)(`Error reading issue configuration: ${error}`); + throw error; } - }; - /** Update an existing PR review comment (e.g. to mark finding as resolved in body). */ - this.updatePullRequestReviewComment = async (owner, repository, commentId, body, token) => { - const octokit = github.getOctokit(token); - await octokit.rest.pulls.updateReviewComment({ - owner, - repo: repository, - comment_id: commentId, - body, - }); - (0, logger_1.logDebugInfo)(`Updated review comment ${commentId}.`); }; } - /** First line (right side) of the first hunk per file, for valid review comment placement. */ - static firstLineFromPatch(patch) { - const match = patch.match(/^@@ -\d+,\d+ \+(\d+),\d+ @@/m); - return match ? parseInt(match[1], 10) : undefined; + get id() { + return 'configuration'; } + get visibleContent() { + return false; + } +} +exports.ConfigurationHandler = ConfigurationHandler; + + +/***/ }), + +/***/ 7879: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getAnswerIssueHelpPrompt = getAnswerIssueHelpPrompt; +/** + * Prompt for the initial reply when a user opens a question/help issue. + * Filled by the prompt provider; use getAnswerIssueHelpPrompt(). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. + +**Answer in this single response:** Give a complete, direct answer. Do not reply that you need to explore the repository, read documentation first, or gather more information—use the project (README, docs/, code, .cursor/rules) to answer now. For "how do I…" or tutorial-style questions (e.g. how to implement or configure this project), provide concrete steps or guidance based on the project's actual documentation and structure. + +{{projectContextInstruction}} + +**Issue description (user's question or request):** +""" +{{description}} +""" + +Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; +function getAnswerIssueHelpPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + description: params.description, + projectContextInstruction: params.projectContextInstruction, + }); +} + + +/***/ }), + +/***/ 1118: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getBugbotPrompt = getBugbotPrompt; +/** + * Prompt for Bugbot detection (detect potential problems on push). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are analyzing the latest code changes for potential bugs and issues. + +{{projectContextInstruction}} + +**Repository context:** +- Owner: {{owner}} +- Repository: {{repo}} +- Branch (head): {{headBranch}} +- Base branch: {{baseBranch}} +- Issue number: {{issueNumber}} +{{ignoreBlock}} + +**Your task 1 (new/current problems):** Determine what has changed in the branch "{{headBranch}}" compared to "{{baseBranch}}" (you must compute or obtain the diff yourself using the repository context above). Then identify potential bugs, logic errors, security issues, and code quality problems. Be strict and descriptive. One finding per distinct problem. Return them in the \`findings\` array (each with id, title, description; optionally file, line, severity, suggestion). Only include findings in files that are not in the ignore list above. +{{previousBlock}} + +**Output:** Return a JSON object with: "findings" (array of new/current problems from task 1), and if we gave you previously reported issues above, "resolved_finding_ids" (array of those ids that are now fixed or no longer apply, as per task 2).`; +function getBugbotPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + ...params, + issueNumber: String(params.issueNumber), + }); +} + + +/***/ }), + +/***/ 9673: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getBugbotFixPrompt = getBugbotFixPrompt; +/** + * Prompt for Bugbot autofix (fix selected findings in workspace). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are in the repository workspace. Your task is to fix the reported code findings (bugs, vulnerabilities, or quality issues) listed below, and only those. The user has explicitly requested these fixes. + +{{projectContextInstruction}} + +**Repository context:** +- Owner: {{owner}} +- Repository: {{repo}} +- Branch (head): {{headBranch}} +- Base branch: {{baseBranch}} +- Issue number: {{issueNumber}} +{{prNumberLine}} + +**Findings to fix (do not change code unrelated to these):** +{{findingsBlock}} + +**User request:** +""" +{{userComment}} +""" + +**Rules:** +1. Fix only the problems described in the findings above. Do not refactor or change other code except as strictly necessary for the fix. +2. You may add or update tests only to validate that the fix is correct. +3. After applying changes, run the verify commands (or standard build/test/lint) and ensure they all pass. If they fail, adjust the fix until they pass. +4. Apply all changes directly in the workspace (edit files, run commands). Do not output diffs for someone else to apply. +{{verifyBlock}} + +Once the fixes are applied and the verify commands pass, reply briefly confirming what was fixed and that checks passed.`; +function getBugbotFixPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + ...params, + issueNumber: String(params.issueNumber), + }); +} + + +/***/ }), + +/***/ 3975: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getBugbotFixIntentPrompt = getBugbotFixIntentPrompt; +/** + * Prompt for detecting if user comment is a fix request and which finding ids to target. + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are analyzing a user comment on an issue or pull request to decide whether they are asking to fix one or more reported code findings (bugs, vulnerabilities, or quality issues). + +{{projectContextInstruction}} + +**List of unresolved findings (id, title, and optional file/line/description):** +{{findingsBlock}} +{{parentBlock}} +**User comment:** +""" +{{userComment}} +""" + +**Your task:** Decide: +1. Is this comment clearly a request to fix one or more of the findings above? (e.g. "fix it", "arreglalo", "fix this", "fix all", "fix vulnerability X", "corrige", "fix the bug in src/foo.ts"). If the user is asking a question, discussing something else, or the intent is ambiguous, set \`is_fix_request\` to false. +2. If it is a fix request, which finding ids should be fixed? Return their exact ids in \`target_finding_ids\`. If the user says "fix all" or equivalent, include every id from the list above. If they refer to a specific finding (e.g. by replying to a comment that contains one finding), return only that finding's id. Use only ids that appear in the list above. +3. Is the user asking to perform some other change or task in the repo? (e.g. "add a test for X", "refactor this", "implement feature Y", "haz que Z"). If yes, set \`is_do_request\` to true. Set false for pure questions or when the only intent is to fix the listed findings. + +Respond with a JSON object: \`is_fix_request\` (boolean), \`target_finding_ids\` (array of strings; empty when \`is_fix_request\` is false), and \`is_do_request\` (boolean).`; +function getBugbotFixIntentPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, params); +} + + +/***/ }), + +/***/ 6320: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getCheckCommentLanguagePrompt = getCheckCommentLanguagePrompt; +exports.getTranslateCommentPrompt = getTranslateCommentPrompt; +/** + * Prompts for checking if a comment is in the target locale and for translating it. + * Used by CheckIssueCommentLanguageUseCase and CheckPullRequestCommentLanguageUseCase. + */ +const fill_1 = __nccwpck_require__(5269); +const CHECK_TEMPLATE = ` + You are a helpful assistant that checks if the text is written in {{locale}}. + + Instructions: + 1. Analyze the provided text + 2. If the text is written in {{locale}}, respond with exactly "done" + 3. If the text is written in any other language, respond with exactly "must_translate" + 4. Do not provide any explanation or additional text + + The text is: {{commentBody}} + `; +const TRANSLATE_TEMPLATE = ` +You are a helpful assistant that translates the text to {{locale}}. + +Instructions: +1. Translate the text to {{locale}} +2. Put the translated text in the translatedText field +3. If you cannot translate (e.g. ambiguous or invalid input), set translatedText to empty string and explain in reason + +The text to translate is: {{commentBody}} + `; +function getCheckCommentLanguagePrompt(params) { + return (0, fill_1.fillTemplate)(CHECK_TEMPLATE.trim(), { + locale: params.locale, + commentBody: params.commentBody, + }); +} +function getTranslateCommentPrompt(params) { + return (0, fill_1.fillTemplate)(TRANSLATE_TEMPLATE.trim(), { + locale: params.locale, + commentBody: params.commentBody, + }); +} + + +/***/ }), + +/***/ 7553: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getCheckProgressPrompt = getCheckProgressPrompt; +/** + * Prompt for assessing issue progress from branch diff (CheckProgressUseCase). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are in the repository workspace. Assess the progress of issue #{{issueNumber}} using the full diff between the base (parent) branch and the current branch. + +{{projectContextInstruction}} + +**Branches:** +- **Base (parent) branch:** \`{{baseBranch}}\` +- **Current branch:** \`{{currentBranch}}\` + +**Instructions:** +1. Get the full diff by running: \`git diff {{baseBranch}}..{{currentBranch}}\` (or \`git diff {{baseBranch}}...{{currentBranch}}\` for merge-base). If you cannot run shell commands, use whatever workspace tools you have to inspect changes between these branches. +2. Optionally confirm the current branch with \`git branch --show-current\` if needed. +3. Based on the full diff and the issue description below, assess completion progress (0-100%) and write a short summary. +4. If progress is below 100%, add a "remaining" field with a short description of what is left to do to complete the task (e.g. missing implementation, tests, docs). Omit "remaining" or leave empty when progress is 100%. + +**Issue description:** +{{issueDescription}} + +Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" }.`; +function getCheckProgressPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + issueNumber: String(params.issueNumber), + baseBranch: params.baseBranch, + currentBranch: params.currentBranch, + issueDescription: params.issueDescription, + }); +} + + +/***/ }), + +/***/ 7663: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getCliDoPrompt = getCliDoPrompt; +/** + * Prompt for CLI "copilot do" command: project context + user prompt. + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `{{projectContextInstruction}} + +{{userPrompt}}`; +function getCliDoPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, params); +} + + +/***/ }), + +/***/ 5269: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.fillTemplate = fillTemplate; +/** + * Replaces {{paramName}} placeholders in a template with values from params. + * Missing keys are left as {{paramName}}. + */ +function fillTemplate(template, params) { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => params[key] ?? `{{${key}}}`); } -exports.PullRequestRepository = PullRequestRepository; -/** Default timeout (ms) for isLinked fetch. */ -PullRequestRepository.IS_LINKED_FETCH_TIMEOUT_MS = 10000; /***/ }), -/***/ 779: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { +/***/ 5554: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.WorkflowRepository = void 0; -const github = __importStar(__nccwpck_require__(5438)); -const workflow_run_1 = __nccwpck_require__(6845); -const constants_1 = __nccwpck_require__(8593); -class WorkflowRepository { - constructor() { - this.getWorkflows = async (params) => { - const octokit = github.getOctokit(params.tokens.token); - const workflows = await octokit.rest.actions.listWorkflowRunsForRepo({ - owner: params.owner, - repo: params.repo, - }); - return workflows.data.workflow_runs.map(w => new workflow_run_1.WorkflowRun({ - id: w.id, - name: w.name ?? 'unknown', - head_branch: w.head_branch, - head_sha: w.head_sha, - run_number: w.run_number, - event: w.event, - status: w.status ?? 'unknown', - conclusion: w.conclusion ?? null, - created_at: w.created_at, - updated_at: w.updated_at, - url: w.url, - html_url: w.html_url, - })); - }; - this.getActivePreviousRuns = async (params) => { - const workflows = await this.getWorkflows(params); - const runId = parseInt(process.env.GITHUB_RUN_ID, 10); - const workflowName = process.env.GITHUB_WORKFLOW; - return workflows.filter((run) => { - const isSameWorkflow = run.name === workflowName; - const isPrevious = run.id < runId; - const isActive = constants_1.WORKFLOW_ACTIVE_STATUSES.includes(run.status); - return isSameWorkflow && isPrevious && isActive; - }); - }; +exports.PROMPT_NAMES = exports.getBugbotFixIntentPrompt = exports.getBugbotFixPrompt = exports.getBugbotPrompt = exports.getCliDoPrompt = exports.getTranslateCommentPrompt = exports.getCheckCommentLanguagePrompt = exports.getCheckProgressPrompt = exports.getRecommendStepsPrompt = exports.getUserRequestPrompt = exports.getUpdatePullRequestDescriptionPrompt = exports.getThinkPrompt = exports.getAnswerIssueHelpPrompt = exports.fillTemplate = void 0; +exports.getPrompt = getPrompt; +/** + * Prompt provider: one file per prompt, each exports a getter that fills the template with params. + * Use getPrompt(name, params) for a generic call or import the typed getter (e.g. getAnswerIssueHelpPrompt). + */ +const answer_issue_help_1 = __nccwpck_require__(7879); +const think_1 = __nccwpck_require__(5725); +const update_pull_request_description_1 = __nccwpck_require__(1482); +const user_request_1 = __nccwpck_require__(1762); +const recommend_steps_1 = __nccwpck_require__(2041); +const check_progress_1 = __nccwpck_require__(7553); +const check_comment_language_1 = __nccwpck_require__(6320); +const cli_do_1 = __nccwpck_require__(7663); +const bugbot_1 = __nccwpck_require__(1118); +const bugbot_fix_1 = __nccwpck_require__(9673); +const bugbot_fix_intent_1 = __nccwpck_require__(3975); +var fill_1 = __nccwpck_require__(5269); +Object.defineProperty(exports, "fillTemplate", ({ enumerable: true, get: function () { return fill_1.fillTemplate; } })); +var answer_issue_help_2 = __nccwpck_require__(7879); +Object.defineProperty(exports, "getAnswerIssueHelpPrompt", ({ enumerable: true, get: function () { return answer_issue_help_2.getAnswerIssueHelpPrompt; } })); +var think_2 = __nccwpck_require__(5725); +Object.defineProperty(exports, "getThinkPrompt", ({ enumerable: true, get: function () { return think_2.getThinkPrompt; } })); +var update_pull_request_description_2 = __nccwpck_require__(1482); +Object.defineProperty(exports, "getUpdatePullRequestDescriptionPrompt", ({ enumerable: true, get: function () { return update_pull_request_description_2.getUpdatePullRequestDescriptionPrompt; } })); +var user_request_2 = __nccwpck_require__(1762); +Object.defineProperty(exports, "getUserRequestPrompt", ({ enumerable: true, get: function () { return user_request_2.getUserRequestPrompt; } })); +var recommend_steps_2 = __nccwpck_require__(2041); +Object.defineProperty(exports, "getRecommendStepsPrompt", ({ enumerable: true, get: function () { return recommend_steps_2.getRecommendStepsPrompt; } })); +var check_progress_2 = __nccwpck_require__(7553); +Object.defineProperty(exports, "getCheckProgressPrompt", ({ enumerable: true, get: function () { return check_progress_2.getCheckProgressPrompt; } })); +var check_comment_language_2 = __nccwpck_require__(6320); +Object.defineProperty(exports, "getCheckCommentLanguagePrompt", ({ enumerable: true, get: function () { return check_comment_language_2.getCheckCommentLanguagePrompt; } })); +Object.defineProperty(exports, "getTranslateCommentPrompt", ({ enumerable: true, get: function () { return check_comment_language_2.getTranslateCommentPrompt; } })); +var cli_do_2 = __nccwpck_require__(7663); +Object.defineProperty(exports, "getCliDoPrompt", ({ enumerable: true, get: function () { return cli_do_2.getCliDoPrompt; } })); +var bugbot_2 = __nccwpck_require__(1118); +Object.defineProperty(exports, "getBugbotPrompt", ({ enumerable: true, get: function () { return bugbot_2.getBugbotPrompt; } })); +var bugbot_fix_2 = __nccwpck_require__(9673); +Object.defineProperty(exports, "getBugbotFixPrompt", ({ enumerable: true, get: function () { return bugbot_fix_2.getBugbotFixPrompt; } })); +var bugbot_fix_intent_2 = __nccwpck_require__(3975); +Object.defineProperty(exports, "getBugbotFixIntentPrompt", ({ enumerable: true, get: function () { return bugbot_fix_intent_2.getBugbotFixIntentPrompt; } })); +/** Known prompt names for getPrompt() */ +exports.PROMPT_NAMES = { + ANSWER_ISSUE_HELP: 'answer_issue_help', + THINK: 'think', + UPDATE_PULL_REQUEST_DESCRIPTION: 'update_pull_request_description', + USER_REQUEST: 'user_request', + RECOMMEND_STEPS: 'recommend_steps', + CHECK_PROGRESS: 'check_progress', + CHECK_COMMENT_LANGUAGE: 'check_comment_language', + TRANSLATE_COMMENT: 'translate_comment', + CLI_DO: 'cli_do', + BUGBOT: 'bugbot', + BUGBOT_FIX: 'bugbot_fix', + BUGBOT_FIX_INTENT: 'bugbot_fix_intent', +}; +const registry = { + [exports.PROMPT_NAMES.ANSWER_ISSUE_HELP]: (p) => (0, answer_issue_help_1.getAnswerIssueHelpPrompt)(p), + [exports.PROMPT_NAMES.THINK]: (p) => (0, think_1.getThinkPrompt)(p), + [exports.PROMPT_NAMES.UPDATE_PULL_REQUEST_DESCRIPTION]: (p) => (0, update_pull_request_description_1.getUpdatePullRequestDescriptionPrompt)(p), + [exports.PROMPT_NAMES.USER_REQUEST]: (p) => (0, user_request_1.getUserRequestPrompt)(p), + [exports.PROMPT_NAMES.RECOMMEND_STEPS]: (p) => (0, recommend_steps_1.getRecommendStepsPrompt)(p), + [exports.PROMPT_NAMES.CHECK_PROGRESS]: (p) => (0, check_progress_1.getCheckProgressPrompt)(p), + [exports.PROMPT_NAMES.CHECK_COMMENT_LANGUAGE]: (p) => (0, check_comment_language_1.getCheckCommentLanguagePrompt)(p), + [exports.PROMPT_NAMES.TRANSLATE_COMMENT]: (p) => (0, check_comment_language_1.getTranslateCommentPrompt)(p), + [exports.PROMPT_NAMES.CLI_DO]: (p) => (0, cli_do_1.getCliDoPrompt)(p), + [exports.PROMPT_NAMES.BUGBOT]: (p) => (0, bugbot_1.getBugbotPrompt)(p), + [exports.PROMPT_NAMES.BUGBOT_FIX]: (p) => (0, bugbot_fix_1.getBugbotFixPrompt)(p), + [exports.PROMPT_NAMES.BUGBOT_FIX_INTENT]: (p) => (0, bugbot_fix_intent_1.getBugbotFixIntentPrompt)(p), +}; +/** + * Returns a filled prompt by name. Params must match the prompt's expected keys. + */ +function getPrompt(name, params) { + const fn = registry[name]; + if (!fn) { + throw new Error(`Unknown prompt: ${name}`); } + return fn(params); } -exports.WorkflowRepository = WorkflowRepository; /***/ }), -/***/ 6365: +/***/ 2041: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.ContentInterface = void 0; -const logger_1 = __nccwpck_require__(8836); -class ContentInterface { - constructor() { - this.getContent = (description) => { - try { - if (description === undefined) { - return undefined; - } - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { - return undefined; - } - return description.split(this.startPattern)[1].split(this.endPattern)[0]; - } - catch (error) { - (0, logger_1.logError)(`Error reading issue configuration: ${error}`); - throw error; - } - }; - this._addContent = (description, content) => { - if (description.indexOf(this.startPattern) === -1 && description.indexOf(this.endPattern) === -1) { - const newContent = `${this.startPattern}\n${content}\n${this.endPattern}`; - return `${description}\n\n${newContent}`; - } - else { - return undefined; - } - }; - this._updateContent = (description, content) => { - if (description.indexOf(this.startPattern) === -1 || description.indexOf(this.endPattern) === -1) { - (0, logger_1.logError)(`The content has a problem with open-close tags: ${this.startPattern} / ${this.endPattern}`); - return undefined; - } - const start = description.split(this.startPattern)[0]; - const mid = `${this.startPattern}\n${content}\n${this.endPattern}`; - const end = description.split(this.endPattern)[1]; - return `${start}${mid}${end}`; - }; - this.updateContent = (description, content) => { - try { - if (description === undefined || content === undefined) { - return undefined; - } - const addedContent = this._addContent(description, content); - if (addedContent !== undefined) { - return addedContent; - } - return this._updateContent(description, content); - } - catch (error) { - (0, logger_1.logError)(`Error updating issue description: ${error}`); - return undefined; - } - }; - } - get _id() { - return `copilot-${this.id}`; - } - get startPattern() { - if (this.visibleContent) { - return ``; - } - return ``; - } - return `${this._id}-end -->`; - } +exports.getRecommendStepsPrompt = getRecommendStepsPrompt; +/** + * Prompt for recommending implementation steps from an issue (RecommendStepsUseCase). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. + +{{projectContextInstruction}} + +**Issue #{{issueNumber}} description:** +{{issueDescription}} + +Provide a numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed.`; +function getRecommendStepsPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + issueNumber: String(params.issueNumber), + issueDescription: params.issueDescription, + }); } -exports.ContentInterface = ContentInterface; /***/ }), -/***/ 9913: +/***/ 5725: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.IssueContentInterface = void 0; -const issue_repository_1 = __nccwpck_require__(57); -const logger_1 = __nccwpck_require__(8836); -const content_interface_1 = __nccwpck_require__(6365); -class IssueContentInterface extends content_interface_1.ContentInterface { - constructor() { - super(...arguments); - this.issueRepository = new issue_repository_1.IssueRepository(); - this.internalGetter = async (execution) => { - try { - let number = -1; - if (execution.isSingleAction) { - number = execution.issueNumber; - } - else if (execution.isIssue) { - number = execution.issue.number; - } - else if (execution.isPullRequest) { - number = execution.pullRequest.number; - } - else if (execution.isPush) { - number = execution.issueNumber; - } - else { - return undefined; - } - const description = await this.issueRepository.getDescription(execution.owner, execution.repo, number, execution.tokens.token); - return this.getContent(description); - } - catch (error) { - (0, logger_1.logError)(`Error reading issue configuration: ${error}`); - throw error; - } - }; - this.internalUpdate = async (execution, content) => { - try { - let number = -1; - if (execution.isSingleAction) { - if (execution.isIssue) { - number = execution.issue.number; - } - else if (execution.isPullRequest) { - number = execution.pullRequest.number; - } - else if (execution.isPush) { - number = execution.issueNumber; - } - else { - number = execution.singleAction.issue; - } - } - else if (execution.isIssue) { - number = execution.issue.number; - } - else if (execution.isPullRequest) { - number = execution.pullRequest.number; - } - else if (execution.isPush) { - number = execution.issueNumber; - } - else { - return undefined; - } - const description = await this.issueRepository.getDescription(execution.owner, execution.repo, number, execution.tokens.token); - const updated = this.updateContent(description, content); - if (updated === undefined) { - return undefined; - } - await this.issueRepository.updateDescription(execution.owner, execution.repo, number, updated, execution.tokens.token); - return updated; - } - catch (error) { - (0, logger_1.logError)(`Error reading issue configuration: ${error}`); - throw error; - } - }; - } +exports.getThinkPrompt = getThinkPrompt; +/** + * Prompt for the Think use case (answer to @mention in issue/PR comment). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are a helpful assistant. Answer the following question concisely, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. + +{{projectContextInstruction}} +{{contextBlock}}Question: {{question}}`; +function getThinkPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + contextBlock: params.contextBlock, + question: params.question, + }); +} + + +/***/ }), + +/***/ 1482: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getUpdatePullRequestDescriptionPrompt = getUpdatePullRequestDescriptionPrompt; +/** + * Prompt for generating PR description from issue and diff (UpdatePullRequestDescriptionUseCase). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. + +{{projectContextInstruction}} + +**Branches:** +- **Base (target) branch:** \`{{baseBranch}}\` +- **Head (source) branch:** \`{{headBranch}}\` + +**Instructions:** +1. Read the pull request template file: \`.github/pull_request_template.md\`. Use its structure (headings, bullet lists, separators) as the skeleton for your output. The checkboxes in the template are **indicative only**: you may check the ones that apply based on the project and the diff, define different or fewer checkboxes if that fits better, or omit a section entirely if it does not apply. +2. Get the full diff by running: \`git diff {{baseBranch}}..{{headBranch}}\` (or \`git diff {{baseBranch}}...{{headBranch}}\` for merge-base). Use the diff to understand what changed. +3. Use the issue description below for context and intent. +4. Fill each section of the template with concrete content derived from the diff and the issue. Keep the same markdown structure (headings, horizontal rules). For checkbox sections (e.g. Test Coverage, Deployment Notes, Security): use the template's options as guidance; check or add only the items that apply, or skip the section if it does not apply. + - **Summary:** brief explanation of what the PR does and why (intent, not implementation details). + - **Related Issues:** include \`Closes #{{issueNumber}}\` and "Related to #" only if relevant. + - **Scope of Changes:** use Added / Updated / Removed / Refactored with short bullet points (high level, not file-by-file). + - **Technical Details:** important decisions, trade-offs, or non-obvious aspects. + - **How to Test:** steps a reviewer can follow (infer from the changes when possible). + - **Test Coverage / Deployment / Security / Performance / Checklist:** treat checkboxes as indicative; check the ones that apply from the diff and project context, or omit the section if it does not apply. + - **Breaking Changes:** list any, or "None". + - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. +5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. + +**Issue description:** +{{issueDescription}} + +Output only the filled template content (the PR description body), starting with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks or add extra commentary.`; +function getUpdatePullRequestDescriptionPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + baseBranch: params.baseBranch, + headBranch: params.headBranch, + issueNumber: String(params.issueNumber), + issueDescription: params.issueDescription, + }); } -exports.IssueContentInterface = IssueContentInterface; /***/ }), -/***/ 4509: +/***/ 1762: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.ConfigurationHandler = void 0; -const config_1 = __nccwpck_require__(1106); -const logger_1 = __nccwpck_require__(8836); -const issue_content_interface_1 = __nccwpck_require__(9913); -/** Keys that must be preserved from stored config when current has undefined (e.g. when branch already existed). */ -const CONFIG_KEYS_TO_PRESERVE = [ - 'parentBranch', - 'workingBranch', - 'releaseBranch', - 'hotfixBranch', - 'hotfixOriginBranch', - 'branchType', -]; -class ConfigurationHandler extends issue_content_interface_1.IssueContentInterface { - constructor() { - super(...arguments); - this.update = async (execution) => { - try { - const current = execution.currentConfiguration; - const payload = { - branchType: current.branchType, - releaseBranch: current.releaseBranch, - workingBranch: current.workingBranch, - parentBranch: current.parentBranch, - hotfixOriginBranch: current.hotfixOriginBranch, - hotfixBranch: current.hotfixBranch, - results: current.results, - branchConfiguration: current.branchConfiguration, - }; - const storedRaw = await this.internalGetter(execution); - if (storedRaw != null && storedRaw.trim().length > 0) { - try { - const stored = JSON.parse(storedRaw); - for (const key of CONFIG_KEYS_TO_PRESERVE) { - if (payload[key] === undefined && stored[key] !== undefined) { - payload[key] = stored[key]; - } - } - } - catch { - /* ignore parse errors, save current as-is */ - } - } - return await this.internalUpdate(execution, JSON.stringify(payload, null, 4)); - } - catch (error) { - (0, logger_1.logError)(`Error updating issue description: ${error}`); - return undefined; - } - }; - this.get = async (execution) => { - try { - const config = await this.internalGetter(execution); - if (config === undefined) { - return undefined; - } - const branchConfig = JSON.parse(config); - return new config_1.Config(branchConfig); - } - catch (error) { - (0, logger_1.logError)(`Error reading issue configuration: ${error}`); - throw error; - } - }; - } - get id() { - return 'configuration'; - } - get visibleContent() { - return false; - } +exports.getUserRequestPrompt = getUserRequestPrompt; +/** + * Prompt for the Do user request use case (generic "do this" in repo). + */ +const fill_1 = __nccwpck_require__(5269); +const TEMPLATE = `You are in the repository workspace. The user has asked you to do something. Perform their request by editing files and running commands directly in the workspace. Do not output diffs for someone else to apply. + +{{projectContextInstruction}} + +**Repository context:** +- Owner: {{owner}} +- Repository: {{repo}} +- Branch (head): {{headBranch}} +- Base branch: {{baseBranch}} +- Issue number: {{issueNumber}} + +**User request:** +""" +{{userComment}} +""" + +**Rules:** +1. Apply all changes directly in the workspace (edit files, run commands). +2. If the project has standard checks (build, test, lint), run them and ensure they pass when relevant. +3. Reply briefly confirming what you did.`; +function getUserRequestPrompt(params) { + return (0, fill_1.fillTemplate)(TEMPLATE, params); } -exports.ConfigurationHandler = ConfigurationHandler; /***/ }), @@ -47769,6 +48497,7 @@ const issue_repository_1 = __nccwpck_require__(57); const branch_repository_1 = __nccwpck_require__(7701); const pull_request_repository_1 = __nccwpck_require__(634); const ai_repository_1 = __nccwpck_require__(8307); +const prompts_1 = __nccwpck_require__(5554); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const PROGRESS_RESPONSE_SCHEMA = { type: 'object', @@ -47874,7 +48603,13 @@ class CheckProgressUseCase { // Get development (parent) branch – we pass this so the OpenCode agent can compute the diff const developmentBranch = param.branches.development || 'develop'; (0, logger_1.logInfo)(`📦 Progress will be assessed from workspace diff: base branch "${developmentBranch}", current branch "${branch}" (OpenCode agent will run git diff).`); - const prompt = this.buildProgressPrompt(issueNumber, issueDescription, branch, developmentBranch); + const prompt = (0, prompts_1.getCheckProgressPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + issueNumber: String(issueNumber), + issueDescription, + baseBranch: developmentBranch, + currentBranch: branch, + }); (0, logger_1.logInfo)('🤖 Analyzing progress using OpenCode Plan agent...'); const attemptResult = await this.fetchProgressAttempt(param.ai, prompt); const progress = attemptResult.progress; @@ -47990,31 +48725,6 @@ class CheckProgressUseCase { : ''; return { progress, summary, reasoning, remaining }; } - /** - * Builds the progress prompt for the OpenCode agent. We do not send the diff from our side: - * we tell the agent the base (parent) branch and current branch so it can run `git diff` - * in the workspace and compute the full diff itself. - */ - buildProgressPrompt(issueNumber, issueDescription, currentBranch, baseBranch) { - return `You are in the repository workspace. Assess the progress of issue #${issueNumber} using the full diff between the base (parent) branch and the current branch. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Branches:** -- **Base (parent) branch:** \`${baseBranch}\` -- **Current branch:** \`${currentBranch}\` - -**Instructions:** -1. Get the full diff by running: \`git diff ${baseBranch}..${currentBranch}\` (or \`git diff ${baseBranch}...${currentBranch}\` for merge-base). If you cannot run shell commands, use whatever workspace tools you have to inspect changes between these branches. -2. Optionally confirm the current branch with \`git branch --show-current\` if needed. -3. Based on the full diff and the issue description below, assess completion progress (0-100%) and write a short summary. -4. If progress is below 100%, add a "remaining" field with a short description of what is left to do to complete the task (e.g. missing implementation, tests, docs). Omit "remaining" or leave empty when progress is 100%. - -**Issue description:** -${issueDescription} - -Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" }.`; - } /** * Returns true if the reasoning text looks truncated (e.g. ends with ":" or trailing spaces, * or no sentence-ending punctuation), so we can append a note in the comment. @@ -48588,11 +49298,12 @@ exports.PublishGithubActionUseCase = PublishGithubActionUseCase; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.RecommendStepsUseCase = void 0; const result_1 = __nccwpck_require__(7305); -const logger_1 = __nccwpck_require__(8836); -const task_emoji_1 = __nccwpck_require__(9785); -const issue_repository_1 = __nccwpck_require__(57); const ai_repository_1 = __nccwpck_require__(8307); +const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); +const logger_1 = __nccwpck_require__(8836); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); +const task_emoji_1 = __nccwpck_require__(9785); class RecommendStepsUseCase { constructor() { this.taskId = 'RecommendStepsUseCase'; @@ -48632,14 +49343,11 @@ class RecommendStepsUseCase { })); return results; } - const prompt = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Issue #${issueNumber} description:** -${issueDescription} - -Provide a numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed.`; + const prompt = (0, prompts_1.getRecommendStepsPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + issueNumber: String(issueNumber), + issueDescription, + }); (0, logger_1.logInfo)(`🤖 Recommending steps using OpenCode Plan agent...`); const response = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt); const steps = typeof response === 'string' @@ -49698,6 +50406,7 @@ function canRunDoUserRequest(payload) { */ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildBugbotFixIntentPrompt = buildBugbotFixIntentPrompt; +const prompts_1 = __nccwpck_require__(5554); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const sanitize_user_comment_for_prompt_1 = __nccwpck_require__(3514); const MAX_TITLE_LENGTH = 200; @@ -49723,24 +50432,12 @@ function buildBugbotFixIntentPrompt(userComment, unresolvedFindings, parentComme : ''; })() : ''; - return `You are analyzing a user comment on an issue or pull request to decide whether they are asking to fix one or more reported code findings (bugs, vulnerabilities, or quality issues). - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**List of unresolved findings (id, title, and optional file/line/description):** -${findingsBlock} -${parentBlock} -**User comment:** -""" -${(0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment)} -""" - -**Your task:** Decide: -1. Is this comment clearly a request to fix one or more of the findings above? (e.g. "fix it", "arreglalo", "fix this", "fix all", "fix vulnerability X", "corrige", "fix the bug in src/foo.ts"). If the user is asking a question, discussing something else, or the intent is ambiguous, set \`is_fix_request\` to false. -2. If it is a fix request, which finding ids should be fixed? Return their exact ids in \`target_finding_ids\`. If the user says "fix all" or equivalent, include every id from the list above. If they refer to a specific finding (e.g. by replying to a comment that contains one finding), return only that finding's id. Use only ids that appear in the list above. -3. Is the user asking to perform some other change or task in the repo? (e.g. "add a test for X", "refactor this", "implement feature Y", "haz que Z"). If yes, set \`is_do_request\` to true. Set false for pure questions or when the only intent is to fix the listed findings. - -Respond with a JSON object: \`is_fix_request\` (boolean), \`target_finding_ids\` (array of strings; empty when \`is_fix_request\` is false), and \`is_do_request\` (boolean).`; + return (0, prompts_1.getBugbotFixIntentPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + findingsBlock, + parentBlock, + userComment: (0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment), + }); } @@ -49755,6 +50452,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.MAX_FINDING_BODY_LENGTH = void 0; exports.truncateFindingBody = truncateFindingBody; exports.buildBugbotFixPrompt = buildBugbotFixPrompt; +const prompts_1 = __nccwpck_require__(5554); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const sanitize_user_comment_for_prompt_1 = __nccwpck_require__(3514); /** Maximum characters for a single finding's full comment body to avoid prompt bloat and token limits. */ @@ -49799,34 +50497,19 @@ function buildBugbotFixPrompt(param, context, targetFindingIds, userComment, ver const verifyBlock = verifyCommands.length > 0 ? `\n**Verify commands (run these in the workspace in order and only consider the fix successful if all pass):**\n${verifyCommands.map((c) => `- \`${String(c).replace(/`/g, "\\`")}\``).join("\n")}\n` : "\n**Verify:** Run any standard project checks (e.g. build, test, lint) that exist in this repo and confirm they pass.\n"; - return `You are in the repository workspace. Your task is to fix the reported code findings (bugs, vulnerabilities, or quality issues) listed below, and only those. The user has explicitly requested these fixes. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Repository context:** -- Owner: ${owner} -- Repository: ${repo} -- Branch (head): ${headBranch} -- Base branch: ${baseBranch} -- Issue number: ${issueNumber} -${prNumber != null ? `- Pull request number: ${prNumber}` : ""} - -**Findings to fix (do not change code unrelated to these):** -${findingsBlock} - -**User request:** -""" -${(0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment)} -""" - -**Rules:** -1. Fix only the problems described in the findings above. Do not refactor or change other code except as strictly necessary for the fix. -2. You may add or update tests only to validate that the fix is correct. -3. After applying changes, run the verify commands (or standard build/test/lint) and ensure they all pass. If they fail, adjust the fix until they pass. -4. Apply all changes directly in the workspace (edit files, run commands). Do not output diffs for someone else to apply. -${verifyBlock} - -Once the fixes are applied and the verify commands pass, reply briefly confirming what was fixed and that checks passed.`; + const prNumberLine = prNumber != null ? `- Pull request number: ${prNumber}` : ""; + return (0, prompts_1.getBugbotFixPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + owner, + repo, + headBranch, + baseBranch, + issueNumber: String(issueNumber), + prNumberLine, + findingsBlock, + userComment: (0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment), + verifyBlock, + }); } @@ -49845,13 +50528,14 @@ Once the fixes are applied and the verify commands pass, reply briefly confirmin */ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildBugbotPrompt = buildBugbotPrompt; +const prompts_1 = __nccwpck_require__(5554); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); +const MAX_IGNORE_BLOCK_LENGTH = 2000; function buildBugbotPrompt(param, context) { const headBranch = param.commit.branch; const baseBranch = param.currentConfiguration.parentBranch ?? param.branches.development ?? 'develop'; const previousBlock = context.previousFindingsBlock; const ignorePatterns = param.ai?.getAiIgnoreFiles?.() ?? []; - const MAX_IGNORE_BLOCK_LENGTH = 2000; const ignoreBlock = ignorePatterns.length > 0 ? (() => { const raw = ignorePatterns.join(", "); @@ -49861,22 +50545,16 @@ function buildBugbotPrompt(param, context) { return `\n**Files to ignore:** Do not report findings in files or paths matching these patterns: ${truncated}.`; })() : ""; - return `You are analyzing the latest code changes for potential bugs and issues. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Repository context:** -- Owner: ${param.owner} -- Repository: ${param.repo} -- Branch (head): ${headBranch} -- Base branch: ${baseBranch} -- Issue number: ${param.issueNumber} -${ignoreBlock} - -**Your task 1 (new/current problems):** Determine what has changed in the branch "${headBranch}" compared to "${baseBranch}" (you must compute or obtain the diff yourself using the repository context above). Then identify potential bugs, logic errors, security issues, and code quality problems. Be strict and descriptive. One finding per distinct problem. Return them in the \`findings\` array (each with id, title, description; optionally file, line, severity, suggestion). Only include findings in files that are not in the ignore list above. -${previousBlock} - -**Output:** Return a JSON object with: "findings" (array of new/current problems from task 1), and if we gave you previously reported issues above, "resolved_finding_ids" (array of those ids that are now fixed or no longer apply, as per task 2).`; + return (0, prompts_1.getBugbotPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + owner: param.owner, + repo: param.repo, + headBranch, + baseBranch, + issueNumber: String(param.issueNumber), + ignoreBlock, + previousBlock, + }); } @@ -51135,39 +51813,13 @@ exports.NotifyNewCommitOnIssueUseCase = NotifyNewCommitOnIssueUseCase; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.DoUserRequestUseCase = void 0; const ai_repository_1 = __nccwpck_require__(8307); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const task_emoji_1 = __nccwpck_require__(9785); const result_1 = __nccwpck_require__(7305); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const sanitize_user_comment_for_prompt_1 = __nccwpck_require__(3514); const TASK_ID = "DoUserRequestUseCase"; -function buildUserRequestPrompt(execution, userComment) { - const headBranch = execution.commit.branch; - const baseBranch = execution.currentConfiguration.parentBranch ?? execution.branches.development ?? "develop"; - const issueNumber = execution.issueNumber; - const owner = execution.owner; - const repo = execution.repo; - return `You are in the repository workspace. The user has asked you to do something. Perform their request by editing files and running commands directly in the workspace. Do not output diffs for someone else to apply. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Repository context:** -- Owner: ${owner} -- Repository: ${repo} -- Branch (head): ${headBranch} -- Base branch: ${baseBranch} -- Issue number: ${issueNumber} - -**User request:** -""" -${(0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment)} -""" - -**Rules:** -1. Apply all changes directly in the workspace (edit files, run commands). -2. If the project has standard checks (build, test, lint), run them and ensure they pass when relevant. -3. Reply briefly confirming what you did.`; -} class DoUserRequestUseCase { constructor() { this.taskId = TASK_ID; @@ -51186,7 +51838,16 @@ class DoUserRequestUseCase { (0, logger_1.logInfo)("No user comment; skipping user request."); return results; } - const prompt = buildUserRequestPrompt(execution, userComment); + const baseBranch = execution.currentConfiguration.parentBranch ?? execution.branches.development ?? "develop"; + const prompt = (0, prompts_1.getUserRequestPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + owner: execution.owner, + repo: execution.repo, + headBranch: execution.commit.branch, + baseBranch, + issueNumber: String(execution.issueNumber), + userComment: (0, sanitize_user_comment_for_prompt_1.sanitizeUserCommentForPrompt)(userComment), + }); (0, logger_1.logInfo)("Running OpenCode build agent to perform user request (changes applied in workspace)."); const response = await this.aiRepository.copilotMessage(execution.ai, prompt); if (!response?.text) { @@ -51919,6 +52580,7 @@ exports.ThinkUseCase = void 0; const result_1 = __nccwpck_require__(7305); const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); class ThinkUseCase { @@ -51991,10 +52653,11 @@ class ThinkUseCase { const contextBlock = issueDescription ? `\n\nContext (issue #${issueNumberForContext} description):\n${issueDescription}\n\n` : '\n\n'; - const prompt = `You are a helpful assistant. Answer the following question concisely, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} -${contextBlock}Question: ${question}`; + const prompt = (0, prompts_1.getThinkPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + contextBlock, + question, + }); const response = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.THINK_RESPONSE_SCHEMA, @@ -52186,6 +52849,7 @@ exports.AnswerIssueHelpUseCase = void 0; const result_1 = __nccwpck_require__(7305); const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const task_emoji_1 = __nccwpck_require__(9785); @@ -52243,16 +52907,10 @@ class AnswerIssueHelpUseCase { return results; } (0, logger_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(this.taskId)} Posting initial help reply for question/help issue #${issueNumber}.`); - const prompt = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. Use the project context when relevant. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Issue description (user's question or request):** -""" -${description} -""" - -Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; + const prompt = (0, prompts_1.getAnswerIssueHelpPrompt)({ + description, + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + }); const response = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.THINK_RESPONSE_SCHEMA, @@ -53621,6 +54279,7 @@ exports.CheckIssueCommentLanguageUseCase = void 0; const result_1 = __nccwpck_require__(7305); const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const task_emoji_1 = __nccwpck_require__(9785); class CheckIssueCommentLanguageUseCase { @@ -53645,17 +54304,7 @@ If you'd like this comment to be translated again, please delete the entire comm return results; } const locale = param.locale.issue; - let prompt = ` - You are a helpful assistant that checks if the text is written in ${locale}. - - Instructions: - 1. Analyze the provided text - 2. If the text is written in ${locale}, respond with exactly "done" - 3. If the text is written in any other language, respond with exactly "must_translate" - 4. Do not provide any explanation or additional text - - The text is: ${commentBody} - `; + let prompt = (0, prompts_1.getCheckCommentLanguagePrompt)({ locale, commentBody }); const checkResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.LANGUAGE_CHECK_RESPONSE_SCHEMA, @@ -53674,16 +54323,7 @@ If you'd like this comment to be translated again, please delete the entire comm })); return results; } - prompt = ` -You are a helpful assistant that translates the text to ${locale}. - -Instructions: -1. Translate the text to ${locale} -2. Put the translated text in the translatedText field -3. If you cannot translate (e.g. ambiguous or invalid input), set translatedText to empty string and explain in reason - -The text to translate is: ${commentBody} - `; + prompt = (0, prompts_1.getTranslateCommentPrompt)({ locale, commentBody }); const translationResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.TRANSLATION_RESPONSE_SCHEMA, @@ -54110,6 +54750,7 @@ const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); const project_repository_1 = __nccwpck_require__(7917); const pull_request_repository_1 = __nccwpck_require__(634); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const opencode_project_context_instruction_1 = __nccwpck_require__(7381); const task_emoji_1 = __nccwpck_require__(9785); @@ -54166,7 +54807,13 @@ class UpdatePullRequestDescriptionUseCase { })); return result; } - const prompt = this.buildPrDescriptionPrompt(param.issueNumber, issueDescription, headBranch, baseBranch); + const prompt = (0, prompts_1.getUpdatePullRequestDescriptionPrompt)({ + projectContextInstruction: opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + baseBranch, + headBranch, + issueNumber: String(param.issueNumber), + issueDescription, + }); const agentResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt); const prBody = typeof agentResponse === 'string' ? agentResponse @@ -54199,41 +54846,6 @@ class UpdatePullRequestDescriptionUseCase { } return result; } - /** - * Builds the PR description prompt. We do not send the diff from our side: - * we pass the base and head branch so the OpenCode agent can run `git diff` - * in the workspace. The agent must read the repo's PR template and fill it - * with the same structure (sections, headings, checkboxes). - */ - buildPrDescriptionPrompt(issueNumber, issueDescription, headBranch, baseBranch) { - return `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. - -${opencode_project_context_instruction_1.OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Branches:** -- **Base (target) branch:** \`${baseBranch}\` -- **Head (source) branch:** \`${headBranch}\` - -**Instructions:** -1. Read the pull request template file: \`.github/pull_request_template.md\`. Use its structure (headings, bullet lists, separators) as the skeleton for your output. The checkboxes in the template are **indicative only**: you may check the ones that apply based on the project and the diff, define different or fewer checkboxes if that fits better, or omit a section entirely if it does not apply. -2. Get the full diff by running: \`git diff ${baseBranch}..${headBranch}\` (or \`git diff ${baseBranch}...${headBranch}\` for merge-base). Use the diff to understand what changed. -3. Use the issue description below for context and intent. -4. Fill each section of the template with concrete content derived from the diff and the issue. Keep the same markdown structure (headings, horizontal rules). For checkbox sections (e.g. Test Coverage, Deployment Notes, Security): use the template's options as guidance; check or add only the items that apply, or skip the section if not relevant. - - **Summary:** brief explanation of what the PR does and why (intent, not implementation details). - - **Related Issues:** include \`Closes #${issueNumber}\` and "Related to #" only if relevant. - - **Scope of Changes:** use Added / Updated / Removed / Refactored with short bullet points (high level, not file-by-file). - - **Technical Details:** important decisions, trade-offs, or non-obvious aspects. - - **How to Test:** steps a reviewer can follow (infer from the changes when possible). - - **Test Coverage / Deployment / Security / Performance / Checklist:** treat checkboxes as indicative; check the ones that apply from the diff and project context, or omit the section if it does not apply. - - **Breaking Changes:** list any, or "None". - - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. -5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. - -**Issue description:** -${issueDescription} - -Output only the filled template content (the PR description body), starting with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks or add extra commentary.`; - } } exports.UpdatePullRequestDescriptionUseCase = UpdatePullRequestDescriptionUseCase; @@ -54250,6 +54862,7 @@ exports.CheckPullRequestCommentLanguageUseCase = void 0; const result_1 = __nccwpck_require__(7305); const ai_repository_1 = __nccwpck_require__(8307); const issue_repository_1 = __nccwpck_require__(57); +const prompts_1 = __nccwpck_require__(5554); const logger_1 = __nccwpck_require__(8836); const task_emoji_1 = __nccwpck_require__(9785); class CheckPullRequestCommentLanguageUseCase { @@ -54274,17 +54887,7 @@ If you'd like this comment to be translated again, please delete the entire comm return results; } const locale = param.locale.pullRequest; - let prompt = ` - You are a helpful assistant that checks if the text is written in ${locale}. - - Instructions: - 1. Analyze the provided text - 2. If the text is written in ${locale}, respond with exactly "done" - 3. If the text is written in any other language, respond with exactly "must_translate" - 4. Do not provide any explanation or additional text - - The text is: ${commentBody} - `; + let prompt = (0, prompts_1.getCheckCommentLanguagePrompt)({ locale, commentBody }); const checkResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.LANGUAGE_CHECK_RESPONSE_SCHEMA, @@ -54303,16 +54906,7 @@ If you'd like this comment to be translated again, please delete the entire comm })); return results; } - prompt = ` -You are a helpful assistant that translates the text to ${locale}. - -Instructions: -1. Translate the text to ${locale} -2. Put the translated text in the translatedText field -3. If you cannot translate (e.g. ambiguous or invalid input), set translatedText to empty string and explain in reason - -The text to translate is: ${commentBody} - `; + prompt = (0, prompts_1.getTranslateCommentPrompt)({ locale, commentBody }); const translationResponse = await this.aiRepository.askAgent(param.ai, ai_repository_1.OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: ai_repository_1.TRANSLATION_RESPONSE_SCHEMA, diff --git a/build/github_action/src/data/repository/branch_compare_repository.d.ts b/build/github_action/src/data/repository/branch_compare_repository.d.ts new file mode 100644 index 00000000..b5c22f18 --- /dev/null +++ b/build/github_action/src/data/repository/branch_compare_repository.d.ts @@ -0,0 +1,43 @@ +import { Labels } from '../model/labels'; +import { SizeThresholds } from '../model/size_thresholds'; +export interface BranchComparisonFile { + filename: string; + status: string; + additions: number; + deletions: number; + changes: number; + blobUrl: string; + rawUrl: string; + contentsUrl: string; + patch: string | undefined; +} +export interface BranchComparisonCommit { + sha: string; + message: string; + author: { + name: string; + email: string; + date: string; + }; + date: string; +} +export interface BranchComparison { + aheadBy: number; + behindBy: number; + totalCommits: number; + files: BranchComparisonFile[]; + commits: BranchComparisonCommit[]; +} +export interface SizeCategoryResult { + size: string; + githubSize: string; + reason: string; +} +/** + * Repository for comparing branches and computing size categories. + * Isolated to allow unit tests with mocked Octokit and pure size logic. + */ +export declare class BranchCompareRepository { + getChanges: (owner: string, repository: string, head: string, base: string, token: string) => Promise; + getSizeCategoryAndReason: (owner: string, repository: string, head: string, base: string, sizeThresholds: SizeThresholds, labels: Labels, token: string) => Promise; +} diff --git a/build/github_action/src/data/repository/branch_repository.d.ts b/build/github_action/src/data/repository/branch_repository.d.ts index f65ea00a..770debfb 100644 --- a/build/github_action/src/data/repository/branch_repository.d.ts +++ b/build/github_action/src/data/repository/branch_repository.d.ts @@ -1,8 +1,16 @@ -import { Execution } from "../model/execution"; +import { Execution } from '../model/execution'; import { Labels } from '../model/labels'; -import { Result } from "../model/result"; +import { Result } from '../model/result'; import { SizeThresholds } from '../model/size_thresholds'; +/** + * Facade for branch-related operations. Delegates to focused repositories + * (GitCli, Workflow, Merge, BranchCompare) for testability. + */ export declare class BranchRepository { + private readonly gitCliRepository; + private readonly workflowRepository; + private readonly mergeRepository; + private readonly branchCompareRepository; fetchRemoteBranches: () => Promise; getLatestTag: () => Promise; getCommitTag: (latestTag: string | undefined) => Promise; @@ -27,35 +35,6 @@ export declare class BranchRepository { getListOfBranches: (owner: string, repository: string, token: string) => Promise; executeWorkflow: (owner: string, repository: string, branch: string, workflow: string, inputs: Record, token: string) => Promise>; mergeBranch: (owner: string, repository: string, head: string, base: string, timeout: number, token: string) => Promise; - getChanges: (owner: string, repository: string, head: string, base: string, token: string) => Promise<{ - aheadBy: number; - behindBy: number; - totalCommits: number; - files: { - filename: string; - status: "added" | "removed" | "modified" | "renamed" | "copied" | "changed" | "unchanged"; - additions: number; - deletions: number; - changes: number; - blobUrl: string; - rawUrl: string; - contentsUrl: string; - patch: string | undefined; - }[]; - commits: { - sha: string; - message: string; - author: { - name?: string; - email?: string; - date?: string; - }; - date: string; - }[]; - }>; - getSizeCategoryAndReason: (owner: string, repository: string, head: string, base: string, sizeThresholds: SizeThresholds, labels: Labels, token: string) => Promise<{ - size: string; - githubSize: string; - reason: string; - }>; + getChanges: (owner: string, repository: string, head: string, base: string, token: string) => Promise; + getSizeCategoryAndReason: (owner: string, repository: string, head: string, base: string, sizeThresholds: SizeThresholds, labels: Labels, token: string) => Promise; } diff --git a/build/github_action/src/data/repository/git_cli_repository.d.ts b/build/github_action/src/data/repository/git_cli_repository.d.ts new file mode 100644 index 00000000..381035e0 --- /dev/null +++ b/build/github_action/src/data/repository/git_cli_repository.d.ts @@ -0,0 +1,9 @@ +/** + * Repository for Git operations executed via CLI (exec). + * Isolated to allow unit tests with mocked @actions/exec and @actions/core. + */ +export declare class GitCliRepository { + fetchRemoteBranches: () => Promise; + getLatestTag: () => Promise; + getCommitTag: (latestTag: string | undefined) => Promise; +} diff --git a/build/github_action/src/data/repository/merge_repository.d.ts b/build/github_action/src/data/repository/merge_repository.d.ts new file mode 100644 index 00000000..a152b014 --- /dev/null +++ b/build/github_action/src/data/repository/merge_repository.d.ts @@ -0,0 +1,8 @@ +import { Result } from '../model/result'; +/** + * Repository for merging branches (via PR or direct merge). + * Isolated to allow unit tests with mocked Octokit. + */ +export declare class MergeRepository { + mergeBranch: (owner: string, repository: string, head: string, base: string, timeout: number, token: string) => Promise; +} diff --git a/build/github_action/src/data/repository/workflow_repository.d.ts b/build/github_action/src/data/repository/workflow_repository.d.ts index aef0f4ba..68b41108 100644 --- a/build/github_action/src/data/repository/workflow_repository.d.ts +++ b/build/github_action/src/data/repository/workflow_repository.d.ts @@ -3,4 +3,5 @@ import { WorkflowRun } from "../model/workflow_run"; export declare class WorkflowRepository { getWorkflows: (params: Execution) => Promise; getActivePreviousRuns: (params: Execution) => Promise; + executeWorkflow: (owner: string, repository: string, branch: string, workflow: string, inputs: Record, token: string) => Promise>; } diff --git a/build/github_action/src/prompts/answer_issue_help.d.ts b/build/github_action/src/prompts/answer_issue_help.d.ts new file mode 100644 index 00000000..3ce4ee5b --- /dev/null +++ b/build/github_action/src/prompts/answer_issue_help.d.ts @@ -0,0 +1,5 @@ +export type AnswerIssueHelpParams = { + description: string; + projectContextInstruction: string; +}; +export declare function getAnswerIssueHelpPrompt(params: AnswerIssueHelpParams): string; diff --git a/build/github_action/src/prompts/bugbot.d.ts b/build/github_action/src/prompts/bugbot.d.ts new file mode 100644 index 00000000..d6eae92e --- /dev/null +++ b/build/github_action/src/prompts/bugbot.d.ts @@ -0,0 +1,11 @@ +export type BugbotParams = { + projectContextInstruction: string; + owner: string; + repo: string; + headBranch: string; + baseBranch: string; + issueNumber: string; + ignoreBlock: string; + previousBlock: string; +}; +export declare function getBugbotPrompt(params: BugbotParams): string; diff --git a/build/github_action/src/prompts/bugbot_fix.d.ts b/build/github_action/src/prompts/bugbot_fix.d.ts new file mode 100644 index 00000000..d316baaf --- /dev/null +++ b/build/github_action/src/prompts/bugbot_fix.d.ts @@ -0,0 +1,13 @@ +export type BugbotFixParams = { + projectContextInstruction: string; + owner: string; + repo: string; + headBranch: string; + baseBranch: string; + issueNumber: string; + prNumberLine: string; + findingsBlock: string; + userComment: string; + verifyBlock: string; +}; +export declare function getBugbotFixPrompt(params: BugbotFixParams): string; diff --git a/build/github_action/src/prompts/bugbot_fix_intent.d.ts b/build/github_action/src/prompts/bugbot_fix_intent.d.ts new file mode 100644 index 00000000..8bd3c8fb --- /dev/null +++ b/build/github_action/src/prompts/bugbot_fix_intent.d.ts @@ -0,0 +1,7 @@ +export type BugbotFixIntentParams = { + projectContextInstruction: string; + findingsBlock: string; + parentBlock: string; + userComment: string; +}; +export declare function getBugbotFixIntentPrompt(params: BugbotFixIntentParams): string; diff --git a/build/github_action/src/prompts/check_comment_language.d.ts b/build/github_action/src/prompts/check_comment_language.d.ts new file mode 100644 index 00000000..4f2949e8 --- /dev/null +++ b/build/github_action/src/prompts/check_comment_language.d.ts @@ -0,0 +1,6 @@ +export type CheckCommentLanguageParams = { + locale: string; + commentBody: string; +}; +export declare function getCheckCommentLanguagePrompt(params: CheckCommentLanguageParams): string; +export declare function getTranslateCommentPrompt(params: CheckCommentLanguageParams): string; diff --git a/build/github_action/src/prompts/check_progress.d.ts b/build/github_action/src/prompts/check_progress.d.ts new file mode 100644 index 00000000..7fb5a036 --- /dev/null +++ b/build/github_action/src/prompts/check_progress.d.ts @@ -0,0 +1,8 @@ +export type CheckProgressParams = { + projectContextInstruction: string; + issueNumber: string; + baseBranch: string; + currentBranch: string; + issueDescription: string; +}; +export declare function getCheckProgressPrompt(params: CheckProgressParams): string; diff --git a/build/github_action/src/prompts/cli_do.d.ts b/build/github_action/src/prompts/cli_do.d.ts new file mode 100644 index 00000000..d2cbd674 --- /dev/null +++ b/build/github_action/src/prompts/cli_do.d.ts @@ -0,0 +1,5 @@ +export type CliDoParams = { + projectContextInstruction: string; + userPrompt: string; +}; +export declare function getCliDoPrompt(params: CliDoParams): string; diff --git a/build/github_action/src/prompts/fill.d.ts b/build/github_action/src/prompts/fill.d.ts new file mode 100644 index 00000000..311e1edc --- /dev/null +++ b/build/github_action/src/prompts/fill.d.ts @@ -0,0 +1,5 @@ +/** + * Replaces {{paramName}} placeholders in a template with values from params. + * Missing keys are left as {{paramName}}. + */ +export declare function fillTemplate(template: string, params: Record): string; diff --git a/build/github_action/src/prompts/index.d.ts b/build/github_action/src/prompts/index.d.ts new file mode 100644 index 00000000..f6a6027b --- /dev/null +++ b/build/github_action/src/prompts/index.d.ts @@ -0,0 +1,68 @@ +import type { AnswerIssueHelpParams } from './answer_issue_help'; +import type { ThinkParams } from './think'; +import type { UpdatePullRequestDescriptionParams } from './update_pull_request_description'; +import type { UserRequestParams } from './user_request'; +import type { RecommendStepsParams } from './recommend_steps'; +import type { CheckProgressParams } from './check_progress'; +import type { CheckCommentLanguageParams } from './check_comment_language'; +import type { CliDoParams } from './cli_do'; +import type { BugbotParams } from './bugbot'; +import type { BugbotFixParams } from './bugbot_fix'; +import type { BugbotFixIntentParams } from './bugbot_fix_intent'; +export { fillTemplate } from './fill'; +export { getAnswerIssueHelpPrompt } from './answer_issue_help'; +export type { AnswerIssueHelpParams } from './answer_issue_help'; +export { getThinkPrompt } from './think'; +export type { ThinkParams } from './think'; +export { getUpdatePullRequestDescriptionPrompt } from './update_pull_request_description'; +export type { UpdatePullRequestDescriptionParams } from './update_pull_request_description'; +export { getUserRequestPrompt } from './user_request'; +export type { UserRequestParams } from './user_request'; +export { getRecommendStepsPrompt } from './recommend_steps'; +export type { RecommendStepsParams } from './recommend_steps'; +export { getCheckProgressPrompt } from './check_progress'; +export type { CheckProgressParams } from './check_progress'; +export { getCheckCommentLanguagePrompt, getTranslateCommentPrompt, } from './check_comment_language'; +export type { CheckCommentLanguageParams } from './check_comment_language'; +export { getCliDoPrompt } from './cli_do'; +export type { CliDoParams } from './cli_do'; +export { getBugbotPrompt } from './bugbot'; +export type { BugbotParams } from './bugbot'; +export { getBugbotFixPrompt } from './bugbot_fix'; +export type { BugbotFixParams } from './bugbot_fix'; +export { getBugbotFixIntentPrompt } from './bugbot_fix_intent'; +export type { BugbotFixIntentParams } from './bugbot_fix_intent'; +/** Known prompt names for getPrompt() */ +export declare const PROMPT_NAMES: { + readonly ANSWER_ISSUE_HELP: "answer_issue_help"; + readonly THINK: "think"; + readonly UPDATE_PULL_REQUEST_DESCRIPTION: "update_pull_request_description"; + readonly USER_REQUEST: "user_request"; + readonly RECOMMEND_STEPS: "recommend_steps"; + readonly CHECK_PROGRESS: "check_progress"; + readonly CHECK_COMMENT_LANGUAGE: "check_comment_language"; + readonly TRANSLATE_COMMENT: "translate_comment"; + readonly CLI_DO: "cli_do"; + readonly BUGBOT: "bugbot"; + readonly BUGBOT_FIX: "bugbot_fix"; + readonly BUGBOT_FIX_INTENT: "bugbot_fix_intent"; +}; +export type PromptName = (typeof PROMPT_NAMES)[keyof typeof PROMPT_NAMES]; +type PromptParamsMap = { + [PROMPT_NAMES.ANSWER_ISSUE_HELP]: AnswerIssueHelpParams; + [PROMPT_NAMES.THINK]: ThinkParams; + [PROMPT_NAMES.UPDATE_PULL_REQUEST_DESCRIPTION]: UpdatePullRequestDescriptionParams; + [PROMPT_NAMES.USER_REQUEST]: UserRequestParams; + [PROMPT_NAMES.RECOMMEND_STEPS]: RecommendStepsParams; + [PROMPT_NAMES.CHECK_PROGRESS]: CheckProgressParams; + [PROMPT_NAMES.CHECK_COMMENT_LANGUAGE]: CheckCommentLanguageParams; + [PROMPT_NAMES.TRANSLATE_COMMENT]: CheckCommentLanguageParams; + [PROMPT_NAMES.CLI_DO]: CliDoParams; + [PROMPT_NAMES.BUGBOT]: BugbotParams; + [PROMPT_NAMES.BUGBOT_FIX]: BugbotFixParams; + [PROMPT_NAMES.BUGBOT_FIX_INTENT]: BugbotFixIntentParams; +}; +/** + * Returns a filled prompt by name. Params must match the prompt's expected keys. + */ +export declare function getPrompt(name: PromptName, params: PromptParamsMap[PromptName]): string; diff --git a/build/github_action/src/prompts/recommend_steps.d.ts b/build/github_action/src/prompts/recommend_steps.d.ts new file mode 100644 index 00000000..3a594ae0 --- /dev/null +++ b/build/github_action/src/prompts/recommend_steps.d.ts @@ -0,0 +1,6 @@ +export type RecommendStepsParams = { + projectContextInstruction: string; + issueNumber: string; + issueDescription: string; +}; +export declare function getRecommendStepsPrompt(params: RecommendStepsParams): string; diff --git a/build/github_action/src/prompts/think.d.ts b/build/github_action/src/prompts/think.d.ts new file mode 100644 index 00000000..e7b2d14a --- /dev/null +++ b/build/github_action/src/prompts/think.d.ts @@ -0,0 +1,6 @@ +export type ThinkParams = { + projectContextInstruction: string; + contextBlock: string; + question: string; +}; +export declare function getThinkPrompt(params: ThinkParams): string; diff --git a/build/github_action/src/prompts/update_pull_request_description.d.ts b/build/github_action/src/prompts/update_pull_request_description.d.ts new file mode 100644 index 00000000..536710ed --- /dev/null +++ b/build/github_action/src/prompts/update_pull_request_description.d.ts @@ -0,0 +1,8 @@ +export type UpdatePullRequestDescriptionParams = { + projectContextInstruction: string; + baseBranch: string; + headBranch: string; + issueNumber: string; + issueDescription: string; +}; +export declare function getUpdatePullRequestDescriptionPrompt(params: UpdatePullRequestDescriptionParams): string; diff --git a/build/github_action/src/prompts/user_request.d.ts b/build/github_action/src/prompts/user_request.d.ts new file mode 100644 index 00000000..8e5600c7 --- /dev/null +++ b/build/github_action/src/prompts/user_request.d.ts @@ -0,0 +1,10 @@ +export type UserRequestParams = { + projectContextInstruction: string; + owner: string; + repo: string; + headBranch: string; + baseBranch: string; + issueNumber: string; + userComment: string; +}; +export declare function getUserRequestPrompt(params: UserRequestParams): string; diff --git a/build/github_action/src/usecase/actions/check_progress_use_case.d.ts b/build/github_action/src/usecase/actions/check_progress_use_case.d.ts index 2c536409..671d3b84 100644 --- a/build/github_action/src/usecase/actions/check_progress_use_case.d.ts +++ b/build/github_action/src/usecase/actions/check_progress_use_case.d.ts @@ -13,12 +13,6 @@ export declare class CheckProgressUseCase implements ParamUseCase; - /** - * Builds the PR description prompt. We do not send the diff from our side: - * we pass the base and head branch so the OpenCode agent can run `git diff` - * in the workspace. The agent must read the repo's PR template and fill it - * with the same structure (sections, headings, checkboxes). - */ - private buildPrDescriptionPrompt; } diff --git a/package-lock.json b/package-lock.json index f7337aea..50067ddb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot", - "version": "1.3.1", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot", - "version": "1.3.1", + "version": "2.0.1", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index b63d02e5..9d8b824f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "copilot", - "version": "2.0.0", + "version": "2.0.1", "description": "Automates branch management, GitHub project linking, and issue/PR tracking with Git-Flow methodology.", "main": "build/github_action/index.js", "bin": { diff --git a/src/cli.ts b/src/cli.ts index e4ea04dc..3eb79519 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,8 +7,9 @@ import { runLocalAction } from './actions/local_action'; import { IssueRepository } from './data/repository/issue_repository'; import { ACTIONS, ERRORS, INPUT_KEYS, OPENCODE_DEFAULT_MODEL, TITLE } from './utils/constants'; import { logError, logInfo } from './utils/logger'; -import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from './utils/opencode_project_context_instruction'; +import { getCliDoPrompt } from './prompts'; import { Ai } from './data/model/ai'; +import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from './utils/opencode_project_context_instruction'; import { AiRepository } from './data/repository/ai_repository'; // Load environment variables from .env file @@ -194,7 +195,10 @@ program try { const ai = new Ai(serverUrl, model, false, false, [], false, 'low', 20); const aiRepository = new AiRepository(); - const fullPrompt = `${OPENCODE_PROJECT_CONTEXT_INSTRUCTION}\n\n${prompt}`; + const fullPrompt = getCliDoPrompt({ + projectContextInstruction: OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + userPrompt: prompt, + }); const result = await aiRepository.copilotMessage(ai, fullPrompt); if (!result) { diff --git a/src/data/model/__tests__/execution.test.ts b/src/data/model/__tests__/execution.test.ts new file mode 100644 index 00000000..ece93616 --- /dev/null +++ b/src/data/model/__tests__/execution.test.ts @@ -0,0 +1,754 @@ +/** + * Unit tests for Execution: getters, constructor, setup(). + */ + +jest.mock('@actions/github', () => ({ + context: { + eventName: 'workflow_dispatch', + actor: 'test-actor', + repo: { repo: 'test-repo', owner: 'test-owner' }, + payload: {}, + }, +})); + +jest.mock('../../../utils/logger', () => ({ + setGlobalLoggerDebug: jest.fn(), + logDebugInfo: jest.fn(), + logError: jest.fn(), +})); + +const mockGetUserFromToken = jest.fn(); +const mockGetLabels = jest.fn(); +const mockIsPullRequest = jest.fn(); +const mockIsIssue = jest.fn(); +const mockGetHeadBranch = jest.fn(); +const mockConfigGet = jest.fn(); +const mockGetLatestTag = jest.fn(); +const mockGetReleaseVersionInvoke = jest.fn(); +const mockGetReleaseTypeInvoke = jest.fn(); +const mockGetHotfixVersionInvoke = jest.fn(); + +jest.mock('../../repository/project_repository', () => ({ + ProjectRepository: jest.fn().mockImplementation(() => ({ + getUserFromToken: mockGetUserFromToken, + })), +})); +jest.mock('../../repository/issue_repository', () => ({ + IssueRepository: jest.fn().mockImplementation(() => ({ + getLabels: mockGetLabels, + isPullRequest: mockIsPullRequest, + isIssue: mockIsIssue, + getHeadBranch: mockGetHeadBranch, + })), +})); +jest.mock('../../../manager/description/configuration_handler', () => ({ + ConfigurationHandler: jest.fn().mockImplementation(() => ({ + get: mockConfigGet, + })), +})); +jest.mock('../../repository/branch_repository', () => ({ + BranchRepository: jest.fn().mockImplementation(() => ({ + getLatestTag: mockGetLatestTag, + })), +})); +jest.mock('../../../usecase/steps/common/get_release_version_use_case', () => ({ + GetReleaseVersionUseCase: jest.fn().mockImplementation(() => ({ + invoke: mockGetReleaseVersionInvoke, + })), +})); +jest.mock('../../../usecase/steps/common/get_release_type_use_case', () => ({ + GetReleaseTypeUseCase: jest.fn().mockImplementation(() => ({ + invoke: mockGetReleaseTypeInvoke, + })), +})); +jest.mock('../../../usecase/steps/common/get_hotfix_version_use_case', () => ({ + GetHotfixVersionUseCase: jest.fn().mockImplementation(() => ({ + invoke: mockGetHotfixVersionInvoke, + })), +})); + +import { INPUT_KEYS } from '../../../utils/constants'; +import { Ai } from '../ai'; +import { Branches } from '../branches'; +import { Emoji } from '../emoji'; +import { Execution } from '../execution'; +import { Hotfix } from '../hotfix'; +import { Images } from '../images'; +import { Issue } from '../issue'; +import { IssueTypes } from '../issue_types'; +import { Labels } from '../labels'; +import { Locale } from '../locale'; +import { ProjectDetail } from '../project_detail'; +import { Projects } from '../projects'; +import { PullRequest } from '../pull_request'; +import { Release } from '../release'; +import { SingleAction } from '../single_action'; +import { SizeThreshold } from '../size_threshold'; +import { SizeThresholds } from '../size_thresholds'; +import { Tokens } from '../tokens'; +import { Workflows } from '../workflows'; + +function makeLabels(): Labels { + return new Labels( + 'launch', + 'bug', + 'bugfix', + 'hotfix', + 'enhancement', + 'feature', + 'release', + 'question', + 'help', + 'deploy', + 'deployed', + 'docs', + 'documentation', + 'chore', + 'maintenance', + 'high', + 'medium', + 'low', + 'none', + 'XXL', + 'XL', + 'L', + 'M', + 'S', + 'XS', + ); +} + +function makeIssue(inputs?: Record): Issue { + return new Issue(false, false, 0, inputs as never); +} + +function makePullRequest(inputs?: Record): PullRequest { + return new PullRequest(0, 0, 0, inputs as never); +} + +function makeBranches(): Branches { + return new Branches( + 'main', + 'develop', + 'feature', + 'bugfix', + 'hotfix', + 'release', + 'docs', + 'chore', + ); +} + +function makeImages(): Images { + const empty: string[] = []; + return new Images( + false, + false, + false, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + ); +} + +function makeIssueTypes(): IssueTypes { + return new IssueTypes( + 'Task', + 'Task desc', + 'BLUE', + 'Bug', + 'Bug desc', + 'RED', + 'Feature', + 'Feature desc', + 'GREEN', + 'Docs', + 'Docs desc', + 'GREY', + 'Maintenance', + 'Maint desc', + 'GREY', + 'Hotfix', + 'Hotfix desc', + 'RED', + 'Release', + 'Release desc', + 'BLUE', + 'Question', + 'Q desc', + 'PURPLE', + 'Help', + 'Help desc', + 'PURPLE', + ); +} + +function makeSizeThresholds(): SizeThresholds { + return new SizeThresholds( + new SizeThreshold(0, 0, 0), + new SizeThreshold(0, 0, 0), + new SizeThreshold(0, 0, 0), + new SizeThreshold(0, 0, 0), + new SizeThreshold(0, 0, 0), + new SizeThreshold(0, 0, 0), + ); +} + +function buildExecution(inputs?: Record, overrides?: Partial<{ + singleAction: SingleAction; + issue: Issue; + pullRequest: PullRequest; + labels: Labels; + branches: Branches; +}>): Execution { + const labels = overrides?.labels ?? makeLabels(); + const branches = overrides?.branches ?? makeBranches(); + return new Execution( + false, + overrides?.singleAction ?? new SingleAction('', '', '', '', ''), + 'prefix', + overrides?.issue ?? makeIssue(inputs), + overrides?.pullRequest ?? makePullRequest(inputs), + new Emoji(false, ''), + makeImages(), + new Tokens('token'), + new Ai('http://localhost', 'model', false, false, [], false, 'High', 10, []), + labels, + makeIssueTypes(), + new Locale('en', 'en'), + makeSizeThresholds(), + branches, + new Release(), + new Hotfix(), + new Workflows('release-wf', 'hotfix-wf'), + new Projects([new ProjectDetail({})], '', '', '', ''), + undefined, + inputs as never, + ); +} + +describe('Execution', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetUserFromToken.mockResolvedValue('token-user'); + mockGetLabels.mockResolvedValue([]); + mockConfigGet.mockResolvedValue(undefined); + }); + + describe('getters (inputs override)', () => { + it('eventName returns inputs.eventName when set', () => { + const e = buildExecution({ eventName: 'issues' }); + expect(e.eventName).toBe('issues'); + }); + + it('eventName returns github.context.eventName when inputs undefined', () => { + const e = buildExecution(undefined); + expect(e.eventName).toBe('workflow_dispatch'); + }); + + it('actor returns inputs.actor when set', () => { + const e = buildExecution({ actor: 'custom-actor' }); + expect(e.actor).toBe('custom-actor'); + }); + + it('repo returns inputs.repo.repo when set', () => { + const e = buildExecution({ repo: { repo: 'my-repo', owner: 'my-owner' } }); + expect(e.repo).toBe('my-repo'); + }); + + it('owner returns inputs.repo.owner when set', () => { + const e = buildExecution({ repo: { repo: 'my-repo', owner: 'my-owner' } }); + expect(e.owner).toBe('my-owner'); + }); + + it('isSingleAction returns true when singleAction.enabledSingleAction is true', () => { + const singleAction = new SingleAction('think', '', '', '', ''); + const e = buildExecution(undefined, { singleAction }); + expect(e.isSingleAction).toBe(true); + }); + + it('isIssue returns true when issue.isIssue is true', () => { + const issue = makeIssue({ eventName: 'issues' }); + const e = buildExecution(undefined, { issue }); + expect(e.isIssue).toBe(true); + }); + + it('isIssue returns true when issue.isIssueComment is true', () => { + const issue = makeIssue({ eventName: 'issue_comment' }); + const e = buildExecution(undefined, { issue }); + expect(e.isIssue).toBe(true); + }); + + it('isPullRequest returns true when pullRequest.isPullRequest is true', () => { + const pullRequest = makePullRequest({ eventName: 'pull_request' }); + const e = buildExecution(undefined, { pullRequest }); + expect(e.isPullRequest).toBe(true); + }); + + it('isPush returns true when eventName is push', () => { + const e = buildExecution({ eventName: 'push' }); + expect(e.isPush).toBe(true); + }); + + it('isPush returns false when eventName is not push', () => { + const e = buildExecution({ eventName: 'issues' }); + expect(e.isPush).toBe(false); + }); + + it('isFeature returns true when issueType equals branches.featureTree', () => { + const labels = makeLabels(); + labels.currentIssueLabels = ['feature']; + const e = buildExecution(undefined, { labels }); + expect(e.issueType).toBe('feature'); + expect(e.isFeature).toBe(true); + }); + + it('isBugfix returns true when issueType equals branches.bugfixTree', () => { + const labels = makeLabels(); + labels.currentIssueLabels = ['bugfix']; + const e = buildExecution(undefined, { labels }); + expect(e.issueType).toBe('bugfix'); + expect(e.isBugfix).toBe(true); + }); + + it('isDocs returns true when issueType equals branches.docsTree', () => { + const labels = makeLabels(); + labels.currentIssueLabels = ['docs']; + const e = buildExecution(undefined, { labels }); + expect(e.issueType).toBe('docs'); + expect(e.isDocs).toBe(true); + }); + + it('isChore returns true when issueType equals branches.choreTree', () => { + const labels = makeLabels(); + labels.currentIssueLabels = ['chore']; + const e = buildExecution(undefined, { labels }); + expect(e.issueType).toBe('chore'); + expect(e.isChore).toBe(true); + }); + + it('isBranched returns true when labels contain branched label', () => { + const labels = makeLabels(); + labels.currentIssueLabels = ['launch']; + const e = buildExecution(undefined, { labels }); + expect(e.isBranched).toBe(true); + }); + + it('issueNotBranched returns true when isIssue and not isBranched', () => { + const issue = makeIssue({ eventName: 'issues' }); + const labels = makeLabels(); + labels.currentIssueLabels = []; + const e = buildExecution(undefined, { issue, labels }); + expect(e.issueNotBranched).toBe(true); + }); + + it('managementBranch returns feature tree when feature label present', () => { + const labels = makeLabels(); + labels.currentIssueLabels = ['feature']; + const e = buildExecution(undefined, { labels }); + expect(e.managementBranch).toBe('feature'); + }); + + it('issueType returns feature when feature label present', () => { + const labels = makeLabels(); + labels.currentIssueLabels = ['feature']; + const e = buildExecution(undefined, { labels }); + expect(e.issueType).toBe('feature'); + }); + + it('cleanIssueBranches returns true when previousConfig branchType differs from current', () => { + const issue = makeIssue({ eventName: 'issues' } as never); + const e = buildExecution({ eventName: 'issues' } as never, { issue }); + e.previousConfiguration = { branchType: 'feature' } as never; + e.currentConfiguration.branchType = 'bugfix'; + expect(e.cleanIssueBranches).toBe(true); + }); + + it('commit returns Commit from inputs', () => { + const e = buildExecution({ + commits: { ref: 'refs/heads/feature/123-foo' }, + } as never); + const commit = e.commit; + expect(commit.branch).toBe('feature/123-foo'); + }); + + it('runnedByToken returns true when tokenUser equals actor', () => { + const e = buildExecution({ actor: 'alice' }); + (e as unknown as { tokenUser: string }).tokenUser = 'alice'; + expect(e.runnedByToken).toBe(true); + }); + + it('runnedByToken returns false when tokenUser differs from actor', () => { + const e = buildExecution({ actor: 'alice' }); + (e as unknown as { tokenUser: string }).tokenUser = 'bob'; + expect(e.runnedByToken).toBe(false); + }); + }); + + describe('constructor', () => { + it('assigns all constructor arguments', () => { + const singleAction = new SingleAction('think', '', '', '', ''); + const issue = makeIssue(); + const pullRequest = makePullRequest(); + const emoji = new Emoji(true, 'x'); + const tokens = new Tokens('t'); + const ai = new Ai('u', 'm', true, true, [], true, 'L', 5, []); + const labels = makeLabels(); + const issueTypes = makeIssueTypes(); + const locale = new Locale('es', 'es'); + const sizeThresholds = makeSizeThresholds(); + const branches = makeBranches(); + const release = new Release(); + const hotfix = new Hotfix(); + const workflows = new Workflows('r', 'h'); + const project = new Projects([], 'a', 'b', 'c', 'd'); + + const e = new Execution( + true, + singleAction, + 'pre', + issue, + pullRequest, + emoji, + makeImages(), + tokens, + ai, + labels, + issueTypes, + locale, + sizeThresholds, + branches, + release, + hotfix, + workflows, + project, + undefined, + { eventName: 'push' } as never, + ); + + expect(e.debug).toBe(true); + expect(e.singleAction).toBe(singleAction); + expect(e.commitPrefixBuilder).toBe('pre'); + expect(e.issue).toBe(issue); + expect(e.pullRequest).toBe(pullRequest); + expect(e.emoji).toBe(emoji); + expect(e.tokens).toBe(tokens); + expect(e.ai).toBe(ai); + expect(e.labels).toBe(labels); + expect(e.issueTypes).toBe(issueTypes); + expect(e.locale).toBe(locale); + expect(e.sizeThresholds).toBe(sizeThresholds); + expect(e.branches).toBe(branches); + expect(e.release).toBe(release); + expect(e.hotfix).toBe(hotfix); + expect(e.workflows).toBe(workflows); + expect(e.project).toBe(project); + expect(e.inputs).toEqual({ eventName: 'push' }); + expect(e.currentConfiguration).toBeDefined(); + expect(e.currentConfiguration.branchType).toBe(''); + }); + }); + + describe('setup', () => { + it('sets tokenUser and throws if getUserFromToken returns null', async () => { + mockGetUserFromToken.mockResolvedValue(null); + const e = buildExecution(undefined, {}); + await expect(e.setup()).rejects.toThrow('Failed to get user from token'); + }); + + it('sets issueNumber from issue when isIssue and not single action', async () => { + const issue = makeIssue({ eventName: 'issues', issue: { number: 99 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 99 } } as never, { issue }); + await e.setup(); + expect(e.issueNumber).toBe(99); + }); + + it('sets issueNumber from pullRequest head when isPullRequest and not single action', async () => { + const pullRequest = makePullRequest({ + eventName: 'pull_request', + pull_request: { head: { ref: 'feature/42-my-branch' } }, + } as never); + const e = buildExecution(undefined, { pullRequest }); + await e.setup(); + expect(e.issueNumber).toBe(42); + }); + + it('sets issueNumber from commit branch when isPush and not single action', async () => { + const e = buildExecution( + { eventName: 'push', commits: { ref: 'refs/heads/feature/7-bar' } } as never, + {}, + ); + await e.setup(); + expect(e.issueNumber).toBe(7); + }); + + it('sets currentIssueLabels from issue repository', async () => { + mockGetLabels.mockResolvedValue(['feature', 'size-m']); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.labels.currentIssueLabels).toEqual(['feature', 'size-m']); + }); + + it('sets release.active from labels.isRelease', async () => { + mockGetLabels.mockResolvedValue(['release']); + mockGetReleaseVersionInvoke.mockResolvedValue([ + { executed: true, success: true, payload: { releaseVersion: '1.0.0' } }, + ]); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.release.active).toBe(true); + }); + + it('sets hotfix.active from labels.isHotfix', async () => { + mockGetLabels.mockResolvedValue(['hotfix']); + mockGetHotfixVersionInvoke.mockResolvedValue([ + { executed: true, success: true, payload: { baseVersion: '1.0.0', hotfixVersion: '1.0.1' } }, + ]); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.hotfix.active).toBe(true); + }); + + it('single action with INPUT SINGLE_ACTION_ISSUE sets issueNumber', async () => { + const singleAction = new SingleAction('check_progress', '0', '', '', ''); + const e = buildExecution( + { [INPUT_KEYS.SINGLE_ACTION_ISSUE]: '123' } as never, + { singleAction }, + ); + await e.setup(); + expect(Number(e.issueNumber)).toBe(123); + expect(Number(e.singleAction.issue)).toBe(123); + }); + + it('single action isIssue path sets issueNumber from issue.number', async () => { + const singleAction = new SingleAction('check_progress', '0', '', '', ''); + const issue = makeIssue({ eventName: 'issues', issue: { number: 88 } } as never); + const e = buildExecution(undefined, { singleAction, issue }); + await e.setup(); + expect(e.issueNumber).toBe(88); + expect(e.singleAction.issue).toBe(88); + }); + + it('single action isPullRequest path sets issueNumber from head branch', async () => { + const singleAction = new SingleAction('check_progress', '0', '', '', ''); + const pullRequest = makePullRequest({ + eventName: 'pull_request', + pull_request: { head: { ref: 'bugfix/33-fix' } }, + } as never); + const e = buildExecution(undefined, { singleAction, pullRequest }); + await e.setup(); + expect(e.issueNumber).toBe(33); + }); + + it('single action isPush path sets issueNumber from commit branch', async () => { + const singleAction = new SingleAction('check_progress', '0', '', '', ''); + const e = buildExecution( + { + eventName: 'push', + commits: { ref: 'refs/heads/feature/11-baz' }, + } as never, + { singleAction }, + ); + await e.setup(); + expect(e.issueNumber).toBe(11); + }); + + it('single action else path: isPullRequest sets issueNumber from getHeadBranch', async () => { + const singleAction = new SingleAction('check_progress', '999', '', '', ''); + mockIsPullRequest.mockResolvedValue(true); + mockIsIssue.mockResolvedValue(false); + mockGetHeadBranch.mockResolvedValue('feature/55-head'); + const e = buildExecution(undefined, { singleAction }); + await e.setup(); + expect(e.issueNumber).toBe(55); + }); + + it('single action else path: getHeadBranch undefined returns early', async () => { + const singleAction = new SingleAction('check_progress', '999', '', '', ''); + mockIsPullRequest.mockResolvedValue(true); + mockIsIssue.mockResolvedValue(false); + mockGetHeadBranch.mockResolvedValue(undefined); + const e = buildExecution(undefined, { singleAction }); + await e.setup(); + expect(e.issueNumber).toBe(-1); + }); + + it('restores previousConfiguration release branch and version', async () => { + mockGetLabels.mockResolvedValue(['release']); + mockConfigGet.mockResolvedValue({ + releaseBranch: 'release/1.2.3', + parentBranch: 'develop', + }); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.release.version).toBe('1.2.3'); + expect(e.release.branch).toBe('release/1.2.3'); + expect(e.currentConfiguration.parentBranch).toBe('develop'); + expect(e.currentConfiguration.releaseBranch).toBe('release/1.2.3'); + }); + + it('restores previousConfiguration hotfix branches', async () => { + mockGetLabels.mockResolvedValue(['hotfix']); + mockConfigGet.mockResolvedValue({ + hotfixOriginBranch: 'tags/v1.0.0', + hotfixBranch: 'hotfix/1.0.1', + }); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.hotfix.baseVersion).toBe('1.0.0'); + expect(e.hotfix.baseBranch).toBe('tags/v1.0.0'); + expect(e.hotfix.version).toBe('1.0.1'); + expect(e.hotfix.branch).toBe('hotfix/1.0.1'); + expect(e.currentConfiguration.hotfixOriginBranch).toBe('tags/v1.0.0'); + expect(e.currentConfiguration.hotfixBranch).toBe('hotfix/1.0.1'); + }); + + it('isPullRequest path fetches PR labels and sets release/hotfix from base', async () => { + mockGetLabels.mockResolvedValue([]); + const pullRequest = makePullRequest({ + eventName: 'pull_request', + pull_request: { base: { ref: 'release/2.0.0' }, head: { ref: 'feature/10-x' } }, + } as never); + const e = buildExecution(undefined, { pullRequest }); + await e.setup(); + expect(e.release.active).toBe(true); + expect(e.hotfix.active).toBe(false); + expect(e.labels.currentPullRequestLabels).toEqual([]); + }); + + it('isPullRequest path sets hotfix.active when base contains hotfix tree', async () => { + mockGetLabels.mockResolvedValue([]); + const pullRequest = makePullRequest({ + eventName: 'pull_request', + pull_request: { base: { ref: 'hotfix/1.0.1' }, head: { ref: 'feature/10-x' } }, + } as never); + const e = buildExecution(undefined, { pullRequest }); + await e.setup(); + expect(e.hotfix.active).toBe(true); + }); + + it('sets currentConfiguration.branchType from issueType', async () => { + mockGetLabels.mockResolvedValue(['feature']); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.currentConfiguration.branchType).toBe('feature'); + }); + + it('single action else path: isIssue sets issueNumber from singleAction.issue', async () => { + const singleAction = new SingleAction('check_progress', '77', '', '', ''); + mockIsPullRequest.mockResolvedValue(false); + mockIsIssue.mockResolvedValue(true); + const e = buildExecution(undefined, { singleAction }); + await e.setup(); + expect(e.issueNumber).toBe(77); + }); + + it('sets parentBranch from previousConfiguration when current parentBranch undefined', async () => { + mockGetLabels.mockResolvedValue(['release']); + mockConfigGet.mockResolvedValue({ parentBranch: 'develop' }); + mockGetReleaseVersionInvoke.mockResolvedValue([ + { executed: true, success: true, payload: { releaseVersion: '2.0.0' } }, + ]); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.currentConfiguration.parentBranch).toBe('develop'); + }); + + it('release version from GetReleaseTypeUseCase and getLatestTag when GetReleaseVersion fails', async () => { + mockGetLabels.mockResolvedValue(['release']); + mockConfigGet.mockResolvedValue(undefined); + mockGetReleaseVersionInvoke.mockResolvedValue([ + { executed: true, success: false }, + ]); + mockGetReleaseTypeInvoke.mockResolvedValue([ + { executed: true, success: true, payload: { releaseType: 'Minor' } }, + ]); + mockGetLatestTag.mockResolvedValue('1.0.0'); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.release.version).toBeDefined(); + expect(e.release.branch).toBe('release/1.1.0'); + }); + + it('hotfix version from getLatestTag when GetHotfixVersionUseCase fails', async () => { + mockGetLabels.mockResolvedValue(['hotfix']); + mockConfigGet.mockResolvedValue(undefined); + mockGetHotfixVersionInvoke.mockResolvedValue([ + { executed: true, success: false }, + ]); + mockGetLatestTag.mockResolvedValue('1.0.0'); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.hotfix.baseVersion).toBe('1.0.0'); + expect(e.hotfix.version).toBeDefined(); + expect(e.hotfix.branch).toBe('hotfix/1.0.1'); + }); + + it('setup returns early when release type from GetReleaseTypeUseCase is undefined', async () => { + mockGetLabels.mockResolvedValue(['release']); + mockConfigGet.mockResolvedValue(undefined); + mockGetReleaseVersionInvoke.mockResolvedValue([{ executed: true, success: false }]); + mockGetReleaseTypeInvoke.mockResolvedValue([ + { executed: true, success: true, payload: { releaseType: undefined } }, + ]); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.release.version).toBeUndefined(); + expect(e.release.branch).toBeUndefined(); + }); + + it('setup returns early when getLatestTag returns undefined in release path', async () => { + mockGetLabels.mockResolvedValue(['release']); + mockConfigGet.mockResolvedValue(undefined); + mockGetReleaseVersionInvoke.mockResolvedValue([{ executed: true, success: false }]); + mockGetReleaseTypeInvoke.mockResolvedValue([ + { executed: true, success: true, payload: { releaseType: 'Minor' } }, + ]); + mockGetLatestTag.mockResolvedValue(undefined); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.release.version).toBeUndefined(); + }); + + it('setup returns early when getLatestTag returns undefined in hotfix path', async () => { + mockGetLabels.mockResolvedValue(['hotfix']); + mockConfigGet.mockResolvedValue(undefined); + mockGetHotfixVersionInvoke.mockResolvedValue([{ executed: true, success: false }]); + mockGetLatestTag.mockResolvedValue(undefined); + const issue = makeIssue({ eventName: 'issues', issue: { number: 1 } } as never); + const e = buildExecution({ eventName: 'issues', issue: { number: 1 } } as never, { issue }); + await e.setup(); + expect(e.hotfix.baseVersion).toBeUndefined(); + expect(e.hotfix.version).toBeUndefined(); + }); + }); +}); diff --git a/src/data/repository/__tests__/ai_repository.test.ts b/src/data/repository/__tests__/ai_repository.test.ts index a4afa88f..9fc269a3 100644 --- a/src/data/repository/__tests__/ai_repository.test.ts +++ b/src/data/repository/__tests__/ai_repository.test.ts @@ -54,6 +54,16 @@ describe('AiRepository', () => { expect(mockFetch).toHaveBeenCalledTimes(OPENCODE_MAX_RETRIES); }); + it('returns undefined when session create returns empty body after all retries', async () => { + const ai = createAi(); + mockFetch.mockResolvedValue({ ok: true, text: async () => '' }); + const promise = repo.askAgent(ai, 'plan', 'Prompt', {}); + await jest.advanceTimersByTimeAsync((OPENCODE_MAX_RETRIES - 1) * OPENCODE_RETRY_DELAY_MS); + const result = await promise; + expect(result).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(OPENCODE_MAX_RETRIES); + }); + it('returns undefined when agent message request fails after all retries', async () => { const ai = createAi(); const sessionOk = { ok: true, text: async () => JSON.stringify({ id: 's1' }) }; @@ -258,6 +268,163 @@ describe('AiRepository', () => { await repo.askAgent(ai, 'plan', 'P', {}); expect(mockFetch).toHaveBeenNthCalledWith(1, 'http://localhost:4096/session', expect.any(Object)); }); + + it('uses session id from session.data.id when session.id is missing', async () => { + const ai = createAi(); + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ data: { id: 'sid-from-data' } }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + parts: [{ type: 'text', text: '{"answer": "ok"}' }], + }), + }); + const result = await repo.askAgent(ai, 'plan', 'P', { + expectJson: true, + schema: {}, + }); + expect(result).toEqual({ answer: 'ok' }); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('/session/sid-from-data/message'), + expect.any(Object) + ); + }); + + it('returns undefined when expectJson is true but agent returns empty text part', async () => { + const ai = createAi(); + const sessionOk = { ok: true, text: async () => JSON.stringify({ id: 's1' }) }; + const messageEmptyText = { + ok: true, + status: 200, + text: async () => JSON.stringify({ parts: [{ type: 'text', text: '' }] }), + }; + for (let i = 0; i < OPENCODE_MAX_RETRIES; i++) { + mockFetch.mockResolvedValueOnce(sessionOk).mockResolvedValueOnce(messageEmptyText); + } + const promise = repo.askAgent(ai, 'plan', 'P', { expectJson: true, schema: {} }); + await jest.advanceTimersByTimeAsync((OPENCODE_MAX_RETRIES - 1) * OPENCODE_RETRY_DELAY_MS); + const result = await promise; + expect(result).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(OPENCODE_MAX_RETRIES * 2); + }); + + it('parses JSON with escaped quote inside string (extractFirstJsonObject)', async () => { + const ai = createAi(); + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ id: 's1' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + parts: [ + { + type: 'text', + text: 'Analysis. {"key": "value with \\"nested\\" quote", "n": 1}', + }, + ], + }), + }); + const result = await repo.askAgent(ai, 'plan', 'P', { expectJson: true, schema: {} }); + expect(result).toEqual({ key: 'value with "nested" quote', n: 1 }); + }); + + it('returns undefined when expectJson and extracted JSON object is invalid', async () => { + const ai = createAi(); + const sessionOk = { ok: true, text: async () => JSON.stringify({ id: 's1' }) }; + const messageInvalidExtracted = { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + parts: [{ type: 'text', text: 'Here is the result. { invalid json here }' }], + }), + }; + for (let i = 0; i < OPENCODE_MAX_RETRIES; i++) { + mockFetch.mockResolvedValueOnce(sessionOk).mockResolvedValueOnce(messageInvalidExtracted); + } + const promise = repo.askAgent(ai, 'plan', 'P', { expectJson: true, schema: {} }); + await jest.advanceTimersByTimeAsync((OPENCODE_MAX_RETRIES - 1) * OPENCODE_RETRY_DELAY_MS); + const result = await promise; + expect(result).toBeUndefined(); + }); + + it('returns undefined when message response has empty parts array (empty text throws and retries exhaust)', async () => { + const ai = createAi(); + const sessionOk = { ok: true, text: async () => JSON.stringify({ id: 's1' }) }; + const messageEmptyParts = { + ok: true, + status: 200, + text: async () => JSON.stringify({ parts: [] }), + }; + for (let i = 0; i < OPENCODE_MAX_RETRIES; i++) { + mockFetch.mockResolvedValueOnce(sessionOk).mockResolvedValueOnce(messageEmptyParts); + } + const promise = repo.askAgent(ai, 'plan', 'P', {}); + await jest.advanceTimersByTimeAsync((OPENCODE_MAX_RETRIES - 1) * OPENCODE_RETRY_DELAY_MS); + const result = await promise; + expect(result).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(OPENCODE_MAX_RETRIES * 2); + }); + + it('returns undefined when expectJson is true but response has no JSON object (no curly brace)', async () => { + const ai = createAi(); + const sessionOk = { ok: true, text: async () => JSON.stringify({ id: 's1' }) }; + const messageNoJson = { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + parts: [{ type: 'text', text: 'No JSON here, just plain text.' }], + }), + }; + for (let i = 0; i < OPENCODE_MAX_RETRIES; i++) { + mockFetch.mockResolvedValueOnce(sessionOk).mockResolvedValueOnce(messageNoJson); + } + const promise = repo.askAgent(ai, 'plan', 'P', { expectJson: true, schema: {} }); + await jest.advanceTimersByTimeAsync((OPENCODE_MAX_RETRIES - 1) * OPENCODE_RETRY_DELAY_MS); + const result = await promise; + expect(result).toBeUndefined(); + }); + + it('returns undefined when session create returns invalid JSON (error with cause)', async () => { + const ai = createAi(); + mockFetch.mockResolvedValue({ ok: true, text: async () => 'not valid json' }); + const promise = repo.askAgent(ai, 'plan', 'P', {}); + await jest.advanceTimersByTimeAsync((OPENCODE_MAX_RETRIES - 1) * OPENCODE_RETRY_DELAY_MS); + const result = await promise; + expect(result).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(OPENCODE_MAX_RETRIES); + }); + + it('hits single-quote path in extractor when response has single-quoted object (invalid JSON)', async () => { + const ai = createAi(); + const sessionOk = { ok: true, text: async () => JSON.stringify({ id: 's1' }) }; + const messageSingleQuote = { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + parts: [{ type: 'text', text: "Note { 'a': 1 }" }], + }), + }; + for (let i = 0; i < OPENCODE_MAX_RETRIES; i++) { + mockFetch.mockResolvedValueOnce(sessionOk).mockResolvedValueOnce(messageSingleQuote); + } + const promise = repo.askAgent(ai, 'plan', 'P', { expectJson: true, schema: {} }); + await jest.advanceTimersByTimeAsync((OPENCODE_MAX_RETRIES - 1) * OPENCODE_RETRY_DELAY_MS); + const result = await promise; + expect(result).toBeUndefined(); + }); }); describe('copilotMessage', () => { diff --git a/src/data/repository/__tests__/branch_compare_repository.test.ts b/src/data/repository/__tests__/branch_compare_repository.test.ts new file mode 100644 index 00000000..4fd80c1e --- /dev/null +++ b/src/data/repository/__tests__/branch_compare_repository.test.ts @@ -0,0 +1,293 @@ +/** + * Unit tests for BranchCompareRepository: getChanges, getSizeCategoryAndReason. + */ + +import { BranchCompareRepository } from '../branch_compare_repository'; +import { Labels } from '../../model/labels'; +import { SizeThresholds } from '../../model/size_thresholds'; +import { SizeThreshold } from '../../model/size_threshold'; + +jest.mock('../../../utils/logger', () => ({ + logDebugInfo: jest.fn(), + logError: jest.fn(), +})); + +const mockCompareCommits = jest.fn(); +jest.mock('@actions/github', () => ({ + getOctokit: () => ({ + rest: { + repos: { + compareCommits: (...args: unknown[]) => mockCompareCommits(...args), + }, + }, + }), +})); + +function makeSizeThresholds(): SizeThresholds { + return new SizeThresholds( + new SizeThreshold(10000, 500, 200), // xxl + new SizeThreshold(2000, 100, 80), // xl + new SizeThreshold(500, 50, 30), // l + new SizeThreshold(200, 25, 15), // m + new SizeThreshold(50, 10, 5), // s + new SizeThreshold(0, 0, 0), // xs + ); +} + +function makeLabels(): Labels { + return new Labels( + 'launch', 'bug', 'bugfix', 'hotfix', 'enhancement', 'feature', 'release', + 'question', 'help', 'deploy', 'deployed', 'docs', 'documentation', 'chore', 'maintenance', + 'high', 'medium', 'low', 'none', + 'XXL', 'XL', 'L', 'M', 'S', 'XS', + ); +} + +describe('BranchCompareRepository', () => { + const repo = new BranchCompareRepository(); + + beforeEach(() => { + mockCompareCommits.mockReset(); + }); + + describe('getChanges', () => { + it('returns comparison with heads refs when head and base are branch names', async () => { + mockCompareCommits.mockResolvedValue({ + data: { + ahead_by: 2, + behind_by: 0, + total_commits: 2, + files: [ + { + filename: 'a.ts', + status: 'modified', + additions: 10, + deletions: 2, + changes: 12, + blob_url: 'https://blob', + raw_url: 'https://raw', + contents_url: 'https://contents', + patch: 'diff', + }, + ], + commits: [ + { + sha: 'abc', + commit: { + message: 'msg', + author: { name: 'A', email: 'a@x.com', date: '2024-01-01' }, + }, + }, + ], + }, + }); + + const result = await repo.getChanges('o', 'r', 'feature/1-foo', 'develop', 'token'); + + expect(mockCompareCommits).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + base: 'heads/develop', + head: 'heads/feature/1-foo', + }); + expect(result.aheadBy).toBe(2); + expect(result.behindBy).toBe(0); + expect(result.totalCommits).toBe(2); + expect(result.files).toHaveLength(1); + expect(result.files[0].filename).toBe('a.ts'); + expect(result.files[0].changes).toBe(12); + expect(result.commits).toHaveLength(1); + expect(result.commits[0].author.name).toBe('A'); + }); + + it('uses tag ref when head contains tags/', async () => { + mockCompareCommits.mockResolvedValue({ + data: { + ahead_by: 0, + behind_by: 0, + total_commits: 0, + files: [], + commits: [], + }, + }); + + await repo.getChanges('o', 'r', 'tags/v1.0', 'develop', 'token'); + + expect(mockCompareCommits).toHaveBeenCalledWith( + expect.objectContaining({ + head: 'tags/v1.0', + base: 'heads/develop', + }), + ); + }); + + it('maps missing file fields to 0 and author to defaults', async () => { + mockCompareCommits.mockResolvedValue({ + data: { + ahead_by: 0, + behind_by: 0, + total_commits: 1, + files: [ + { + filename: 'b.ts', + status: 'added', + blob_url: '', + raw_url: '', + contents_url: '', + }, + ], + commits: [ + { + sha: 'def', + commit: { + message: 'm', + author: undefined, + }, + }, + ], + }, + }); + + const result = await repo.getChanges('o', 'r', 'h', 'b', 'token'); + + expect(result.files[0].additions).toBe(0); + expect(result.files[0].deletions).toBe(0); + expect(result.files[0].changes).toBe(0); + expect(result.commits[0].author.name).toBe('Unknown'); + expect(result.commits[0].author.email).toBe('unknown@example.com'); + }); + + it('throws and logs on compareCommits error', async () => { + const { logError } = require('../../../utils/logger'); + mockCompareCommits.mockRejectedValue(new Error('API error')); + + await expect(repo.getChanges('o', 'r', 'h', 'b', 'token')).rejects.toThrow('API error'); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('Error comparing branches')); + }); + }); + + describe('getSizeCategoryAndReason', () => { + const sizeThresholds = makeSizeThresholds(); + const labels = makeLabels(); + + it('returns XS for small changes', async () => { + mockCompareCommits.mockResolvedValue({ + data: { + ahead_by: 1, + behind_by: 0, + total_commits: 1, + files: [ + { filename: 'f', status: 'modified', additions: 5, deletions: 2, changes: 7, blob_url: '', raw_url: '', contents_url: '' }, + ], + commits: [], + }, + }); + + const result = await repo.getSizeCategoryAndReason( + 'o', 'r', 'head', 'base', sizeThresholds, labels, 'token', + ); + + expect(result.size).toBe('XS'); + expect(result.githubSize).toBe('XS'); + expect(result.reason).toMatch(/Small changes/); + }); + + it('returns S when over s threshold by lines', async () => { + mockCompareCommits.mockResolvedValue({ + data: { + ahead_by: 1, + behind_by: 0, + total_commits: 1, + files: [ + { filename: 'f', status: 'modified', additions: 60, deletions: 0, changes: 60, blob_url: '', raw_url: '', contents_url: '' }, + ], + commits: [], + }, + }); + + const result = await repo.getSizeCategoryAndReason( + 'o', 'r', 'head', 'base', sizeThresholds, labels, 'token', + ); + + expect(result.size).toBe('S'); + expect(result.githubSize).toBe('S'); + expect(result.reason).toContain('50 lines'); + }); + + it('returns M when over m threshold', async () => { + mockCompareCommits.mockResolvedValue({ + data: { + ahead_by: 5, + behind_by: 0, + total_commits: 5, + files: Array.from({ length: 30 }, (_, i) => ({ + filename: `f${i}.ts`, + status: 'modified', + additions: 10, + deletions: 2, + changes: 12, + blob_url: '', + raw_url: '', + contents_url: '', + })), + commits: [], + }, + }); + + const result = await repo.getSizeCategoryAndReason( + 'o', 'r', 'head', 'base', sizeThresholds, labels, 'token', + ); + + expect(result.size).toBe('M'); + expect(result.githubSize).toBe('M'); + }); + + it('returns L when over l threshold by commits', async () => { + mockCompareCommits.mockResolvedValue({ + data: { + ahead_by: 40, + behind_by: 0, + total_commits: 40, + files: [{ filename: 'x', status: 'modified', additions: 1, deletions: 0, changes: 1, blob_url: '', raw_url: '', contents_url: '' }], + commits: [], + }, + }); + + const result = await repo.getSizeCategoryAndReason( + 'o', 'r', 'head', 'base', sizeThresholds, labels, 'token', + ); + + expect(result.size).toBe('L'); + expect(result.githubSize).toBe('L'); + }); + + it('returns XL and XXL for higher thresholds', async () => { + mockCompareCommits.mockResolvedValue({ + data: { + ahead_by: 3000, + behind_by: 0, + total_commits: 1, + files: [{ filename: 'x', status: 'modified', additions: 3000, deletions: 0, changes: 3000, blob_url: '', raw_url: '', contents_url: '' }], + commits: [], + }, + }); + + const result = await repo.getSizeCategoryAndReason( + 'o', 'r', 'head', 'base', sizeThresholds, labels, 'token', + ); + + expect(result.size).toBe('XL'); + expect(result.githubSize).toBe('XL'); + }); + + it('throws and logs when getChanges fails', async () => { + const { logError } = require('../../../utils/logger'); + mockCompareCommits.mockRejectedValue(new Error('compare failed')); + + await expect( + repo.getSizeCategoryAndReason('o', 'r', 'head', 'base', sizeThresholds, labels, 'token'), + ).rejects.toThrow('compare failed'); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('Error comparing branches')); + }); + }); +}); diff --git a/src/data/repository/__tests__/branch_repository.createLinkedBranch.test.ts b/src/data/repository/__tests__/branch_repository.createLinkedBranch.test.ts index 68e98a25..acf03a34 100644 --- a/src/data/repository/__tests__/branch_repository.createLinkedBranch.test.ts +++ b/src/data/repository/__tests__/branch_repository.createLinkedBranch.test.ts @@ -16,6 +16,25 @@ jest.mock("@actions/github", () => ({ }), })); +describe("BranchRepository", () => { + const repo = new BranchRepository(); + + describe("formatBranchName", () => { + it("lowercases and replaces spaces with single dash", () => { + expect(repo.formatBranchName("Hello World", 1)).toBe("hello-world"); + }); + it("strips leading/trailing dashes", () => { + expect(repo.formatBranchName(" - foo - ", 1)).toBe("foo"); + }); + it("sanitizes to alphanumeric and dashes (dashes removed in late step)", () => { + expect(repo.formatBranchName("1-my-feature", 1)).toBe("1myfeature"); + }); + it("collapses multiple dashes then strips non-alphanumeric", () => { + expect(repo.formatBranchName("a---b", 2)).toBe("ab"); + }); + }); +}); + describe("createLinkedBranch", () => { const repo = new BranchRepository(); @@ -75,4 +94,35 @@ describe("createLinkedBranch", () => { const queryString = mockGraphql.mock.calls[0][0] as string; expect(queryString).toContain("refs/heads/feature\\\\branch"); }); + + it("returns error result when repository id or oid is missing", async () => { + mockGraphql.mockResolvedValueOnce({ + repository: { + id: undefined, + issue: { id: "I_1" }, + ref: { target: { oid: "abc" } }, + }, + }); + + const result = await repo.createLinkedBranch( + "o", "r", "develop", "feature/1-foo", 1, undefined, "token" + ); + + expect(result).toHaveLength(1); + expect(result[0].success).toBe(false); + expect(result[0].steps).toContainEqual(expect.stringContaining("Repository not found")); + expect(mockGraphql).toHaveBeenCalledTimes(1); + }); + + it("returns error result when graphql throws", async () => { + mockGraphql.mockRejectedValueOnce(new Error("GraphQL error")); + + const result = await repo.createLinkedBranch( + "o", "r", "develop", "feature/1-foo", 1, undefined, "token" + ); + + expect(result).toHaveLength(1); + expect(result[0].success).toBe(false); + expect(result[0].steps).toContainEqual(expect.stringContaining("problem")); + }); }); diff --git a/src/data/repository/__tests__/branch_repository.test.ts b/src/data/repository/__tests__/branch_repository.test.ts new file mode 100644 index 00000000..dc9b35af --- /dev/null +++ b/src/data/repository/__tests__/branch_repository.test.ts @@ -0,0 +1,202 @@ +/** + * Unit tests for BranchRepository: getListOfBranches, removeBranch, manageBranches. + */ + +import { Execution } from '../../model/execution'; +import { BranchRepository } from '../branch_repository'; + +jest.mock('../../../utils/logger', () => ({ + logDebugInfo: jest.fn(), + logError: jest.fn(), +})); + +const mockListBranches = jest.fn(); +const mockGetRef = jest.fn(); +const mockDeleteRef = jest.fn(); +const mockGraphql = jest.fn(); + +jest.mock('@actions/github', () => ({ + getOctokit: () => ({ + rest: { + repos: { + listBranches: (...args: unknown[]) => mockListBranches(...args), + }, + git: { + getRef: (...args: unknown[]) => mockGetRef(...args), + deleteRef: (...args: unknown[]) => mockDeleteRef(...args), + }, + }, + graphql: (...args: unknown[]) => mockGraphql(...args), + }), +})); + +function mockExecution(overrides: Partial = {}): Execution { + const e = { + branches: { + featureTree: 'feature', + bugfixTree: 'bugfix', + docsTree: 'docs', + choreTree: 'chore', + }, + currentConfiguration: { parentBranch: undefined as string | undefined }, + ...overrides, + }; + return e as unknown as Execution; +} + +describe('BranchRepository', () => { + const repo = new BranchRepository(); + + beforeEach(() => { + mockListBranches.mockReset(); + mockGetRef.mockReset(); + mockDeleteRef.mockReset(); + mockGraphql.mockReset(); + }); + + describe('getListOfBranches', () => { + it('returns branch names from single page', async () => { + mockListBranches + .mockResolvedValueOnce({ data: [{ name: 'main' }, { name: 'develop' }] }) + .mockResolvedValueOnce({ data: [] }); + + const result = await repo.getListOfBranches('owner', 'repo', 'token'); + + expect(result).toEqual(['main', 'develop']); + expect(mockListBranches).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + per_page: 100, + page: 1, + }); + }); + + it('paginates until empty data', async () => { + mockListBranches + .mockResolvedValueOnce({ data: [{ name: 'a' }] }) + .mockResolvedValueOnce({ data: [{ name: 'b' }] }) + .mockResolvedValueOnce({ data: [] }); + + const result = await repo.getListOfBranches('o', 'r', 't'); + + expect(result).toEqual(['a', 'b']); + expect(mockListBranches).toHaveBeenCalledTimes(3); + }); + }); + + describe('removeBranch', () => { + it('deletes ref and returns true', async () => { + mockGetRef.mockResolvedValue({ data: { ref: 'refs/heads/feature/1-foo' } }); + mockDeleteRef.mockResolvedValue({}); + + const result = await repo.removeBranch('owner', 'repo', 'feature/1-foo', 'token'); + + expect(result).toBe(true); + expect(mockGetRef).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + ref: 'heads/feature/1-foo', + }); + expect(mockDeleteRef).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + ref: 'heads/feature/1-foo', + }); + }); + + it('throws when getRef fails', async () => { + mockGetRef.mockRejectedValue(new Error('Not found')); + + await expect( + repo.removeBranch('o', 'r', 'branch', 't'), + ).rejects.toThrow('Not found'); + }); + }); + + describe('manageBranches', () => { + it('returns error result when hotfixBranch is undefined and isHotfix is true', async () => { + mockListBranches + .mockResolvedValueOnce({ data: [] }); + const param = mockExecution(); + const result = await repo.manageBranches( + param, + 'owner', + 'repo', + 1, + 'Title', + 'hotfix', + 'develop', + undefined, + true, + 'token', + ); + + expect(result).toHaveLength(1); + expect(result[0].success).toBe(false); + expect(result[0].executed).toBe(true); + expect(result[0].steps).toContainEqual( + expect.stringContaining('hotfix branch was not found'), + ); + }); + + it('returns success executed false when branch already exists', async () => { + mockListBranches + .mockResolvedValueOnce({ data: [{ name: 'feature/1-title' }, { name: 'develop' }] }) + .mockResolvedValueOnce({ data: [] }); + + const param = mockExecution(); + const result = await repo.manageBranches( + param, + 'owner', + 'repo', + 1, + 'Title', + 'feature', + 'develop', + undefined, + false, + 'token', + ); + + expect(result).toHaveLength(1); + expect(result[0].success).toBe(true); + expect(result[0].executed).toBe(false); + }); + + it('creates linked branch when branch does not exist', async () => { + mockListBranches + .mockResolvedValueOnce({ data: [{ name: 'develop' }] }) + .mockResolvedValueOnce({ data: [] }) + .mockResolvedValueOnce({ data: [] }); + mockGraphql + .mockResolvedValueOnce({ + repository: { + id: 'R_1', + issue: { id: 'I_1' }, + ref: { target: { oid: 'abc123' } }, + }, + }) + .mockResolvedValueOnce({ + createLinkedBranch: { linkedBranch: { id: 'LB_1', ref: { name: 'feature/1-mytitle' } } }, + }); + + const param = mockExecution(); + const result = await repo.manageBranches( + param, + 'owner', + 'repo', + 1, + 'My Title', + 'feature', + 'develop', + undefined, + false, + 'token', + ); + + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result.some(r => r.success === true && r.executed === true)).toBe(true); + expect(mockGraphql).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/data/repository/__tests__/git_cli_repository.test.ts b/src/data/repository/__tests__/git_cli_repository.test.ts new file mode 100644 index 00000000..217c5141 --- /dev/null +++ b/src/data/repository/__tests__/git_cli_repository.test.ts @@ -0,0 +1,166 @@ +/** + * Unit tests for GitCliRepository: fetchRemoteBranches, getLatestTag, getCommitTag. + */ + +import { GitCliRepository } from '../git_cli_repository'; + +jest.mock('../../../utils/logger', () => ({ + logDebugInfo: jest.fn(), +})); + +const mockExec = jest.fn(); +jest.mock('@actions/exec', () => ({ + exec: (...args: unknown[]) => mockExec(...args), +})); + +const mockSetFailed = jest.fn(); +jest.mock('@actions/core', () => ({ + setFailed: (...args: unknown[]) => mockSetFailed(...args), +})); + +jest.mock('../../../utils/version_utils', () => ({ + getLatestVersion: (tags: string[]) => (tags.length > 0 ? tags[0] : undefined), +})); + +describe('GitCliRepository', () => { + const repo = new GitCliRepository(); + + beforeEach(() => { + mockExec.mockReset(); + mockSetFailed.mockReset(); + }); + + describe('fetchRemoteBranches', () => { + it('runs fetch --tags --force and fetch --all -v', async () => { + mockExec.mockResolvedValue(0); + + await repo.fetchRemoteBranches(); + + expect(mockExec).toHaveBeenCalledTimes(2); + expect(mockExec).toHaveBeenNthCalledWith(1, 'git', ['fetch', '--tags', '--force']); + expect(mockExec).toHaveBeenNthCalledWith(2, 'git', ['fetch', '--all', '-v']); + }); + + it('calls core.setFailed on error', async () => { + mockExec.mockRejectedValue(new Error('git failed')); + + await repo.fetchRemoteBranches(); + + expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Error fetching remote branches')); + }); + }); + + describe('getLatestTag', () => { + it('returns latest valid semver tag', async () => { + mockExec + .mockResolvedValueOnce(0) + .mockImplementationOnce((_cmd: string, _args: string[], opts: { listeners?: { stdout: (d: Buffer) => void } }) => { + const stdout = opts?.listeners?.stdout; + if (stdout) { + stdout(Buffer.from('2.0.0\n1.9.0\n1.0.0\n')); + } + return Promise.resolve(0); + }); + + const result = await repo.getLatestTag(); + + expect(mockExec).toHaveBeenCalledWith('git', ['fetch', '--tags']); + expect(mockExec).toHaveBeenCalledWith('git', ['tag', '--sort=-creatordate'], expect.any(Object)); + expect(result).toBe('2.0.0'); + }); + + it('strips leading v from tags when parsing', async () => { + mockExec + .mockResolvedValueOnce(0) + .mockImplementationOnce((_cmd: string, _args: string[], opts: { listeners?: { stdout: (d: Buffer) => void } }) => { + const stdout = opts?.listeners?.stdout; + if (stdout) stdout(Buffer.from('v1.0.0\n')); + return Promise.resolve(0); + }); + + await repo.getLatestTag(); + + expect(mockExec).toHaveBeenCalledTimes(2); + }); + + it('returns undefined when no valid tags', async () => { + mockExec + .mockResolvedValueOnce(0) + .mockImplementationOnce((_cmd: string, _args: string[], opts: { listeners?: { stdout: (d: Buffer) => void } }) => { + const stdout = opts?.listeners?.stdout; + if (stdout) stdout(Buffer.from('not-semver\n')); + return Promise.resolve(0); + }); + + const result = await repo.getLatestTag(); + + expect(result).toBeUndefined(); + }); + + it('returns undefined and calls setFailed on error', async () => { + mockExec.mockRejectedValue(new Error('fetch failed')); + + const result = await repo.getLatestTag(); + + expect(result).toBeUndefined(); + expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Error fetching the latest tag')); + }); + }); + + describe('getCommitTag', () => { + it('calls setFailed and returns undefined when latestTag is undefined', async () => { + const result = await repo.getCommitTag(undefined); + + expect(result).toBeUndefined(); + expect(mockSetFailed).toHaveBeenCalledWith('No LATEST_TAG found in the environment'); + expect(mockExec).not.toHaveBeenCalled(); + }); + + it('uses tag as-is when it starts with v', async () => { + mockExec.mockImplementation((cmd: string, args: string[], opts: { listeners?: { stdout: (d: Buffer) => void } }) => { + if (args?.[0] === 'rev-list' && opts?.listeners?.stdout) { + opts.listeners.stdout(Buffer.from('abc123')); + } + return Promise.resolve(0); + }); + + const result = await repo.getCommitTag('v1.0.0'); + + expect(mockExec).toHaveBeenCalledWith('git', ['rev-list', '-n', '1', 'v1.0.0'], expect.any(Object)); + expect(result).toBe('abc123'); + }); + + it('prepends v when tag does not start with v', async () => { + mockExec.mockImplementation((_cmd: string, args: string[], opts: { listeners?: { stdout: (d: Buffer) => void } }) => { + if (opts?.listeners?.stdout) opts.listeners.stdout(Buffer.from('oid456')); + return Promise.resolve(0); + }); + + const result = await repo.getCommitTag('1.0.0'); + + expect(mockExec).toHaveBeenCalledWith('git', ['rev-list', '-n', '1', 'v1.0.0'], expect.any(Object)); + expect(result).toBe('oid456'); + }); + + it('calls setFailed when rev-list returns empty oid', async () => { + mockExec.mockImplementation((_cmd: string, _args: string[], opts: { listeners?: { stdout: (d: Buffer) => void } }) => { + if (opts?.listeners?.stdout) opts.listeners.stdout(Buffer.from('')); + return Promise.resolve(0); + }); + + const result = await repo.getCommitTag('v1.0.0'); + + expect(mockSetFailed).toHaveBeenCalledWith('No commit found for the tag'); + expect(result).toBeUndefined(); + }); + + it('returns undefined and calls setFailed on exec error', async () => { + mockExec.mockRejectedValue(new Error('rev-list failed')); + + const result = await repo.getCommitTag('v1.0.0'); + + expect(result).toBeUndefined(); + expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Error fetching the commit hash')); + }); + }); +}); diff --git a/src/data/repository/__tests__/issue_repository.test.ts b/src/data/repository/__tests__/issue_repository.test.ts new file mode 100644 index 00000000..87ce9733 --- /dev/null +++ b/src/data/repository/__tests__/issue_repository.test.ts @@ -0,0 +1,960 @@ +/** + * Unit tests for IssueRepository: getDescription, addComment, getIssueDescription, isIssue, isPullRequest, + * getLabels, setLabels, getTitle, getId, getMilestone, updateDescription, cleanTitle, getHeadBranch, + * updateComment, listIssueComments, closeIssue, openIssue, getCurrentAssignees, assignMembersToIssue, + * listLabelsForRepo, createLabel, ensureLabel, setProgressLabel, ensureProgressLabels. + */ + +import { IssueRepository, PROGRESS_LABEL_PATTERN } from '../issue_repository'; +import { Labels } from '../../model/labels'; +import { IssueTypes } from '../../model/issue_types'; + +jest.mock('../../../utils/logger', () => ({ + logDebugInfo: jest.fn(), + logError: jest.fn(), +})); + +const mockSetFailed = jest.fn(); +jest.mock('@actions/core', () => ({ + setFailed: (...args: unknown[]) => mockSetFailed(...args), +})); + +const mockRest = { + issues: { + get: jest.fn(), + update: jest.fn(), + createComment: jest.fn(), + updateComment: jest.fn(), + listLabelsOnIssue: jest.fn(), + setLabels: jest.fn(), + addAssignees: jest.fn(), + listLabelsForRepo: jest.fn(), + createLabel: jest.fn(), + listComments: jest.fn(), + }, + pulls: { + get: jest.fn(), + }, +}; + +const mockPaginateIterator = jest.fn(); +const mockGraphql = jest.fn(); + +jest.mock('@actions/github', () => ({ + getOctokit: () => ({ + rest: mockRest, + graphql: (...args: unknown[]) => mockGraphql(...args), + paginate: { + iterator: (...args: unknown[]) => mockPaginateIterator(...args), + }, + }), +})); + +/** Build Labels with optional currentIssueLabels (for isHotfix, containsBranchedLabel, etc.). */ +function makeLabels(overrides: { currentIssueLabels?: string[] } = {}): Labels { + const labels = new Labels( + 'launch', + 'bug', + 'bugfix', + 'hotfix', + 'enhancement', + 'feature', + 'release', + 'question', + 'help', + 'deploy', + 'deployed', + 'docs', + 'documentation', + 'chore', + 'maintenance', + 'priority-high', + 'priority-medium', + 'priority-low', + 'priority-none', + 'xxl', + 'xl', + 'l', + 'm', + 's', + 'xs' + ); + if (overrides.currentIssueLabels) { + labels.currentIssueLabels = overrides.currentIssueLabels; + } + return labels; +} + +describe('IssueRepository', () => { + const repo = new IssueRepository(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('updateTitleIssueFormat', () => { + it('updates title with emoji when hotfix and branched', async () => { + const labels = makeLabels({ currentIssueLabels: ['hotfix', 'launch'] }); + mockRest.issues.update.mockResolvedValue(undefined); + const result = await repo.updateTitleIssueFormat( + 'o', + 'r', + '', + 'Fix login', + 1, + false, + 'x', + labels, + 'token' + ); + expect(result).toBe('🔥x - Fix login'); + expect(mockRest.issues.update).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 1, + title: '🔥x - Fix login', + }); + }); + + it('returns undefined when formatted title equals current title', async () => { + const labels = makeLabels(); + const result = await repo.updateTitleIssueFormat( + 'o', + 'r', + '', + '🤖 - Clean title', + 1, + false, + 'x', + labels, + 'token' + ); + expect(result).toBeUndefined(); + expect(mockRest.issues.update).not.toHaveBeenCalled(); + }); + + it('includes version in title when version length > 0', async () => { + const labels = makeLabels({ currentIssueLabels: ['feature', 'launch'] }); + mockRest.issues.update.mockResolvedValue(undefined); + const result = await repo.updateTitleIssueFormat( + 'o', + 'r', + 'v1.0', + 'Add API', + 2, + true, + 'y', + labels, + 'token' + ); + expect(result).toContain('v1.0'); + expect(result).toContain('Add API'); + }); + + it('returns undefined and setFailed when update throws', async () => { + const labels = makeLabels({ currentIssueLabels: ['bug'] }); + mockRest.issues.update.mockRejectedValue(new Error('API error')); + const result = await repo.updateTitleIssueFormat( + 'o', + 'r', + '', + 'Broken', + 1, + false, + 'x', + labels, + 'token' + ); + expect(result).toBeUndefined(); + expect(mockSetFailed).toHaveBeenCalled(); + }); + }); + + describe('updateTitlePullRequestFormat', () => { + it('updates PR title with [#N] and emoji when title differs', async () => { + const labels = makeLabels({ currentIssueLabels: ['feature'] }); + mockRest.issues.update.mockResolvedValue(undefined); + const result = await repo.updateTitlePullRequestFormat( + 'o', + 'r', + 'Old PR title', + 'Add feature', + 42, + 10, + false, + 'x', + labels, + 'token' + ); + expect(result).toBe('[#42] ✨ - Add feature'); + expect(mockRest.issues.update).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 10, + title: '[#42] ✨ - Add feature', + }); + }); + + it('returns undefined when formatted title equals pullRequestTitle', async () => { + const labels = makeLabels(); + const result = await repo.updateTitlePullRequestFormat( + 'o', + 'r', + '[#1] 🤖 - Same', + 'Same', + 1, + 5, + false, + 'x', + labels, + 'token' + ); + expect(result).toBeUndefined(); + expect(mockRest.issues.update).not.toHaveBeenCalled(); + }); + + it('returns undefined and setFailed when update throws', async () => { + const labels = makeLabels({ currentIssueLabels: ['hotfix'] }); + mockRest.issues.update.mockRejectedValue(new Error('API error')); + const result = await repo.updateTitlePullRequestFormat( + 'o', + 'r', + 'PR', + 'Fix', + 1, + 1, + false, + 'x', + labels, + 'token' + ); + expect(result).toBeUndefined(); + expect(mockSetFailed).toHaveBeenCalled(); + }); + }); + + describe('getDescription', () => { + it('returns undefined when issueNumber is -1', async () => { + const result = await repo.getDescription('o', 'r', -1, 'token'); + expect(result).toBeUndefined(); + expect(mockRest.issues.get).not.toHaveBeenCalled(); + }); + + it('returns body when issue exists', async () => { + mockRest.issues.get.mockResolvedValue({ + data: { body: 'Issue body text' }, + }); + const result = await repo.getDescription('owner', 'repo', 42, 'token'); + expect(result).toBe('Issue body text'); + expect(mockRest.issues.get).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + issue_number: 42, + }); + }); + + it('returns empty string when issue body is null', async () => { + mockRest.issues.get.mockResolvedValue({ data: { body: null } }); + const result = await repo.getDescription('o', 'r', 1, 'token'); + expect(result).toBe(''); + }); + + it('returns undefined when get throws', async () => { + mockRest.issues.get.mockRejectedValue(new Error('Not found')); + const result = await repo.getDescription('o', 'r', 1, 'token'); + expect(result).toBeUndefined(); + }); + }); + + describe('addComment', () => { + it('calls issues.createComment with owner, repo, issue_number, body', async () => { + mockRest.issues.createComment.mockResolvedValue(undefined); + await repo.addComment('owner', 'repo', 10, 'Hello comment', 'token'); + expect(mockRest.issues.createComment).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + issue_number: 10, + body: 'Hello comment', + }); + }); + }); + + describe('getIssueDescription', () => { + it('returns issue body', async () => { + mockRest.issues.get.mockResolvedValue({ + data: { body: 'Full issue description' }, + }); + const result = await repo.getIssueDescription('o', 'r', 5, 'token'); + expect(result).toBe('Full issue description'); + expect(mockRest.issues.get).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 5, + }); + }); + + it('returns empty string when body is null', async () => { + mockRest.issues.get.mockResolvedValue({ data: { body: null } }); + const result = await repo.getIssueDescription('o', 'r', 1, 'token'); + expect(result).toBe(''); + }); + }); + + describe('isPullRequest', () => { + it('returns true when issue is a pull request', async () => { + mockRest.issues.get.mockResolvedValue({ + data: { pull_request: {} }, + }); + const result = await repo.isPullRequest('o', 'r', 3, 'token'); + expect(result).toBe(true); + }); + + it('returns false when issue is not a pull request', async () => { + mockRest.issues.get.mockResolvedValue({ + data: {}, + }); + const result = await repo.isPullRequest('o', 'r', 3, 'token'); + expect(result).toBe(false); + }); + }); + + describe('isIssue', () => { + it('returns true when isPullRequest returns false', async () => { + mockRest.issues.get.mockResolvedValue({ data: {} }); + const result = await repo.isIssue('o', 'r', 3, 'token'); + expect(result).toBe(true); + }); + + it('returns false when isPullRequest returns true', async () => { + mockRest.issues.get.mockResolvedValue({ data: { pull_request: {} } }); + const result = await repo.isIssue('o', 'r', 3, 'token'); + expect(result).toBe(false); + }); + }); + + describe('getLabels', () => { + it('returns empty array when issueNumber is -1', async () => { + const result = await repo.getLabels('o', 'r', -1, 'token'); + expect(result).toEqual([]); + expect(mockRest.issues.listLabelsOnIssue).not.toHaveBeenCalled(); + }); + + it('returns label names from listLabelsOnIssue', async () => { + mockRest.issues.listLabelsOnIssue.mockResolvedValue({ + data: [{ name: 'bug' }, { name: 'feature' }], + }); + const result = await repo.getLabels('owner', 'repo', 1, 'token'); + expect(result).toEqual(['bug', 'feature']); + expect(mockRest.issues.listLabelsOnIssue).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + issue_number: 1, + }); + }); + }); + + describe('setLabels', () => { + it('calls issues.setLabels with given labels', async () => { + mockRest.issues.setLabels.mockResolvedValue(undefined); + await repo.setLabels('o', 'r', 5, ['a', 'b'], 'token'); + expect(mockRest.issues.setLabels).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 5, + labels: ['a', 'b'], + }); + }); + }); + + describe('getTitle', () => { + it('returns issue title', async () => { + mockRest.issues.get.mockResolvedValue({ + data: { title: 'My issue title' }, + }); + const result = await repo.getTitle('o', 'r', 1, 'token'); + expect(result).toBe('My issue title'); + }); + + it('returns undefined when get throws', async () => { + mockRest.issues.get.mockRejectedValue(new Error('Not found')); + const result = await repo.getTitle('o', 'r', 1, 'token'); + expect(result).toBeUndefined(); + }); + }); + + describe('getId', () => { + it('returns issue node id from GraphQL', async () => { + mockGraphql.mockResolvedValue({ + repository: { issue: { id: 'I_kwDOABC123' } }, + }); + const result = await repo.getId('o', 'r', 1, 'token'); + expect(result).toBe('I_kwDOABC123'); + expect(mockGraphql).toHaveBeenCalled(); + }); + }); + + describe('getMilestone', () => { + it('returns Milestone when issue has milestone', async () => { + mockRest.issues.get.mockResolvedValue({ + data: { + milestone: { + id: 42, + title: 'v1.0', + description: 'Release 1.0', + }, + }, + }); + const result = await repo.getMilestone('o', 'r', 1, 'token'); + expect(result).not.toBeUndefined(); + expect(result?.title).toBe('v1.0'); + expect(result?.id).toBe(42); + }); + + it('returns undefined when issue has no milestone', async () => { + mockRest.issues.get.mockResolvedValue({ data: { milestone: null } }); + const result = await repo.getMilestone('o', 'r', 1, 'token'); + expect(result).toBeUndefined(); + }); + }); + + describe('updateDescription', () => { + it('calls issues.update with body', async () => { + mockRest.issues.update.mockResolvedValue(undefined); + await repo.updateDescription('o', 'r', 1, 'New body', 'token'); + expect(mockRest.issues.update).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 1, + body: 'New body', + }); + }); + + it('throws when update throws', async () => { + mockRest.issues.update.mockRejectedValue(new Error('API error')); + await expect( + repo.updateDescription('o', 'r', 1, 'Body', 'token') + ).rejects.toThrow('API error'); + }); + }); + + describe('cleanTitle', () => { + it('updates title when sanitized differs from original', async () => { + mockRest.issues.update.mockResolvedValue(undefined); + const result = await repo.cleanTitle('o', 'r', ' messy title ', 1, 'token'); + expect(result).toBe('messy title'); + expect(mockRest.issues.update).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 1, + title: 'messy title', + }); + }); + + it('returns undefined when title already clean', async () => { + const result = await repo.cleanTitle('o', 'r', 'Clean title', 1, 'token'); + expect(result).toBeUndefined(); + expect(mockRest.issues.update).not.toHaveBeenCalled(); + }); + + it('returns undefined and setFailed when update throws', async () => { + mockRest.issues.update.mockRejectedValue(new Error('Fail')); + const result = await repo.cleanTitle('o', 'r', ' x ', 1, 'token'); + expect(result).toBeUndefined(); + expect(mockSetFailed).toHaveBeenCalled(); + }); + }); + + describe('getHeadBranch', () => { + it('returns undefined when not a PR', async () => { + mockRest.issues.get.mockResolvedValue({ data: {} }); + const result = await repo.getHeadBranch('o', 'r', 3, 'token'); + expect(result).toBeUndefined(); + expect(mockRest.pulls.get).not.toHaveBeenCalled(); + }); + + it('returns head ref when issue is a PR', async () => { + mockRest.issues.get.mockResolvedValue({ data: { pull_request: {} } }); + mockRest.pulls.get.mockResolvedValue({ + data: { head: { ref: 'feature/123-branch' } }, + }); + const result = await repo.getHeadBranch('o', 'r', 3, 'token'); + expect(result).toBe('feature/123-branch'); + expect(mockRest.pulls.get).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + pull_number: 3, + }); + }); + }); + + describe('updateComment', () => { + it('calls issues.updateComment with comment_id and body', async () => { + mockRest.issues.updateComment.mockResolvedValue(undefined); + await repo.updateComment('o', 'r', 1, 100, 'Updated body', 'token'); + expect(mockRest.issues.updateComment).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + comment_id: 100, + body: 'Updated body', + }); + }); + }); + + describe('listIssueComments', () => { + it('returns all comments from paginated iterator', async () => { + const asyncIter = (async function* () { + yield { data: [{ id: 1, body: 'c1', user: { login: 'u1' } }] }; + yield { data: [{ id: 2, body: 'c2', user: undefined }] }; + })(); + mockPaginateIterator.mockReturnValue(asyncIter); + const result = await repo.listIssueComments('o', 'r', 5, 'token'); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ id: 1, body: 'c1', user: { login: 'u1' } }); + expect(result[1]).toEqual({ id: 2, body: 'c2', user: undefined }); + }); + }); + + describe('closeIssue', () => { + it('closes issue when open and returns true', async () => { + mockRest.issues.get.mockResolvedValue({ data: { state: 'open' } }); + mockRest.issues.update.mockResolvedValue(undefined); + const result = await repo.closeIssue('o', 'r', 1, 'token'); + expect(result).toBe(true); + expect(mockRest.issues.update).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 1, + state: 'closed', + }); + }); + + it('returns false when issue already closed', async () => { + mockRest.issues.get.mockResolvedValue({ data: { state: 'closed' } }); + const result = await repo.closeIssue('o', 'r', 1, 'token'); + expect(result).toBe(false); + expect(mockRest.issues.update).not.toHaveBeenCalled(); + }); + }); + + describe('openIssue', () => { + it('reopens issue when closed and returns true', async () => { + mockRest.issues.get.mockResolvedValue({ data: { state: 'closed' } }); + mockRest.issues.update.mockResolvedValue(undefined); + const result = await repo.openIssue('o', 'r', 1, 'token'); + expect(result).toBe(true); + expect(mockRest.issues.update).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 1, + state: 'open', + }); + }); + + it('returns false when issue already open', async () => { + mockRest.issues.get.mockResolvedValue({ data: { state: 'open' } }); + const result = await repo.openIssue('o', 'r', 1, 'token'); + expect(result).toBe(false); + expect(mockRest.issues.update).not.toHaveBeenCalled(); + }); + }); + + describe('getCurrentAssignees', () => { + it('returns assignee logins', async () => { + mockRest.issues.get.mockResolvedValue({ + data: { assignees: [{ login: 'alice' }, { login: 'bob' }] }, + }); + const result = await repo.getCurrentAssignees('o', 'r', 1, 'token'); + expect(result).toEqual(['alice', 'bob']); + }); + + it('returns empty array when assignees null', async () => { + mockRest.issues.get.mockResolvedValue({ data: { assignees: null } }); + const result = await repo.getCurrentAssignees('o', 'r', 1, 'token'); + expect(result).toEqual([]); + }); + + it('returns empty array when get throws', async () => { + mockRest.issues.get.mockRejectedValue(new Error('API error')); + const result = await repo.getCurrentAssignees('o', 'r', 1, 'token'); + expect(result).toEqual([]); + }); + }); + + describe('assignMembersToIssue', () => { + it('adds assignees and returns their logins', async () => { + mockRest.issues.addAssignees.mockResolvedValue({ + data: { assignees: [{ login: 'alice' }, { login: 'bob' }] }, + }); + const result = await repo.assignMembersToIssue('o', 'r', 1, ['alice', 'bob'], 'token'); + expect(result).toEqual(['alice', 'bob']); + expect(mockRest.issues.addAssignees).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 1, + assignees: ['alice', 'bob'], + }); + }); + + it('returns empty array when members empty', async () => { + const result = await repo.assignMembersToIssue('o', 'r', 1, [], 'token'); + expect(result).toEqual([]); + expect(mockRest.issues.addAssignees).not.toHaveBeenCalled(); + }); + + it('returns empty array when addAssignees throws', async () => { + mockRest.issues.addAssignees.mockRejectedValue(new Error('API error')); + const result = await repo.assignMembersToIssue('o', 'r', 1, ['x'], 'token'); + expect(result).toEqual([]); + }); + }); + + describe('listLabelsForRepo', () => { + it('returns labels with name, color, description', async () => { + mockRest.issues.listLabelsForRepo.mockResolvedValue({ + data: [ + { name: 'bug', color: 'd73a4a', description: 'Bug' }, + { name: 'feature', color: '0e8a16', description: null }, + ], + }); + const result = await repo.listLabelsForRepo('o', 'r', 'token'); + expect(result).toEqual([ + { name: 'bug', color: 'd73a4a', description: 'Bug' }, + { name: 'feature', color: '0e8a16', description: null }, + ]); + }); + }); + + describe('createLabel', () => { + it('calls issues.createLabel', async () => { + mockRest.issues.createLabel.mockResolvedValue(undefined); + await repo.createLabel('o', 'r', 'new-label', 'abc123', 'Description', 'token'); + expect(mockRest.issues.createLabel).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + name: 'new-label', + color: 'abc123', + description: 'Description', + }); + }); + }); + + describe('ensureLabel', () => { + it('returns existed true when label already exists', async () => { + mockRest.issues.listLabelsForRepo.mockResolvedValue({ + data: [{ name: 'existing', color: 'x', description: null }], + }); + const result = await repo.ensureLabel('o', 'r', 'existing', 'abc', 'Desc', 'token'); + expect(result).toEqual({ created: false, existed: true }); + expect(mockRest.issues.createLabel).not.toHaveBeenCalled(); + }); + + it('returns created true when label created', async () => { + mockRest.issues.listLabelsForRepo.mockResolvedValue({ data: [] }); + mockRest.issues.createLabel.mockResolvedValue(undefined); + const result = await repo.ensureLabel('o', 'r', 'new-label', 'abc', 'Desc', 'token'); + expect(result).toEqual({ created: true, existed: false }); + expect(mockRest.issues.createLabel).toHaveBeenCalled(); + }); + + it('returns existed true on 422 already exists', async () => { + mockRest.issues.listLabelsForRepo.mockResolvedValue({ data: [] }); + mockRest.issues.createLabel.mockRejectedValue( + Object.assign(new Error('already exists'), { status: 422 }) + ); + const result = await repo.ensureLabel('o', 'r', 'new', 'abc', 'd', 'token'); + expect(result).toEqual({ created: false, existed: true }); + }); + + it('returns created false and existed false when name is empty', async () => { + const result = await repo.ensureLabel('o', 'r', ' ', 'abc', 'd', 'token'); + expect(result).toEqual({ created: false, existed: false }); + expect(mockRest.issues.createLabel).not.toHaveBeenCalled(); + }); + }); + + describe('setProgressLabel', () => { + it('sets progress label and removes other percentage labels', async () => { + mockRest.issues.listLabelsOnIssue.mockResolvedValue({ + data: [{ name: '50%' }, { name: 'feature' }], + }); + mockRest.issues.setLabels.mockResolvedValue(undefined); + await repo.setProgressLabel('o', 'r', 1, 75, 'token'); + expect(mockRest.issues.setLabels).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + issue_number: 1, + labels: ['feature', '75%'], + }); + }); + }); + + describe('ensureProgressLabels', () => { + it('creates and counts created/existing progress labels', async () => { + mockRest.issues.listLabelsForRepo.mockResolvedValue({ + data: [{ name: '0%', color: 'x', description: null }], + }); + mockRest.issues.createLabel.mockResolvedValue(undefined); + const result = await repo.ensureProgressLabels('o', 'r', 'token'); + expect(result.errors).toEqual([]); + expect(result.created + result.existing).toBeGreaterThan(0); + }); + + it('collects errors when ensureLabel throws', async () => { + mockRest.issues.listLabelsForRepo.mockResolvedValue({ data: [] }); + mockRest.issues.createLabel.mockRejectedValue(new Error('API error')); + const result = await repo.ensureProgressLabels('o', 'r', 'token'); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('setIssueType', () => { + const issueTypes = new IssueTypes( + 'Task', 'Task desc', 'BLUE', + 'Bug', 'Bug desc', 'RED', + 'Feature', 'Feature desc', 'GREEN', + 'Docs', 'Docs desc', 'GREY', + 'Maintenance', 'Maint desc', 'GREY', + 'Hotfix', 'Hotfix desc', 'RED', + 'Release', 'Release desc', 'BLUE', + 'Question', 'Q desc', 'PURPLE', + 'Help', 'Help desc', 'PURPLE' + ); + + it('sets issue type when type exists in organization', async () => { + const labels = makeLabels({ currentIssueLabels: ['bug'] }); + mockGraphql + .mockResolvedValueOnce({ repository: { issue: { id: 'I_1' } } }) + .mockResolvedValueOnce({ + organization: { + id: 'O_1', + issueTypes: { nodes: [{ id: 'T_BUG', name: 'Bug' }] }, + }, + }) + .mockResolvedValueOnce({ updateIssueIssueType: { issue: { id: 'I_1', issueType: { id: 'T_BUG', name: 'Bug' } } } }); + await repo.setIssueType('org', 'repo', 1, labels, issueTypes, 'token'); + expect(mockGraphql).toHaveBeenCalledTimes(3); + }); + + it('creates issue type when not found then updates issue', async () => { + const labels = makeLabels({ currentIssueLabels: ['hotfix'] }); + mockGraphql + .mockResolvedValueOnce({ repository: { issue: { id: 'I_1' } } }) + .mockResolvedValueOnce({ + organization: { id: 'O_1', issueTypes: { nodes: [] } }, + }) + .mockResolvedValueOnce({ createIssueType: { issueType: { id: 'T_NEW' } } }) + .mockResolvedValueOnce({ updateIssueIssueType: { issue: { id: 'I_1' } } }); + await repo.setIssueType('org', 'repo', 1, labels, issueTypes, 'token'); + expect(mockGraphql).toHaveBeenCalledTimes(4); + }); + + it('returns early when createIssueType throws', async () => { + const labels = makeLabels({ currentIssueLabels: ['release'] }); + mockGraphql + .mockResolvedValueOnce({ repository: { issue: { id: 'I_1' } } }) + .mockResolvedValueOnce({ + organization: { id: 'O_1', issueTypes: { nodes: [] } }, + }) + .mockRejectedValueOnce(new Error('Create failed')); + await repo.setIssueType('org', 'repo', 1, labels, issueTypes, 'token'); + expect(mockGraphql).toHaveBeenCalledTimes(3); + }); + + it('throws when getId or organization query fails', async () => { + const labels = makeLabels({ currentIssueLabels: ['feature'] }); + mockGraphql.mockRejectedValue(new Error('GraphQL error')); + await expect( + repo.setIssueType('org', 'repo', 1, labels, issueTypes, 'token') + ).rejects.toThrow('GraphQL error'); + }); + }); + + describe('ensureLabels', () => { + it('ensures all required labels and returns counts', async () => { + const labels = new Labels( + 'launch', + 'bug', + 'bugfix', + 'hotfix', + 'enhancement', + 'feature', + 'release', + 'question', + 'help', + 'deploy', + 'deployed', + 'docs', + 'documentation', + 'chore', + 'maintenance', + 'priority-high', + 'priority-medium', + 'priority-low', + 'priority-none', + 'xxl', + 'xl', + 'l', + 'm', + 's', + 'xs' + ); + mockRest.issues.listLabelsForRepo.mockResolvedValue({ data: [] }); + mockRest.issues.createLabel.mockResolvedValue(undefined); + const result = await repo.ensureLabels('o', 'r', labels, 'token'); + expect(result.errors).toEqual([]); + expect(result.created).toBeGreaterThan(0); + }); + + it('collects errors when one ensureLabel throws', async () => { + const labels = makeLabels(); + mockRest.issues.listLabelsForRepo.mockResolvedValue({ data: [] }); + let callCount = 0; + mockRest.issues.createLabel.mockImplementation(() => { + callCount++; + if (callCount === 2) return Promise.reject(new Error('Label exists')); + return Promise.resolve(undefined); + }); + const result = await repo.ensureLabels('o', 'r', labels, 'token'); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('Label exists'))).toBe(true); + }); + }); + + describe('listIssueTypes', () => { + it('returns issue types from organization', async () => { + mockGraphql.mockResolvedValue({ + organization: { + id: 'O_1', + issueTypes: { nodes: [{ id: 'T1', name: 'Task' }, { id: 'T2', name: 'Bug' }] }, + }, + }); + const result = await repo.listIssueTypes('org', 'token'); + expect(result).toEqual([{ id: 'T1', name: 'Task' }, { id: 'T2', name: 'Bug' }]); + }); + + it('throws when organization is missing', async () => { + mockGraphql.mockResolvedValue({ organization: null }); + await expect(repo.listIssueTypes('org', 'token')).rejects.toThrow(); + }); + }); + + describe('createIssueType', () => { + it('returns new issue type id', async () => { + mockGraphql + .mockResolvedValueOnce({ organization: { id: 'O_1' } }) + .mockResolvedValueOnce({ + createIssueType: { issueType: { id: 'NEW_ID' } }, + }); + const result = await repo.createIssueType('org', 'Task', 'A task', 'BLUE', 'token'); + expect(result).toBe('NEW_ID'); + }); + + it('throws when organization is missing', async () => { + mockGraphql.mockResolvedValue({ organization: null }); + await expect( + repo.createIssueType('org', 'T', 'D', 'BLUE', 'token') + ).rejects.toThrow(); + }); + }); + + describe('ensureIssueType', () => { + it('returns existed true when type already exists', async () => { + mockGraphql.mockResolvedValue({ + organization: { issueTypes: { nodes: [{ id: 'T1', name: 'task' }] } }, + }); + const result = await repo.ensureIssueType('org', 'Task', 'Desc', 'BLUE', 'token'); + expect(result).toEqual({ created: false, existed: true }); + }); + + it('returns created true when type created', async () => { + mockGraphql + .mockResolvedValueOnce({ + organization: { issueTypes: { nodes: [] } }, + }) + .mockResolvedValueOnce({ organization: { id: 'O_1' } }) + .mockResolvedValueOnce({ createIssueType: { issueType: { id: 'NEW' } } }); + const result = await repo.ensureIssueType('org', 'NewType', 'D', 'BLUE', 'token'); + expect(result).toEqual({ created: true, existed: false }); + }); + }); + + describe('ensureIssueTypes', () => { + it('ensures issue types and returns counts when types already exist', async () => { + const issueTypes = new IssueTypes( + 'Task', 'Task desc', 'BLUE', + 'Bug', 'Bug desc', 'RED', + 'Feature', 'Feature desc', 'GREEN', + 'Docs', 'Docs desc', 'GREY', + 'Maintenance', 'Maint desc', 'GREY', + 'Hotfix', 'Hotfix desc', 'RED', + 'Release', 'Release desc', 'BLUE', + 'Question', 'Q desc', 'PURPLE', + 'Help', 'Help desc', 'PURPLE' + ); + mockGraphql.mockResolvedValue({ + organization: { + id: 'O_1', + issueTypes: { + nodes: [ + { id: 'T1', name: 'Task' }, + { id: 'T2', name: 'Bug' }, + { id: 'T3', name: 'Feature' }, + { id: 'T4', name: 'Docs' }, + { id: 'T5', name: 'Maintenance' }, + { id: 'T6', name: 'Hotfix' }, + { id: 'T7', name: 'Release' }, + { id: 'T8', name: 'Question' }, + { id: 'T9', name: 'Help' }, + ], + }, + }, + }); + const result = await repo.ensureIssueTypes('org', issueTypes, 'token'); + expect(result.existing).toBe(9); + expect(result.created).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('collects errors when one ensureIssueType throws', async () => { + const issueTypesForTest = new IssueTypes( + 'Task', 'Task desc', 'BLUE', + 'Bug', 'Bug desc', 'RED', + 'Feature', 'Feature desc', 'GREEN', + 'Docs', 'Docs desc', 'GREY', + 'Maintenance', 'Maint desc', 'GREY', + 'Hotfix', 'Hotfix desc', 'RED', + 'Release', 'Release desc', 'BLUE', + 'Question', 'Q desc', 'PURPLE', + 'Help', 'Help desc', 'PURPLE' + ); + let callCount = 0; + mockGraphql.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + organization: { id: 'O_1', issueTypes: { nodes: [{ id: 'T1', name: 'Task' }] } }, + }); + } + if (callCount <= 3) { + return Promise.resolve({ organization: { id: 'O_1', issueTypes: { nodes: [] } } }); + } + return Promise.reject(new Error('Create failed')); + }); + const result = await repo.ensureIssueTypes('org', issueTypesForTest, 'token'); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); +}); + +describe('PROGRESS_LABEL_PATTERN', () => { + it('matches percentage labels', () => { + expect('0%').toMatch(PROGRESS_LABEL_PATTERN); + expect('50%').toMatch(PROGRESS_LABEL_PATTERN); + expect('100%').toMatch(PROGRESS_LABEL_PATTERN); + }); + + it('does not match non-percentage strings', () => { + expect('feature').not.toMatch(PROGRESS_LABEL_PATTERN); + expect('50').not.toMatch(PROGRESS_LABEL_PATTERN); + }); +}); diff --git a/src/data/repository/__tests__/merge_repository.test.ts b/src/data/repository/__tests__/merge_repository.test.ts new file mode 100644 index 00000000..470ab780 --- /dev/null +++ b/src/data/repository/__tests__/merge_repository.test.ts @@ -0,0 +1,239 @@ +/** + * Unit tests for MergeRepository: mergeBranch (PR merge and direct merge fallback). + */ + +import { MergeRepository } from '../merge_repository'; + +jest.mock('../../../utils/logger', () => ({ + logDebugInfo: jest.fn(), + logError: jest.fn(), +})); + +const mockPullsCreate = jest.fn(); +const mockPullsListCommits = jest.fn(); +const mockPullsUpdate = jest.fn(); +const mockPullsMerge = jest.fn(); +const mockReposMerge = jest.fn(); +const mockChecksListForRef = jest.fn(); +const mockReposGetCombinedStatusForRef = jest.fn(); + +jest.mock('@actions/github', () => ({ + getOctokit: () => ({ + rest: { + pulls: { + create: (...args: unknown[]) => mockPullsCreate(...args), + listCommits: (...args: unknown[]) => mockPullsListCommits(...args), + update: (...args: unknown[]) => mockPullsUpdate(...args), + merge: (...args: unknown[]) => mockPullsMerge(...args), + }, + repos: { + merge: (...args: unknown[]) => mockReposMerge(...args), + getCombinedStatusForRef: (...args: unknown[]) => mockReposGetCombinedStatusForRef(...args), + }, + checks: { + listForRef: (...args: unknown[]) => mockChecksListForRef(...args), + }, + }, + }), +})); + +describe('MergeRepository', () => { + const repo = new MergeRepository(); + + beforeEach(() => { + mockPullsCreate.mockReset(); + mockPullsListCommits.mockReset(); + mockPullsUpdate.mockReset(); + mockPullsMerge.mockReset(); + mockReposMerge.mockReset(); + mockChecksListForRef.mockReset(); + mockReposGetCombinedStatusForRef.mockReset(); + }); + + it('creates PR, updates body, merges and returns success (timeout <= 10 skips wait)', async () => { + mockPullsCreate.mockResolvedValue({ + data: { number: 42 }, + }); + mockPullsListCommits.mockResolvedValue({ + data: [{ commit: { message: 'fix: thing' } }], + }); + mockPullsUpdate.mockResolvedValue({}); + mockPullsMerge.mockResolvedValue({}); + + const result = await repo.mergeBranch( + 'owner', + 'repo', + 'feature/1-foo', + 'develop', + 5, + 'token', + ); + + expect(result).toHaveLength(1); + expect(result[0].success).toBe(true); + expect(result[0].steps).toContainEqual(expect.stringContaining('was merged into')); + expect(mockPullsCreate).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + head: 'feature/1-foo', + base: 'develop', + title: expect.any(String), + body: expect.any(String), + }); + expect(mockPullsListCommits).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + pull_number: 42, + }); + expect(mockPullsMerge).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + pull_number: 42, + merge_method: 'merge', + commit_title: expect.any(String), + }); + }); + + it('on PR create failure falls back to direct merge and returns success', async () => { + mockPullsCreate.mockRejectedValue(new Error('PR create failed')); + mockReposMerge.mockResolvedValue({}); + + const result = await repo.mergeBranch( + 'o', + 'r', + 'head', + 'base', + 0, + 'token', + ); + + expect(result).toHaveLength(1); + expect(result[0].success).toBe(true); + expect(result[0].steps).toContainEqual(expect.stringContaining('using direct merge')); + expect(mockReposMerge).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + base: 'base', + head: 'head', + commit_message: expect.any(String), + }); + }); + + it('on PR failure and direct merge failure returns multiple error results', async () => { + mockPullsCreate.mockRejectedValue(new Error('PR failed')); + mockReposMerge.mockRejectedValue(new Error('Direct merge failed')); + + const result = await repo.mergeBranch( + 'o', + 'r', + 'head', + 'base', + 0, + 'token', + ); + + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result.some(r => r.success === false && r.steps?.some(s => s.includes('Failed to merge')))).toBe(true); + expect(result.length).toBeGreaterThanOrEqual(2); + }); + + it('when timeout > 10 waits for check runs (all completed) then merges', async () => { + mockPullsCreate.mockResolvedValue({ data: { number: 1 } }); + mockPullsListCommits.mockResolvedValue({ data: [{ commit: { message: 'msg' } }] }); + mockPullsUpdate.mockResolvedValue({}); + mockPullsMerge.mockResolvedValue({}); + mockChecksListForRef.mockResolvedValue({ + data: { + check_runs: [ + { name: 'ci', status: 'completed', conclusion: 'success' }, + ], + }, + }); + mockReposGetCombinedStatusForRef.mockResolvedValue({ + data: { state: 'success', statuses: [] }, + }); + + const result = await repo.mergeBranch( + 'owner', + 'repo', + 'feature/1-x', + 'develop', + 30, + 'token', + ); + + expect(result).toHaveLength(1); + expect(result[0].success).toBe(true); + expect(mockChecksListForRef).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + ref: 'feature/1-x', + }); + expect(mockPullsMerge).toHaveBeenCalled(); + }); + + it('when timeout > 10 and check runs have failure throws then direct merge fallback fails', async () => { + mockPullsCreate.mockResolvedValue({ data: { number: 1 } }); + mockPullsListCommits.mockResolvedValue({ data: [] }); + mockPullsUpdate.mockResolvedValue({}); + mockChecksListForRef.mockResolvedValue({ + data: { + check_runs: [ + { name: 'ci', status: 'completed', conclusion: 'failure' }, + ], + }, + }); + mockReposGetCombinedStatusForRef.mockResolvedValue({ + data: { state: 'success', statuses: [] }, + }); + mockReposMerge.mockRejectedValue(new Error('Direct merge failed')); + + const result = await repo.mergeBranch('o', 'r', 'head', 'base', 30, 'token'); + + expect(result.some(r => r.success === false && r.steps?.some(s => s.includes('Failed to merge')))).toBe(true); + }); + + it('when timeout > 10 and no check runs uses status checks (all completed)', async () => { + mockPullsCreate.mockResolvedValue({ data: { number: 1 } }); + mockPullsListCommits.mockResolvedValue({ data: [] }); + mockPullsUpdate.mockResolvedValue({}); + mockChecksListForRef.mockResolvedValue({ + data: { check_runs: [] }, + }); + mockReposGetCombinedStatusForRef.mockResolvedValue({ + data: { state: 'success', statuses: [{ context: 'ci', state: 'success' }] }, + }); + mockPullsMerge.mockResolvedValue({}); + + const result = await repo.mergeBranch( + 'o', 'r', 'head', 'base', 30, 'token', + ); + + expect(result).toHaveLength(1); + expect(result[0].success).toBe(true); + expect(mockReposGetCombinedStatusForRef).toHaveBeenCalled(); + }); + + it('when timeout > 10 and checks never complete throws then direct merge succeeds', async () => { + jest.useFakeTimers(); + mockPullsCreate.mockResolvedValue({ data: { number: 1 } }); + mockPullsListCommits.mockResolvedValue({ data: [] }); + mockPullsUpdate.mockResolvedValue({}); + mockChecksListForRef.mockResolvedValue({ + data: { + check_runs: [{ name: 'ci', status: 'in_progress', conclusion: null }], + }, + }); + mockReposGetCombinedStatusForRef.mockResolvedValue({ + data: { state: 'pending', statuses: [] }, + }); + mockReposMerge.mockResolvedValue({}); + + const promise = repo.mergeBranch('o', 'r', 'head', 'base', 30, 'token'); + await jest.runAllTimersAsync(); + const result = await promise; + + jest.useRealTimers(); + expect(result.some(r => r.success === true && r.steps?.some(s => s.includes('direct merge')))).toBe(true); + }); +}); diff --git a/src/data/repository/__tests__/project_repository.test.ts b/src/data/repository/__tests__/project_repository.test.ts index fc873a6e..ce2733af 100644 --- a/src/data/repository/__tests__/project_repository.test.ts +++ b/src/data/repository/__tests__/project_repository.test.ts @@ -1,29 +1,65 @@ /** - * Unit tests for ProjectRepository.isActorAllowedToModifyFiles: org member, user owner, 404/errors. + * Unit tests for ProjectRepository: isActorAllowedToModifyFiles, getProjectDetail, getUserFromToken, + * getTokenUserDetails, isContentLinked, linkContentId, getRandomMembers, getAllMembers, + * createRelease, createTag, updateTag, updateRelease. */ import { ProjectRepository } from "../project_repository"; +import { ProjectDetail } from "../../model/project_detail"; jest.mock("../../../utils/logger", () => ({ logDebugInfo: jest.fn(), logError: jest.fn(), + logInfo: jest.fn(), })); const mockGetByUsername = jest.fn(); const mockCheckMembershipForUser = jest.fn(); +const mockGetAuthenticated = jest.fn(); +const mockGraphql = jest.fn(); +const mockTeamsList = jest.fn(); +const mockListMembersInOrg = jest.fn(); +const mockGetRef = jest.fn(); +const mockCreateRef = jest.fn(); +const mockUpdateRef = jest.fn(); +const mockGetReleaseByTag = jest.fn(); +const mockListReleases = jest.fn(); +const mockUpdateRelease = jest.fn(); +const mockCreateRelease = jest.fn(); +const mockContext = { repo: { owner: "test-owner" } }; jest.mock("@actions/github", () => ({ getOctokit: () => ({ rest: { users: { getByUsername: (...args: unknown[]) => mockGetByUsername(...args), + getAuthenticated: (...args: unknown[]) => mockGetAuthenticated(...args), }, orgs: { checkMembershipForUser: (...args: unknown[]) => mockCheckMembershipForUser(...args), }, + teams: { + list: (...args: unknown[]) => mockTeamsList(...args), + listMembersInOrg: (...args: unknown[]) => mockListMembersInOrg(...args), + }, + git: { + getRef: (...args: unknown[]) => mockGetRef(...args), + createRef: (...args: unknown[]) => mockCreateRef(...args), + updateRef: (...args: unknown[]) => mockUpdateRef(...args), + }, + repos: { + getReleaseByTag: (...args: unknown[]) => mockGetReleaseByTag(...args), + listReleases: (...args: unknown[]) => mockListReleases(...args), + updateRelease: (...args: unknown[]) => mockUpdateRelease(...args), + createRelease: (...args: unknown[]) => mockCreateRelease(...args), + }, }, + graphql: (...args: unknown[]) => mockGraphql(...args), }), + get context() { + return mockContext; + }, })); describe("ProjectRepository.isActorAllowedToModifyFiles", () => { @@ -89,4 +125,621 @@ describe("ProjectRepository.isActorAllowedToModifyFiles", () => { expect(result).toBe(false); }); + + it("returns false when Organization and checkMembershipForUser throws non-404", async () => { + mockGetByUsername.mockResolvedValue({ + data: { type: "Organization", login: "my-org" }, + }); + mockCheckMembershipForUser.mockRejectedValue(new Error("Forbidden")); + + const result = await repo.isActorAllowedToModifyFiles("my-org", "user", "token"); + + expect(result).toBe(false); + }); +}); + +describe("ProjectRepository.getProjectDetail", () => { + const repo = new ProjectRepository(); + + beforeEach(() => { + mockGetByUsername.mockReset(); + mockGraphql.mockReset(); + mockContext.repo = { owner: "test-owner" }; + }); + + it("throws when projectId is not a number", async () => { + await expect(repo.getProjectDetail("abc", "token")).rejects.toThrow("Invalid project ID"); + expect(mockGraphql).not.toHaveBeenCalled(); + }); + + it("returns ProjectDetail when owner is User", async () => { + mockGetByUsername.mockResolvedValue({ data: { type: "User", login: "test-owner" } }); + mockGraphql.mockResolvedValue({ + user: { + projectV2: { + id: "PVT_1", + title: "My Project", + url: "https://github.com/users/test-owner/projects/1", + }, + }, + }); + const result = await repo.getProjectDetail("1", "token"); + expect(result.id).toBe("PVT_1"); + expect(result.title).toBe("My Project"); + expect(result.type).toBe("user"); + }); + + it("returns ProjectDetail when owner is Organization", async () => { + mockGetByUsername.mockResolvedValue({ data: { type: "Organization", login: "test-owner" } }); + mockGraphql.mockResolvedValue({ + organization: { + projectV2: { + id: "PVT_2", + title: "Org Project", + url: "https://github.com/orgs/test-owner/projects/2", + }, + }, + }); + const result = await repo.getProjectDetail("2", "token"); + expect(result.id).toBe("PVT_2"); + expect(result.title).toBe("Org Project"); + expect(result.type).toBe("organization"); + }); + + it("throws when project not found", async () => { + mockGetByUsername.mockResolvedValue({ data: { type: "User", login: "test-owner" } }); + mockGraphql.mockResolvedValue({ user: { projectV2: null } }); + await expect(repo.getProjectDetail("99", "token")).rejects.toThrow("Project not found"); + }); + + it("throws when getByUsername fails", async () => { + mockGetByUsername.mockRejectedValue(new Error("Not found")); + await expect(repo.getProjectDetail("1", "token")).rejects.toThrow("Failed to get owner"); + }); + + it("throws when graphql project query fails", async () => { + mockGetByUsername.mockResolvedValue({ data: { type: "User", login: "test-owner" } }); + mockGraphql.mockRejectedValue(new Error("Network error")); + await expect(repo.getProjectDetail("1", "token")).rejects.toThrow("Failed to fetch project data"); + }); +}); + +describe("ProjectRepository.getUserFromToken", () => { + const repo = new ProjectRepository(); + + beforeEach(() => { + mockGetAuthenticated.mockReset(); + }); + + it("returns user login", async () => { + mockGetAuthenticated.mockResolvedValue({ data: { login: "octocat" } }); + const result = await repo.getUserFromToken("token"); + expect(result).toBe("octocat"); + }); +}); + +describe("ProjectRepository.getTokenUserDetails", () => { + const repo = new ProjectRepository(); + + beforeEach(() => { + mockGetAuthenticated.mockReset(); + }); + + it("returns name and email from user", async () => { + mockGetAuthenticated.mockResolvedValue({ + data: { login: "octocat", name: "Octo Cat", email: "octo@example.com" }, + }); + const result = await repo.getTokenUserDetails("token"); + expect(result).toEqual({ name: "Octo Cat", email: "octo@example.com" }); + }); + + it("uses login and noreply email when name/email missing", async () => { + mockGetAuthenticated.mockResolvedValue({ + data: { login: "bot", name: null, email: null }, + }); + const result = await repo.getTokenUserDetails("token"); + expect(result.name).toBe("bot"); + expect(result.email).toBe("bot@users.noreply.github.com"); + }); +}); + +describe("ProjectRepository.isContentLinked", () => { + const repo = new ProjectRepository(); + const project = new ProjectDetail({ + id: "PVT_1", + title: "P", + url: "https://example.com", + type: "orgs", + owner: "org", + number: 1, + }); + + beforeEach(() => { + mockGraphql.mockReset(); + }); + + it("returns true when content is in project items", async () => { + mockGraphql.mockResolvedValue({ + node: { + items: { + nodes: [{ content: { id: "content-123" } }, { content: { id: "other" } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + const result = await repo.isContentLinked(project, "content-123", "token"); + expect(result).toBe(true); + }); + + it("returns false when content is not in project items", async () => { + mockGraphql.mockResolvedValue({ + node: { + items: { + nodes: [{ content: { id: "other" } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + const result = await repo.isContentLinked(project, "content-123", "token"); + expect(result).toBe(false); + }); +}); + +describe("ProjectRepository.linkContentId", () => { + const repo = new ProjectRepository(); + const project = new ProjectDetail({ + id: "PVT_1", + title: "P", + url: "https://example.com", + type: "orgs", + owner: "org", + number: 1, + }); + + beforeEach(() => { + mockGraphql.mockReset(); + }); + + it("returns false when content already linked", async () => { + mockGraphql + .mockResolvedValueOnce({ + node: { + items: { + nodes: [{ content: { id: "content-123" } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }) + .mockResolvedValueOnce({ addProjectV2ItemById: { item: { id: "item-1" } } }); + const result = await repo.linkContentId(project, "content-123", "token"); + expect(result).toBe(false); + }); + + it("returns true and links when content not linked", async () => { + mockGraphql + .mockResolvedValueOnce({ + node: { + items: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }, + }, + }) + .mockResolvedValueOnce({ addProjectV2ItemById: { item: { id: "item-1" } } }); + const result = await repo.linkContentId(project, "content-123", "token"); + expect(result).toBe(true); + }); +}); + +describe("ProjectRepository.getRandomMembers", () => { + const repo = new ProjectRepository(); + + beforeEach(() => { + mockTeamsList.mockReset(); + mockListMembersInOrg.mockReset(); + }); + + it("returns empty array when membersToAdd is 0", async () => { + const result = await repo.getRandomMembers("org", 0, [], "token"); + expect(result).toEqual([]); + expect(mockTeamsList).not.toHaveBeenCalled(); + }); + + it("returns empty array when org has no teams", async () => { + mockTeamsList.mockResolvedValue({ data: [] }); + const result = await repo.getRandomMembers("org", 2, [], "token"); + expect(result).toEqual([]); + }); + + it("returns empty array when no available members (all in currentMembers)", async () => { + mockTeamsList.mockResolvedValue({ data: [{ slug: "team-a" }] }); + mockListMembersInOrg.mockResolvedValue({ + data: [{ login: "alice" }, { login: "bob" }], + }); + const result = await repo.getRandomMembers("org", 2, ["alice", "bob"], "token"); + expect(result).toEqual([]); + }); + + it("returns all available when membersToAdd >= availableMembers.length", async () => { + mockTeamsList.mockResolvedValue({ data: [{ slug: "team-a" }] }); + mockListMembersInOrg.mockResolvedValue({ + data: [{ login: "alice" }, { login: "bob" }], + }); + const result = await repo.getRandomMembers("org", 5, [], "token"); + expect(result).toHaveLength(2); + expect(result).toContain("alice"); + expect(result).toContain("bob"); + }); + + it("returns members not in currentMembers", async () => { + mockTeamsList.mockResolvedValue({ data: [{ slug: "team-a" }] }); + mockListMembersInOrg.mockResolvedValue({ + data: [{ login: "alice" }, { login: "bob" }], + }); + const result = await repo.getRandomMembers("org", 1, ["alice"], "token"); + expect(result).toHaveLength(1); + expect(result[0]).toBe("bob"); + }); + + it("returns empty array when teams.list throws", async () => { + mockTeamsList.mockRejectedValue(new Error("API error")); + const result = await repo.getRandomMembers("org", 2, [], "token"); + expect(result).toEqual([]); + }); +}); + +describe("ProjectRepository.getAllMembers", () => { + const repo = new ProjectRepository(); + + beforeEach(() => { + mockTeamsList.mockReset(); + mockListMembersInOrg.mockReset(); + }); + + it("returns empty array when org has no teams", async () => { + mockTeamsList.mockResolvedValue({ data: [] }); + const result = await repo.getAllMembers("org", "token"); + expect(result).toEqual([]); + }); + + it("returns unique member logins from all teams", async () => { + mockTeamsList.mockResolvedValue({ data: [{ slug: "t1" }, { slug: "t2" }] }); + mockListMembersInOrg + .mockResolvedValueOnce({ data: [{ login: "a" }, { login: "b" }] }) + .mockResolvedValueOnce({ data: [{ login: "b" }, { login: "c" }] }); + const result = await repo.getAllMembers("org", "token"); + expect(result.sort()).toEqual(["a", "b", "c"]); + }); + + it("returns empty array when teams.list throws", async () => { + mockTeamsList.mockRejectedValue(new Error("API error")); + const result = await repo.getAllMembers("org", "token"); + expect(result).toEqual([]); + }); +}); + +describe("ProjectRepository.createRelease", () => { + const repo = new ProjectRepository(); + + beforeEach(() => { + mockCreateRelease.mockReset(); + }); + + it("returns release html_url on success", async () => { + mockCreateRelease.mockResolvedValue({ + data: { html_url: "https://github.com/o/r/releases/tag/v1.0" }, + }); + const result = await repo.createRelease( + "owner", + "repo", + "1.0", + "First release", + "Changelog", + "token" + ); + expect(result).toBe("https://github.com/o/r/releases/tag/v1.0"); + expect(mockCreateRelease).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "owner", + repo: "repo", + tag_name: "v1.0", + name: "v1.0 - First release", + body: "Changelog", + }) + ); + }); + + it("returns undefined when create fails", async () => { + mockCreateRelease.mockRejectedValue(new Error("API error")); + const result = await repo.createRelease("o", "r", "1.0", "Title", "Body", "token"); + expect(result).toBeUndefined(); + }); +}); + +describe("ProjectRepository.createTag", () => { + const repo = new ProjectRepository(); + + beforeEach(() => { + mockGetRef.mockReset(); + mockCreateRef.mockReset(); + }); + + it("returns existing tag sha when tag already exists", async () => { + mockGetRef.mockResolvedValue({ + data: { object: { sha: "abc123" } }, + }); + const result = await repo.createTag("owner", "repo", "main", "v1.0", "token"); + expect(result).toBe("abc123"); + expect(mockCreateRef).not.toHaveBeenCalled(); + }); + + it("creates tag from branch and returns sha", async () => { + mockGetRef + .mockRejectedValueOnce(new Error("Not found")) + .mockResolvedValueOnce({ data: { object: { sha: "branch-sha" } } }); + mockCreateRef.mockResolvedValue(undefined); + const result = await repo.createTag("owner", "repo", "main", "v1.0", "token"); + expect(result).toBe("branch-sha"); + expect(mockCreateRef).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "owner", + repo: "repo", + ref: "refs/tags/v1.0", + sha: "branch-sha", + }) + ); + }); + + it("returns undefined when branch ref fails", async () => { + mockGetRef.mockRejectedValue(new Error("Not found")); + const result = await repo.createTag("owner", "repo", "main", "v1.0", "token"); + expect(result).toBeUndefined(); + }); +}); + +describe("ProjectRepository.updateTag", () => { + const repo = new ProjectRepository(); + + beforeEach(() => { + mockGetRef.mockReset(); + mockCreateRef.mockReset(); + mockUpdateRef.mockReset(); + }); + + it("updates existing target tag to point to source", async () => { + mockGetRef + .mockResolvedValueOnce({ data: { object: { sha: "source-sha" } } }) + .mockResolvedValueOnce({ data: { object: { sha: "old-target-sha" } } }); + mockUpdateRef.mockResolvedValue(undefined); + await repo.updateTag("owner", "repo", "v1.0", "latest", "token"); + expect(mockUpdateRef).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "owner", + repo: "repo", + ref: "tags/latest", + sha: "source-sha", + force: true, + }) + ); + }); + + it("creates target ref when target tag does not exist", async () => { + mockGetRef + .mockResolvedValueOnce({ data: { object: { sha: "source-sha" } } }) + .mockRejectedValueOnce(new Error("Not found")); + mockCreateRef.mockResolvedValue(undefined); + await repo.updateTag("owner", "repo", "v1.0", "latest", "token"); + expect(mockCreateRef).toHaveBeenCalledWith( + expect.objectContaining({ + ref: "refs/tags/latest", + sha: "source-sha", + }) + ); + }); + + it("returns early when source tag does not exist", async () => { + mockGetRef.mockRejectedValue(new Error("Not found")); + await repo.updateTag("owner", "repo", "missing-tag", "latest", "token"); + expect(mockUpdateRef).not.toHaveBeenCalled(); + expect(mockCreateRef).not.toHaveBeenCalled(); + }); +}); + +describe("ProjectRepository.updateRelease", () => { + const repo = new ProjectRepository(); + + beforeEach(() => { + mockGetReleaseByTag.mockReset(); + mockListReleases.mockReset(); + mockUpdateRelease.mockReset(); + mockCreateRelease.mockReset(); + }); + + it("updates existing release when target exists", async () => { + mockGetReleaseByTag.mockResolvedValue({ + data: { + name: "v1.0", + body: "Changelog", + draft: false, + prerelease: false, + }, + }); + mockListReleases.mockResolvedValue({ + data: [{ tag_name: "latest", id: 42 }], + }); + mockUpdateRelease.mockResolvedValue({ data: { id: 42 } }); + const result = await repo.updateRelease("owner", "repo", "v1.0", "latest", "token"); + expect(result).toBe("42"); + expect(mockUpdateRelease).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "owner", + repo: "repo", + release_id: 42, + name: "v1.0", + body: "Changelog", + }) + ); + }); + + it("creates new release when target tag has no release", async () => { + mockGetReleaseByTag.mockResolvedValue({ + data: { + name: "v1.0", + body: "Changelog", + draft: false, + prerelease: false, + }, + }); + mockListReleases.mockResolvedValue({ data: [] }); + mockCreateRelease.mockResolvedValue({ data: { id: 100 } }); + const result = await repo.updateRelease("owner", "repo", "v1.0", "latest", "token"); + expect(result).toBe("100"); + expect(mockCreateRelease).toHaveBeenCalledWith( + expect.objectContaining({ + tag_name: "latest", + name: "v1.0", + body: "Changelog", + }) + ); + }); + + it("returns undefined when source release has no name or body", async () => { + mockGetReleaseByTag.mockResolvedValue({ + data: { name: "", body: null, draft: false, prerelease: false }, + }); + const result = await repo.updateRelease("owner", "repo", "v1.0", "latest", "token"); + expect(result).toBeUndefined(); + expect(mockListReleases).not.toHaveBeenCalled(); + }); +}); + +describe("ProjectRepository.setTaskPriority", () => { + const repo = new ProjectRepository(); + const project = new ProjectDetail({ + id: "PVT_1", + title: "P", + url: "https://example.com", + type: "orgs", + owner: "org", + number: 1, + }); + + beforeEach(() => { + mockGraphql.mockReset(); + }); + + const fieldQueryResponseWithItem = { + node: { + fields: { + nodes: [ + { + id: "f1", + name: "Priority", + options: [{ id: "opt_high", name: "High" }], + }, + ], + }, + items: { + nodes: [ + { + id: "item_1", + fieldValues: { nodes: [] }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + + it("sets priority and returns true when mutation succeeds", async () => { + mockGraphql + .mockResolvedValueOnce({ + repository: { issueOrPullRequest: { id: "I_issue1" } }, + }) + .mockResolvedValueOnce({ + node: { + items: { + nodes: [{ id: "item_1", content: { id: "I_issue1" } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }) + .mockResolvedValueOnce(fieldQueryResponseWithItem) + .mockResolvedValueOnce(fieldQueryResponseWithItem) + .mockResolvedValueOnce({ + updateProjectV2ItemFieldValue: { projectV2Item: { id: "item_1" } }, + }); + const result = await repo.setTaskPriority( + project, + "owner", + "repo", + 1, + "High", + "token" + ); + expect(result).toBe(true); + expect(mockGraphql).toHaveBeenCalledTimes(5); + }); + + it("returns false when field already set to target value", async () => { + const fieldQueryResponseAlreadySet = { + node: { + fields: { + nodes: [ + { + id: "f1", + name: "Priority", + options: [{ id: "opt_high", name: "High" }], + }, + ], + }, + items: { + nodes: [ + { + id: "item_1", + fieldValues: { + nodes: [ + { + field: { name: "Priority" }, + optionId: "opt_high", + }, + ], + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + mockGraphql + .mockResolvedValueOnce({ + repository: { issueOrPullRequest: { id: "I_issue1" } }, + }) + .mockResolvedValueOnce({ + node: { + items: { + nodes: [{ id: "item_1", content: { id: "I_issue1" } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }) + .mockResolvedValueOnce(fieldQueryResponseAlreadySet) + .mockResolvedValueOnce(fieldQueryResponseAlreadySet); + const result = await repo.setTaskPriority( + project, + "owner", + "repo", + 1, + "High", + "token" + ); + expect(result).toBe(false); + expect(mockGraphql).toHaveBeenCalledTimes(4); + }); + + it("throws when content id not found for issue", async () => { + mockGraphql.mockResolvedValueOnce({ + repository: { issueOrPullRequest: null }, + }); + await expect( + repo.setTaskPriority(project, "owner", "repo", 999, "High", "token") + ).rejects.toThrow("Content ID not found"); + }); }); diff --git a/src/data/repository/__tests__/pull_request_repository.getHeadBranchForIssue.test.ts b/src/data/repository/__tests__/pull_request_repository.getHeadBranchForIssue.test.ts index c4bde009..3d41353e 100644 --- a/src/data/repository/__tests__/pull_request_repository.getHeadBranchForIssue.test.ts +++ b/src/data/repository/__tests__/pull_request_repository.getHeadBranchForIssue.test.ts @@ -97,4 +97,10 @@ describe("getHeadBranchForIssue", () => { const branch = await repo.getHeadBranchForIssue("o", "r", 123, "token"); expect(branch).toBe("feature/123-b"); }); + + it("returns undefined when pulls.list throws", async () => { + mockPullsList.mockRejectedValue(new Error("API error")); + const branch = await repo.getHeadBranchForIssue("o", "r", 123, "token"); + expect(branch).toBeUndefined(); + }); }); diff --git a/src/data/repository/__tests__/pull_request_repository.getOpenPullRequestNumbersByHeadBranch.test.ts b/src/data/repository/__tests__/pull_request_repository.getOpenPullRequestNumbersByHeadBranch.test.ts new file mode 100644 index 00000000..41e00d0c --- /dev/null +++ b/src/data/repository/__tests__/pull_request_repository.getOpenPullRequestNumbersByHeadBranch.test.ts @@ -0,0 +1,534 @@ +/** + * Unit tests for PullRequestRepository: getOpenPullRequestNumbersByHeadBranch, updateDescription, + * updateBaseBranch, getCurrentReviewers, addReviewersToPullRequest, getChangedFiles, + * getFilesWithFirstDiffLine, getPullRequestChanges, getPullRequestHeadSha, + * listPullRequestReviewComments, getPullRequestReviewCommentBody, updatePullRequestReviewComment, + * createReviewWithComments, isLinked. + */ + +import { PullRequestRepository } from '../pull_request_repository'; + +jest.mock('../../../utils/logger', () => ({ + logDebugInfo: jest.fn(), + logError: jest.fn(), +})); + +const mockPullsList = jest.fn(); +const mockPullsUpdate = jest.fn(); +const mockPullsGet = jest.fn(); +const mockListRequestedReviewers = jest.fn(); +const mockListReviews = jest.fn(); +const mockRequestReviewers = jest.fn(); +const mockListFiles = jest.fn(); +const mockListReviewComments = jest.fn(); +const mockGetReviewComment = jest.fn(); +const mockUpdateReviewComment = jest.fn(); +const mockCreateReviewComment = jest.fn(); +const mockPaginateIterator = jest.fn(); +const mockGraphql = jest.fn(); + +jest.mock('@actions/github', () => ({ + getOctokit: () => ({ + rest: { + pulls: { + list: (...args: unknown[]) => mockPullsList(...args), + update: (...args: unknown[]) => mockPullsUpdate(...args), + get: (...args: unknown[]) => mockPullsGet(...args), + listRequestedReviewers: (...args: unknown[]) => mockListRequestedReviewers(...args), + listReviews: (...args: unknown[]) => mockListReviews(...args), + requestReviewers: (...args: unknown[]) => mockRequestReviewers(...args), + listFiles: (...args: unknown[]) => mockListFiles(...args), + listReviewComments: (...args: unknown[]) => mockListReviewComments(...args), + getReviewComment: (...args: unknown[]) => mockGetReviewComment(...args), + updateReviewComment: (...args: unknown[]) => mockUpdateReviewComment(...args), + createReviewComment: (...args: unknown[]) => mockCreateReviewComment(...args), + }, + }, + paginate: { + iterator: (...args: unknown[]) => mockPaginateIterator(...args), + }, + graphql: (...args: unknown[]) => mockGraphql(...args), + }), +})); + +describe('PullRequestRepository', () => { + const repo = new PullRequestRepository(); + + beforeEach(() => { + mockPullsList.mockReset(); + mockPullsUpdate.mockReset(); + mockPullsGet.mockReset(); + mockListRequestedReviewers.mockReset(); + mockListReviews.mockReset(); + mockRequestReviewers.mockReset(); + mockListFiles.mockReset(); + mockListReviewComments.mockReset(); + mockGetReviewComment.mockReset(); + mockUpdateReviewComment.mockReset(); + mockCreateReviewComment.mockReset(); + mockPaginateIterator.mockReset(); + mockGraphql.mockReset(); + }); + + describe('getOpenPullRequestNumbersByHeadBranch', () => { + it('returns PR numbers for head branch', async () => { + mockPullsList.mockResolvedValue({ + data: [ + { number: 10, head: { ref: 'feature/42-x' } }, + { number: 11, head: { ref: 'feature/42-x' } }, + ], + }); + const result = await repo.getOpenPullRequestNumbersByHeadBranch( + 'owner', + 'repo', + 'feature/42-x', + 'token' + ); + expect(result).toEqual([10, 11]); + expect(mockPullsList).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + state: 'open', + head: 'owner:feature/42-x', + }); + }); + + it('returns empty array when no PRs', async () => { + mockPullsList.mockResolvedValue({ data: [] }); + const result = await repo.getOpenPullRequestNumbersByHeadBranch( + 'o', + 'r', + 'develop', + 'token' + ); + expect(result).toEqual([]); + }); + + it('returns empty array when list throws', async () => { + mockPullsList.mockRejectedValue(new Error('API error')); + const result = await repo.getOpenPullRequestNumbersByHeadBranch( + 'o', + 'r', + 'branch', + 'token' + ); + expect(result).toEqual([]); + }); + }); + + describe('updateDescription', () => { + it('calls pulls.update with body', async () => { + mockPullsUpdate.mockResolvedValue(undefined); + await repo.updateDescription( + 'owner', + 'repo', + 5, + '## Summary\nUpdated body', + 'token' + ); + expect(mockPullsUpdate).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + pull_number: 5, + body: '## Summary\nUpdated body', + }); + }); + }); + + describe('updateBaseBranch', () => { + it('calls pulls.update with base branch', async () => { + mockPullsUpdate.mockResolvedValue(undefined); + await repo.updateBaseBranch('owner', 'repo', 5, 'main', 'token'); + expect(mockPullsUpdate).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + pull_number: 5, + base: 'main', + }); + }); + }); + + describe('getCurrentReviewers', () => { + it('returns union of requested and review authors', async () => { + mockListRequestedReviewers.mockResolvedValue({ + data: { users: [{ login: 'alice' }] }, + }); + mockListReviews.mockResolvedValue({ + data: [{ user: { login: 'bob' } }], + }); + const result = await repo.getCurrentReviewers('o', 'r', 1, 'token'); + expect(result.sort()).toEqual(['alice', 'bob']); + }); + + it('returns empty array when API throws', async () => { + mockListRequestedReviewers.mockRejectedValue(new Error('API error')); + const result = await repo.getCurrentReviewers('o', 'r', 1, 'token'); + expect(result).toEqual([]); + }); + }); + + describe('addReviewersToPullRequest', () => { + it('requests reviewers and returns their logins', async () => { + mockRequestReviewers.mockResolvedValue({ + data: { requested_reviewers: [{ login: 'alice' }, { login: 'bob' }] }, + }); + const result = await repo.addReviewersToPullRequest('o', 'r', 1, ['alice', 'bob'], 'token'); + expect(result).toEqual(['alice', 'bob']); + expect(mockRequestReviewers).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + pull_number: 1, + reviewers: ['alice', 'bob'], + }); + }); + + it('returns empty array when reviewers empty', async () => { + const result = await repo.addReviewersToPullRequest('o', 'r', 1, [], 'token'); + expect(result).toEqual([]); + expect(mockRequestReviewers).not.toHaveBeenCalled(); + }); + + it('returns empty array when API throws', async () => { + mockRequestReviewers.mockRejectedValue(new Error('API error')); + const result = await repo.addReviewersToPullRequest('o', 'r', 1, ['x'], 'token'); + expect(result).toEqual([]); + }); + }); + + describe('getChangedFiles', () => { + it('returns filename and status from paginated listFiles', async () => { + const asyncIter = (async function* () { + yield { data: [{ filename: 'a.ts', status: 'modified' }] }; + yield { data: [{ filename: 'b.ts', status: 'added' }] }; + })(); + mockPaginateIterator.mockReturnValue(asyncIter); + const result = await repo.getChangedFiles('o', 'r', 1, 'token'); + expect(result).toEqual([ + { filename: 'a.ts', status: 'modified' }, + { filename: 'b.ts', status: 'added' }, + ]); + }); + + it('returns empty array when listFiles throws', async () => { + mockPaginateIterator.mockImplementation(() => { + throw new Error('API error'); + }); + const result = await repo.getChangedFiles('o', 'r', 1, 'token'); + expect(result).toEqual([]); + }); + }); + + describe('getFilesWithFirstDiffLine', () => { + it('returns path and first line from patch (excludes removed and empty patch)', async () => { + mockListFiles.mockResolvedValue({ + data: [ + { filename: 'a.ts', status: 'modified', patch: '@@ -1,3 +5,2 @@\n context' }, + { filename: 'b.ts', status: 'removed', patch: null }, + { filename: 'c.ts', status: 'added', patch: '@@ -0,0 +1,1 @@\n+line' }, + ], + }); + const result = await repo.getFilesWithFirstDiffLine('o', 'r', 1, 'token'); + expect(result).toEqual([ + { path: 'a.ts', firstLine: 5 }, + { path: 'c.ts', firstLine: 1 }, + ]); + }); + + it('returns empty array when listFiles throws', async () => { + mockListFiles.mockRejectedValue(new Error('API error')); + const result = await repo.getFilesWithFirstDiffLine('o', 'r', 1, 'token'); + expect(result).toEqual([]); + }); + }); + + describe('getPullRequestChanges', () => { + it('returns files with additions, deletions, patch', async () => { + const asyncIter = (async function* () { + yield { + data: [ + { + filename: 'x.ts', + status: 'modified', + additions: 2, + deletions: 1, + patch: '+line', + }, + ], + }; + })(); + mockPaginateIterator.mockReturnValue(asyncIter); + const result = await repo.getPullRequestChanges('o', 'r', 1, 'token'); + expect(result).toEqual([ + { + filename: 'x.ts', + status: 'modified', + additions: 2, + deletions: 1, + patch: '+line', + }, + ]); + }); + + it('returns empty array when API throws', async () => { + mockPaginateIterator.mockImplementation(() => { + throw new Error('API error'); + }); + const result = await repo.getPullRequestChanges('o', 'r', 1, 'token'); + expect(result).toEqual([]); + }); + }); + + describe('getPullRequestHeadSha', () => { + it('returns head sha', async () => { + mockPullsGet.mockResolvedValue({ data: { head: { sha: 'abc123' } } }); + const result = await repo.getPullRequestHeadSha('o', 'r', 1, 'token'); + expect(result).toBe('abc123'); + }); + + it('returns undefined when get throws', async () => { + mockPullsGet.mockRejectedValue(new Error('API error')); + const result = await repo.getPullRequestHeadSha('o', 'r', 1, 'token'); + expect(result).toBeUndefined(); + }); + }); + + describe('listPullRequestReviewComments', () => { + it('returns comments from paginated iterator', async () => { + const asyncIter = (async function* () { + yield { + data: [ + { id: 1, body: 'c1', path: 'a.ts', line: 10, node_id: 'N1' }, + { id: 2, body: null, path: undefined, line: null, node_id: undefined }, + ], + }; + })(); + mockPaginateIterator.mockReturnValue(asyncIter); + const result = await repo.listPullRequestReviewComments('o', 'r', 1, 'token'); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 1, + body: 'c1', + path: 'a.ts', + line: 10, + node_id: 'N1', + }); + expect(result[1].body).toBeNull(); + }); + + it('returns empty array when API throws', async () => { + mockPaginateIterator.mockImplementation(() => { + throw new Error('API error'); + }); + const result = await repo.listPullRequestReviewComments('o', 'r', 1, 'token'); + expect(result).toEqual([]); + }); + }); + + describe('getPullRequestReviewCommentBody', () => { + it('returns comment body', async () => { + mockGetReviewComment.mockResolvedValue({ data: { body: 'Comment text' } }); + const result = await repo.getPullRequestReviewCommentBody( + 'o', + 'r', + 1, + 100, + 'token' + ); + expect(result).toBe('Comment text'); + expect(mockGetReviewComment).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + comment_id: 100, + }); + }); + + it('returns null when comment not found', async () => { + mockGetReviewComment.mockRejectedValue(new Error('404')); + const result = await repo.getPullRequestReviewCommentBody( + 'o', + 'r', + 1, + 100, + 'token' + ); + expect(result).toBeNull(); + }); + }); + + describe('updatePullRequestReviewComment', () => { + it('calls pulls.updateReviewComment', async () => { + mockUpdateReviewComment.mockResolvedValue(undefined); + await repo.updatePullRequestReviewComment('o', 'r', 100, 'New body', 'token'); + expect(mockUpdateReviewComment).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + comment_id: 100, + body: 'New body', + }); + }); + }); + + describe('createReviewWithComments', () => { + it('creates review comments and does not throw', async () => { + mockCreateReviewComment.mockResolvedValue({ data: { id: 1 } }); + await repo.createReviewWithComments( + 'o', + 'r', + 1, + 'commit-sha', + [{ path: 'a.ts', line: 1, body: 'Comment' }], + 'token' + ); + expect(mockCreateReviewComment).toHaveBeenCalledWith({ + owner: 'o', + repo: 'r', + pull_number: 1, + commit_id: 'commit-sha', + path: 'a.ts', + line: 1, + side: 'RIGHT', + body: 'Comment', + }); + }); + + it('logs error but continues when one comment fails', async () => { + mockCreateReviewComment + .mockResolvedValueOnce({ data: { id: 1 } }) + .mockRejectedValueOnce(new Error('Comment failed')) + .mockResolvedValueOnce({ data: { id: 2 } }); + await repo.createReviewWithComments( + 'o', + 'r', + 1, + 'sha', + [ + { path: 'a.ts', line: 1, body: 'First' }, + { path: 'b.ts', line: 2, body: 'Second' }, + { path: 'c.ts', line: 3, body: 'Third' }, + ], + 'token' + ); + expect(mockCreateReviewComment).toHaveBeenCalledTimes(3); + }); + + it('does nothing when comments empty', async () => { + await repo.createReviewWithComments('o', 'r', 1, 'sha', [], 'token'); + expect(mockCreateReviewComment).not.toHaveBeenCalled(); + }); + }); + + describe('resolvePullRequestReviewThread', () => { + it('finds thread with comment and resolves it', async () => { + mockGraphql + .mockResolvedValueOnce({ + repository: { + pullRequest: { + reviewThreads: { + nodes: [ + { + id: 'THREAD_1', + comments: { + nodes: [{ id: 'COMMENT_NODE_123' }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + pageInfo: { hasNextPage: false, endCursor: null }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }) + .mockResolvedValueOnce({ + resolveReviewThread: { thread: { id: 'THREAD_1' } }, + }); + await repo.resolvePullRequestReviewThread( + 'owner', + 'repo', + 1, + 'COMMENT_NODE_123', + 'token' + ); + expect(mockGraphql).toHaveBeenCalledTimes(2); + }); + + it('does not throw when no thread found for comment', async () => { + mockGraphql.mockResolvedValue({ + repository: { + pullRequest: { + reviewThreads: { + nodes: [ + { + id: 'THREAD_1', + comments: { + nodes: [{ id: 'OTHER_COMMENT' }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + pageInfo: { hasNextPage: false, endCursor: null }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }); + await repo.resolvePullRequestReviewThread( + 'owner', + 'repo', + 1, + 'MISSING_COMMENT_ID', + 'token' + ); + expect(mockGraphql).toHaveBeenCalledTimes(1); + }); + + it('does not throw when graphql throws', async () => { + mockGraphql.mockRejectedValue(new Error('GraphQL error')); + await repo.resolvePullRequestReviewThread( + 'owner', + 'repo', + 1, + 'COMMENT_ID', + 'token' + ); + }); + }); + + describe('isLinked', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('returns true when page does not contain has_github_issues=false', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: async () => 'Linked project', + }); + const result = await repo.isLinked('https://github.com/o/r/pull/1'); + expect(result).toBe(true); + }); + + it('returns false when page contains has_github_issues=false', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: async () => 'has_github_issues=false', + }); + const result = await repo.isLinked('https://github.com/o/r/pull/1'); + expect(result).toBe(false); + }); + + it('returns false when response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 404 }); + const result = await repo.isLinked('https://github.com/o/r/pull/1'); + expect(result).toBe(false); + }); + + it('returns false when fetch throws', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + const result = await repo.isLinked('https://github.com/o/r/pull/1'); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/data/repository/branch_compare_repository.ts b/src/data/repository/branch_compare_repository.ts new file mode 100644 index 00000000..f593e3ff --- /dev/null +++ b/src/data/repository/branch_compare_repository.ts @@ -0,0 +1,180 @@ +import * as github from '@actions/github'; +import { logDebugInfo, logError } from '../../utils/logger'; +import { Labels } from '../model/labels'; +import { SizeThresholds } from '../model/size_thresholds'; + +export interface BranchComparisonFile { + filename: string; + status: string; + additions: number; + deletions: number; + changes: number; + blobUrl: string; + rawUrl: string; + contentsUrl: string; + patch: string | undefined; +} + +export interface BranchComparisonCommit { + sha: string; + message: string; + author: { name: string; email: string; date: string }; + date: string; +} + +export interface BranchComparison { + aheadBy: number; + behindBy: number; + totalCommits: number; + files: BranchComparisonFile[]; + commits: BranchComparisonCommit[]; +} + +export interface SizeCategoryResult { + size: string; + githubSize: string; + reason: string; +} + +/** + * Repository for comparing branches and computing size categories. + * Isolated to allow unit tests with mocked Octokit and pure size logic. + */ +export class BranchCompareRepository { + + getChanges = async ( + owner: string, + repository: string, + head: string, + base: string, + token: string, + ): Promise => { + try { + const octokit = github.getOctokit(token); + + logDebugInfo(`Comparing branches: ${head} with ${base}`); + + let headRef = `heads/${head}`; + if (head.indexOf('tags/') > -1) { + headRef = head; + } + + let baseRef = `heads/${base}`; + if (base.indexOf('tags/') > -1) { + baseRef = base; + } + + const { data: comparison } = await octokit.rest.repos.compareCommits({ + owner: owner, + repo: repository, + base: baseRef, + head: headRef, + }); + + return { + aheadBy: comparison.ahead_by, + behindBy: comparison.behind_by, + totalCommits: comparison.total_commits, + files: (comparison.files || []).map(file => ({ + filename: file.filename, + status: file.status, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + changes: file.changes ?? 0, + blobUrl: file.blob_url, + rawUrl: file.raw_url, + contentsUrl: file.contents_url, + patch: file.patch, + })), + commits: comparison.commits.map(commit => { + const author = commit.commit.author; + return { + sha: commit.sha, + message: commit.commit.message, + author: { + name: author?.name ?? 'Unknown', + email: author?.email ?? 'unknown@example.com', + date: author?.date ?? new Date().toISOString(), + }, + date: author?.date ?? new Date().toISOString(), + }; + }), + }; + } catch (error) { + logError(`Error comparing branches: ${error}`); + throw error; + } + }; + + getSizeCategoryAndReason = async ( + owner: string, + repository: string, + head: string, + base: string, + sizeThresholds: SizeThresholds, + labels: Labels, + token: string, + ): Promise => { + try { + const headBranchChanges = await this.getChanges( + owner, + repository, + head, + base, + token, + ); + + const totalChanges = headBranchChanges.files.reduce((sum, file) => sum + file.changes, 0); + const totalFiles = headBranchChanges.files.length; + const totalCommits = headBranchChanges.totalCommits; + + let sizeCategory: string; + let githubSize: string; + let sizeReason: string; + if (totalChanges > sizeThresholds.xxl.lines || totalFiles > sizeThresholds.xxl.files || totalCommits > sizeThresholds.xxl.commits) { + sizeCategory = labels.sizeXxl; + githubSize = `XL`; + sizeReason = totalChanges > sizeThresholds.xxl.lines ? `More than ${sizeThresholds.xxl.lines} lines changed` : + totalFiles > sizeThresholds.xxl.files ? `More than ${sizeThresholds.xxl.files} files modified` : + `More than ${sizeThresholds.xxl.commits} commits`; + } else if (totalChanges > sizeThresholds.xl.lines || totalFiles > sizeThresholds.xl.files || totalCommits > sizeThresholds.xl.commits) { + sizeCategory = labels.sizeXl; + githubSize = `XL`; + sizeReason = totalChanges > sizeThresholds.xl.lines ? `More than ${sizeThresholds.xl.lines} lines changed` : + totalFiles > sizeThresholds.xl.files ? `More than ${sizeThresholds.xl.files} files modified` : + `More than ${sizeThresholds.xl.commits} commits`; + } else if (totalChanges > sizeThresholds.l.lines || totalFiles > sizeThresholds.l.files || totalCommits > sizeThresholds.l.commits) { + sizeCategory = labels.sizeL; + githubSize = `L`; + sizeReason = totalChanges > sizeThresholds.l.lines ? `More than ${sizeThresholds.l.lines} lines changed` : + totalFiles > sizeThresholds.l.files ? `More than ${sizeThresholds.l.files} files modified` : + `More than ${sizeThresholds.l.commits} commits`; + } else if (totalChanges > sizeThresholds.m.lines || totalFiles > sizeThresholds.m.files || totalCommits > sizeThresholds.m.commits) { + sizeCategory = labels.sizeM; + githubSize = `M`; + sizeReason = totalChanges > sizeThresholds.m.lines ? `More than ${sizeThresholds.m.lines} lines changed` : + totalFiles > sizeThresholds.m.files ? `More than ${sizeThresholds.m.files} files modified` : + `More than ${sizeThresholds.m.commits} commits`; + } else if (totalChanges > sizeThresholds.s.lines || totalFiles > sizeThresholds.s.files || totalCommits > sizeThresholds.s.commits) { + sizeCategory = labels.sizeS; + githubSize = `S`; + sizeReason = totalChanges > sizeThresholds.s.lines ? `More than ${sizeThresholds.s.lines} lines changed` : + totalFiles > sizeThresholds.s.files ? `More than ${sizeThresholds.s.files} files modified` : + `More than ${sizeThresholds.s.commits} commits`; + } else { + sizeCategory = labels.sizeXs; + githubSize = `XS`; + sizeReason = `Small changes (${totalChanges} lines, ${totalFiles} files)`; + } + + return { + size: sizeCategory, + githubSize: githubSize, + reason: sizeReason, + }; + } catch (error) { + logError(`Error comparing branches: ${error}`); + throw error; + } + }; +} diff --git a/src/data/repository/branch_repository.ts b/src/data/repository/branch_repository.ts index f44fc3d7..01ded2f7 100644 --- a/src/data/repository/branch_repository.ts +++ b/src/data/repository/branch_repository.ts @@ -1,98 +1,38 @@ -import * as core from '@actions/core'; -import * as exec from '@actions/exec'; -import * as github from "@actions/github"; +import * as github from '@actions/github'; import { logDebugInfo, logError } from '../../utils/logger'; -import { getLatestVersion } from "../../utils/version_utils"; import { LinkedBranchResponse } from '../graph/linked_branch_response'; import { RepositoryResponse } from '../graph/repository_response'; -import { Execution } from "../model/execution"; +import { Execution } from '../model/execution'; import { Labels } from '../model/labels'; -import { Result } from "../model/result"; +import { Result } from '../model/result'; import { SizeThresholds } from '../model/size_thresholds'; - +import { BranchCompareRepository } from './branch_compare_repository'; +import { GitCliRepository } from './git_cli_repository'; +import { MergeRepository } from './merge_repository'; +import { WorkflowRepository } from './workflow_repository'; + +/** + * Facade for branch-related operations. Delegates to focused repositories + * (GitCli, Workflow, Merge, BranchCompare) for testability. + */ export class BranchRepository { - fetchRemoteBranches = async () => { - try { - logDebugInfo('Fetching tags and forcing fetch...'); - await exec.exec('git', ['fetch', '--tags', '--force']); - - logDebugInfo('Fetching all remote branches with verbose output...'); - await exec.exec('git', ['fetch', '--all', '-v']); - - logDebugInfo('Successfully fetched all remote branches.'); - } catch (error) { - core.setFailed(`Error fetching remote branches: ${error}`); - } - } - - getLatestTag = async () => { - try { - logDebugInfo('Fetching the latest tag...'); - await exec.exec('git', ['fetch', '--tags']); - - const tags: string[] = []; - await exec.exec('git', ['tag', '--sort=-creatordate'], { - listeners: { - stdout: (data: Buffer) => { - tags.push(...data.toString().split('\n').map((v) => { - return v.replace('v', '') - })); - }, - }, - }); - - const validTags = tags.filter(tag => /\d+\.\d+\.\d+$/.test(tag)); - - if (validTags.length > 0) { - const latestTag = getLatestVersion(validTags); - logDebugInfo(`Latest tag: ${latestTag}`); - return latestTag; - } else { - logDebugInfo('No valid tags found.'); - return undefined; - } - } catch (error) { - core.setFailed(`Error fetching the latest tag: ${error}`); - return undefined - } - } - - getCommitTag = async (latestTag: string | undefined) => { - try { - if (!latestTag) { - core.setFailed('No LATEST_TAG found in the environment'); - return; - } + private readonly gitCliRepository = new GitCliRepository(); + private readonly workflowRepository = new WorkflowRepository(); + private readonly mergeRepository = new MergeRepository(); + private readonly branchCompareRepository = new BranchCompareRepository(); - let tagVersion: string - if (latestTag.startsWith('v')) { - tagVersion = latestTag; - } else { - tagVersion = `v${latestTag}`; - } + fetchRemoteBranches = async (): Promise => { + return this.gitCliRepository.fetchRemoteBranches(); + }; - logDebugInfo(`Fetching commit hash for the tag: ${tagVersion}`); - let commitOid = ''; - await exec.exec('git', ['rev-list', '-n', '1', tagVersion], { - listeners: { - stdout: (data: Buffer) => { - commitOid = data.toString().trim(); - }, - }, - }); + getLatestTag = async (): Promise => { + return this.gitCliRepository.getLatestTag(); + }; - if (commitOid) { - logDebugInfo(`Commit tag: ${commitOid}`); - return commitOid; - } else { - core.setFailed('No commit found for the tag'); - } - } catch (error) { - core.setFailed(`Error fetching the commit hash: ${error}`); - } - return undefined - } + getCommitTag = async (latestTag: string | undefined): Promise => { + return this.gitCliRepository.getCommitTag(latestTag); + }; /** * Returns replaced branch (if any). @@ -467,15 +407,8 @@ export class BranchRepository { inputs: Record, token: string, ) => { - const octokit = github.getOctokit(token); - return octokit.rest.actions.createWorkflowDispatch({ - owner: owner, - repo: repository, - workflow_id: workflow, - ref: branch, - inputs: inputs - }); - } + return this.workflowRepository.executeWorkflow(owner, repository, branch, workflow, inputs, token); + }; mergeBranch = async ( owner: string, @@ -485,203 +418,8 @@ export class BranchRepository { timeout: number, token: string, ): Promise => { - const result: Result[] = []; - try { - const octokit = github.getOctokit(token); - logDebugInfo(`Creating merge from ${head} into ${base}`); - - // Build PR body with commit list - const prBody = `🚀 Automated Merge - -This PR merges **${head}** into **${base}**. - -**Commits included:**`; - - // We need PAT for creating PR to ensure it can trigger workflows - const { data: pullRequest } = await octokit.rest.pulls.create({ - owner: owner, - repo: repository, - head: head, - base: base, - title: `Merge ${head} into ${base}`, - body: prBody, - }); - - logDebugInfo(`Pull request #${pullRequest.number} created, getting commits...`); - - // Get all commits in the PR - const { data: commits } = await octokit.rest.pulls.listCommits({ - owner: owner, - repo: repository, - pull_number: pullRequest.number - }); - - const commitMessages = commits.map(commit => commit.commit.message); - - logDebugInfo(`Found ${commitMessages.length} commits in PR`); - - // Update PR with commit list and footer - await octokit.rest.pulls.update({ - owner: owner, - repo: repository, - pull_number: pullRequest.number, - body: prBody + '\n' + commitMessages.map(msg => `- ${msg}`).join('\n') + - '\n\nThis PR was automatically created by [`copilot`](https://github.com/vypdev/copilot).' - }); - - const iteration = 10; - if (timeout > iteration) { - // Wait for checks to complete - can use regular token for reading checks - let checksCompleted = false; - let attempts = 0; - const maxAttempts = timeout > iteration ? Math.floor(timeout / iteration) : iteration; - - while (!checksCompleted && attempts < maxAttempts) { - const { data: checkRuns } = await octokit.rest.checks.listForRef({ - owner: owner, - repo: repository, - ref: head, - }); - - // Get commit status checks for the PR head commit - const { data: commitStatus } = await octokit.rest.repos.getCombinedStatusForRef({ - owner: owner, - repo: repository, - ref: head - }); - - logDebugInfo(`Combined status state: ${commitStatus.state}`); - logDebugInfo(`Number of check runs: ${checkRuns.check_runs.length}`); - - // If there are check runs, prioritize those over status checks - if (checkRuns.check_runs.length > 0) { - const pendingCheckRuns = checkRuns.check_runs.filter( - check => check.status !== 'completed' - ); - - if (pendingCheckRuns.length === 0) { - checksCompleted = true; - logDebugInfo('All check runs have completed.'); - - // Verify if all checks passed - const failedChecks = checkRuns.check_runs.filter( - check => check.conclusion === 'failure' - ); - - if (failedChecks.length > 0) { - throw new Error(`Checks failed: ${failedChecks.map(check => check.name).join(', ')}`); - } - } else { - logDebugInfo(`Waiting for ${pendingCheckRuns.length} check runs to complete:`); - pendingCheckRuns.forEach(check => { - logDebugInfo(` - ${check.name} (Status: ${check.status})`); - }); - await new Promise(resolve => setTimeout(resolve, iteration * 1000)); - attempts++; - continue; - } - } else { - // Fall back to status checks if no check runs exist - const pendingChecks = commitStatus.statuses.filter(status => { - logDebugInfo(`Status check: ${status.context} (State: ${status.state})`); - return status.state === 'pending'; - }); - - if (pendingChecks.length === 0) { - checksCompleted = true; - logDebugInfo('All status checks have completed.'); - } else { - logDebugInfo(`Waiting for ${pendingChecks.length} status checks to complete:`); - pendingChecks.forEach(check => { - logDebugInfo(` - ${check.context} (State: ${check.state})`); - }); - await new Promise(resolve => setTimeout(resolve, iteration * 1000)); - attempts++; - } - } - } - - if (!checksCompleted) { - throw new Error('Timed out waiting for checks to complete'); - } - } - - // Need PAT for merging to ensure it can trigger subsequent workflows - await octokit.rest.pulls.merge({ - owner: owner, - repo: repository, - pull_number: pullRequest.number, - merge_method: 'merge', - commit_title: `Merge ${head} into ${base}. Forced merge with PAT token.`, - }); - - result.push( - new Result({ - id: 'branch_repository', - success: true, - executed: true, - steps: [ - `The branch \`${head}\` was merged into \`${base}\`.`, - ], - }) - ); - } catch (error) { - logError(`Error in PR workflow: ${error}`); - - // If the PR workflow fails, we try to merge directly - need PAT for direct merge to ensure it can trigger workflows - try { - const octokit = github.getOctokit(token); - await octokit.rest.repos.merge({ - owner: owner, - repo: repository, - base: base, - head: head, - commit_message: `Forced merge of ${head} into ${base}. Automated merge with PAT token.`, - }); - - result.push( - new Result({ - id: 'branch_repository', - success: true, - executed: true, - steps: [ - `The branch \`${head}\` was merged into \`${base}\` using direct merge.`, - ], - }) - ); - return result; - } catch (directMergeError) { - logError(`Error in direct merge attempt: ${directMergeError}`); - result.push( - new Result({ - id: 'branch_repository', - success: false, - executed: true, - steps: [ - `Failed to merge branch \`${head}\` into \`${base}\`.`, - ], - }) - ); - result.push( - new Result({ - id: 'branch_repository', - success: false, - executed: true, - error: error, - }) - ); - result.push( - new Result({ - id: 'branch_repository', - success: false, - executed: true, - error: directMergeError, - }) - ); - } - } - return result; - } + return this.mergeRepository.mergeBranch(owner, repository, head, base, timeout, token); + }; getChanges = async ( owner: string, @@ -689,56 +427,9 @@ This PR merges **${head}** into **${base}**. head: string, base: string, token: string, - ) => { - const octokit = github.getOctokit(token); - - try { - logDebugInfo(`Comparing branches: ${head} with ${base}`); - - let headRef = `heads/${head}` - if (head.indexOf('tags/') > -1) { - headRef = head - } - - let baseRef = `heads/${base}` - if (base.indexOf('tags/') > -1) { - baseRef = base - } - - const { data: comparison } = await octokit.rest.repos.compareCommits({ - owner: owner, - repo: repository, - base: baseRef, - head: headRef, - }); - - return { - aheadBy: comparison.ahead_by, - behindBy: comparison.behind_by, - totalCommits: comparison.total_commits, - files: (comparison.files || []).map(file => ({ - filename: file.filename, - status: file.status, - additions: file.additions, - deletions: file.deletions, - changes: file.changes, - blobUrl: file.blob_url, - rawUrl: file.raw_url, - contentsUrl: file.contents_url, - patch: file.patch - })), - commits: comparison.commits.map(commit => ({ - sha: commit.sha, - message: commit.commit.message, - author: commit.commit.author || { name: 'Unknown', email: 'unknown@example.com', date: new Date().toISOString() }, - date: commit.commit.author?.date || new Date().toISOString() - })) - }; - } catch (error) { - logError(`Error comparing branches: ${error}`); - throw error; - } - } + ) => { + return this.branchCompareRepository.getChanges(owner, repository, head, base, token); + }; getSizeCategoryAndReason = async ( owner: string, @@ -749,66 +440,14 @@ This PR merges **${head}** into **${base}**. labels: Labels, token: string, ) => { - try { - const headBranchChanges = await this.getChanges( - owner, - repository, - head, - base, - token, - ) - - const totalChanges = headBranchChanges.files.reduce((sum, file) => sum + file.changes, 0); - const totalFiles = headBranchChanges.files.length; - const totalCommits = headBranchChanges.totalCommits; - - let sizeCategory: string; - let githubSize: string; - let sizeReason: string; - if (totalChanges > sizeThresholds.xxl.lines || totalFiles > sizeThresholds.xxl.files || totalCommits > sizeThresholds.xxl.commits) { - sizeCategory = labels.sizeXxl; - githubSize = `XL`; - sizeReason = totalChanges > sizeThresholds.xxl.lines ? `More than ${sizeThresholds.xxl.lines} lines changed` : - totalFiles > sizeThresholds.xxl.files ? `More than ${sizeThresholds.xxl.files} files modified` : - `More than ${sizeThresholds.xxl.commits} commits`; - } else if (totalChanges > sizeThresholds.xl.lines || totalFiles > sizeThresholds.xl.files || totalCommits > sizeThresholds.xl.commits) { - sizeCategory = labels.sizeXl; - githubSize = `XL`; - sizeReason = totalChanges > sizeThresholds.xl.lines ? `More than ${sizeThresholds.xl.lines} lines changed` : - totalFiles > sizeThresholds.xl.files ? `More than ${sizeThresholds.xl.files} files modified` : - `More than ${sizeThresholds.xl.commits} commits`; - } else if (totalChanges > sizeThresholds.l.lines || totalFiles > sizeThresholds.l.files || totalCommits > sizeThresholds.l.commits) { - sizeCategory = labels.sizeL; - githubSize = `L`; - sizeReason = totalChanges > sizeThresholds.l.lines ? `More than ${sizeThresholds.l.lines} lines changed` : - totalFiles > sizeThresholds.l.files ? `More than ${sizeThresholds.l.files} files modified` : - `More than ${sizeThresholds.l.commits} commits`; - } else if (totalChanges > sizeThresholds.m.lines || totalFiles > sizeThresholds.m.files || totalCommits > sizeThresholds.m.commits) { - sizeCategory = labels.sizeM; - githubSize = `M`; - sizeReason = totalChanges > sizeThresholds.m.lines ? `More than ${sizeThresholds.m.lines} lines changed` : - totalFiles > sizeThresholds.m.files ? `More than ${sizeThresholds.m.files} files modified` : - `More than ${sizeThresholds.m.commits} commits`; - } else if (totalChanges > sizeThresholds.s.lines || totalFiles > sizeThresholds.s.files || totalCommits > sizeThresholds.s.commits) { - sizeCategory = labels.sizeS; - githubSize = `S`; - sizeReason = totalChanges > sizeThresholds.s.lines ? `More than ${sizeThresholds.s.lines} lines changed` : - totalFiles > sizeThresholds.s.files ? `More than ${sizeThresholds.s.files} files modified` : - `More than ${sizeThresholds.s.commits} commits`; - } else { - sizeCategory = labels.sizeXs; - githubSize = `XS`; - sizeReason = `Small changes (${totalChanges} lines, ${totalFiles} files)`; - } - - return { - size: sizeCategory, - githubSize: githubSize, - reason: sizeReason - } - } catch (error) { - logError(`Error comparing branches: ${error}`); - throw error; - } - } + return this.branchCompareRepository.getSizeCategoryAndReason( + owner, + repository, + head, + base, + sizeThresholds, + labels, + token, + ); + }; } \ No newline at end of file diff --git a/src/data/repository/git_cli_repository.ts b/src/data/repository/git_cli_repository.ts new file mode 100644 index 00000000..4ba60beb --- /dev/null +++ b/src/data/repository/git_cli_repository.ts @@ -0,0 +1,93 @@ +import * as core from '@actions/core'; +import * as exec from '@actions/exec'; +import { logDebugInfo } from '../../utils/logger'; +import { getLatestVersion } from '../../utils/version_utils'; + +/** + * Repository for Git operations executed via CLI (exec). + * Isolated to allow unit tests with mocked @actions/exec and @actions/core. + */ +export class GitCliRepository { + + fetchRemoteBranches = async (): Promise => { + try { + logDebugInfo('Fetching tags and forcing fetch...'); + await exec.exec('git', ['fetch', '--tags', '--force']); + + logDebugInfo('Fetching all remote branches with verbose output...'); + await exec.exec('git', ['fetch', '--all', '-v']); + + logDebugInfo('Successfully fetched all remote branches.'); + } catch (error) { + core.setFailed(`Error fetching remote branches: ${error}`); + } + }; + + getLatestTag = async (): Promise => { + try { + logDebugInfo('Fetching the latest tag...'); + await exec.exec('git', ['fetch', '--tags']); + + const tags: string[] = []; + await exec.exec('git', ['tag', '--sort=-creatordate'], { + listeners: { + stdout: (data: Buffer) => { + tags.push(...data.toString().split('\n').map((v) => { + return v.replace('v', ''); + })); + }, + }, + }); + + const validTags = tags.filter(tag => /\d+\.\d+\.\d+$/.test(tag)); + + if (validTags.length > 0) { + const latestTag = getLatestVersion(validTags); + logDebugInfo(`Latest tag: ${latestTag}`); + return latestTag; + } else { + logDebugInfo('No valid tags found.'); + return undefined; + } + } catch (error) { + core.setFailed(`Error fetching the latest tag: ${error}`); + return undefined; + } + }; + + getCommitTag = async (latestTag: string | undefined): Promise => { + try { + if (!latestTag) { + core.setFailed('No LATEST_TAG found in the environment'); + return undefined; + } + + let tagVersion: string; + if (latestTag.startsWith('v')) { + tagVersion = latestTag; + } else { + tagVersion = `v${latestTag}`; + } + + logDebugInfo(`Fetching commit hash for the tag: ${tagVersion}`); + let commitOid = ''; + await exec.exec('git', ['rev-list', '-n', '1', tagVersion], { + listeners: { + stdout: (data: Buffer) => { + commitOid = data.toString().trim(); + }, + }, + }); + + if (commitOid) { + logDebugInfo(`Commit tag: ${commitOid}`); + return commitOid; + } else { + core.setFailed('No commit found for the tag'); + } + } catch (error) { + core.setFailed(`Error fetching the commit hash: ${error}`); + } + return undefined; + }; +} diff --git a/src/data/repository/merge_repository.ts b/src/data/repository/merge_repository.ts new file mode 100644 index 00000000..5181bdf5 --- /dev/null +++ b/src/data/repository/merge_repository.ts @@ -0,0 +1,216 @@ +import * as github from '@actions/github'; +import { logDebugInfo, logError } from '../../utils/logger'; +import { Result } from '../model/result'; + +/** + * Repository for merging branches (via PR or direct merge). + * Isolated to allow unit tests with mocked Octokit. + */ +export class MergeRepository { + + mergeBranch = async ( + owner: string, + repository: string, + head: string, + base: string, + timeout: number, + token: string, + ): Promise => { + const result: Result[] = []; + try { + const octokit = github.getOctokit(token); + logDebugInfo(`Creating merge from ${head} into ${base}`); + + // Build PR body with commit list + const prBody = `🚀 Automated Merge + +This PR merges **${head}** into **${base}**. + +**Commits included:**`; + + // We need PAT for creating PR to ensure it can trigger workflows + const { data: pullRequest } = await octokit.rest.pulls.create({ + owner: owner, + repo: repository, + head: head, + base: base, + title: `Merge ${head} into ${base}`, + body: prBody, + }); + + logDebugInfo(`Pull request #${pullRequest.number} created, getting commits...`); + + // Get all commits in the PR + const { data: commits } = await octokit.rest.pulls.listCommits({ + owner: owner, + repo: repository, + pull_number: pullRequest.number, + }); + + const commitMessages = commits.map(commit => commit.commit.message); + + logDebugInfo(`Found ${commitMessages.length} commits in PR`); + + // Update PR with commit list and footer + await octokit.rest.pulls.update({ + owner: owner, + repo: repository, + pull_number: pullRequest.number, + body: prBody + '\n' + commitMessages.map(msg => `- ${msg}`).join('\n') + + '\n\nThis PR was automatically created by [`copilot`](https://github.com/vypdev/copilot).', + }); + + const iteration = 10; + if (timeout > iteration) { + // Wait for checks to complete - can use regular token for reading checks + let checksCompleted = false; + let attempts = 0; + const maxAttempts = timeout > iteration ? Math.floor(timeout / iteration) : iteration; + + while (!checksCompleted && attempts < maxAttempts) { + const { data: checkRuns } = await octokit.rest.checks.listForRef({ + owner: owner, + repo: repository, + ref: head, + }); + + // Get commit status checks for the PR head commit + const { data: commitStatus } = await octokit.rest.repos.getCombinedStatusForRef({ + owner: owner, + repo: repository, + ref: head, + }); + + logDebugInfo(`Combined status state: ${commitStatus.state}`); + logDebugInfo(`Number of check runs: ${checkRuns.check_runs.length}`); + + // If there are check runs, prioritize those over status checks + if (checkRuns.check_runs.length > 0) { + const pendingCheckRuns = checkRuns.check_runs.filter( + check => check.status !== 'completed', + ); + + if (pendingCheckRuns.length === 0) { + checksCompleted = true; + logDebugInfo('All check runs have completed.'); + + // Verify if all checks passed + const failedChecks = checkRuns.check_runs.filter( + check => check.conclusion === 'failure', + ); + + if (failedChecks.length > 0) { + throw new Error(`Checks failed: ${failedChecks.map(check => check.name).join(', ')}`); + } + } else { + logDebugInfo(`Waiting for ${pendingCheckRuns.length} check runs to complete:`); + pendingCheckRuns.forEach(check => { + logDebugInfo(` - ${check.name} (Status: ${check.status})`); + }); + await new Promise(resolve => setTimeout(resolve, iteration * 1000)); + attempts++; + continue; + } + } else { + // Fall back to status checks if no check runs exist + const pendingChecks = commitStatus.statuses.filter(status => { + logDebugInfo(`Status check: ${status.context} (State: ${status.state})`); + return status.state === 'pending'; + }); + + if (pendingChecks.length === 0) { + checksCompleted = true; + logDebugInfo('All status checks have completed.'); + } else { + logDebugInfo(`Waiting for ${pendingChecks.length} status checks to complete:`); + pendingChecks.forEach(check => { + logDebugInfo(` - ${check.context} (State: ${check.state})`); + }); + await new Promise(resolve => setTimeout(resolve, iteration * 1000)); + attempts++; + } + } + } + + if (!checksCompleted) { + throw new Error('Timed out waiting for checks to complete'); + } + } + + // Need PAT for merging to ensure it can trigger subsequent workflows + await octokit.rest.pulls.merge({ + owner: owner, + repo: repository, + pull_number: pullRequest.number, + merge_method: 'merge', + commit_title: `Merge ${head} into ${base}. Forced merge with PAT token.`, + }); + + result.push( + new Result({ + id: 'branch_repository', + success: true, + executed: true, + steps: [ + `The branch \`${head}\` was merged into \`${base}\`.`, + ], + }), + ); + } catch (error) { + logError(`Error in PR workflow: ${error}`); + + // If the PR workflow fails, we try to merge directly - need PAT for direct merge to ensure it can trigger workflows + try { + const octokit = github.getOctokit(token); + await octokit.rest.repos.merge({ + owner: owner, + repo: repository, + base: base, + head: head, + commit_message: `Forced merge of ${head} into ${base}. Automated merge with PAT token.`, + }); + + result.push( + new Result({ + id: 'branch_repository', + success: true, + executed: true, + steps: [ + `The branch \`${head}\` was merged into \`${base}\` using direct merge.`, + ], + }), + ); + return result; + } catch (directMergeError) { + logError(`Error in direct merge attempt: ${directMergeError}`); + result.push( + new Result({ + id: 'branch_repository', + success: false, + executed: true, + steps: [ + `Failed to merge branch \`${head}\` into \`${base}\`.`, + ], + }), + ); + result.push( + new Result({ + id: 'branch_repository', + success: false, + executed: true, + error: error, + }), + ); + result.push( + new Result({ + id: 'branch_repository', + success: false, + executed: true, + error: directMergeError, + }), + ); + } + } + return result; + }; +} diff --git a/src/data/repository/workflow_repository.ts b/src/data/repository/workflow_repository.ts index 01ec737e..6bd22c4c 100644 --- a/src/data/repository/workflow_repository.ts +++ b/src/data/repository/workflow_repository.ts @@ -43,4 +43,22 @@ export class WorkflowRepository { return isSameWorkflow && isPrevious && isActive; }); } + + executeWorkflow = async ( + owner: string, + repository: string, + branch: string, + workflow: string, + inputs: Record, + token: string, + ) => { + const octokit = github.getOctokit(token); + return octokit.rest.actions.createWorkflowDispatch({ + owner: owner, + repo: repository, + workflow_id: workflow, + ref: branch, + inputs: inputs, + }); + }; } \ No newline at end of file diff --git a/src/prompts/__tests__/answer_issue_help.test.ts b/src/prompts/__tests__/answer_issue_help.test.ts new file mode 100644 index 00000000..64ae1e5b --- /dev/null +++ b/src/prompts/__tests__/answer_issue_help.test.ts @@ -0,0 +1,15 @@ +import { getAnswerIssueHelpPrompt } from '../answer_issue_help'; + +describe('getAnswerIssueHelpPrompt', () => { + it('fills description and projectContextInstruction', () => { + const prompt = getAnswerIssueHelpPrompt({ + description: 'How do I configure X?', + projectContextInstruction: '**Use project context.**', + }); + expect(prompt).toContain('question/help issue'); + expect(prompt).toContain('How do I configure X?'); + expect(prompt).toContain('**Use project context.**'); + expect(prompt).not.toContain('{{description}}'); + expect(prompt).not.toContain('{{projectContextInstruction}}'); + }); +}); diff --git a/src/prompts/__tests__/bugbot.test.ts b/src/prompts/__tests__/bugbot.test.ts new file mode 100644 index 00000000..373ff5f7 --- /dev/null +++ b/src/prompts/__tests__/bugbot.test.ts @@ -0,0 +1,27 @@ +import { getBugbotPrompt } from '../bugbot'; + +describe('getBugbotPrompt', () => { + it('fills repo context, ignoreBlock and previousBlock', () => { + const prompt = getBugbotPrompt({ + projectContextInstruction: '**Context.**', + owner: 'org', + repo: 'repo', + headBranch: 'feature/42', + baseBranch: 'develop', + issueNumber: '42', + ignoreBlock: '\n**Files to ignore:** *.test.ts', + previousBlock: '(No previous findings.)', + }); + expect(prompt).toContain('**Context.**'); + expect(prompt).toContain('org'); + expect(prompt).toContain('repo'); + expect(prompt).toContain('feature/42'); + expect(prompt).toContain('develop'); + expect(prompt).toContain('42'); + expect(prompt).toContain('*.test.ts'); + expect(prompt).toContain('(No previous findings.)'); + expect(prompt).toContain('findings'); + expect(prompt).toContain('resolved_finding_ids'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/__tests__/bugbot_fix.test.ts b/src/prompts/__tests__/bugbot_fix.test.ts new file mode 100644 index 00000000..621e0d32 --- /dev/null +++ b/src/prompts/__tests__/bugbot_fix.test.ts @@ -0,0 +1,42 @@ +import { getBugbotFixPrompt } from '../bugbot_fix'; + +describe('getBugbotFixPrompt', () => { + it('fills all params including prNumberLine and verifyBlock', () => { + const prompt = getBugbotFixPrompt({ + projectContextInstruction: '**Context.**', + owner: 'o', + repo: 'r', + headBranch: 'fix/branch', + baseBranch: 'main', + issueNumber: '1', + prNumberLine: '- Pull request number: 5', + findingsBlock: '---\n**Finding id:** `f1`\n\n**Full comment:**\nBug here.\n', + userComment: 'fix it', + verifyBlock: '\n**Verify commands:**\n- `npm test`\n', + }); + expect(prompt).toContain('**Context.**'); + expect(prompt).toContain('Pull request number: 5'); + expect(prompt).toContain('Finding id'); + expect(prompt).toContain('fix it'); + expect(prompt).toContain('Verify commands'); + expect(prompt).toContain('npm test'); + expect(prompt).not.toContain('{{'); + }); + + it('allows empty prNumberLine', () => { + const prompt = getBugbotFixPrompt({ + projectContextInstruction: 'X', + owner: 'o', + repo: 'r', + headBranch: 'b', + baseBranch: 'main', + issueNumber: '1', + prNumberLine: '', + findingsBlock: 'findings', + userComment: 'fix', + verifyBlock: 'Verify.', + }); + expect(prompt).toContain('findings'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/__tests__/bugbot_fix_intent.test.ts b/src/prompts/__tests__/bugbot_fix_intent.test.ts new file mode 100644 index 00000000..8233305f --- /dev/null +++ b/src/prompts/__tests__/bugbot_fix_intent.test.ts @@ -0,0 +1,34 @@ +import { getBugbotFixIntentPrompt } from '../bugbot_fix_intent'; + +describe('getBugbotFixIntentPrompt', () => { + it('fills findingsBlock, parentBlock and userComment', () => { + const prompt = getBugbotFixIntentPrompt({ + projectContextInstruction: '**Context.**', + findingsBlock: '- **id:** `find-1` | **title:** Bug one', + parentBlock: '\n**Parent comment:**\nPrevious finding.\n', + userComment: 'fix find-1 please', + }); + expect(prompt).toContain('**Context.**'); + expect(prompt).toContain('find-1'); + expect(prompt).toContain('Bug one'); + expect(prompt).toContain('Parent comment'); + expect(prompt).toContain('Previous finding.'); + expect(prompt).toContain('fix find-1 please'); + expect(prompt).toContain('is_fix_request'); + expect(prompt).toContain('target_finding_ids'); + expect(prompt).toContain('is_do_request'); + expect(prompt).not.toContain('{{'); + }); + + it('allows empty parentBlock', () => { + const prompt = getBugbotFixIntentPrompt({ + projectContextInstruction: 'X', + findingsBlock: '(No unresolved findings.)', + parentBlock: '', + userComment: 'fix all', + }); + expect(prompt).toContain('(No unresolved findings.)'); + expect(prompt).toContain('fix all'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/__tests__/check_comment_language.test.ts b/src/prompts/__tests__/check_comment_language.test.ts new file mode 100644 index 00000000..2a53f4ff --- /dev/null +++ b/src/prompts/__tests__/check_comment_language.test.ts @@ -0,0 +1,31 @@ +import { + getCheckCommentLanguagePrompt, + getTranslateCommentPrompt, +} from '../check_comment_language'; + +describe('getCheckCommentLanguagePrompt', () => { + it('fills locale and commentBody', () => { + const prompt = getCheckCommentLanguagePrompt({ + locale: 'en', + commentBody: 'Hello world', + }); + expect(prompt).toContain('en'); + expect(prompt).toContain('Hello world'); + expect(prompt).toContain('done'); + expect(prompt).toContain('must_translate'); + expect(prompt).not.toContain('{{'); + }); +}); + +describe('getTranslateCommentPrompt', () => { + it('fills locale and commentBody', () => { + const prompt = getTranslateCommentPrompt({ + locale: 'es', + commentBody: 'Translate this please', + }); + expect(prompt).toContain('es'); + expect(prompt).toContain('Translate this please'); + expect(prompt).toContain('translatedText'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/__tests__/check_progress.test.ts b/src/prompts/__tests__/check_progress.test.ts new file mode 100644 index 00000000..ca268a4c --- /dev/null +++ b/src/prompts/__tests__/check_progress.test.ts @@ -0,0 +1,21 @@ +import { getCheckProgressPrompt } from '../check_progress'; + +describe('getCheckProgressPrompt', () => { + it('fills branches and issue description', () => { + const prompt = getCheckProgressPrompt({ + projectContextInstruction: '**Context.**', + issueNumber: '3', + baseBranch: 'develop', + currentBranch: 'feature/wip', + issueDescription: 'Build the API.', + }); + expect(prompt).toContain('**Context.**'); + expect(prompt).toContain('issue #3'); + expect(prompt).toContain('`develop`'); + expect(prompt).toContain('`feature/wip`'); + expect(prompt).toContain('Build the API.'); + expect(prompt).toContain('progress'); + expect(prompt).toContain('remaining'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/__tests__/cli_do.test.ts b/src/prompts/__tests__/cli_do.test.ts new file mode 100644 index 00000000..26ec0092 --- /dev/null +++ b/src/prompts/__tests__/cli_do.test.ts @@ -0,0 +1,13 @@ +import { getCliDoPrompt } from '../cli_do'; + +describe('getCliDoPrompt', () => { + it('fills projectContextInstruction and userPrompt', () => { + const prompt = getCliDoPrompt({ + projectContextInstruction: '**Read the repo.**', + userPrompt: 'Refactor module X', + }); + expect(prompt).toContain('**Read the repo.**'); + expect(prompt).toContain('Refactor module X'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/__tests__/fill.test.ts b/src/prompts/__tests__/fill.test.ts new file mode 100644 index 00000000..d0a9564c --- /dev/null +++ b/src/prompts/__tests__/fill.test.ts @@ -0,0 +1,21 @@ +import { fillTemplate } from '../fill'; + +describe('fillTemplate', () => { + it('replaces all placeholders with params', () => { + const out = fillTemplate('Hello {{name}}, you have {{count}} items.', { + name: 'Alice', + count: '3', + }); + expect(out).toBe('Hello Alice, you have 3 items.'); + }); + + it('leaves missing keys as placeholder', () => { + const out = fillTemplate('{{a}} and {{b}}', { a: '1' }); + expect(out).toBe('1 and {{b}}'); + }); + + it('handles empty params', () => { + const out = fillTemplate('{{x}}', {}); + expect(out).toBe('{{x}}'); + }); +}); diff --git a/src/prompts/__tests__/index.test.ts b/src/prompts/__tests__/index.test.ts new file mode 100644 index 00000000..3af83557 --- /dev/null +++ b/src/prompts/__tests__/index.test.ts @@ -0,0 +1,54 @@ +import type { PromptName } from '../index'; +import { + getPrompt, + PROMPT_NAMES, + getAnswerIssueHelpPrompt, + getThinkPrompt, +} from '../index'; + +describe('getPrompt', () => { + it('returns same result as getAnswerIssueHelpPrompt for ANSWER_ISSUE_HELP', () => { + const params = { + description: 'How to install?', + projectContextInstruction: '**Use context.**', + }; + const viaRegistry = getPrompt(PROMPT_NAMES.ANSWER_ISSUE_HELP, params); + const viaGetter = getAnswerIssueHelpPrompt(params); + expect(viaRegistry).toBe(viaGetter); + expect(viaRegistry).toContain('How to install?'); + }); + + it('returns same result as getThinkPrompt for THINK', () => { + const params = { + projectContextInstruction: 'X', + contextBlock: '\n\n', + question: 'q', + }; + const viaRegistry = getPrompt(PROMPT_NAMES.THINK, params); + const viaGetter = getThinkPrompt(params); + expect(viaRegistry).toBe(viaGetter); + }); + + it('throws for unknown prompt name', () => { + expect(() => + getPrompt('unknown' as PromptName, { description: 'x', projectContextInstruction: 'y' }) + ).toThrow('Unknown prompt'); + }); +}); + +describe('PROMPT_NAMES', () => { + it('includes all expected prompt keys', () => { + expect(PROMPT_NAMES.ANSWER_ISSUE_HELP).toBe('answer_issue_help'); + expect(PROMPT_NAMES.THINK).toBe('think'); + expect(PROMPT_NAMES.UPDATE_PULL_REQUEST_DESCRIPTION).toBe('update_pull_request_description'); + expect(PROMPT_NAMES.USER_REQUEST).toBe('user_request'); + expect(PROMPT_NAMES.RECOMMEND_STEPS).toBe('recommend_steps'); + expect(PROMPT_NAMES.CHECK_PROGRESS).toBe('check_progress'); + expect(PROMPT_NAMES.CHECK_COMMENT_LANGUAGE).toBe('check_comment_language'); + expect(PROMPT_NAMES.TRANSLATE_COMMENT).toBe('translate_comment'); + expect(PROMPT_NAMES.CLI_DO).toBe('cli_do'); + expect(PROMPT_NAMES.BUGBOT).toBe('bugbot'); + expect(PROMPT_NAMES.BUGBOT_FIX).toBe('bugbot_fix'); + expect(PROMPT_NAMES.BUGBOT_FIX_INTENT).toBe('bugbot_fix_intent'); + }); +}); diff --git a/src/prompts/__tests__/recommend_steps.test.ts b/src/prompts/__tests__/recommend_steps.test.ts new file mode 100644 index 00000000..5683a24f --- /dev/null +++ b/src/prompts/__tests__/recommend_steps.test.ts @@ -0,0 +1,16 @@ +import { getRecommendStepsPrompt } from '../recommend_steps'; + +describe('getRecommendStepsPrompt', () => { + it('fills issue number and description', () => { + const prompt = getRecommendStepsPrompt({ + projectContextInstruction: '**Use project.**', + issueNumber: '7', + issueDescription: 'Implement OAuth flow.', + }); + expect(prompt).toContain('**Use project.**'); + expect(prompt).toContain('Issue #7'); + expect(prompt).toContain('Implement OAuth flow.'); + expect(prompt).toContain('recommend concrete steps'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/__tests__/think.test.ts b/src/prompts/__tests__/think.test.ts new file mode 100644 index 00000000..2c5dc656 --- /dev/null +++ b/src/prompts/__tests__/think.test.ts @@ -0,0 +1,27 @@ +import { getThinkPrompt } from '../think'; + +describe('getThinkPrompt', () => { + it('fills projectContextInstruction, contextBlock and question', () => { + const prompt = getThinkPrompt({ + projectContextInstruction: '**Use context.**', + contextBlock: '\n\nContext (issue #1):\nFix the bug.\n\n', + question: 'what is 2+2?', + }); + expect(prompt).toContain('helpful assistant'); + expect(prompt).toContain('**Use context.**'); + expect(prompt).toContain('Context (issue #1):'); + expect(prompt).toContain('Fix the bug.'); + expect(prompt).toContain('Question: what is 2+2?'); + expect(prompt).not.toContain('{{'); + }); + + it('allows empty contextBlock', () => { + const prompt = getThinkPrompt({ + projectContextInstruction: 'X', + contextBlock: '\n\n', + question: 'q', + }); + expect(prompt).toContain('Question: q'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/__tests__/update_pull_request_description.test.ts b/src/prompts/__tests__/update_pull_request_description.test.ts new file mode 100644 index 00000000..ddc9c5d1 --- /dev/null +++ b/src/prompts/__tests__/update_pull_request_description.test.ts @@ -0,0 +1,21 @@ +import { getUpdatePullRequestDescriptionPrompt } from '../update_pull_request_description'; + +describe('getUpdatePullRequestDescriptionPrompt', () => { + it('fills all params and contains template instructions', () => { + const prompt = getUpdatePullRequestDescriptionPrompt({ + projectContextInstruction: '**Use repo.**', + baseBranch: 'main', + headBranch: 'feature/123', + issueNumber: '42', + issueDescription: 'Add login screen.', + }); + expect(prompt).toContain('**Use repo.**'); + expect(prompt).toContain('`main`'); + expect(prompt).toContain('`feature/123`'); + expect(prompt).toContain('Closes #42'); + expect(prompt).toContain('Add login screen.'); + expect(prompt).toContain('pull_request_template.md'); + expect(prompt).toContain('git diff'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/__tests__/user_request.test.ts b/src/prompts/__tests__/user_request.test.ts new file mode 100644 index 00000000..82057576 --- /dev/null +++ b/src/prompts/__tests__/user_request.test.ts @@ -0,0 +1,24 @@ +import { getUserRequestPrompt } from '../user_request'; + +describe('getUserRequestPrompt', () => { + it('fills repository context and user comment', () => { + const prompt = getUserRequestPrompt({ + projectContextInstruction: '**Context.**', + owner: 'my-org', + repo: 'my-repo', + headBranch: 'feature/x', + baseBranch: 'develop', + issueNumber: '10', + userComment: 'add a unit test for foo', + }); + expect(prompt).toContain('**Context.**'); + expect(prompt).toContain('my-org'); + expect(prompt).toContain('my-repo'); + expect(prompt).toContain('feature/x'); + expect(prompt).toContain('develop'); + expect(prompt).toContain('10'); + expect(prompt).toContain('add a unit test for foo'); + expect(prompt).toContain('Apply all changes directly'); + expect(prompt).not.toContain('{{'); + }); +}); diff --git a/src/prompts/answer_issue_help.ts b/src/prompts/answer_issue_help.ts new file mode 100644 index 00000000..158d4834 --- /dev/null +++ b/src/prompts/answer_issue_help.ts @@ -0,0 +1,30 @@ +/** + * Prompt for the initial reply when a user opens a question/help issue. + * Filled by the prompt provider; use getAnswerIssueHelpPrompt(). + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. + +**Answer in this single response:** Give a complete, direct answer. Do not reply that you need to explore the repository, read documentation first, or gather more information—use the project (README, docs/, code, .cursor/rules) to answer now. For "how do I…" or tutorial-style questions (e.g. how to implement or configure this project), provide concrete steps or guidance based on the project's actual documentation and structure. + +{{projectContextInstruction}} + +**Issue description (user's question or request):** +""" +{{description}} +""" + +Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; + +export type AnswerIssueHelpParams = { + description: string; + projectContextInstruction: string; +}; + +export function getAnswerIssueHelpPrompt(params: AnswerIssueHelpParams): string { + return fillTemplate(TEMPLATE, { + description: params.description, + projectContextInstruction: params.projectContextInstruction, + }); +} diff --git a/src/prompts/bugbot.ts b/src/prompts/bugbot.ts new file mode 100644 index 00000000..acc8d6c5 --- /dev/null +++ b/src/prompts/bugbot.ts @@ -0,0 +1,39 @@ +/** + * Prompt for Bugbot detection (detect potential problems on push). + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `You are analyzing the latest code changes for potential bugs and issues. + +{{projectContextInstruction}} + +**Repository context:** +- Owner: {{owner}} +- Repository: {{repo}} +- Branch (head): {{headBranch}} +- Base branch: {{baseBranch}} +- Issue number: {{issueNumber}} +{{ignoreBlock}} + +**Your task 1 (new/current problems):** Determine what has changed in the branch "{{headBranch}}" compared to "{{baseBranch}}" (you must compute or obtain the diff yourself using the repository context above). Then identify potential bugs, logic errors, security issues, and code quality problems. Be strict and descriptive. One finding per distinct problem. Return them in the \`findings\` array (each with id, title, description; optionally file, line, severity, suggestion). Only include findings in files that are not in the ignore list above. +{{previousBlock}} + +**Output:** Return a JSON object with: "findings" (array of new/current problems from task 1), and if we gave you previously reported issues above, "resolved_finding_ids" (array of those ids that are now fixed or no longer apply, as per task 2).`; + +export type BugbotParams = { + projectContextInstruction: string; + owner: string; + repo: string; + headBranch: string; + baseBranch: string; + issueNumber: string; + ignoreBlock: string; + previousBlock: string; +}; + +export function getBugbotPrompt(params: BugbotParams): string { + return fillTemplate(TEMPLATE, { + ...params, + issueNumber: String(params.issueNumber), + }); +} diff --git a/src/prompts/bugbot_fix.ts b/src/prompts/bugbot_fix.ts new file mode 100644 index 00000000..184d81a1 --- /dev/null +++ b/src/prompts/bugbot_fix.ts @@ -0,0 +1,53 @@ +/** + * Prompt for Bugbot autofix (fix selected findings in workspace). + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `You are in the repository workspace. Your task is to fix the reported code findings (bugs, vulnerabilities, or quality issues) listed below, and only those. The user has explicitly requested these fixes. + +{{projectContextInstruction}} + +**Repository context:** +- Owner: {{owner}} +- Repository: {{repo}} +- Branch (head): {{headBranch}} +- Base branch: {{baseBranch}} +- Issue number: {{issueNumber}} +{{prNumberLine}} + +**Findings to fix (do not change code unrelated to these):** +{{findingsBlock}} + +**User request:** +""" +{{userComment}} +""" + +**Rules:** +1. Fix only the problems described in the findings above. Do not refactor or change other code except as strictly necessary for the fix. +2. You may add or update tests only to validate that the fix is correct. +3. After applying changes, run the verify commands (or standard build/test/lint) and ensure they all pass. If they fail, adjust the fix until they pass. +4. Apply all changes directly in the workspace (edit files, run commands). Do not output diffs for someone else to apply. +{{verifyBlock}} + +Once the fixes are applied and the verify commands pass, reply briefly confirming what was fixed and that checks passed.`; + +export type BugbotFixParams = { + projectContextInstruction: string; + owner: string; + repo: string; + headBranch: string; + baseBranch: string; + issueNumber: string; + prNumberLine: string; + findingsBlock: string; + userComment: string; + verifyBlock: string; +}; + +export function getBugbotFixPrompt(params: BugbotFixParams): string { + return fillTemplate(TEMPLATE, { + ...params, + issueNumber: String(params.issueNumber), + }); +} diff --git a/src/prompts/bugbot_fix_intent.ts b/src/prompts/bugbot_fix_intent.ts new file mode 100644 index 00000000..4f1e55bf --- /dev/null +++ b/src/prompts/bugbot_fix_intent.ts @@ -0,0 +1,34 @@ +/** + * Prompt for detecting if user comment is a fix request and which finding ids to target. + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `You are analyzing a user comment on an issue or pull request to decide whether they are asking to fix one or more reported code findings (bugs, vulnerabilities, or quality issues). + +{{projectContextInstruction}} + +**List of unresolved findings (id, title, and optional file/line/description):** +{{findingsBlock}} +{{parentBlock}} +**User comment:** +""" +{{userComment}} +""" + +**Your task:** Decide: +1. Is this comment clearly a request to fix one or more of the findings above? (e.g. "fix it", "arreglalo", "fix this", "fix all", "fix vulnerability X", "corrige", "fix the bug in src/foo.ts"). If the user is asking a question, discussing something else, or the intent is ambiguous, set \`is_fix_request\` to false. +2. If it is a fix request, which finding ids should be fixed? Return their exact ids in \`target_finding_ids\`. If the user says "fix all" or equivalent, include every id from the list above. If they refer to a specific finding (e.g. by replying to a comment that contains one finding), return only that finding's id. Use only ids that appear in the list above. +3. Is the user asking to perform some other change or task in the repo? (e.g. "add a test for X", "refactor this", "implement feature Y", "haz que Z"). If yes, set \`is_do_request\` to true. Set false for pure questions or when the only intent is to fix the listed findings. + +Respond with a JSON object: \`is_fix_request\` (boolean), \`target_finding_ids\` (array of strings; empty when \`is_fix_request\` is false), and \`is_do_request\` (boolean).`; + +export type BugbotFixIntentParams = { + projectContextInstruction: string; + findingsBlock: string; + parentBlock: string; + userComment: string; +}; + +export function getBugbotFixIntentPrompt(params: BugbotFixIntentParams): string { + return fillTemplate(TEMPLATE, params); +} diff --git a/src/prompts/check_comment_language.ts b/src/prompts/check_comment_language.ts new file mode 100644 index 00000000..ebb79f59 --- /dev/null +++ b/src/prompts/check_comment_language.ts @@ -0,0 +1,47 @@ +/** + * Prompts for checking if a comment is in the target locale and for translating it. + * Used by CheckIssueCommentLanguageUseCase and CheckPullRequestCommentLanguageUseCase. + */ +import { fillTemplate } from './fill'; + +const CHECK_TEMPLATE = ` + You are a helpful assistant that checks if the text is written in {{locale}}. + + Instructions: + 1. Analyze the provided text + 2. If the text is written in {{locale}}, respond with exactly "done" + 3. If the text is written in any other language, respond with exactly "must_translate" + 4. Do not provide any explanation or additional text + + The text is: {{commentBody}} + `; + +const TRANSLATE_TEMPLATE = ` +You are a helpful assistant that translates the text to {{locale}}. + +Instructions: +1. Translate the text to {{locale}} +2. Put the translated text in the translatedText field +3. If you cannot translate (e.g. ambiguous or invalid input), set translatedText to empty string and explain in reason + +The text to translate is: {{commentBody}} + `; + +export type CheckCommentLanguageParams = { + locale: string; + commentBody: string; +}; + +export function getCheckCommentLanguagePrompt(params: CheckCommentLanguageParams): string { + return fillTemplate(CHECK_TEMPLATE.trim(), { + locale: params.locale, + commentBody: params.commentBody, + }); +} + +export function getTranslateCommentPrompt(params: CheckCommentLanguageParams): string { + return fillTemplate(TRANSLATE_TEMPLATE.trim(), { + locale: params.locale, + commentBody: params.commentBody, + }); +} diff --git a/src/prompts/check_progress.ts b/src/prompts/check_progress.ts new file mode 100644 index 00000000..75f8ce0c --- /dev/null +++ b/src/prompts/check_progress.ts @@ -0,0 +1,41 @@ +/** + * Prompt for assessing issue progress from branch diff (CheckProgressUseCase). + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `You are in the repository workspace. Assess the progress of issue #{{issueNumber}} using the full diff between the base (parent) branch and the current branch. + +{{projectContextInstruction}} + +**Branches:** +- **Base (parent) branch:** \`{{baseBranch}}\` +- **Current branch:** \`{{currentBranch}}\` + +**Instructions:** +1. Get the full diff by running: \`git diff {{baseBranch}}..{{currentBranch}}\` (or \`git diff {{baseBranch}}...{{currentBranch}}\` for merge-base). If you cannot run shell commands, use whatever workspace tools you have to inspect changes between these branches. +2. Optionally confirm the current branch with \`git branch --show-current\` if needed. +3. Based on the full diff and the issue description below, assess completion progress (0-100%) and write a short summary. +4. If progress is below 100%, add a "remaining" field with a short description of what is left to do to complete the task (e.g. missing implementation, tests, docs). Omit "remaining" or leave empty when progress is 100%. + +**Issue description:** +{{issueDescription}} + +Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" }.`; + +export type CheckProgressParams = { + projectContextInstruction: string; + issueNumber: string; + baseBranch: string; + currentBranch: string; + issueDescription: string; +}; + +export function getCheckProgressPrompt(params: CheckProgressParams): string { + return fillTemplate(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + issueNumber: String(params.issueNumber), + baseBranch: params.baseBranch, + currentBranch: params.currentBranch, + issueDescription: params.issueDescription, + }); +} diff --git a/src/prompts/cli_do.ts b/src/prompts/cli_do.ts new file mode 100644 index 00000000..df6ab5fc --- /dev/null +++ b/src/prompts/cli_do.ts @@ -0,0 +1,17 @@ +/** + * Prompt for CLI "copilot do" command: project context + user prompt. + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `{{projectContextInstruction}} + +{{userPrompt}}`; + +export type CliDoParams = { + projectContextInstruction: string; + userPrompt: string; +}; + +export function getCliDoPrompt(params: CliDoParams): string { + return fillTemplate(TEMPLATE, params); +} diff --git a/src/prompts/fill.ts b/src/prompts/fill.ts new file mode 100644 index 00000000..a555f96a --- /dev/null +++ b/src/prompts/fill.ts @@ -0,0 +1,7 @@ +/** + * Replaces {{paramName}} placeholders in a template with values from params. + * Missing keys are left as {{paramName}}. + */ +export function fillTemplate(template: string, params: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => params[key] ?? `{{${key}}}`); +} diff --git a/src/prompts/index.ts b/src/prompts/index.ts new file mode 100644 index 00000000..97263787 --- /dev/null +++ b/src/prompts/index.ts @@ -0,0 +1,119 @@ +/** + * Prompt provider: one file per prompt, each exports a getter that fills the template with params. + * Use getPrompt(name, params) for a generic call or import the typed getter (e.g. getAnswerIssueHelpPrompt). + */ +import { getAnswerIssueHelpPrompt } from './answer_issue_help'; +import type { AnswerIssueHelpParams } from './answer_issue_help'; +import { getThinkPrompt } from './think'; +import type { ThinkParams } from './think'; +import { getUpdatePullRequestDescriptionPrompt } from './update_pull_request_description'; +import type { UpdatePullRequestDescriptionParams } from './update_pull_request_description'; +import { getUserRequestPrompt } from './user_request'; +import type { UserRequestParams } from './user_request'; +import { getRecommendStepsPrompt } from './recommend_steps'; +import type { RecommendStepsParams } from './recommend_steps'; +import { getCheckProgressPrompt } from './check_progress'; +import type { CheckProgressParams } from './check_progress'; +import { + getCheckCommentLanguagePrompt, + getTranslateCommentPrompt, +} from './check_comment_language'; +import type { CheckCommentLanguageParams } from './check_comment_language'; +import { getCliDoPrompt } from './cli_do'; +import type { CliDoParams } from './cli_do'; +import { getBugbotPrompt } from './bugbot'; +import type { BugbotParams } from './bugbot'; +import { getBugbotFixPrompt } from './bugbot_fix'; +import type { BugbotFixParams } from './bugbot_fix'; +import { getBugbotFixIntentPrompt } from './bugbot_fix_intent'; +import type { BugbotFixIntentParams } from './bugbot_fix_intent'; + +export { fillTemplate } from './fill'; +export { getAnswerIssueHelpPrompt } from './answer_issue_help'; +export type { AnswerIssueHelpParams } from './answer_issue_help'; +export { getThinkPrompt } from './think'; +export type { ThinkParams } from './think'; +export { getUpdatePullRequestDescriptionPrompt } from './update_pull_request_description'; +export type { UpdatePullRequestDescriptionParams } from './update_pull_request_description'; +export { getUserRequestPrompt } from './user_request'; +export type { UserRequestParams } from './user_request'; +export { getRecommendStepsPrompt } from './recommend_steps'; +export type { RecommendStepsParams } from './recommend_steps'; +export { getCheckProgressPrompt } from './check_progress'; +export type { CheckProgressParams } from './check_progress'; +export { + getCheckCommentLanguagePrompt, + getTranslateCommentPrompt, +} from './check_comment_language'; +export type { CheckCommentLanguageParams } from './check_comment_language'; +export { getCliDoPrompt } from './cli_do'; +export type { CliDoParams } from './cli_do'; +export { getBugbotPrompt } from './bugbot'; +export type { BugbotParams } from './bugbot'; +export { getBugbotFixPrompt } from './bugbot_fix'; +export type { BugbotFixParams } from './bugbot_fix'; +export { getBugbotFixIntentPrompt } from './bugbot_fix_intent'; +export type { BugbotFixIntentParams } from './bugbot_fix_intent'; + +/** Known prompt names for getPrompt() */ +export const PROMPT_NAMES = { + ANSWER_ISSUE_HELP: 'answer_issue_help', + THINK: 'think', + UPDATE_PULL_REQUEST_DESCRIPTION: 'update_pull_request_description', + USER_REQUEST: 'user_request', + RECOMMEND_STEPS: 'recommend_steps', + CHECK_PROGRESS: 'check_progress', + CHECK_COMMENT_LANGUAGE: 'check_comment_language', + TRANSLATE_COMMENT: 'translate_comment', + CLI_DO: 'cli_do', + BUGBOT: 'bugbot', + BUGBOT_FIX: 'bugbot_fix', + BUGBOT_FIX_INTENT: 'bugbot_fix_intent', +} as const; + +export type PromptName = (typeof PROMPT_NAMES)[keyof typeof PROMPT_NAMES]; + +type PromptParamsMap = { + [PROMPT_NAMES.ANSWER_ISSUE_HELP]: AnswerIssueHelpParams; + [PROMPT_NAMES.THINK]: ThinkParams; + [PROMPT_NAMES.UPDATE_PULL_REQUEST_DESCRIPTION]: UpdatePullRequestDescriptionParams; + [PROMPT_NAMES.USER_REQUEST]: UserRequestParams; + [PROMPT_NAMES.RECOMMEND_STEPS]: RecommendStepsParams; + [PROMPT_NAMES.CHECK_PROGRESS]: CheckProgressParams; + [PROMPT_NAMES.CHECK_COMMENT_LANGUAGE]: CheckCommentLanguageParams; + [PROMPT_NAMES.TRANSLATE_COMMENT]: CheckCommentLanguageParams; + [PROMPT_NAMES.CLI_DO]: CliDoParams; + [PROMPT_NAMES.BUGBOT]: BugbotParams; + [PROMPT_NAMES.BUGBOT_FIX]: BugbotFixParams; + [PROMPT_NAMES.BUGBOT_FIX_INTENT]: BugbotFixIntentParams; +}; + +const registry: Record) => string> = { + [PROMPT_NAMES.ANSWER_ISSUE_HELP]: (p) => getAnswerIssueHelpPrompt(p as AnswerIssueHelpParams), + [PROMPT_NAMES.THINK]: (p) => getThinkPrompt(p as ThinkParams), + [PROMPT_NAMES.UPDATE_PULL_REQUEST_DESCRIPTION]: (p) => + getUpdatePullRequestDescriptionPrompt(p as UpdatePullRequestDescriptionParams), + [PROMPT_NAMES.USER_REQUEST]: (p) => getUserRequestPrompt(p as UserRequestParams), + [PROMPT_NAMES.RECOMMEND_STEPS]: (p) => getRecommendStepsPrompt(p as RecommendStepsParams), + [PROMPT_NAMES.CHECK_PROGRESS]: (p) => getCheckProgressPrompt(p as CheckProgressParams), + [PROMPT_NAMES.CHECK_COMMENT_LANGUAGE]: (p) => + getCheckCommentLanguagePrompt(p as CheckCommentLanguageParams), + [PROMPT_NAMES.TRANSLATE_COMMENT]: (p) => + getTranslateCommentPrompt(p as CheckCommentLanguageParams), + [PROMPT_NAMES.CLI_DO]: (p) => getCliDoPrompt(p as CliDoParams), + [PROMPT_NAMES.BUGBOT]: (p) => getBugbotPrompt(p as BugbotParams), + [PROMPT_NAMES.BUGBOT_FIX]: (p) => getBugbotFixPrompt(p as BugbotFixParams), + [PROMPT_NAMES.BUGBOT_FIX_INTENT]: (p) => + getBugbotFixIntentPrompt(p as BugbotFixIntentParams), +}; + +/** + * Returns a filled prompt by name. Params must match the prompt's expected keys. + */ +export function getPrompt(name: PromptName, params: PromptParamsMap[PromptName]): string { + const fn = registry[name]; + if (!fn) { + throw new Error(`Unknown prompt: ${name}`); + } + return fn(params as Record); +} diff --git a/src/prompts/recommend_steps.ts b/src/prompts/recommend_steps.ts new file mode 100644 index 00000000..c79b5da2 --- /dev/null +++ b/src/prompts/recommend_steps.ts @@ -0,0 +1,27 @@ +/** + * Prompt for recommending implementation steps from an issue (RecommendStepsUseCase). + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. + +{{projectContextInstruction}} + +**Issue #{{issueNumber}} description:** +{{issueDescription}} + +Provide a numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed.`; + +export type RecommendStepsParams = { + projectContextInstruction: string; + issueNumber: string; + issueDescription: string; +}; + +export function getRecommendStepsPrompt(params: RecommendStepsParams): string { + return fillTemplate(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + issueNumber: String(params.issueNumber), + issueDescription: params.issueDescription, + }); +} diff --git a/src/prompts/think.ts b/src/prompts/think.ts new file mode 100644 index 00000000..949af5b3 --- /dev/null +++ b/src/prompts/think.ts @@ -0,0 +1,23 @@ +/** + * Prompt for the Think use case (answer to @mention in issue/PR comment). + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `You are a helpful assistant. Answer the following question concisely, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. + +{{projectContextInstruction}} +{{contextBlock}}Question: {{question}}`; + +export type ThinkParams = { + projectContextInstruction: string; + contextBlock: string; + question: string; +}; + +export function getThinkPrompt(params: ThinkParams): string { + return fillTemplate(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + contextBlock: params.contextBlock, + question: params.question, + }); +} diff --git a/src/prompts/update_pull_request_description.ts b/src/prompts/update_pull_request_description.ts new file mode 100644 index 00000000..ca9c0e78 --- /dev/null +++ b/src/prompts/update_pull_request_description.ts @@ -0,0 +1,50 @@ +/** + * Prompt for generating PR description from issue and diff (UpdatePullRequestDescriptionUseCase). + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. + +{{projectContextInstruction}} + +**Branches:** +- **Base (target) branch:** \`{{baseBranch}}\` +- **Head (source) branch:** \`{{headBranch}}\` + +**Instructions:** +1. Read the pull request template file: \`.github/pull_request_template.md\`. Use its structure (headings, bullet lists, separators) as the skeleton for your output. The checkboxes in the template are **indicative only**: you may check the ones that apply based on the project and the diff, define different or fewer checkboxes if that fits better, or omit a section entirely if it does not apply. +2. Get the full diff by running: \`git diff {{baseBranch}}..{{headBranch}}\` (or \`git diff {{baseBranch}}...{{headBranch}}\` for merge-base). Use the diff to understand what changed. +3. Use the issue description below for context and intent. +4. Fill each section of the template with concrete content derived from the diff and the issue. Keep the same markdown structure (headings, horizontal rules). For checkbox sections (e.g. Test Coverage, Deployment Notes, Security): use the template's options as guidance; check or add only the items that apply, or skip the section if it does not apply. + - **Summary:** brief explanation of what the PR does and why (intent, not implementation details). + - **Related Issues:** include \`Closes #{{issueNumber}}\` and "Related to #" only if relevant. + - **Scope of Changes:** use Added / Updated / Removed / Refactored with short bullet points (high level, not file-by-file). + - **Technical Details:** important decisions, trade-offs, or non-obvious aspects. + - **How to Test:** steps a reviewer can follow (infer from the changes when possible). + - **Test Coverage / Deployment / Security / Performance / Checklist:** treat checkboxes as indicative; check the ones that apply from the diff and project context, or omit the section if it does not apply. + - **Breaking Changes:** list any, or "None". + - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. +5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. + +**Issue description:** +{{issueDescription}} + +Output only the filled template content (the PR description body), starting with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks or add extra commentary.`; + +export type UpdatePullRequestDescriptionParams = { + projectContextInstruction: string; + baseBranch: string; + headBranch: string; + issueNumber: string; + issueDescription: string; +}; + +export function getUpdatePullRequestDescriptionPrompt(params: UpdatePullRequestDescriptionParams): string { + return fillTemplate(TEMPLATE, { + projectContextInstruction: params.projectContextInstruction, + baseBranch: params.baseBranch, + headBranch: params.headBranch, + issueNumber: String(params.issueNumber), + issueDescription: params.issueDescription, + }); +} diff --git a/src/prompts/user_request.ts b/src/prompts/user_request.ts new file mode 100644 index 00000000..02b1a265 --- /dev/null +++ b/src/prompts/user_request.ts @@ -0,0 +1,39 @@ +/** + * Prompt for the Do user request use case (generic "do this" in repo). + */ +import { fillTemplate } from './fill'; + +const TEMPLATE = `You are in the repository workspace. The user has asked you to do something. Perform their request by editing files and running commands directly in the workspace. Do not output diffs for someone else to apply. + +{{projectContextInstruction}} + +**Repository context:** +- Owner: {{owner}} +- Repository: {{repo}} +- Branch (head): {{headBranch}} +- Base branch: {{baseBranch}} +- Issue number: {{issueNumber}} + +**User request:** +""" +{{userComment}} +""" + +**Rules:** +1. Apply all changes directly in the workspace (edit files, run commands). +2. If the project has standard checks (build, test, lint), run them and ensure they pass when relevant. +3. Reply briefly confirming what you did.`; + +export type UserRequestParams = { + projectContextInstruction: string; + owner: string; + repo: string; + headBranch: string; + baseBranch: string; + issueNumber: string; + userComment: string; +}; + +export function getUserRequestPrompt(params: UserRequestParams): string { + return fillTemplate(TEMPLATE, params); +} diff --git a/src/usecase/actions/__tests__/check_progress_use_case.test.ts b/src/usecase/actions/__tests__/check_progress_use_case.test.ts index 7d309cfa..53419825 100644 --- a/src/usecase/actions/__tests__/check_progress_use_case.test.ts +++ b/src/usecase/actions/__tests__/check_progress_use_case.test.ts @@ -169,6 +169,11 @@ describe('CheckProgressUseCase', () => { expect(results[0].success).toBe(true); expect(results[0].payload).toMatchObject({ progress: 60, branch: 'feature/123-add-feature' }); expect(mockAskAgent).toHaveBeenCalled(); + const prompt = mockAskAgent.mock.calls[0][2]; + expect(prompt).toContain('123'); + expect(prompt).toContain('develop'); + expect(prompt).toContain('feature/123-add-feature'); + expect(prompt).toContain('Issue body'); }); it('returns error result when askAgent returns undefined (OpenCode failure)', async () => { diff --git a/src/usecase/actions/__tests__/recommend_steps_use_case.test.ts b/src/usecase/actions/__tests__/recommend_steps_use_case.test.ts index 34044a55..1d9c4e88 100644 --- a/src/usecase/actions/__tests__/recommend_steps_use_case.test.ts +++ b/src/usecase/actions/__tests__/recommend_steps_use_case.test.ts @@ -80,6 +80,9 @@ describe('RecommendStepsUseCase', () => { expect(results[0].success).toBe(true); expect(results[0].steps).toBeDefined(); expect(results[0].payload?.recommendedSteps).toContain('1. Add auth module'); + const prompt = mockAskAgent.mock.calls[0][2]; + expect(prompt).toContain('42'); + expect(prompt).toContain('Implement login feature.'); }); it('returns success when AI returns object with steps', async () => { diff --git a/src/usecase/actions/check_progress_use_case.ts b/src/usecase/actions/check_progress_use_case.ts index f8325791..51e0e00d 100644 --- a/src/usecase/actions/check_progress_use_case.ts +++ b/src/usecase/actions/check_progress_use_case.ts @@ -8,6 +8,7 @@ import { IssueRepository, PROGRESS_LABEL_PATTERN } from '../../data/repository/i import { BranchRepository } from '../../data/repository/branch_repository'; import { PullRequestRepository } from '../../data/repository/pull_request_repository'; import { AiRepository, OPENCODE_AGENT_PLAN } from '../../data/repository/ai_repository'; +import { getCheckProgressPrompt } from '../../prompts'; import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from '../../utils/opencode_project_context_instruction'; const PROGRESS_RESPONSE_SCHEMA = { @@ -153,7 +154,13 @@ export class CheckProgressUseCase implements ParamUseCase { logInfo(`📦 Progress will be assessed from workspace diff: base branch "${developmentBranch}", current branch "${branch}" (OpenCode agent will run git diff).`); - const prompt = this.buildProgressPrompt(issueNumber, issueDescription, branch, developmentBranch); + const prompt = getCheckProgressPrompt({ + projectContextInstruction: OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + issueNumber: String(issueNumber), + issueDescription, + baseBranch: developmentBranch, + currentBranch: branch, + }); logInfo('🤖 Analyzing progress using OpenCode Plan agent...'); const attemptResult = await this.fetchProgressAttempt(param.ai, prompt); @@ -317,37 +324,6 @@ export class CheckProgressUseCase implements ParamUseCase { return { progress, summary, reasoning, remaining }; } - /** - * Builds the progress prompt for the OpenCode agent. We do not send the diff from our side: - * we tell the agent the base (parent) branch and current branch so it can run `git diff` - * in the workspace and compute the full diff itself. - */ - private buildProgressPrompt( - issueNumber: number, - issueDescription: string, - currentBranch: string, - baseBranch: string - ): string { - return `You are in the repository workspace. Assess the progress of issue #${issueNumber} using the full diff between the base (parent) branch and the current branch. - -${OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Branches:** -- **Base (parent) branch:** \`${baseBranch}\` -- **Current branch:** \`${currentBranch}\` - -**Instructions:** -1. Get the full diff by running: \`git diff ${baseBranch}..${currentBranch}\` (or \`git diff ${baseBranch}...${currentBranch}\` for merge-base). If you cannot run shell commands, use whatever workspace tools you have to inspect changes between these branches. -2. Optionally confirm the current branch with \`git branch --show-current\` if needed. -3. Based on the full diff and the issue description below, assess completion progress (0-100%) and write a short summary. -4. If progress is below 100%, add a "remaining" field with a short description of what is left to do to complete the task (e.g. missing implementation, tests, docs). Omit "remaining" or leave empty when progress is 100%. - -**Issue description:** -${issueDescription} - -Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" }.`; - } - /** * Returns true if the reasoning text looks truncated (e.g. ends with ":" or trailing spaces, * or no sentence-ending punctuation), so we can append a note in the comment. diff --git a/src/usecase/actions/recommend_steps_use_case.ts b/src/usecase/actions/recommend_steps_use_case.ts index e9d12203..a9935de3 100644 --- a/src/usecase/actions/recommend_steps_use_case.ts +++ b/src/usecase/actions/recommend_steps_use_case.ts @@ -1,11 +1,12 @@ import { Execution } from '../../data/model/execution'; import { Result } from '../../data/model/result'; +import { AiRepository, OPENCODE_AGENT_PLAN } from '../../data/repository/ai_repository'; +import { IssueRepository } from '../../data/repository/issue_repository'; +import { getRecommendStepsPrompt } from '../../prompts'; import { logError, logInfo } from '../../utils/logger'; +import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from '../../utils/opencode_project_context_instruction'; import { getTaskEmoji } from '../../utils/task_emoji'; import { ParamUseCase } from '../base/param_usecase'; -import { IssueRepository } from '../../data/repository/issue_repository'; -import { AiRepository, OPENCODE_AGENT_PLAN } from '../../data/repository/ai_repository'; -import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from '../../utils/opencode_project_context_instruction'; export class RecommendStepsUseCase implements ParamUseCase { taskId: string = 'RecommendStepsUseCase'; @@ -62,14 +63,11 @@ export class RecommendStepsUseCase implements ParamUseCase return results; } - const prompt = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. - -${OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Issue #${issueNumber} description:** -${issueDescription} - -Provide a numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed.`; + const prompt = getRecommendStepsPrompt({ + projectContextInstruction: OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + issueNumber: String(issueNumber), + issueDescription, + }); logInfo(`🤖 Recommending steps using OpenCode Plan agent...`); const response = await this.aiRepository.askAgent( diff --git a/src/usecase/steps/commit/bugbot/build_bugbot_fix_intent_prompt.ts b/src/usecase/steps/commit/bugbot/build_bugbot_fix_intent_prompt.ts index 3dc1ff7d..e49e97b2 100644 --- a/src/usecase/steps/commit/bugbot/build_bugbot_fix_intent_prompt.ts +++ b/src/usecase/steps/commit/bugbot/build_bugbot_fix_intent_prompt.ts @@ -3,6 +3,7 @@ * to fix one or more bugbot findings and which finding ids to target. */ +import { getBugbotFixIntentPrompt } from "../../../../prompts"; import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from "../../../../utils/opencode_project_context_instruction"; import { sanitizeUserCommentForPrompt } from "./sanitize_user_comment_for_prompt"; @@ -50,22 +51,10 @@ export function buildBugbotFixIntentPrompt( })() : ''; - return `You are analyzing a user comment on an issue or pull request to decide whether they are asking to fix one or more reported code findings (bugs, vulnerabilities, or quality issues). - -${OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**List of unresolved findings (id, title, and optional file/line/description):** -${findingsBlock} -${parentBlock} -**User comment:** -""" -${sanitizeUserCommentForPrompt(userComment)} -""" - -**Your task:** Decide: -1. Is this comment clearly a request to fix one or more of the findings above? (e.g. "fix it", "arreglalo", "fix this", "fix all", "fix vulnerability X", "corrige", "fix the bug in src/foo.ts"). If the user is asking a question, discussing something else, or the intent is ambiguous, set \`is_fix_request\` to false. -2. If it is a fix request, which finding ids should be fixed? Return their exact ids in \`target_finding_ids\`. If the user says "fix all" or equivalent, include every id from the list above. If they refer to a specific finding (e.g. by replying to a comment that contains one finding), return only that finding's id. Use only ids that appear in the list above. -3. Is the user asking to perform some other change or task in the repo? (e.g. "add a test for X", "refactor this", "implement feature Y", "haz que Z"). If yes, set \`is_do_request\` to true. Set false for pure questions or when the only intent is to fix the listed findings. - -Respond with a JSON object: \`is_fix_request\` (boolean), \`target_finding_ids\` (array of strings; empty when \`is_fix_request\` is false), and \`is_do_request\` (boolean).`; + return getBugbotFixIntentPrompt({ + projectContextInstruction: OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + findingsBlock, + parentBlock, + userComment: sanitizeUserCommentForPrompt(userComment), + }); } diff --git a/src/usecase/steps/commit/bugbot/build_bugbot_fix_prompt.ts b/src/usecase/steps/commit/bugbot/build_bugbot_fix_prompt.ts index 916afdf3..894d0001 100644 --- a/src/usecase/steps/commit/bugbot/build_bugbot_fix_prompt.ts +++ b/src/usecase/steps/commit/bugbot/build_bugbot_fix_prompt.ts @@ -1,5 +1,6 @@ import type { Execution } from "../../../../data/model/execution"; import type { BugbotContext } from "./types"; +import { getBugbotFixPrompt } from "../../../../prompts"; import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from "../../../../utils/opencode_project_context_instruction"; import { sanitizeUserCommentForPrompt } from "./sanitize_user_comment_for_prompt"; @@ -55,32 +56,18 @@ export function buildBugbotFixPrompt( ? `\n**Verify commands (run these in the workspace in order and only consider the fix successful if all pass):**\n${verifyCommands.map((c) => `- \`${String(c).replace(/`/g, "\\`")}\``).join("\n")}\n` : "\n**Verify:** Run any standard project checks (e.g. build, test, lint) that exist in this repo and confirm they pass.\n"; - return `You are in the repository workspace. Your task is to fix the reported code findings (bugs, vulnerabilities, or quality issues) listed below, and only those. The user has explicitly requested these fixes. + const prNumberLine = prNumber != null ? `- Pull request number: ${prNumber}` : ""; -${OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Repository context:** -- Owner: ${owner} -- Repository: ${repo} -- Branch (head): ${headBranch} -- Base branch: ${baseBranch} -- Issue number: ${issueNumber} -${prNumber != null ? `- Pull request number: ${prNumber}` : ""} - -**Findings to fix (do not change code unrelated to these):** -${findingsBlock} - -**User request:** -""" -${sanitizeUserCommentForPrompt(userComment)} -""" - -**Rules:** -1. Fix only the problems described in the findings above. Do not refactor or change other code except as strictly necessary for the fix. -2. You may add or update tests only to validate that the fix is correct. -3. After applying changes, run the verify commands (or standard build/test/lint) and ensure they all pass. If they fail, adjust the fix until they pass. -4. Apply all changes directly in the workspace (edit files, run commands). Do not output diffs for someone else to apply. -${verifyBlock} - -Once the fixes are applied and the verify commands pass, reply briefly confirming what was fixed and that checks passed.`; + return getBugbotFixPrompt({ + projectContextInstruction: OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + owner, + repo, + headBranch, + baseBranch, + issueNumber: String(issueNumber), + prNumberLine, + findingsBlock, + userComment: sanitizeUserCommentForPrompt(userComment), + verifyBlock, + }); } diff --git a/src/usecase/steps/commit/bugbot/build_bugbot_prompt.ts b/src/usecase/steps/commit/bugbot/build_bugbot_prompt.ts index 62b79ffe..98edbd40 100644 --- a/src/usecase/steps/commit/bugbot/build_bugbot_prompt.ts +++ b/src/usecase/steps/commit/bugbot/build_bugbot_prompt.ts @@ -5,16 +5,18 @@ * We do not pass a pre-computed diff or file list. */ +import { getBugbotPrompt } from "../../../../prompts"; import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from "../../../../utils/opencode_project_context_instruction"; import type { Execution } from "../../../../data/model/execution"; import type { BugbotContext } from "./types"; +const MAX_IGNORE_BLOCK_LENGTH = 2000; + export function buildBugbotPrompt(param: Execution, context: BugbotContext): string { const headBranch = param.commit.branch; const baseBranch = param.currentConfiguration.parentBranch ?? param.branches.development ?? 'develop'; const previousBlock = context.previousFindingsBlock; const ignorePatterns = param.ai?.getAiIgnoreFiles?.() ?? []; - const MAX_IGNORE_BLOCK_LENGTH = 2000; const ignoreBlock = ignorePatterns.length > 0 ? (() => { @@ -27,20 +29,14 @@ export function buildBugbotPrompt(param: Execution, context: BugbotContext): str })() : ""; - return `You are analyzing the latest code changes for potential bugs and issues. - -${OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Repository context:** -- Owner: ${param.owner} -- Repository: ${param.repo} -- Branch (head): ${headBranch} -- Base branch: ${baseBranch} -- Issue number: ${param.issueNumber} -${ignoreBlock} - -**Your task 1 (new/current problems):** Determine what has changed in the branch "${headBranch}" compared to "${baseBranch}" (you must compute or obtain the diff yourself using the repository context above). Then identify potential bugs, logic errors, security issues, and code quality problems. Be strict and descriptive. One finding per distinct problem. Return them in the \`findings\` array (each with id, title, description; optionally file, line, severity, suggestion). Only include findings in files that are not in the ignore list above. -${previousBlock} - -**Output:** Return a JSON object with: "findings" (array of new/current problems from task 1), and if we gave you previously reported issues above, "resolved_finding_ids" (array of those ids that are now fixed or no longer apply, as per task 2).`; + return getBugbotPrompt({ + projectContextInstruction: OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + owner: param.owner, + repo: param.repo, + headBranch, + baseBranch, + issueNumber: String(param.issueNumber), + ignoreBlock, + previousBlock, + }); } diff --git a/src/usecase/steps/commit/user_request_use_case.ts b/src/usecase/steps/commit/user_request_use_case.ts index c5881465..9bb30aa7 100644 --- a/src/usecase/steps/commit/user_request_use_case.ts +++ b/src/usecase/steps/commit/user_request_use_case.ts @@ -6,6 +6,7 @@ import type { Execution } from "../../../data/model/execution"; import { AiRepository } from "../../../data/repository/ai_repository"; +import { getUserRequestPrompt } from "../../../prompts"; import { logError, logInfo } from "../../../utils/logger"; import { getTaskEmoji } from "../../../utils/task_emoji"; import { ParamUseCase } from "../../base/param_usecase"; @@ -21,36 +22,6 @@ export interface DoUserRequestParam { branchOverride?: string; } -function buildUserRequestPrompt(execution: Execution, userComment: string): string { - const headBranch = execution.commit.branch; - const baseBranch = - execution.currentConfiguration.parentBranch ?? execution.branches.development ?? "develop"; - const issueNumber = execution.issueNumber; - const owner = execution.owner; - const repo = execution.repo; - - return `You are in the repository workspace. The user has asked you to do something. Perform their request by editing files and running commands directly in the workspace. Do not output diffs for someone else to apply. - -${OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Repository context:** -- Owner: ${owner} -- Repository: ${repo} -- Branch (head): ${headBranch} -- Base branch: ${baseBranch} -- Issue number: ${issueNumber} - -**User request:** -""" -${sanitizeUserCommentForPrompt(userComment)} -""" - -**Rules:** -1. Apply all changes directly in the workspace (edit files, run commands). -2. If the project has standard checks (build, test, lint), run them and ensure they pass when relevant. -3. Reply briefly confirming what you did.`; -} - export class DoUserRequestUseCase implements ParamUseCase { taskId: string = TASK_ID; @@ -73,7 +44,17 @@ export class DoUserRequestUseCase implements ParamUseCase { const contextBlock = issueDescription ? `\n\nContext (issue #${issueNumberForContext} description):\n${issueDescription}\n\n` : '\n\n'; - const prompt = `You are a helpful assistant. Answer the following question concisely, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. - -${OPENCODE_PROJECT_CONTEXT_INSTRUCTION} -${contextBlock}Question: ${question}`; + const prompt = getThinkPrompt({ + projectContextInstruction: OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + contextBlock, + question, + }); const response = await this.aiRepository.askAgent(param.ai, OPENCODE_AGENT_PLAN, prompt, { expectJson: true, schema: THINK_RESPONSE_SCHEMA as unknown as Record, diff --git a/src/usecase/steps/issue/__tests__/prepare_branches_use_case.test.ts b/src/usecase/steps/issue/__tests__/prepare_branches_use_case.test.ts index 0ba4af0c..8e109903 100644 --- a/src/usecase/steps/issue/__tests__/prepare_branches_use_case.test.ts +++ b/src/usecase/steps/issue/__tests__/prepare_branches_use_case.test.ts @@ -14,11 +14,15 @@ jest.mock('@actions/core', () => ({ const mockFetchRemoteBranches = jest.fn(); const mockGetListOfBranches = jest.fn(); const mockManageBranches = jest.fn(); +const mockGetCommitTag = jest.fn(); +const mockCreateLinkedBranch = jest.fn(); jest.mock('../../../../data/repository/branch_repository', () => ({ BranchRepository: jest.fn().mockImplementation(() => ({ fetchRemoteBranches: mockFetchRemoteBranches, getListOfBranches: mockGetListOfBranches, manageBranches: mockManageBranches, + getCommitTag: mockGetCommitTag, + createLinkedBranch: mockCreateLinkedBranch, })), })); @@ -29,6 +33,13 @@ jest.mock('../../../../data/repository/project_repository', () => ({ })), })); +const mockMoveIssueInvoke = jest.fn(); +jest.mock('../move_issue_to_in_progress', () => ({ + MoveIssueToInProgressUseCase: jest.fn().mockImplementation(() => ({ + invoke: mockMoveIssueInvoke, + })), +})); + function baseParam(overrides: Record = {}) { return { owner: 'o', @@ -61,9 +72,15 @@ describe('PrepareBranchesUseCase', () => { beforeEach(() => { jest.useFakeTimers(); useCase = new PrepareBranchesUseCase(); + jest.clearAllMocks(); mockFetchRemoteBranches.mockResolvedValue(undefined); mockGetListOfBranches.mockResolvedValue(['develop', 'main']); mockMoveIssueToColumn.mockResolvedValue(true); + mockGetCommitTag.mockResolvedValue('abc123'); + mockCreateLinkedBranch.mockResolvedValue([ + { success: true, executed: true, payload: {} }, + ]); + mockMoveIssueInvoke.mockResolvedValue([]); mockManageBranches.mockResolvedValue([ { id: 'PrepareBranchesUseCase', @@ -111,4 +128,136 @@ describe('PrepareBranchesUseCase', () => { expect(results.length).toBeGreaterThan(0); expect(results.some((r) => r.success === true)).toBe(true); }); + + it('hotfix path: creates linked branch when hotfix branch not in list', async () => { + mockGetListOfBranches.mockResolvedValue(['develop', 'main']); + mockGetCommitTag.mockResolvedValue('oid123'); + mockCreateLinkedBranch.mockResolvedValue([ + { success: true, executed: true }, + ]); + const param = baseParam({ + hotfix: { + active: true, + baseVersion: '1.0.0', + version: '1.0.1', + branch: 'hotfix/1.0.1', + baseBranch: 'tags/v1.0.0', + }, + currentConfiguration: {}, + }); + const promise = useCase.invoke(param); + await jest.advanceTimersByTimeAsync(10000); + const results = await promise; + expect(mockCreateLinkedBranch).toHaveBeenCalledWith( + 'o', + 'r', + 'tags/v1.0.0', + 'hotfix/1.0.1', + 42, + 'oid123', + 't', + ); + expect(results.some((r) => r.success === true && r.steps?.some((s) => s.includes('tag')))).toBe(true); + }); + + it('hotfix path: branch already exists returns step', async () => { + mockGetListOfBranches.mockResolvedValue(['develop', 'main', 'hotfix/1.0.1']); + mockGetCommitTag.mockResolvedValue('oid123'); + const param = baseParam({ + hotfix: { + active: true, + baseVersion: '1.0.0', + version: '1.0.1', + branch: 'hotfix/1.0.1', + baseBranch: 'tags/v1.0.0', + }, + currentConfiguration: {}, + }); + const promise = useCase.invoke(param); + await jest.advanceTimersByTimeAsync(10000); + const results = await promise; + expect(mockCreateLinkedBranch).not.toHaveBeenCalled(); + expect(results.some((r) => r.steps?.some((s) => s.includes('already exists')))).toBe(true); + }); + + it('hotfix path: no tag found returns failure', async () => { + mockGetListOfBranches.mockResolvedValue(['develop']); + const param = baseParam({ + hotfix: { + active: true, + baseVersion: undefined, + version: undefined, + branch: undefined, + baseBranch: undefined, + }, + currentConfiguration: {}, + }); + const promise = useCase.invoke(param); + await jest.advanceTimersByTimeAsync(0); + const results = await promise; + expect(results.some((r) => r.success === false && r.steps?.some((s) => s.includes('no tag')))).toBe(true); + }); + + it('release path: creates linked branch when release branch not in list', async () => { + mockGetListOfBranches.mockResolvedValue(['develop', 'main']); + mockCreateLinkedBranch.mockResolvedValue([ + { + success: true, + executed: true, + payload: { newBranchName: 'release/2.0.0', newBranchUrl: 'https://github.com/o/r/tree/release/2.0.0' }, + }, + ]); + const param = baseParam({ + release: { active: true, version: '2.0.0', branch: 'release/2.0.0' }, + labels: { deploy: 'deploy' }, + workflows: { release: 'release-wf' }, + currentConfiguration: {}, + }); + const promise = useCase.invoke(param); + await jest.advanceTimersByTimeAsync(0); + const results = await promise; + expect(mockCreateLinkedBranch).toHaveBeenCalledWith( + 'o', + 'r', + 'develop', + 'release/2.0.0', + 42, + undefined, + 't', + ); + expect(results.some((r) => r.success === true)).toBe(true); + }); + + it('release path: branch already exists returns reminders', async () => { + mockGetListOfBranches.mockResolvedValue(['develop', 'main', 'release/2.0.0']); + const param = baseParam({ + release: { active: true, version: '2.0.0', branch: 'release/2.0.0' }, + currentConfiguration: {}, + }); + const promise = useCase.invoke(param); + await jest.advanceTimersByTimeAsync(10000); + const results = await promise; + expect(mockCreateLinkedBranch).not.toHaveBeenCalled(); + expect(results.some((r) => r.reminders?.length)).toBe(true); + }); + + it('release path: no release version returns failure', async () => { + mockGetListOfBranches.mockResolvedValue(['develop']); + const param = baseParam({ + release: { active: true, version: undefined, branch: undefined }, + currentConfiguration: {}, + }); + const promise = useCase.invoke(param); + await jest.advanceTimersByTimeAsync(0); + const results = await promise; + expect(results.some((r) => r.success === false && r.steps?.some((s) => s.includes('release version')))).toBe(true); + }); + + it('calls MoveIssueToInProgressUseCase when manageBranches last action success and executed', async () => { + const param = baseParam(); + const promise = useCase.invoke(param); + await jest.advanceTimersByTimeAsync(10000); + await promise; + expect(mockMoveIssueInvoke).toHaveBeenCalledWith(param); + }); }); diff --git a/src/usecase/steps/issue/answer_issue_help_use_case.ts b/src/usecase/steps/issue/answer_issue_help_use_case.ts index 22903e48..8c247fc4 100644 --- a/src/usecase/steps/issue/answer_issue_help_use_case.ts +++ b/src/usecase/steps/issue/answer_issue_help_use_case.ts @@ -8,6 +8,7 @@ import { Execution } from '../../../data/model/execution'; import { Result } from '../../../data/model/result'; import { AiRepository, OPENCODE_AGENT_PLAN, THINK_RESPONSE_SCHEMA } from '../../../data/repository/ai_repository'; import { IssueRepository } from '../../../data/repository/issue_repository'; +import { getAnswerIssueHelpPrompt } from '../../../prompts'; import { logError, logInfo } from '../../../utils/logger'; import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from '../../../utils/opencode_project_context_instruction'; import { getTaskEmoji } from '../../../utils/task_emoji'; @@ -83,16 +84,10 @@ export class AnswerIssueHelpUseCase implements ParamUseCase logInfo(`${getTaskEmoji(this.taskId)} Posting initial help reply for question/help issue #${issueNumber}.`); - const prompt = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. Use the project context when relevant. - -${OPENCODE_PROJECT_CONTEXT_INSTRUCTION} - -**Issue description (user's question or request):** -""" -${description} -""" - -Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; + const prompt = getAnswerIssueHelpPrompt({ + description, + projectContextInstruction: OPENCODE_PROJECT_CONTEXT_INSTRUCTION, + }); const response = await this.aiRepository.askAgent(param.ai, OPENCODE_AGENT_PLAN, prompt, { expectJson: true, diff --git a/src/usecase/steps/issue_comment/__tests__/check_issue_comment_language_use_case.test.ts b/src/usecase/steps/issue_comment/__tests__/check_issue_comment_language_use_case.test.ts index 819c72a0..1cf74dad 100644 --- a/src/usecase/steps/issue_comment/__tests__/check_issue_comment_language_use_case.test.ts +++ b/src/usecase/steps/issue_comment/__tests__/check_issue_comment_language_use_case.test.ts @@ -71,6 +71,9 @@ describe('CheckIssueCommentLanguageUseCase', () => { expect(results[0].success).toBe(true); expect(results[0].executed).toBe(true); expect(mockAskAgent).toHaveBeenCalledTimes(1); + const checkPrompt = mockAskAgent.mock.calls[0][2]; + expect(checkPrompt).toContain('Spanish'); + expect(checkPrompt).toContain('Hello world'); expect(mockUpdateComment).not.toHaveBeenCalled(); }); @@ -84,6 +87,9 @@ describe('CheckIssueCommentLanguageUseCase', () => { const results = await useCase.invoke(param); expect(mockAskAgent).toHaveBeenCalledTimes(2); + const translatePrompt = mockAskAgent.mock.calls[1][2]; + expect(translatePrompt).toContain('Spanish'); + expect(translatePrompt).toContain('Hello world'); expect(mockUpdateComment).toHaveBeenCalledWith( 'o', 'r', diff --git a/src/usecase/steps/issue_comment/check_issue_comment_language_use_case.ts b/src/usecase/steps/issue_comment/check_issue_comment_language_use_case.ts index a51997eb..a413dafc 100644 --- a/src/usecase/steps/issue_comment/check_issue_comment_language_use_case.ts +++ b/src/usecase/steps/issue_comment/check_issue_comment_language_use_case.ts @@ -7,6 +7,7 @@ import { TRANSLATION_RESPONSE_SCHEMA, } from "../../../data/repository/ai_repository"; import { IssueRepository } from "../../../data/repository/issue_repository"; +import { getCheckCommentLanguagePrompt, getTranslateCommentPrompt } from "../../../prompts"; import { logInfo } from "../../../utils/logger"; import { getTaskEmoji } from "../../../utils/task_emoji"; import { ParamUseCase } from "../../base/param_usecase"; @@ -39,17 +40,7 @@ If you'd like this comment to be translated again, please delete the entire comm } const locale = param.locale.issue; - let prompt = ` - You are a helpful assistant that checks if the text is written in ${locale}. - - Instructions: - 1. Analyze the provided text - 2. If the text is written in ${locale}, respond with exactly "done" - 3. If the text is written in any other language, respond with exactly "must_translate" - 4. Do not provide any explanation or additional text - - The text is: ${commentBody} - `; + let prompt = getCheckCommentLanguagePrompt({ locale, commentBody }); const checkResponse = await this.aiRepository.askAgent( param.ai, OPENCODE_AGENT_PLAN, @@ -77,16 +68,7 @@ If you'd like this comment to be translated again, please delete the entire comm return results; } - prompt = ` -You are a helpful assistant that translates the text to ${locale}. - -Instructions: -1. Translate the text to ${locale} -2. Put the translated text in the translatedText field -3. If you cannot translate (e.g. ambiguous or invalid input), set translatedText to empty string and explain in reason - -The text to translate is: ${commentBody} - `; + prompt = getTranslateCommentPrompt({ locale, commentBody }); const translationResponse = await this.aiRepository.askAgent( param.ai, OPENCODE_AGENT_PLAN, diff --git a/src/usecase/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts b/src/usecase/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts index 6fc97047..ef2547a7 100644 --- a/src/usecase/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts +++ b/src/usecase/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts @@ -77,6 +77,11 @@ describe('UpdatePullRequestDescriptionUseCase', () => { const param = baseParam(); const results = await useCase.invoke(param); expect(mockAskAgent).toHaveBeenCalled(); + const prompt = mockAskAgent.mock.calls[0][2]; + expect(prompt).toContain('feature/42-x'); + expect(prompt).toContain('develop'); + expect(prompt).toContain('Issue description'); + expect(prompt).toContain('Closes #42'); expect(mockUpdateDescription).toHaveBeenCalledWith('o', 'r', 10, '## Summary\nPR does X.', 't'); expect(results.some((r) => r.success === true)).toBe(true); }); diff --git a/src/usecase/steps/pull_request/update_pull_request_description_use_case.ts b/src/usecase/steps/pull_request/update_pull_request_description_use_case.ts index fed7c3e7..bba573f8 100644 --- a/src/usecase/steps/pull_request/update_pull_request_description_use_case.ts +++ b/src/usecase/steps/pull_request/update_pull_request_description_use_case.ts @@ -4,6 +4,7 @@ import { AiRepository, OPENCODE_AGENT_PLAN } from "../../../data/repository/ai_r import { IssueRepository } from "../../../data/repository/issue_repository"; import { ProjectRepository } from "../../../data/repository/project_repository"; import { PullRequestRepository } from "../../../data/repository/pull_request_repository"; +import { getUpdatePullRequestDescriptionPrompt } from "../../../prompts"; import { logDebugInfo, logError } from "../../../utils/logger"; import { OPENCODE_PROJECT_CONTEXT_INSTRUCTION } from "../../../utils/opencode_project_context_instruction"; import { getTaskEmoji } from "../../../utils/task_emoji"; @@ -88,12 +89,13 @@ export class UpdatePullRequestDescriptionUseCase implements ParamUseCase { expect(results[0].success).toBe(true); expect(results[0].executed).toBe(true); expect(mockAskAgent).toHaveBeenCalledTimes(1); + const checkPrompt = mockAskAgent.mock.calls[0][2]; + expect(checkPrompt).toContain('Spanish'); + expect(checkPrompt).toContain('Hello'); }); it('calls updateComment when must_translate and askAgent returns schema with translatedText', async () => { @@ -82,6 +85,9 @@ describe('CheckPullRequestCommentLanguageUseCase', () => { const results = await useCase.invoke(param); expect(mockAskAgent).toHaveBeenCalledTimes(2); + const translatePrompt = mockAskAgent.mock.calls[1][2]; + expect(translatePrompt).toContain('Spanish'); + expect(translatePrompt).toContain('Hello'); expect(mockUpdateComment).toHaveBeenCalledWith( 'o', 'r', diff --git a/src/usecase/steps/pull_request_review_comment/check_pull_request_comment_language_use_case.ts b/src/usecase/steps/pull_request_review_comment/check_pull_request_comment_language_use_case.ts index 287936ad..26eff18d 100644 --- a/src/usecase/steps/pull_request_review_comment/check_pull_request_comment_language_use_case.ts +++ b/src/usecase/steps/pull_request_review_comment/check_pull_request_comment_language_use_case.ts @@ -7,6 +7,7 @@ import { TRANSLATION_RESPONSE_SCHEMA, } from "../../../data/repository/ai_repository"; import { IssueRepository } from "../../../data/repository/issue_repository"; +import { getCheckCommentLanguagePrompt, getTranslateCommentPrompt } from "../../../prompts"; import { logInfo } from "../../../utils/logger"; import { getTaskEmoji } from "../../../utils/task_emoji"; import { ParamUseCase } from "../../base/param_usecase"; @@ -39,17 +40,7 @@ If you'd like this comment to be translated again, please delete the entire comm } const locale = param.locale.pullRequest; - let prompt = ` - You are a helpful assistant that checks if the text is written in ${locale}. - - Instructions: - 1. Analyze the provided text - 2. If the text is written in ${locale}, respond with exactly "done" - 3. If the text is written in any other language, respond with exactly "must_translate" - 4. Do not provide any explanation or additional text - - The text is: ${commentBody} - `; + let prompt = getCheckCommentLanguagePrompt({ locale, commentBody }); const checkResponse = await this.aiRepository.askAgent( param.ai, OPENCODE_AGENT_PLAN, @@ -77,16 +68,7 @@ If you'd like this comment to be translated again, please delete the entire comm return results; } - prompt = ` -You are a helpful assistant that translates the text to ${locale}. - -Instructions: -1. Translate the text to ${locale} -2. Put the translated text in the translatedText field -3. If you cannot translate (e.g. ambiguous or invalid input), set translatedText to empty string and explain in reason - -The text to translate is: ${commentBody} - `; + prompt = getTranslateCommentPrompt({ locale, commentBody }); const translationResponse = await this.aiRepository.askAgent( param.ai, OPENCODE_AGENT_PLAN,