diff --git a/src/git/conventionalCommits.ts b/src/git/conventionalCommits.ts new file mode 100644 index 0000000..749fb5d --- /dev/null +++ b/src/git/conventionalCommits.ts @@ -0,0 +1,158 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export const CONVENTIONAL_COMMIT_TYPES = [ + 'feat', + 'fix', + 'docs', + 'style', + 'refactor', + 'perf', + 'test', + 'chore', + 'ci', +] as const; + +export type ConventionalCommitType = (typeof CONVENTIONAL_COMMIT_TYPES)[number]; + +export interface ConventionalCommitParts { + type: string; + scope?: string; + breaking?: boolean; + subject: string; + body?: string; + footer?: string; +} + +export interface IssueSuggestionItem { + number: number; + title: string; + state?: string; +} + +/** + * Formats structured Conventional Commit fields into a standard Git commit message. + */ +export function formatConventionalCommit(parts: ConventionalCommitParts): string { + const cleanType = (parts.type || 'feat').trim().toLowerCase() || 'feat'; + const cleanScope = parts.scope ? parts.scope.trim().replace(/[()\r\n]/g, '') : ''; + const cleanSubject = (parts.subject || '').trim().replace(/\r?\n/g, ' '); + const bang = parts.breaking ? '!' : ''; + const scopeSegment = cleanScope ? `(${cleanScope})` : ''; + + const header = `${cleanType}${scopeSegment}${bang}: ${cleanSubject}`; + const sections: string[] = [header]; + + const cleanBody = parts.body ? parts.body.trim() : ''; + if (cleanBody) { + sections.push(cleanBody); + } + + let cleanFooter = parts.footer ? parts.footer.trim() : ''; + if (parts.breaking && !/BREAKING CHANGE:/i.test(cleanFooter)) { + const breakingNote = `BREAKING CHANGE: ${cleanSubject || 'introduces breaking changes'}`; + cleanFooter = cleanFooter ? `${cleanFooter}\n${breakingNote}` : breakingNote; + } + if (cleanFooter) { + sections.push(cleanFooter); + } + + return sections.join('\n\n'); +} + +/** + * Parses a Conventional Commit string back into its structured parts if it matches the spec. + */ +export function parseConventionalCommit(message: string): ConventionalCommitParts | null { + const normalized = (message || '').replace(/\r\n/g, '\n').trim(); + if (!normalized) return null; + + const firstNewline = normalized.indexOf('\n'); + const header = firstNewline === -1 ? normalized : normalized.slice(0, firstNewline); + const rest = firstNewline === -1 ? '' : normalized.slice(firstNewline + 1).trim(); + + const match = header.match(/^([a-zA-Z0-9_-]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/); + if (!match) return null; + + const hasBreakingFooter = /BREAKING CHANGE:/i.test(rest); + return { + type: match[1].toLowerCase(), + scope: match[2]?.trim() || undefined, + breaking: Boolean(match[3]) || hasBreakingFooter, + subject: match[4].trim(), + body: rest || undefined, + }; +} + +/** + * Strips `#` comment lines from a `.gitmessage` template while collapsing extra blank lines. + */ +export function stripGitTemplateComments(rawTemplate: string): string { + const lines = rawTemplate + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')); + + return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +/** + * Reads a repository's commit template from `.gitmessage`, `.git/.gitmessage`, or `.github/COMMIT_TEMPLATE`. + */ +export function readGitMessageTemplate(repoRoot: string): string | null { + if (!repoRoot) return null; + + const candidatePaths = [ + path.join(repoRoot, '.gitmessage'), + path.join(repoRoot, '.gitmessage.txt'), + path.join(repoRoot, '.git', '.gitmessage'), + path.join(repoRoot, '.github', 'COMMIT_TEMPLATE'), + ]; + + for (const candidate of candidatePaths) { + try { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + const raw = fs.readFileSync(candidate, 'utf8'); + const cleaned = stripGitTemplateComments(raw); + if (cleaned.length > 0) { + return cleaned; + } + } + } catch { + // Ignore unreadable template files and continue fallback chain + } + } + + return null; +} + +/** + * Filters repository issues for `#issue` inline autocomplete in the commit builder. + */ +export function filterIssueSuggestions( + issues: IssueSuggestionItem[], + query: string, + limit = 8 +): IssueSuggestionItem[] { + const normalizedQuery = (query || '').replace(/^#/, '').trim().toLowerCase(); + const safeLimit = Math.max(1, Math.min(25, limit)); + + const sorted = [...issues].sort((a, b) => { + const aClosed = a.state === 'closed' ? 1 : 0; + const bClosed = b.state === 'closed' ? 1 : 0; + if (aClosed !== bClosed) return aClosed - bClosed; + return b.number - a.number; + }); + + if (!normalizedQuery) { + return sorted.slice(0, safeLimit); + } + + return sorted + .filter( + (issue) => + String(issue.number).includes(normalizedQuery) || + (issue.title || '').toLowerCase().includes(normalizedQuery) + ) + .slice(0, safeLimit); +} diff --git a/src/git/gitClient.ts b/src/git/gitClient.ts index 5e0717d..c340e80 100644 --- a/src/git/gitClient.ts +++ b/src/git/gitClient.ts @@ -26,6 +26,7 @@ import { RebaseSimulationResult, RemoteRepoInfo, RepoHealthMetrics, + ConflictedFileInfo, RepositoryOption, RepoStats, UserPersonalStats, @@ -1171,6 +1172,19 @@ export class GitClient { const userIdentity = await this.getUserIdentity(cwd); const pullRequests: PullRequestInfo[] = []; + // 0. Prefer native RemoteProvider REST integration (GitHub Enterprise / GitLab / Forgejo) when available + if (remoteInfo) { + try { + const provider = RemoteManager.getInstance().getProvider(remoteInfo.provider); + if (provider?.getPullRequests) { + const providerPrs = await provider.getPullRequests(remoteInfo, 'all'); + if (providerPrs.length > 0) { + return providerPrs; + } + } + } catch {} + } + // 1. Try GitHub CLI (`gh pr list`) if remote is GitHub if (remoteInfo?.provider === 'github') { try { @@ -2377,4 +2391,130 @@ export class GitClient { public async skipRebase(cwd: string): Promise { return await this.execute(['rebase', '--skip'], cwd); } + + /** + * Aborts an in-flight merge operation + */ + public async abortMerge(cwd: string): Promise { + return await this.execute(['merge', '--abort'], cwd); + } + + /** + * Returns unmerged conflicted files with conflict hunk counts and header preview lines. + */ + public async getConflictedFiles(cwd: string): Promise { + let raw = ''; + try { + raw = await this.execute(['diff', '--name-only', '--diff-filter=U'], cwd); + } catch { + return []; + } + + const relPaths = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const rootResolved = path.resolve(cwd); + const results: ConflictedFileInfo[] = []; + + for (const relPath of relPaths) { + const absPath = path.resolve(cwd, relPath); + if (absPath !== rootResolved && !absPath.startsWith(rootResolved + path.sep)) { + continue; + } + + let hunksCount = 0; + let oursLabel: string | undefined; + let theirsLabel: string | undefined; + const previewLines: string[] = []; + + try { + if (fs.existsSync(absPath) && fs.statSync(absPath).isFile()) { + const content = fs.readFileSync(absPath, 'utf8'); + const lines = content.split(/\r?\n/); + for (const line of lines) { + if (line.startsWith('<<<<<<<')) { + hunksCount++; + if (!oursLabel) oursLabel = line.slice(7).trim() || 'HEAD'; + } else if (line.startsWith('>>>>>>>') && !theirsLabel) { + theirsLabel = line.slice(7).trim() || 'incoming'; + } + if (previewLines.length < 12) { + previewLines.push(line); + } + } + } + } catch { + // Binary or unreadable conflicted file; keep metadata entry + } + + results.push({ + filePath: relPath.replace(/\\/g, '/'), + hunksCount, + oursLabel, + theirsLabel, + previewLines, + }); + } + + return results; + } + + /** + * Resolves a conflicted file using 'ours', 'theirs', or staging manually edited content. + */ + public async resolveConflictFile( + cwd: string, + filePath: string, + strategy: 'ours' | 'theirs' | 'mark-resolved' + ): Promise { + const cleanPath = (filePath || '').trim(); + if (!cleanPath) { + throw new Error('File path is required to resolve conflict.'); + } + + const rootResolved = path.resolve(cwd); + const absPath = path.resolve(cwd, cleanPath); + if (absPath !== rootResolved && !absPath.startsWith(rootResolved + path.sep)) { + throw new Error('Invalid conflict path outside repository root.'); + } + + if (strategy === 'ours') { + await this.execute(['checkout', '--ours', '--', cleanPath], cwd); + } else if (strategy === 'theirs') { + await this.execute(['checkout', '--theirs', '--', cleanPath], cwd); + } + + await this.execute(['add', '--', cleanPath], cwd); + } + + /** + * Commits staged changes (optionally staging all tracked/untracked changes when index is empty). + */ + public async commitStagedChanges( + cwd: string, + message: string, + stageAllIfEmpty = false + ): Promise<{ hash: string; subject: string }> { + const cleanMessage = (message || '').trim(); + if (!cleanMessage) { + throw new Error('Commit message cannot be empty.'); + } + + let staged = (await this.execute(['diff', '--cached', '--name-only'], cwd)).trim(); + if (!staged && stageAllIfEmpty) { + await this.execute(['add', '-A'], cwd); + staged = (await this.execute(['diff', '--cached', '--name-only'], cwd)).trim(); + } + + if (!staged) { + throw new Error('No staged changes found. Stage files or enable Stage All Changes.'); + } + + await this.execute(['commit', '-m', cleanMessage], cwd); + const hash = (await this.execute(['rev-parse', 'HEAD'], cwd)).trim(); + const subject = cleanMessage.split(/\r?\n/)[0].trim(); + return { hash, subject }; + } } diff --git a/src/git/types.ts b/src/git/types.ts index 81cab4c..6f4d7f7 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -351,7 +351,17 @@ export interface WebviewToExtensionMessage { | 'applyDagFilters' | 'dndResetBranchToCommit' | 'dndCherryPickToBranch' - | 'dndRebaseOntoCommit'; + | 'dndRebaseOntoCommit' + // Conflict Resolution Studio, Conventional Commits & PR Actions: + | 'requestConflictedFiles' + | 'resolveConflictFile' + | 'openMergeEditor' + | 'abortMerge' + | 'requestCommitTemplate' + | 'commitStagedChanges' + | 'requestPrComments' + | 'addPrComment' + | 'approvePullRequest'; payload?: any; } @@ -395,6 +405,12 @@ export interface ExtensionToWebviewMessage { | 'aiKeysData' | 'aiKeyGenerated' | 'aiKeyRevoked' + | 'conflictedFilesData' + | 'commitTemplateData' + | 'commitCreated' + | 'prCommentsData' + | 'prCommentAdded' + | 'prApproved' | 'error' | 'loading'; payload?: any; @@ -496,3 +512,11 @@ export interface RebaseSimulationResult { dropCount: number; } +export interface ConflictedFileInfo { + filePath: string; + hunksCount: number; + oursLabel?: string; + theirsLabel?: string; + previewLines?: string[]; +} + diff --git a/src/graph/graphWebviewProvider.ts b/src/graph/graphWebviewProvider.ts index ff3a05b..4aecc42 100644 --- a/src/graph/graphWebviewProvider.ts +++ b/src/graph/graphWebviewProvider.ts @@ -11,6 +11,8 @@ import { ForkIntegration } from '../remotes/forkIntegration'; import { PersistenceManager } from '../state/persistenceManager'; import { ApiKeyManager } from '../mcp/apiKeyManager'; import { AdaptiveDebouncer } from '../git/branchWatcher'; +import { readGitMessageTemplate } from '../git/conventionalCommits'; +import { RemoteManager } from '../remotes/remoteManager'; import { CommitFlowSettings, DagQueryFilters, @@ -274,6 +276,7 @@ export class GraphWebviewProvider bisectStatus, repositories, submodules, + conflictedFiles, ] = await Promise.all([ this.gitClient.getCommits(root, limit, 0, this.currentDagFilters), this.gitClient.getBranches(root), @@ -288,6 +291,7 @@ export class GraphWebviewProvider this.gitClient.getBisectStatus(root), this.gitClient.getWorkspaceRepositories(root), this.gitClient.getSubmodules(root), + this.gitClient.getConflictedFiles(root), ]); const themeConfig = config.get('graph.theme', 'auto'); @@ -353,6 +357,7 @@ export class GraphWebviewProvider bisectStatus, repositories, submodules, + conflictedFiles, activeRepo: root, forkInstalled: this.forkIntegration.findForkExecutable() !== null, forkEnabled: this.persistence.isForkIntegrationEnabled(), @@ -1393,6 +1398,191 @@ export class GraphWebviewProvider } catch {} break; } + + // --- Conflict Resolution Studio (Issue #14) --- + case 'requestConflictedFiles': { + if (root) { + try { + const files = await this.gitClient.getConflictedFiles(root); + webview.postMessage({ + command: 'conflictedFilesData', + payload: { files }, + } as ExtensionToWebviewMessage); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to inspect conflicts: ${err.message}`); + } + } + break; + } + + case 'resolveConflictFile': { + if (root && msg.payload?.filePath && msg.payload?.strategy) { + try { + await this.gitClient.resolveConflictFile( + root, + msg.payload.filePath, + msg.payload.strategy + ); + const files = await this.gitClient.getConflictedFiles(root); + webview.postMessage({ + command: 'conflictedFilesData', + payload: { files }, + } as ExtensionToWebviewMessage); + vscode.window.showInformationMessage( + `Resolved conflict in ${msg.payload.filePath} (${msg.payload.strategy}).` + ); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Conflict resolution failed: ${err.message}`); + } + } + break; + } + + case 'openMergeEditor': { + if (root && msg.payload?.filePath) { + try { + const resolvedRoot = path.resolve(root); + const targetAbs = path.resolve(root, String(msg.payload.filePath)); + if (targetAbs === resolvedRoot || targetAbs.startsWith(resolvedRoot + path.sep)) { + const fileUri = vscode.Uri.file(targetAbs); + try { + await vscode.commands.executeCommand('git.openMergeEditor', fileUri); + } catch { + await vscode.window.showTextDocument(fileUri); + } + } + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to open merge editor: ${err.message}`); + } + } + break; + } + + case 'abortMerge': { + if (root) { + try { + await this.gitClient.abortMerge(root); + vscode.window.showInformationMessage('Merge operation aborted.'); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to abort merge: ${err.message}`); + } + } + break; + } + + // --- Conventional Commits Builder & #issue Autocomplete (Issue #15) --- + case 'requestCommitTemplate': { + if (root) { + try { + const template = readGitMessageTemplate(root); + const workingChanges = await this.gitClient.getWorkingTreeChanges(root); + webview.postMessage({ + command: 'commitTemplateData', + payload: { + template, + changesCount: workingChanges.length, + }, + } as ExtensionToWebviewMessage); + } catch { + webview.postMessage({ + command: 'commitTemplateData', + payload: { template: null, changesCount: 0 }, + } as ExtensionToWebviewMessage); + } + } + break; + } + + case 'commitStagedChanges': { + if (root && msg.payload?.message) { + try { + const res = await this.gitClient.commitStagedChanges( + root, + msg.payload.message, + Boolean(msg.payload.stageAll) + ); + webview.postMessage({ + command: 'commitCreated', + payload: res, + } as ExtensionToWebviewMessage); + vscode.window.showInformationMessage(`Created commit ${res.hash.slice(0, 7)}: ${res.subject}`); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Commit failed: ${err.message}`); + } + } + break; + } + + // --- Provider PR Parity: Comments & Approvals (Issue #16) --- + case 'requestPrComments': { + if (root && msg.payload?.prNumber) { + try { + const remoteInfo = await this.gitClient.getRemoteRepoInfo(root); + const provider = remoteInfo ? RemoteManager.getInstance().getProvider(remoteInfo.provider) : undefined; + const comments = + remoteInfo && provider?.getPullRequestComments + ? await provider.getPullRequestComments(remoteInfo, Number(msg.payload.prNumber)) + : []; + webview.postMessage({ + command: 'prCommentsData', + payload: { prNumber: Number(msg.payload.prNumber), comments }, + } as ExtensionToWebviewMessage); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to load PR comments: ${err.message}`); + } + } + break; + } + + case 'addPrComment': { + if (root && msg.payload?.prNumber && msg.payload?.body) { + try { + const remoteInfo = await this.gitClient.getRemoteRepoInfo(root); + const provider = remoteInfo ? RemoteManager.getInstance().getProvider(remoteInfo.provider) : undefined; + if (!remoteInfo || !provider?.createPullRequestComment) { + throw new Error('PR comments are not supported by the active remote provider.'); + } + const comment = await provider.createPullRequestComment( + remoteInfo, + Number(msg.payload.prNumber), + String(msg.payload.body) + ); + webview.postMessage({ + command: 'prCommentAdded', + payload: { prNumber: Number(msg.payload.prNumber), comment }, + } as ExtensionToWebviewMessage); + vscode.window.showInformationMessage(`Added comment to PR #${msg.payload.prNumber}`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to add PR comment: ${err.message}`); + } + } + break; + } + + case 'approvePullRequest': { + if (root && msg.payload?.prNumber) { + try { + const remoteInfo = await this.gitClient.getRemoteRepoInfo(root); + const provider = remoteInfo ? RemoteManager.getInstance().getProvider(remoteInfo.provider) : undefined; + if (!remoteInfo || !provider?.approvePullRequest) { + throw new Error('PR approvals are not supported by the active remote provider.'); + } + await provider.approvePullRequest(remoteInfo, Number(msg.payload.prNumber)); + webview.postMessage({ + command: 'prApproved', + payload: { prNumber: Number(msg.payload.prNumber) }, + } as ExtensionToWebviewMessage); + vscode.window.showInformationMessage(`Approved PR #${msg.payload.prNumber}`); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to approve PR: ${err.message}`); + } + } + break; + } } }); } @@ -1634,6 +1824,9 @@ export class GraphWebviewProvider + @@ -1714,6 +1907,9 @@ export class GraphWebviewProvider Rebase en progreso: Paso 1 de ?. Resuelve los conflictos y continúa.
+ @@ -2499,6 +2695,124 @@ Always check repo state and active branch before making modifications.

+ + + + + + + + +
diff --git a/src/remotes/forgejoProvider.ts b/src/remotes/forgejoProvider.ts index 37dac36..c7e724f 100644 --- a/src/remotes/forgejoProvider.ts +++ b/src/remotes/forgejoProvider.ts @@ -474,6 +474,33 @@ export class ForgejoProvider implements RemoteProvider { return this.prsCache.get(cacheKey) || []; } + public async getPullRequestComments(repoInfo: RemoteRepoInfo, prNumber: number): Promise { + return this.getIssueComments(repoInfo, prNumber); + } + + public async createPullRequestComment( + repoInfo: RemoteRepoInfo, + prNumber: number, + body: string + ): Promise { + return this.createIssueComment(repoInfo, prNumber, body); + } + + public async approvePullRequest(repoInfo: RemoteRepoInfo, prNumber: number): Promise { + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/reviews`; + const headers = await this.getAuthHeaders(repoInfo); + headers['Content-Type'] = 'application/json'; + const res = await this.safeFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ event: 'APPROVED' }), + }); + if (!res.ok) { + throw new Error(`Forgejo PR approval failed: HTTP ${res.status}`); + } + } + /** * Retrieves CI commit status checks from /api/v1/repos/{owner}/{repo}/statuses/{sha} */ diff --git a/src/remotes/githubProvider.ts b/src/remotes/githubProvider.ts index aa502cf..6e365c0 100644 --- a/src/remotes/githubProvider.ts +++ b/src/remotes/githubProvider.ts @@ -8,10 +8,24 @@ import { RemoteRepoInfo, } from './types'; import { PersistenceManager } from '../state/persistenceManager'; +import { LruCache } from '../utils/lruCache'; export class GitHubProvider implements RemoteProvider { public readonly type: RemoteProviderType = 'github'; private readonly persistence = PersistenceManager.getInstance(); + private readonly prsCache = new LruCache(30, 120_000); + private readonly statusesCache = new LruCache< + string, + Array<{ context: string; state: 'success' | 'failure' | 'pending' | 'warning' | 'error'; description?: string; targetUrl?: string }> + >(100, 120_000); + + private getApiBaseUrl(repoInfo: RemoteRepoInfo): string { + const cleanHost = (repoInfo.host || 'github.com').toLowerCase(); + if (cleanHost === 'github.com' || cleanHost.endsWith('.github.com')) { + return 'https://api.github.com'; + } + return `https://${cleanHost}/api/v3`; + } public matches(_remoteUrl: string, host: string): boolean { const cleanHost = host.toLowerCase(); @@ -187,42 +201,170 @@ export class GitHubProvider implements RemoteProvider { public async getPullRequests( repoInfo: RemoteRepoInfo, - _filter: 'blocked' | 'ready' | 'mine' | 'all' = 'all' + filter: 'blocked' | 'ready' | 'mine' | 'all' = 'all' ): Promise { - const url = `https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}/pulls?state=open&per_page=50`; + const cacheKey = `${repoInfo.owner}/${repoInfo.repo}:prs:${filter}`; + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/repos/${repoInfo.owner}/${repoInfo.repo}/pulls?state=open&per_page=50`; + try { + const headers = await this.getAuthHeaders(); + const res = await fetch(url, { headers }); + if (res.ok) { + const raw: any[] = await res.json(); + const prs: PullRequestInfo[] = raw.map((r) => { + const isDraft = r.draft === true; + const isBlocked = + r.mergeable === false || + r.mergeable_state === 'dirty' || + r.mergeable_state === 'blocked'; + const reviewStatus: PullRequestInfo['reviewStatus'] = + Array.isArray(r.requested_reviewers) && r.requested_reviewers.length > 0 + ? 'review_required' + : 'none'; + const isReady = !isDraft && !isBlocked; + return { + id: r.id, + number: r.number, + title: r.title, + state: isDraft ? 'draft' : 'open', + reviewStatus, + ciStatus: 'none', + author: { + name: r.user?.login || 'User', + email: '', + date: new Date(r.created_at), + relativeTime: this.formatRelativeTime(new Date(r.created_at)), + avatarUrl: r.user?.avatar_url || '', + }, + sourceBranch: r.head?.ref || '', + targetBranch: r.base?.ref || '', + url: r.html_url || `${repoInfo.webUrl}/pull/${r.number}`, + updatedAt: r.updated_at, + isBlocked, + isReady, + isPending: !isBlocked && !isReady, + commentsCount: r.comments, + }; + }); + const filtered = this.filterPullRequests(prs, filter); + this.prsCache.set(cacheKey, filtered); + return filtered; + } + } catch { + // Fallback to cached results on network error + } + return this.prsCache.get(cacheKey) || []; + } + + private filterPullRequests( + prs: PullRequestInfo[], + filter: 'blocked' | 'ready' | 'mine' | 'all' + ): PullRequestInfo[] { + if (filter === 'blocked') return prs.filter((p) => p.isBlocked); + if (filter === 'ready') return prs.filter((p) => p.isReady); + return prs; + } + + public async getCommitStatuses( + repoInfo: RemoteRepoInfo, + sha: string + ): Promise> { + const cacheKey = `${repoInfo.owner}/${repoInfo.repo}:status:${sha}`; + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/repos/${repoInfo.owner}/${repoInfo.repo}/commits/${sha}/status`; + try { + const headers = await this.getAuthHeaders(); + const res = await fetch(url, { headers }); + if (res.ok) { + const data: any = await res.json(); + const rawList: any[] = Array.isArray(data?.statuses) ? data.statuses : []; + const mapped = rawList.map((s) => ({ + context: s.context || 'ci', + state: (s.state === 'success' || s.state === 'failure' || s.state === 'pending' || s.state === 'error' + ? s.state + : 'pending') as 'success' | 'failure' | 'pending' | 'warning' | 'error', + description: s.description, + targetUrl: s.target_url, + })); + this.statusesCache.set(cacheKey, mapped); + return mapped; + } + } catch {} + return this.statusesCache.get(cacheKey) || []; + } + + public async getPullRequestComments(repoInfo: RemoteRepoInfo, prNumber: number): Promise { + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${prNumber}/comments`; try { const headers = await this.getAuthHeaders(); const res = await fetch(url, { headers }); if (!res.ok) return []; const raw: any[] = await res.json(); - return raw.map((r) => ({ - id: r.id, - number: r.number, - title: r.title, - state: r.draft ? 'draft' : 'open', - reviewStatus: 'none', - ciStatus: 'none', + return raw.map((c) => ({ + id: c.id, author: { - name: r.user?.login || 'User', + name: c.user?.login || 'User', email: '', - date: new Date(r.created_at), - relativeTime: this.formatRelativeTime(new Date(r.created_at)), - avatarUrl: r.user?.avatar_url || '', + date: new Date(c.created_at), + relativeTime: this.formatRelativeTime(new Date(c.created_at)), + avatarUrl: c.user?.avatar_url || '', }, - sourceBranch: r.head?.ref || '', - targetBranch: r.base?.ref || '', - url: r.html_url || `${repoInfo.webUrl}/pull/${r.number}`, - updatedAt: r.updated_at, - isBlocked: false, - isReady: !r.draft, - isPending: false, - commentsCount: r.comments, + body: c.body || '', + createdAt: c.created_at, })); } catch { return []; } } + public async createPullRequestComment( + repoInfo: RemoteRepoInfo, + prNumber: number, + body: string + ): Promise { + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${prNumber}/comments`; + const headers = await this.getAuthHeaders(); + headers['Content-Type'] = 'application/json'; + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + throw new Error(`GitHub PR comment failed: HTTP ${res.status}`); + } + const c: any = await res.json(); + return { + id: c.id, + author: { + name: c.user?.login || 'Me', + email: '', + date: new Date(c.created_at || Date.now()), + relativeTime: 'just now', + avatarUrl: c.user?.avatar_url || '', + }, + body: c.body || body, + createdAt: c.created_at || new Date().toISOString(), + }; + } + + public async approvePullRequest(repoInfo: RemoteRepoInfo, prNumber: number): Promise { + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}/reviews`; + const headers = await this.getAuthHeaders(); + headers['Content-Type'] = 'application/json'; + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ event: 'APPROVE' }), + }); + if (!res.ok) { + throw new Error(`GitHub PR approval failed: HTTP ${res.status}`); + } + } + private mapGitHubIssue(raw: any, repoInfo: RemoteRepoInfo): IssueInfo { const author: GitAuthor = { name: raw.user?.login || 'User', diff --git a/src/remotes/gitlabProvider.ts b/src/remotes/gitlabProvider.ts index b313a84..c9e6ad7 100644 --- a/src/remotes/gitlabProvider.ts +++ b/src/remotes/gitlabProvider.ts @@ -8,10 +8,21 @@ import { RemoteRepoInfo, } from './types'; import { PersistenceManager } from '../state/persistenceManager'; +import { LruCache } from '../utils/lruCache'; export class GitLabProvider implements RemoteProvider { public readonly type: RemoteProviderType = 'gitlab'; private readonly persistence = PersistenceManager.getInstance(); + private readonly prsCache = new LruCache(30, 120_000); + private readonly statusesCache = new LruCache< + string, + Array<{ context: string; state: 'success' | 'failure' | 'pending' | 'warning' | 'error'; description?: string; targetUrl?: string }> + >(100, 120_000); + + private getApiBaseUrl(repoInfo: RemoteRepoInfo): string { + const cleanHost = (repoInfo.host || 'gitlab.com').trim(); + return `https://${cleanHost}/api/v4`; + } public matches(_remoteUrl: string, host: string): boolean { const cleanHost = host.toLowerCase(); @@ -130,45 +141,228 @@ export class GitLabProvider implements RemoteProvider { } } + public async createIssueComment( + repoInfo: RemoteRepoInfo, + issueNumber: number, + body: string + ): Promise { + const projectPath = encodeURIComponent(`${repoInfo.owner}/${repoInfo.repo}`); + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/projects/${projectPath}/issues/${issueNumber}/notes`; + const headers = await this.getAuthHeaders(); + headers['Content-Type'] = 'application/json'; + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + throw new Error(`GitLab issue comment failed: HTTP ${res.status}`); + } + const n: any = await res.json(); + return { + id: n.id, + author: { + name: n.author?.name || n.author?.username || 'Me', + email: '', + date: new Date(n.created_at || Date.now()), + relativeTime: 'just now', + avatarUrl: n.author?.avatar_url || '', + }, + body: n.body || body, + createdAt: n.created_at || new Date().toISOString(), + }; + } + + public async setIssueState( + repoInfo: RemoteRepoInfo, + issueNumber: number, + state: 'open' | 'closed' + ): Promise { + const projectPath = encodeURIComponent(`${repoInfo.owner}/${repoInfo.repo}`); + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/projects/${projectPath}/issues/${issueNumber}`; + const headers = await this.getAuthHeaders(); + headers['Content-Type'] = 'application/json'; + const res = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify({ state_event: state === 'closed' ? 'close' : 'reopen' }), + }); + if (!res.ok) { + throw new Error(`GitLab update issue state failed: HTTP ${res.status}`); + } + } + public async getPullRequests( repoInfo: RemoteRepoInfo, - _filter: 'blocked' | 'ready' | 'mine' | 'all' = 'all' + filter: 'blocked' | 'ready' | 'mine' | 'all' = 'all' ): Promise { + const cacheKey = `${repoInfo.owner}/${repoInfo.repo}:mrs:${filter}`; const projectPath = encodeURIComponent(`${repoInfo.owner}/${repoInfo.repo}`); - const url = `https://gitlab.com/api/v4/projects/${projectPath}/merge_requests?state=opened&per_page=50`; + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/projects/${projectPath}/merge_requests?state=opened&per_page=50`; + try { + const headers = await this.getAuthHeaders(); + const res = await fetch(url, { headers }); + if (res.ok) { + const raw: any[] = await res.json(); + const prs: PullRequestInfo[] = raw.map((mr) => { + const isDraft = mr.draft === true || mr.work_in_progress === true; + const pipelineStatus = mr.head_pipeline?.status; + let ciStatus: PullRequestInfo['ciStatus'] = 'none'; + if (pipelineStatus === 'success') ciStatus = 'success'; + else if (pipelineStatus === 'failed' || pipelineStatus === 'canceled') ciStatus = 'failure'; + else if (pipelineStatus === 'running' || pipelineStatus === 'pending') ciStatus = 'pending'; + + const reviewStatus: PullRequestInfo['reviewStatus'] = + mr.approved === true ? 'approved' : mr.blocking_discussions_resolved === false ? 'changes_requested' : 'none'; + const isBlocked = mr.has_conflicts === true || ciStatus === 'failure' || mr.blocking_discussions_resolved === false; + const isReady = !isDraft && !isBlocked; + return { + id: mr.id, + number: mr.iid, + title: mr.title, + state: isDraft ? 'draft' : 'open', + reviewStatus, + ciStatus, + author: { + name: mr.author?.name || mr.author?.username || 'User', + email: '', + date: new Date(mr.created_at), + relativeTime: 'recently', + avatarUrl: mr.author?.avatar_url || '', + }, + sourceBranch: mr.source_branch || '', + targetBranch: mr.target_branch || '', + url: mr.web_url || `${repoInfo.webUrl}/-/merge_requests/${mr.iid}`, + updatedAt: mr.updated_at, + isBlocked, + isReady, + isPending: !isBlocked && !isReady, + commentsCount: mr.user_notes_count, + }; + }); + const filtered = + filter === 'blocked' + ? prs.filter((p) => p.isBlocked) + : filter === 'ready' + ? prs.filter((p) => p.isReady) + : prs; + this.prsCache.set(cacheKey, filtered); + return filtered; + } + } catch {} + return this.prsCache.get(cacheKey) || []; + } + + public async getCommitStatuses( + repoInfo: RemoteRepoInfo, + sha: string + ): Promise> { + const cacheKey = `${repoInfo.owner}/${repoInfo.repo}:status:${sha}`; + const projectPath = encodeURIComponent(`${repoInfo.owner}/${repoInfo.repo}`); + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/projects/${projectPath}/repository/commits/${sha}/statuses`; + try { + const headers = await this.getAuthHeaders(); + const res = await fetch(url, { headers }); + if (res.ok) { + const raw: any[] = await res.json(); + const mapped = raw.map((s) => ({ + context: s.name || 'gitlab-ci', + state: (s.status === 'success' + ? 'success' + : s.status === 'failed' + ? 'failure' + : s.status === 'canceled' + ? 'error' + : 'pending') as 'success' | 'failure' | 'pending' | 'warning' | 'error', + description: s.description, + targetUrl: s.target_url, + })); + this.statusesCache.set(cacheKey, mapped); + return mapped; + } + } catch {} + return this.statusesCache.get(cacheKey) || []; + } + + public async getPullRequestComments(repoInfo: RemoteRepoInfo, prNumber: number): Promise { + const projectPath = encodeURIComponent(`${repoInfo.owner}/${repoInfo.repo}`); + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/projects/${projectPath}/merge_requests/${prNumber}/notes`; try { const headers = await this.getAuthHeaders(); const res = await fetch(url, { headers }); if (!res.ok) return []; const raw: any[] = await res.json(); - return raw.map((mr) => ({ - id: mr.id, - number: mr.iid, - title: mr.title, - state: mr.draft ? 'draft' : 'open', - reviewStatus: 'none', - ciStatus: 'none', - author: { - name: mr.author?.name || mr.author?.username || 'User', - email: '', - date: new Date(mr.created_at), - relativeTime: 'recently', - avatarUrl: mr.author?.avatar_url || '', - }, - sourceBranch: mr.source_branch || '', - targetBranch: mr.target_branch || '', - url: mr.web_url || `${repoInfo.webUrl}/-/merge_requests/${mr.iid}`, - updatedAt: mr.updated_at, - isBlocked: mr.has_conflicts === true, - isReady: !mr.draft && !mr.has_conflicts, - isPending: false, - commentsCount: mr.user_notes_count, - })); + return raw + .filter((n) => !n.system) + .map((n) => ({ + id: n.id, + author: { + name: n.author?.name || n.author?.username || 'User', + email: '', + date: new Date(n.created_at), + relativeTime: 'recently', + avatarUrl: n.author?.avatar_url || '', + }, + body: n.body || '', + createdAt: n.created_at, + })); } catch { return []; } } + public async createPullRequestComment( + repoInfo: RemoteRepoInfo, + prNumber: number, + body: string + ): Promise { + const projectPath = encodeURIComponent(`${repoInfo.owner}/${repoInfo.repo}`); + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/projects/${projectPath}/merge_requests/${prNumber}/notes`; + const headers = await this.getAuthHeaders(); + headers['Content-Type'] = 'application/json'; + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + throw new Error(`GitLab MR comment failed: HTTP ${res.status}`); + } + const n: any = await res.json(); + return { + id: n.id, + author: { + name: n.author?.name || n.author?.username || 'Me', + email: '', + date: new Date(n.created_at || Date.now()), + relativeTime: 'just now', + avatarUrl: n.author?.avatar_url || '', + }, + body: n.body || body, + createdAt: n.created_at || new Date().toISOString(), + }; + } + + public async approvePullRequest(repoInfo: RemoteRepoInfo, prNumber: number): Promise { + const projectPath = encodeURIComponent(`${repoInfo.owner}/${repoInfo.repo}`); + const apiBase = this.getApiBaseUrl(repoInfo); + const url = `${apiBase}/projects/${projectPath}/merge_requests/${prNumber}/approve`; + const headers = await this.getAuthHeaders(); + const res = await fetch(url, { + method: 'POST', + headers, + }); + if (!res.ok) { + throw new Error(`GitLab MR approval failed: HTTP ${res.status}`); + } + } + private mapGitLabIssue(raw: any, repoInfo: RemoteRepoInfo): IssueInfo { const author: GitAuthor = { name: raw.author?.name || raw.author?.username || 'User', diff --git a/src/remotes/types.ts b/src/remotes/types.ts index 5cdcdcd..a666c28 100644 --- a/src/remotes/types.ts +++ b/src/remotes/types.ts @@ -99,6 +99,9 @@ export interface RemoteProvider { // Pull Request operations getPullRequests?(repoInfo: RemoteRepoInfo, filter?: 'blocked' | 'ready' | 'mine' | 'all'): Promise; + getPullRequestComments?(repoInfo: RemoteRepoInfo, prNumber: number): Promise; + createPullRequestComment?(repoInfo: RemoteRepoInfo, prNumber: number, body: string): Promise; + approvePullRequest?(repoInfo: RemoteRepoInfo, prNumber: number): Promise; // Repository metadata & CI statuses getRepoDetails?(repoInfo: RemoteRepoInfo): Promise | null>; diff --git a/src/webview/main.ts b/src/webview/main.ts index 31c8890..3624016 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -366,6 +366,7 @@ window.addEventListener('message', (event) => { bisectStatus = message.payload.bisectStatus || null; updateBisectBanner(); + updateConflictBanner(message.payload.conflictedFiles || []); if (activeTab === 'tab-worktrees') renderWorktrees(); if (activeTab === 'tab-prs') renderPullRequests(); @@ -445,6 +446,37 @@ window.addEventListener('message', (event) => { handleAiKeyGenerated(message.payload); break; + case 'conflictedFilesData': + updateConflictBanner(message.payload?.files || []); + renderConflictStudioFiles(message.payload?.files || []); + break; + + case 'commitTemplateData': + handleCommitTemplateData(message.payload); + break; + + case 'commitCreated': + closeCommitBuilderModal(); + break; + + case 'prCommentsData': + if (message.payload?.prNumber) { + renderPrComments(message.payload.prNumber, message.payload.comments || []); + } + break; + + case 'prCommentAdded': + if (message.payload?.prNumber && activePrModalNumber === Number(message.payload.prNumber)) { + vscode.postMessage({ command: 'requestPrComments', payload: { prNumber: activePrModalNumber } }); + } + break; + + case 'prApproved': + if (activePrModalNumber) { + vscode.postMessage({ command: 'requestPullRequests' }); + } + break; + case 'rebaseCommitsData': handleRebaseCommitsData(message.payload); break; @@ -2976,6 +3008,12 @@ function renderPullRequests(): void { + + ${ pr.url ? ` + + + +
+
+ `; + }) + .join(''); + + const bindResolve = (selector: string, strategy: 'ours' | 'theirs' | 'mark-resolved') => { + listEl.querySelectorAll(selector).forEach((btn) => { + btn.addEventListener('click', () => { + const filePath = btn.getAttribute('data-path'); + if (filePath) { + vscode.postMessage({ + command: 'resolveConflictFile', + payload: { filePath, strategy }, + }); + } + }); + }); + }; + + bindResolve('.btn-resolve-ours', 'ours'); + bindResolve('.btn-resolve-theirs', 'theirs'); + bindResolve('.btn-mark-resolved', 'mark-resolved'); + + listEl.querySelectorAll('.btn-open-merge-editor').forEach((btn) => { + btn.addEventListener('click', () => { + const filePath = btn.getAttribute('data-path'); + if (filePath) { + vscode.postMessage({ + command: 'openMergeEditor', + payload: { filePath }, + }); + } + }); + }); +} + +function openConflictStudioModal(): void { + const modal = document.getElementById('conflict-studio-modal'); + if (!modal) return; + modal.classList.add('open'); + renderConflictStudioFiles(currentConflictedFiles); + vscode.postMessage({ command: 'requestConflictedFiles' }); +} + +function closeConflictStudioModal(): void { + document.getElementById('conflict-studio-modal')?.classList.remove('open'); +} + +document.getElementById('btn-open-conflict-studio')?.addEventListener('click', openConflictStudioModal); +document.getElementById('conflict-studio-close')?.addEventListener('click', closeConflictStudioModal); +document.getElementById('btn-conflict-studio-done')?.addEventListener('click', closeConflictStudioModal); +document.getElementById('btn-conflict-abort-merge')?.addEventListener('click', () => { + vscode.postMessage({ command: 'abortMerge' }); + closeConflictStudioModal(); +}); +document.getElementById('btn-conflict-continue-rebase')?.addEventListener('click', () => { + vscode.postMessage({ command: 'continueRebase' }); + closeConflictStudioModal(); +}); + +// Conventional Commits Builder & #issue Autocomplete (Issue #15) +let activeCommitAutocompleteInput: HTMLInputElement | HTMLTextAreaElement | null = null; + +function formatConventionalCommitPreview(): string { + const typeEl = document.getElementById('cb-type') as HTMLSelectElement | null; + const scopeEl = document.getElementById('cb-scope') as HTMLInputElement | null; + const subjectEl = document.getElementById('cb-subject') as HTMLInputElement | null; + const bodyEl = document.getElementById('cb-body') as HTMLTextAreaElement | null; + const breakingEl = document.getElementById('cb-breaking') as HTMLInputElement | null; + const previewEl = document.getElementById('cb-preview'); + const countEl = document.getElementById('cb-subject-count'); + + const type = (typeEl?.value || 'feat').trim(); + const scope = (scopeEl?.value || '').trim().replace(/[()\r\n]/g, ''); + const subject = (subjectEl?.value || '').trim(); + const body = (bodyEl?.value || '').trim(); + const breaking = Boolean(breakingEl?.checked); + + if (countEl) { + const len = subject.length; + countEl.textContent = `${len}/72`; + countEl.style.color = len > 72 ? '#ef4444' : 'var(--text-muted)'; + } + + const header = `${type}${scope ? `(${scope})` : ''}${breaking ? '!' : ''}: ${subject || ''}`; + const sections = [header]; + if (body) sections.push(body); + if (breaking && !/BREAKING CHANGE:/i.test(body)) { + sections.push(`BREAKING CHANGE: ${subject || 'introduces breaking changes'}`); + } + const full = sections.join('\n\n'); + if (previewEl) previewEl.textContent = full; + return subject ? full : ''; +} + +function hideCommitIssueAutocomplete(): void { + const box = document.getElementById('commit-issue-autocomplete'); + if (box) box.style.display = 'none'; + activeCommitAutocompleteInput = null; +} + +function updateCommitIssueAutocomplete(target: HTMLInputElement | HTMLTextAreaElement): void { + const box = document.getElementById('commit-issue-autocomplete'); + if (!box) return; + const cursor = target.selectionStart ?? target.value.length; + const beforeCursor = target.value.slice(0, cursor); + const hashMatch = beforeCursor.match(/(?:^|\s)#([a-zA-Z0-9_-]*)$/); + if (!hashMatch) { + box.style.display = 'none'; + return; + } + + const q = (hashMatch[1] || '').toLowerCase(); + const matches = (issuesHubData.allIssues || []) + .filter((i: any) => !q || String(i.number).includes(q) || (i.title || '').toLowerCase().includes(q)) + .slice(0, 6); + + if (matches.length === 0) { + box.style.display = 'none'; + return; + } + + activeCommitAutocompleteInput = target; + box.innerHTML = matches + .map( + (i: any) => ` + + ` + ) + .join(''); + box.style.display = 'flex'; + + box.querySelectorAll('.commit-issue-option').forEach((opt) => { + opt.addEventListener('click', () => { + const num = opt.getAttribute('data-num'); + if (!num || !activeCommitAutocompleteInput) return; + const inp = activeCommitAutocompleteInput; + const pos = inp.selectionStart ?? inp.value.length; + const left = inp.value.slice(0, pos); + const right = inp.value.slice(pos); + const replacedLeft = left.replace(/#([a-zA-Z0-9_-]*)$/, `#${num} `); + inp.value = replacedLeft + right; + inp.focus(); + formatConventionalCommitPreview(); + hideCommitIssueAutocomplete(); + }); + }); +} + +function handleCommitTemplateData(payload: { template?: string | null; changesCount?: number }): void { + const infoEl = document.getElementById('cb-changes-info'); + const bodyEl = document.getElementById('cb-body') as HTMLTextAreaElement | null; + if (infoEl) { + const cnt = payload?.changesCount || 0; + infoEl.textContent = `${cnt} modified file${cnt === 1 ? '' : 's'} detected`; + } + if (bodyEl && !bodyEl.value.trim() && payload?.template) { + bodyEl.value = payload.template; + } + formatConventionalCommitPreview(); +} + +function openCommitBuilderModal(): void { + const modal = document.getElementById('commit-builder-modal'); + if (!modal) return; + modal.classList.add('open'); + formatConventionalCommitPreview(); + vscode.postMessage({ command: 'requestCommitTemplate' }); + if ((issuesHubData.allIssues || []).length === 0) { + vscode.postMessage({ command: 'requestIssues' }); + } +} + +function closeCommitBuilderModal(): void { + document.getElementById('commit-builder-modal')?.classList.remove('open'); + hideCommitIssueAutocomplete(); +} + +document.getElementById('btn-commit-builder')?.addEventListener('click', openCommitBuilderModal); +document.getElementById('commit-builder-close')?.addEventListener('click', closeCommitBuilderModal); +document.getElementById('btn-cb-cancel')?.addEventListener('click', closeCommitBuilderModal); + +['cb-type', 'cb-scope', 'cb-breaking'].forEach((id) => { + document.getElementById(id)?.addEventListener('change', formatConventionalCommitPreview); + document.getElementById(id)?.addEventListener('input', formatConventionalCommitPreview); +}); + +['cb-subject', 'cb-body'].forEach((id) => { + const el = document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement | null; + el?.addEventListener('input', () => { + formatConventionalCommitPreview(); + updateCommitIssueAutocomplete(el); + }); +}); + +document.getElementById('btn-cb-submit')?.addEventListener('click', () => { + const message = formatConventionalCommitPreview(); + if (!message) { + (document.getElementById('cb-subject') as HTMLInputElement | null)?.focus(); + return; + } + const stageAll = Boolean((document.getElementById('cb-stage-all') as HTMLInputElement | null)?.checked); + vscode.postMessage({ + command: 'commitStagedChanges', + payload: { message, stageAll }, + }); +}); + +// PR Comments & Approval Modal (Issue #16) +let activePrModalNumber: number | null = null; + +function openPrDetailModal(prNumber: number, prTitle: string): void { + activePrModalNumber = prNumber; + const modal = document.getElementById('pr-detail-modal'); + const titleEl = document.getElementById('pr-detail-title'); + const listEl = document.getElementById('pr-comments-list'); + if (titleEl) { + titleEl.textContent = `PR #${prNumber}: ${prTitle}`; + } + if (listEl) { + listEl.innerHTML = '
Loading comments...
'; + } + modal?.classList.add('open'); + vscode.postMessage({ command: 'requestPrComments', payload: { prNumber } }); +} + +function renderPrComments(prNumber: number, comments: any[]): void { + if (activePrModalNumber !== prNumber) return; + const listEl = document.getElementById('pr-comments-list'); + if (!listEl) return; + if (!comments || comments.length === 0) { + listEl.innerHTML = '
No comments on this Pull Request yet.
'; + return; + } + listEl.innerHTML = comments + .map( + (c) => ` +
+
+ ${escapeHtml(c.author?.name || 'User')} + ${escapeHtml(c.author?.relativeTime || c.createdAt || '')} +
+
${escapeHtml(c.body || '').replace(/\n/g, '
')}
+
+ ` + ) + .join(''); +} + +function closePrDetailModal(): void { + document.getElementById('pr-detail-modal')?.classList.remove('open'); + activePrModalNumber = null; +} + +document.getElementById('pr-detail-close')?.addEventListener('click', closePrDetailModal); +document.getElementById('btn-pr-modal-close')?.addEventListener('click', closePrDetailModal); +document.getElementById('btn-pr-modal-comment')?.addEventListener('click', () => { + const input = document.getElementById('pr-comment-input') as HTMLTextAreaElement | null; + const body = input?.value.trim() || ''; + if (activePrModalNumber && body) { + vscode.postMessage({ + command: 'addPrComment', + payload: { prNumber: activePrModalNumber, body }, + }); + if (input) input.value = ''; + } +}); +document.getElementById('btn-pr-modal-approve')?.addEventListener('click', () => { + if (activePrModalNumber) { + vscode.postMessage({ + command: 'approvePullRequest', + payload: { prNumber: activePrModalNumber }, + }); + } +}); diff --git a/src/webview/style.css b/src/webview/style.css index f57969a..bc652fc 100644 --- a/src/webview/style.css +++ b/src/webview/style.css @@ -5574,3 +5574,86 @@ button:focus-visible, width: auto; } } + +/* Conflict Resolution Studio, Conventional Commit Builder & PR Discussion (Issues #14, #15, #16) */ +.conflict-studio-list, +.pr-comments-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.conflict-file-card, +.pr-comment-card { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px; + background: var(--bg-secondary); + display: flex; + flex-direction: column; + gap: 6px; +} + +.conflict-file-header, +.pr-comment-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: var(--font-size-xs); +} + +.conflict-file-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.conflict-preview-pre, +.cb-preview-box { + margin: 4px 0 0 0; + padding: 8px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + white-space: pre-wrap; + word-break: break-word; + max-height: 120px; + overflow-y: auto; +} + +.commit-builder-row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.commit-issue-autocomplete { + display: flex; + flex-direction: column; + border: 1px solid var(--vscode-focusBorder, var(--accent-cyan)); + border-radius: 6px; + background: var(--bg-secondary); + max-height: 150px; + overflow-y: auto; +} + +.commit-issue-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: none; + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; + font-size: var(--font-size-xs); +} + +.commit-issue-option:hover, +.commit-issue-option:focus-visible { + background: var(--bg-hover); +} diff --git a/test/runTests.ts b/test/runTests.ts index 59f53b2..debfecb 100644 --- a/test/runTests.ts +++ b/test/runTests.ts @@ -1,6 +1,8 @@ import assert from 'assert'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; +import * as cp from 'child_process'; import { AvatarService } from '../src/avatars/avatarService'; import { GitParser } from '../src/git/gitParser'; import { GraphLayout, THEME_LANE_COLORS } from '../src/graph/graphLayout'; @@ -24,6 +26,16 @@ import { computeClampedPosition, escapeHtml } from '../src/webview/primitives'; import { DagWorkerEngine } from '../src/graph/dagWorkerClient'; import { AdaptiveDebouncer } from '../src/git/branchWatcher'; import { GraphWebviewProvider } from '../src/graph/graphWebviewProvider'; +import { + CONVENTIONAL_COMMIT_TYPES, + filterIssueSuggestions, + formatConventionalCommit, + parseConventionalCommit, + readGitMessageTemplate, + stripGitTemplateComments, +} from '../src/git/conventionalCommits'; +import { GitHubProvider } from '../src/remotes/githubProvider'; +import { GitLabProvider } from '../src/remotes/gitlabProvider'; console.log('Starting CommitFlow Extended Test Suite...\n'); @@ -1657,7 +1669,239 @@ Host *.internal.corp } console.log(' [PASS] DAG Minimap, Advanced Filters, Interactive Drag-and-Drop & Onboarding tests passed!'); - console.log('\n[PASS] ALL 47 COMMITFLOW TEST SUITES PASSED WITH 100% SUCCESS!\n'); + // 48. Test Conflict Resolution Studio, Conventional Commits Builder & Provider Parity (Issues #14, #15, #16) + console.log('▶ Testing Conflict Studio, Conventional Commits & GitHub/GitLab Parity...'); + assert.ok(CONVENTIONAL_COMMIT_TYPES.includes('feat')); + assert.ok(CONVENTIONAL_COMMIT_TYPES.includes('fix')); + const formattedCommit = formatConventionalCommit({ + type: 'feat', + scope: 'dag', + breaking: true, + subject: 'drop legacy layout (#15)', + body: 'Switches rendering pipeline to worker layout.\nCloses #15', + }); + assert.ok(formattedCommit.startsWith('feat(dag)!: drop legacy layout (#15)')); + assert.ok(formattedCommit.includes('BREAKING CHANGE:')); + const parsedCommit = parseConventionalCommit(formattedCommit); + assert.strictEqual(parsedCommit?.type, 'feat'); + assert.strictEqual(parsedCommit?.scope, 'dag'); + assert.strictEqual(parsedCommit?.breaking, true); + assert.strictEqual(parsedCommit?.subject, 'drop legacy layout (#15)'); + + const cleanedTemplate = stripGitTemplateComments('# Comment header\n\nFixes #\n# Footer'); + assert.strictEqual(cleanedTemplate, 'Fixes #'); + + const suggestions = filterIssueSuggestions( + [ + { number: 12, title: 'Closed old bug', state: 'closed' }, + { number: 14, title: 'Conflict Studio 3-way editor', state: 'open' }, + { number: 15, title: 'Conventional Commit Builder', state: 'open' }, + ], + 'conv' + ); + assert.strictEqual(suggestions.length, 1); + assert.strictEqual(suggestions[0].number, 15); + + const tmpConflictRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'commitflow-conflict-test-')); + try { + const execGit = (args: string[]) => cp.execFileSync('git', args, { cwd: tmpConflictRepo, encoding: 'utf8' }); + execGit(['init']); + execGit(['config', 'user.name', 'CommitFlow Test']); + execGit(['config', 'user.email', 'test@commitflow.local']); + fs.writeFileSync(path.join(tmpConflictRepo, '.gitmessage'), '# Enter commit message\n\nRefs #\n', 'utf8'); + assert.strictEqual(readGitMessageTemplate(tmpConflictRepo), 'Refs #'); + + fs.writeFileSync(path.join(tmpConflictRepo, 'conflict.txt'), 'line 1\nbase line\n', 'utf8'); + execGit(['add', '.']); + execGit(['commit', '-m', 'chore: initial base']); + const defaultBranch = execGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + + execGit(['checkout', '-b', 'feat-branch']); + fs.writeFileSync(path.join(tmpConflictRepo, 'conflict.txt'), 'line 1\ntheirs change\n', 'utf8'); + execGit(['add', 'conflict.txt']); + execGit(['commit', '-m', 'feat: incoming change']); + + execGit(['checkout', defaultBranch]); + fs.writeFileSync(path.join(tmpConflictRepo, 'conflict.txt'), 'line 1\nours change\n', 'utf8'); + execGit(['add', 'conflict.txt']); + execGit(['commit', '-m', 'fix: current branch change']); + + let mergeConflicted = false; + try { + execGit(['merge', 'feat-branch']); + } catch { + mergeConflicted = true; + } + assert.strictEqual(mergeConflicted, true, 'Merge should enter conflict state'); + + const conflictClient = GitClient.getInstance(); + const detectedConflicts = await conflictClient.getConflictedFiles(tmpConflictRepo); + assert.strictEqual(detectedConflicts.length, 1); + assert.strictEqual(detectedConflicts[0].filePath, 'conflict.txt'); + assert.ok(detectedConflicts[0].hunksCount >= 1); + + let traversalBlocked = false; + try { + await conflictClient.resolveConflictFile(tmpConflictRepo, '../outside.txt', 'ours'); + } catch { + traversalBlocked = true; + } + assert.strictEqual(traversalBlocked, true, 'Path traversal should be rejected by resolveConflictFile'); + + await conflictClient.resolveConflictFile(tmpConflictRepo, 'conflict.txt', 'theirs'); + const afterResolve = await conflictClient.getConflictedFiles(tmpConflictRepo); + assert.strictEqual(afterResolve.length, 0); + assert.ok(fs.readFileSync(path.join(tmpConflictRepo, 'conflict.txt'), 'utf8').includes('theirs change')); + + const createdCommit = await conflictClient.commitStagedChanges(tmpConflictRepo, 'fix(merge): resolve conflict #14'); + assert.ok(createdCommit.hash.length >= 7); + assert.strictEqual(createdCommit.subject, 'fix(merge): resolve conflict #14'); + + let emptyCommitBlocked = false; + try { + await conflictClient.commitStagedChanges(tmpConflictRepo, 'chore: empty', false); + } catch { + emptyCommitBlocked = true; + } + assert.strictEqual(emptyCommitBlocked, true, 'Clean index commit should reject when stageAllIfEmpty is false'); + } finally { + fs.rmSync(tmpConflictRepo, { recursive: true, force: true }); + } + + // Provider parity & HTTP mocking for GitHub + GitLab + const ghProv = new GitHubProvider(); + const glProv = new GitLabProvider(); + const fgProv = new ForgejoProvider(); + assert.strictEqual(typeof ghProv.getCommitStatuses, 'function'); + assert.strictEqual(typeof ghProv.getPullRequestComments, 'function'); + assert.strictEqual(typeof ghProv.createPullRequestComment, 'function'); + assert.strictEqual(typeof ghProv.approvePullRequest, 'function'); + assert.strictEqual(typeof glProv.createIssueComment, 'function'); + assert.strictEqual(typeof glProv.setIssueState, 'function'); + assert.strictEqual(typeof glProv.getCommitStatuses, 'function'); + assert.strictEqual(typeof glProv.getPullRequestComments, 'function'); + assert.strictEqual(typeof glProv.createPullRequestComment, 'function'); + assert.strictEqual(typeof glProv.approvePullRequest, 'function'); + assert.strictEqual(typeof fgProv.getPullRequestComments, 'function'); + assert.strictEqual(typeof fgProv.approvePullRequest, 'function'); + + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + try { + (globalThis as any).fetch = async (url: string, init?: any) => { + fetchedUrls.push(`${init?.method || 'GET'} ${url}`); + if (url.includes('/pulls') && url.includes('/reviews')) { + return { ok: true, status: 200, json: async () => ({ id: 99 }) }; + } + if (url.includes('/pulls')) { + return { + ok: true, + status: 200, + json: async () => [ + { + id: 1, + number: 42, + title: 'Enterprise GH PR', + draft: false, + mergeable: true, + user: { login: 'octo' }, + head: { ref: 'feat/x' }, + base: { ref: 'staging' }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + }; + } + if (url.includes('/merge_requests/') && url.endsWith('/approve')) { + return { ok: true, status: 201, json: async () => ({ approved: true }) }; + } + if (url.includes('/merge_requests')) { + return { + ok: true, + status: 200, + json: async () => [ + { + id: 2, + iid: 77, + title: 'Self-hosted GL MR', + draft: false, + has_conflicts: true, + head_pipeline: { status: 'failed' }, + author: { username: 'gluser' }, + source_branch: 'fix/y', + target_branch: 'staging', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + }; + } + if (url.includes('/commits/') && url.endsWith('/status')) { + return { + ok: true, + status: 200, + json: async () => ({ statuses: [{ context: 'ci/build', state: 'success' }] }), + }; + } + if (url.includes('/issues/42/comments')) { + return { + ok: true, + status: 200, + json: async () => (init?.method === 'POST' ? { id: 501, body: 'LGTM', user: { login: 'me' } } : []), + }; + } + return { ok: true, status: 200, json: async () => [] }; + }; + + const gheRepo = { + provider: 'github' as const, + host: 'github.corp.local', + owner: 'org', + repo: 'app', + webUrl: 'https://github.corp.local/org/app', + isPrivate: true, + authConnected: true, + }; + const ghePrs = await ghProv.getPullRequests(gheRepo, 'ready'); + assert.strictEqual(ghePrs.length, 1); + assert.strictEqual(ghePrs[0].number, 42); + assert.ok(fetchedUrls.some((u) => u.includes('https://github.corp.local/api/v3/repos/org/app/pulls'))); + const gheStatus = await ghProv.getCommitStatuses(gheRepo, 'abc1234'); + assert.strictEqual(gheStatus[0].state, 'success'); + await ghProv.approvePullRequest(gheRepo, 42); + assert.ok(fetchedUrls.some((u) => u.includes('POST https://github.corp.local/api/v3/repos/org/app/pulls/42/reviews'))); + + const glRepo = { + provider: 'gitlab' as const, + host: 'gitlab.corp.local', + owner: 'group', + repo: 'service', + webUrl: 'https://gitlab.corp.local/group/service', + isPrivate: true, + authConnected: true, + }; + const glBlocked = await glProv.getPullRequests(glRepo, 'blocked'); + assert.strictEqual(glBlocked.length, 1); + assert.strictEqual(glBlocked[0].ciStatus, 'failure'); + await glProv.approvePullRequest(glRepo, 77); + assert.ok(fetchedUrls.some((u) => u.includes('POST https://gitlab.corp.local/api/v4/projects/group%2Fservice/merge_requests/77/approve'))); + + // Verify LRU cached fallback when fetch throws + (globalThis as any).fetch = async () => { + throw new Error('Offline'); + }; + const cachedGhePrs = await ghProv.getPullRequests(gheRepo, 'ready'); + assert.strictEqual(cachedGhePrs.length, 1); + const cachedGlMrs = await glProv.getPullRequests(glRepo, 'blocked'); + assert.strictEqual(cachedGlMrs.length, 1); + } finally { + globalThis.fetch = originalFetch; + } + + console.log(' [PASS] Conflict Studio, Conventional Commits & GitHub/GitLab Parity tests passed!'); + + console.log('\n[PASS] ALL 48 COMMITFLOW TEST SUITES PASSED WITH 100% SUCCESS!\n'); })().catch((err) => { console.error('[CommitFlow] Test suite failed:', err); process.exit(1);