diff --git a/drizzle/0010_careless_proudstar.sql b/drizzle/0010_careless_proudstar.sql new file mode 100644 index 000000000..96ab78e16 --- /dev/null +++ b/drizzle/0010_careless_proudstar.sql @@ -0,0 +1 @@ +ALTER TABLE `projects` ADD `git_project` text; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 000000000..99afe8987 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,487 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8791bf3c-58ec-4622-bca1-219e8b1e9c67", + "prevId": "117fd460-8aeb-4646-8ff8-010720741f5f", + "tables": { + "anthropic_accounts": { + "name": "anthropic_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "desktop_user_id": { + "name": "desktop_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "anthropic_settings": { + "name": "anthropic_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'singleton'" + }, + "active_account_id": { + "name": "active_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chats_worktree_path_idx": { + "name": "chats_worktree_path_idx", + "columns": [ + "worktree_path" + ], + "isUnique": false + }, + "chats_project_id_idx": { + "name": "chats_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chats_project_id_projects_id_fk": { + "name": "chats_project_id_projects_id_fk", + "tableFrom": "chats", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_code_credentials": { + "name": "claude_code_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_provider": { + "name": "git_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_owner": { + "name": "git_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_repo": { + "name": "git_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_project": { + "name": "git_project", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_path": { + "name": "icon_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_path_unique": { + "name": "projects_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sub_chats": { + "name": "sub_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "file_stats_additions": { + "name": "file_stats_additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_stats_deletions": { + "name": "file_stats_deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_stats_file_count": { + "name": "file_stats_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sub_chats_chat_id_idx": { + "name": "sub_chats_chat_id_idx", + "columns": [ + "chat_id" + ], + "isUnique": false + }, + "sub_chats_stream_id_idx": { + "name": "sub_chats_stream_id_idx", + "columns": [ + "stream_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sub_chats_chat_id_chats_id_fk": { + "name": "sub_chats_chat_id_chats_id_fk", + "tableFrom": "sub_chats", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 550ea615e..65650fed3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1776530863612, "tag": "0009_wise_rumiko_fujikawa", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1776705123163, + "tag": "0010_careless_proudstar", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/main/lib/db/schema/index.ts b/src/main/lib/db/schema/index.ts index a0f037ad4..e7bcb2f5b 100644 --- a/src/main/lib/db/schema/index.ts +++ b/src/main/lib/db/schema/index.ts @@ -17,9 +17,10 @@ export const projects = sqliteTable("projects", { ), // Git remote info (extracted from local .git) gitRemoteUrl: text("git_remote_url"), - gitProvider: text("git_provider"), // "github" | "gitlab" | "bitbucket" | null + gitProvider: text("git_provider"), // "github" | "gitlab" | "bitbucket" | "azure" | null gitOwner: text("git_owner"), gitRepo: text("git_repo"), + gitProject: text("git_project"), // Azure DevOps project (null for other providers) // Custom project icon (absolute path to local image file) iconPath: text("icon_path"), }) diff --git a/src/main/lib/git/git-operations.ts b/src/main/lib/git/git-operations.ts index 6fc7c69b9..23f39a23d 100644 --- a/src/main/lib/git/git-operations.ts +++ b/src/main/lib/git/git-operations.ts @@ -8,7 +8,7 @@ import { REMOTE_AHEAD_ERROR_PREFIX, } from "./git-utils"; import { assertRegisteredWorktree } from "./security"; -import { fetchGitHubPRStatus } from "./github"; +import { buildCreatePRWebUrl, fetchPRStatus, resolveProvider } from "./providers"; import { gitCache } from "./cache"; import { createGit, @@ -644,6 +644,7 @@ export const createGitOperationsRouter = () => { .input( z.object({ worktreePath: z.string(), + baseBranch: z.string().optional(), }), ) .mutation( @@ -665,18 +666,20 @@ export const createGitOperationsRouter = () => { await withLockRetry(input.worktreePath, () => git.push()); } - // Get the remote URL to construct the GitHub compare URL const remoteUrl = (await git.remote(["get-url", "origin"])) || ""; - const repoMatch = remoteUrl - .trim() - .match(/github\.com[:/](.+?)(?:\.git)?$/); - - if (!repoMatch) { - throw new Error("Could not determine GitHub repository URL"); + const provider = await resolveProvider(input.worktreePath); + if (!provider) { + throw new Error( + "Could not determine repository provider from remote URL", + ); } - const repo = repoMatch[1].replace(/\.git$/, ""); - const url = `https://github.com/${repo}/compare/${branch}?expand=1`; + const url = buildCreatePRWebUrl({ + provider, + remoteUrl: remoteUrl.trim(), + branch, + baseBranch: input.baseBranch ?? "main", + }); await shell.openExternal(url); await git.fetch(); @@ -687,6 +690,9 @@ export const createGitOperationsRouter = () => { }, ), + // Procedure name preserved for back-compat with the renderer (usePRStatus + // hook calls `trpc.changes.getGitHubStatus.useQuery`). Under the hood it + // now dispatches by provider — GitHub stays byte-identical, Azure uses az. getGitHubStatus: publicProcedure .input( z.object({ @@ -695,7 +701,7 @@ export const createGitOperationsRouter = () => { ) .query(async ({ input }) => { assertRegisteredWorktree(input.worktreePath); - return await fetchGitHubPRStatus(input.worktreePath); + return await fetchPRStatus(input.worktreePath); }), }); }; diff --git a/src/main/lib/git/index.ts b/src/main/lib/git/index.ts index eca95f673..9074b923e 100644 --- a/src/main/lib/git/index.ts +++ b/src/main/lib/git/index.ts @@ -40,13 +40,15 @@ export const createGitRouter = () => { // ============ GIT REMOTE INFO ============ -export type GitProvider = "github" | "gitlab" | "bitbucket" | null; +export type GitProvider = "github" | "gitlab" | "bitbucket" | "azure" | null; export interface GitRemoteInfo { remoteUrl: string | null; provider: GitProvider; owner: string | null; repo: string | null; + /** Only populated for Azure DevOps (org / project / repo). Null otherwise. */ + project: string | null; } /** @@ -81,6 +83,62 @@ function parseGitRemoteUrl(url: string): Omit { let owner: string | null = null; let repo: string | null = null; + // Azure HTTPS: https://[org@]dev.azure.com/{org}/{project}/_git/{repo} + const azureHttpsMatch = normalized.match( + /https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)/, + ); + if (azureHttpsMatch) { + const [, orgPart, projectPart, repoPart] = azureHttpsMatch; + return { + provider: "azure", + owner: orgPart || null, + project: projectPart || null, + repo: repoPart || null, + }; + } + + // Legacy Azure HTTPS: https://{org}.visualstudio.com/[DefaultCollection/]{project}/_git/{repo} + const legacyAzureHttpsMatch = normalized.match( + /https?:\/\/([^.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)/, + ); + if (legacyAzureHttpsMatch) { + const [, orgPart, projectPart, repoPart] = legacyAzureHttpsMatch; + return { + provider: "azure", + owner: orgPart || null, + project: projectPart || null, + repo: repoPart || null, + }; + } + + // Azure SSH: git@ssh.dev.azure.com:v3/{org}/{project}/{repo} + const azureSshMatch = normalized.match( + /git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/(.+)/, + ); + if (azureSshMatch) { + const [, orgPart, projectPart, repoPart] = azureSshMatch; + return { + provider: "azure", + owner: orgPart || null, + project: projectPart || null, + repo: repoPart || null, + }; + } + + // Legacy Azure SSH: {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo} + const legacyVsSshMatch = normalized.match( + /[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/(.+)/, + ); + if (legacyVsSshMatch) { + const [, orgPart, projectPart, repoPart] = legacyVsSshMatch; + return { + provider: "azure", + owner: orgPart || null, + project: projectPart || null, + repo: repoPart || null, + }; + } + // Match HTTPS format: https://github.com/owner/repo const httpsMatch = normalized.match( /https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/([^/]+)\/([^/]+)/, @@ -97,7 +155,7 @@ function parseGitRemoteUrl(url: string): Omit { : null; owner = ownerPart || null; repo = repoPart || null; - return { provider, owner, repo }; + return { provider, owner, repo, project: null }; } // Match SSH format: git@github.com:owner/repo @@ -116,10 +174,10 @@ function parseGitRemoteUrl(url: string): Omit { : null; owner = ownerPart || null; repo = repoPart || null; - return { provider, owner, repo }; + return { provider, owner, repo, project: null }; } - return { provider: null, owner: null, repo: null }; + return { provider: null, owner: null, repo: null, project: null }; } /** @@ -134,6 +192,7 @@ export async function getGitRemoteInfo( provider: null, owner: null, repo: null, + project: null, }; // Check if it's a git repo diff --git a/src/main/lib/git/providers/azure/azure.ts b/src/main/lib/git/providers/azure/azure.ts new file mode 100644 index 000000000..f03a2ea6b --- /dev/null +++ b/src/main/lib/git/providers/azure/azure.ts @@ -0,0 +1,450 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { execWithShellEnv } from "../../shell-env"; +import { branchExistsOnRemote } from "../../worktree"; +import { getGitRemoteInfo } from "../../index"; +import type { GitHubStatus, PRComment } from "../../github/types"; +import { detectAzureCli, detectionToToastMessage } from "./detect"; +import { + type AzurePR, + type AzurePolicyEval, + AzurePRSchema, + AzurePolicyEvalSchema, +} from "./types"; +import { parseAzureRemoteUrl, type AzureRemote } from "./parse-url"; + +const execFileAsync = promisify(execFile); + +// 10s status cache — matches the GitHub provider. +const statusCache = new Map(); +const STATUS_TTL_MS = 10_000; + +// 30s comments cache — stubbed to [] in v1 but wired for future use. +const commentsCache = new Map(); +const COMMENTS_TTL_MS = 30_000; + +export function invalidateAzurePRCache(worktreePath?: string): void { + if (worktreePath) { + statusCache.delete(worktreePath); + } else { + statusCache.clear(); + } +} + +export function invalidateAzurePRCommentsCache(worktreePath?: string): void { + if (worktreePath) { + commentsCache.delete(worktreePath); + } else { + commentsCache.clear(); + } +} + +/** + * Fetch PR status for the current branch in an Azure DevOps worktree. + * Returns null for any failure (missing CLI, not logged in, no PR, network, etc.) + * so the polling UI never throws. Cached 10s per worktree. + */ +export async function fetchAzurePRStatus( + worktreePath: string, +): Promise { + const cached = statusCache.get(worktreePath); + if (cached && Date.now() - cached.timestamp < STATUS_TTL_MS) { + return cached.data; + } + + try { + const detection = await detectAzureCli(); + if (detection.status !== "ok") return null; + + const remote = await resolveAzureRemote(worktreePath); + if (!remote) return null; + + const branch = await getCurrentBranch(worktreePath); + if (!branch) return null; + + const [branchCheck, prSummary] = await Promise.all([ + branchExistsOnRemote(worktreePath, branch), + findPRForBranch(worktreePath, remote, branch), + ]); + const existsOnRemote = branchCheck.status === "exists"; + + let prData: GitHubStatus["pr"] = null; + if (prSummary) { + const [full, checks] = await Promise.all([ + showPR(worktreePath, remote, prSummary.pullRequestId), + listPolicyChecks(worktreePath, remote, prSummary.pullRequestId), + ]); + if (full) { + prData = mapAzurePRToStatus(full, checks, remote); + } + } + + const result: GitHubStatus = { + pr: prData, + repoUrl: remote.repoWebUrl, + branchExistsOnRemote: existsOnRemote, + lastRefreshed: Date.now(), + }; + + statusCache.set(worktreePath, { data: result, timestamp: Date.now() }); + return result; + } catch (err) { + console.warn("[Azure] fetchAzurePRStatus failed:", err); + return null; + } +} + +/** v1: stubbed to [] — Azure's thread-based comment model is deferred to v2. */ +export async function fetchAzurePRComments( + _worktreePath: string, +): Promise { + return []; +} + +/** + * Complete an Azure PR. Throws on user-visible errors with MERGE_CONFLICT: + * prefix when appropriate (matches GitHub's contract with the renderer). + */ +export async function mergeAzurePR(args: { + worktreePath: string; + prNumber: number; + method: "merge" | "squash" | "rebase"; +}): Promise<{ success: true }> { + const detection = await detectAzureCli(); + if (detection.status !== "ok") { + throw new Error(detectionToToastMessage(detection)); + } + + const remote = await resolveAzureRemote(args.worktreePath); + if (!remote) { + throw new Error("Could not determine Azure DevOps remote for this worktree."); + } + + // Precheck mergeability so we can surface the existing MERGE_CONFLICT: contract. + const pr = await showPR(args.worktreePath, remote, args.prNumber); + if (pr?.mergeStatus === "conflicts") { + throw new Error( + "MERGE_CONFLICT: PR has merge conflicts. Sync with the target branch and resolve them.", + ); + } + + if (args.method === "rebase") { + console.warn( + "[Azure] Rebase merge not directly supported by az CLI; falling back to non-squash merge.", + ); + } + + const azArgs = [ + "repos", + "pr", + "update", + "--id", + String(args.prNumber), + "--status", + "completed", + "--squash", + args.method === "squash" ? "true" : "false", + "--delete-source-branch", + "true", + "--organization", + remote.orgUrl, + "--output", + "json", + ]; + + try { + await execWithShellEnv("az", azArgs, { cwd: args.worktreePath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/conflict/i.test(msg) || /not mergeable/i.test(msg)) { + throw new Error(`MERGE_CONFLICT: ${msg}`); + } + throw new Error(`Azure PR merge failed: ${msg}`); + } + + invalidateAzurePRCache(args.worktreePath); + invalidateAzurePRCommentsCache(args.worktreePath); + return { success: true }; +} + +export async function updateAzurePRTitle(args: { + worktreePath: string; + title: string; + prNumber?: number; +}): Promise<{ success: true; title: string }> { + const detection = await detectAzureCli(); + if (detection.status !== "ok") { + throw new Error(detectionToToastMessage(detection)); + } + + const remote = await resolveAzureRemote(args.worktreePath); + if (!remote) { + throw new Error("Could not determine Azure DevOps remote for this worktree."); + } + + let prNumber = args.prNumber; + if (prNumber == null) { + const branch = await getCurrentBranch(args.worktreePath); + if (!branch) { + throw new Error("Could not determine current branch."); + } + const summary = await findPRForBranch(args.worktreePath, remote, branch); + if (!summary) { + throw new Error("No pull request found for the current branch."); + } + prNumber = summary.pullRequestId; + } + + const azArgs = [ + "repos", + "pr", + "update", + "--id", + String(prNumber), + "--title", + args.title, + "--organization", + remote.orgUrl, + "--output", + "json", + ]; + + try { + await execWithShellEnv("az", azArgs, { cwd: args.worktreePath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Azure PR title update failed: ${msg}`); + } + + invalidateAzurePRCache(args.worktreePath); + return { success: true, title: args.title }; +} + +// ---------- internal helpers ---------- + +async function resolveAzureRemote( + worktreePath: string, +): Promise { + const info = await getGitRemoteInfo(worktreePath); + if (info.provider !== "azure" || !info.remoteUrl) return null; + return parseAzureRemoteUrl(info.remoteUrl); +} + +async function getCurrentBranch(worktreePath: string): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["rev-parse", "--abbrev-ref", "HEAD"], + { cwd: worktreePath }, + ); + const b = stdout.trim(); + return b || null; + } catch { + return null; + } +} + +/** Return the first active PR (or most recent non-active) for the branch. */ +async function findPRForBranch( + worktreePath: string, + remote: AzureRemote, + branch: string, +): Promise { + const base = [ + "repos", + "pr", + "list", + "--source-branch", + `refs/heads/${branch}`, + "--repository", + remote.repository, + "--project", + remote.project, + "--organization", + remote.orgUrl, + "--output", + "json", + ]; + + // Try active first, fall back to all (so merged/abandoned PRs still display). + for (const status of ["active", "all"] as const) { + try { + const { stdout } = await execWithShellEnv( + "az", + [...base, "--status", status], + { cwd: worktreePath }, + ); + const raw = JSON.parse(stdout); + if (!Array.isArray(raw) || raw.length === 0) continue; + + // Pick the highest pullRequestId (most recent) + const sorted = raw + .map((r) => AzurePRSchema.safeParse(r)) + .filter((r) => r.success) + .map((r) => (r.success ? r.data : null)) + .filter((r): r is AzurePR => r !== null) + .sort((a, b) => b.pullRequestId - a.pullRequestId); + + if (sorted[0]) return sorted[0]; + } catch { + // fall through to next status + } + } + + return null; +} + +async function showPR( + worktreePath: string, + remote: AzureRemote, + prId: number, +): Promise { + try { + const { stdout } = await execWithShellEnv( + "az", + [ + "repos", + "pr", + "show", + "--id", + String(prId), + "--organization", + remote.orgUrl, + "--output", + "json", + ], + { cwd: worktreePath }, + ); + const raw = JSON.parse(stdout); + const parsed = AzurePRSchema.safeParse(raw); + if (!parsed.success) { + console.warn("[Azure] PR schema validation failed:", parsed.error); + return null; + } + return parsed.data; + } catch (err) { + console.warn("[Azure] showPR failed:", err); + return null; + } +} + +async function listPolicyChecks( + worktreePath: string, + remote: AzureRemote, + prId: number, +): Promise { + try { + const { stdout } = await execWithShellEnv( + "az", + [ + "repos", + "pr", + "policy", + "list", + "--id", + String(prId), + "--organization", + remote.orgUrl, + "--output", + "json", + ], + { cwd: worktreePath }, + ); + const raw = JSON.parse(stdout); + if (!Array.isArray(raw)) return []; + return raw + .map((r) => AzurePolicyEvalSchema.safeParse(r)) + .filter((r) => r.success) + .map((r) => (r.success ? r.data : null)) + .filter((r): r is AzurePolicyEval => r !== null); + } catch { + return []; + } +} + +function mapAzurePRToStatus( + pr: AzurePR, + policies: AzurePolicyEval[], + remote: AzureRemote, +): NonNullable { + return { + number: pr.pullRequestId, + title: pr.title, + url: `${remote.repoWebUrl}/pullrequest/${pr.pullRequestId}`, + state: mapState(pr.status, pr.isDraft ?? false), + mergedAt: + pr.status === "completed" && pr.closedDate + ? Date.parse(pr.closedDate) || undefined + : undefined, + additions: 0, // v1: Azure JSON doesn't expose this cheaply + deletions: 0, + reviewDecision: mapReviewDecision(pr.reviewers ?? []), + checksStatus: computeChecksStatus(policies), + checks: policies.map((p) => ({ + name: p.configuration?.type?.displayName ?? "Policy", + status: mapPolicyStatus(p.status), + })), + mergeable: mapMergeable(pr.mergeStatus), + }; +} + +function mapState( + status: AzurePR["status"], + isDraft: boolean, +): NonNullable["state"] { + if (status === "completed") return "merged"; + if (status === "abandoned") return "closed"; + if (isDraft) return "draft"; + return "open"; +} + +function mapReviewDecision( + reviewers: NonNullable, +): NonNullable["reviewDecision"] { + if (reviewers.some((r) => r.vote === -10)) return "changes_requested"; + const required = reviewers.filter((r) => r.isRequired); + if (required.length > 0 && required.every((r) => r.vote >= 5)) { + return "approved"; + } + if (reviewers.some((r) => r.vote >= 5) && required.length === 0) { + return "approved"; + } + return "pending"; +} + +function mapMergeable( + status: AzurePR["mergeStatus"], +): NonNullable["mergeable"] { + if (status === "conflicts" || status === "rejectedByPolicy" || status === "failure") { + return "CONFLICTING"; + } + if (status === "succeeded") return "MERGEABLE"; + return "UNKNOWN"; +} + +function mapPolicyStatus( + status: string, +): NonNullable["checks"][number]["status"] { + const s = status.toLowerCase(); + if (s === "approved") return "success"; + if (s === "rejected" || s === "broken") return "failure"; + if (s === "queued" || s === "running") return "pending"; + if (s === "notapplicable") return "skipped"; + return "pending"; +} + +function computeChecksStatus( + policies: AzurePolicyEval[], +): NonNullable["checksStatus"] { + const relevant = policies.filter( + (p) => p.status.toLowerCase() !== "notapplicable", + ); + if (relevant.length === 0) return "none"; + if (relevant.some((p) => /^(rejected|broken)$/i.test(p.status))) { + return "failure"; + } + if (relevant.some((p) => /^(queued|running)$/i.test(p.status))) { + return "pending"; + } + return "success"; +} diff --git a/src/main/lib/git/providers/azure/detect.ts b/src/main/lib/git/providers/azure/detect.ts new file mode 100644 index 000000000..d8f7f59d7 --- /dev/null +++ b/src/main/lib/git/providers/azure/detect.ts @@ -0,0 +1,88 @@ +import { execWithShellEnv } from "../../shell-env"; + +export type AzureDetection = + | { status: "ok" } + | { status: "missing_cli" } + | { status: "missing_extension" } + | { status: "not_logged_in" } + | { status: "error"; message: string }; + +// Cached for 60s to avoid repeated shell spawns on every PR poll. +let cached: { value: AzureDetection; timestamp: number } | null = null; +const TTL_MS = 60_000; + +/** + * Silent, cached detection of az CLI + azure-devops extension + `az account` auth. + * Never prompts the user, never auto-installs. Safe to call from polling queries. + */ +export async function detectAzureCli(): Promise { + if (cached && Date.now() - cached.timestamp < TTL_MS) { + return cached.value; + } + const value = await runDetection(); + cached = { value, timestamp: Date.now() }; + return value; +} + +/** Drop the cached detection result (e.g. after the user installs `az`). */ +export function invalidateAzureDetection(): void { + cached = null; +} + +async function runDetection(): Promise { + // 1. `az` on PATH? + try { + await execWithShellEnv("which", ["az"]); + } catch { + return { status: "missing_cli" }; + } + + // 2. azure-devops extension installed? + try { + const { stdout } = await execWithShellEnv("az", [ + "extension", + "list", + "--query", + "[?name=='azure-devops'].name", + "-o", + "tsv", + ]); + if (!stdout.trim()) { + return { status: "missing_extension" }; + } + } catch (err) { + return { + status: "error", + message: err instanceof Error ? err.message : String(err), + }; + } + + // 3. logged in? `az account show` errors when there's no subscription context. + try { + await execWithShellEnv("az", ["account", "show", "--output", "json"]); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/az login/i.test(msg)) { + return { status: "not_logged_in" }; + } + return { status: "not_logged_in" }; + } + + return { status: "ok" }; +} + +/** Human-readable toast text for a detection failure. Used by mutations. */ +export function detectionToToastMessage(d: AzureDetection): string { + switch (d.status) { + case "ok": + return ""; + case "missing_cli": + return "Azure CLI not found. Install from https://aka.ms/install-az and retry."; + case "missing_extension": + return "Azure DevOps extension missing. Run: az extension add --name azure-devops"; + case "not_logged_in": + return "Not logged in to Azure. Run az login and retry."; + case "error": + return d.message; + } +} diff --git a/src/main/lib/git/providers/azure/parse-url.ts b/src/main/lib/git/providers/azure/parse-url.ts new file mode 100644 index 000000000..6bd287771 --- /dev/null +++ b/src/main/lib/git/providers/azure/parse-url.ts @@ -0,0 +1,96 @@ +/** + * Azure DevOps remote URL parsing helpers. + * + * Cloud only (v1): dev.azure.com + legacy visualstudio.com hosts. + * On-prem Azure DevOps Server is intentionally out of scope. + */ + +export interface AzureRemote { + organization: string; + project: string; + repository: string; + /** Base org URL without trailing slash, e.g. "https://dev.azure.com/myorg". */ + orgUrl: string; + /** Canonical web URL of the repo, e.g. "https://dev.azure.com/myorg/MyProject/_git/myrepo". */ + repoWebUrl: string; +} + +/** + * Parse any supported Azure DevOps remote URL into structured org/project/repo parts. + * Returns null if the URL doesn't match an Azure pattern. + */ +export function parseAzureRemoteUrl(url: string): AzureRemote | null { + let normalized = url.trim(); + if (normalized.endsWith(".git")) { + normalized = normalized.slice(0, -4); + } + + // HTTPS: https://[org@]dev.azure.com/{org}/{project}/_git/{repo} + const azureHttps = normalized.match( + /https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)/, + ); + if (azureHttps) { + const [, org, project, repo] = azureHttps; + return buildRemote(org, project, repo); + } + + // Legacy HTTPS: https://{org}.visualstudio.com/[DefaultCollection/]{project}/_git/{repo} + const legacyHttps = normalized.match( + /https?:\/\/([^.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)/, + ); + if (legacyHttps) { + const [, org, project, repo] = legacyHttps; + return buildRemote(org, project, repo); + } + + // SSH: git@ssh.dev.azure.com:v3/{org}/{project}/{repo} + const azureSsh = normalized.match( + /git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/(.+)/, + ); + if (azureSsh) { + const [, org, project, repo] = azureSsh; + return buildRemote(org, project, repo); + } + + // Legacy SSH: {anything}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo} + const legacyVsSsh = normalized.match( + /[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/(.+)/, + ); + if (legacyVsSsh) { + const [, org, project, repo] = legacyVsSsh; + return buildRemote(org, project, repo); + } + + return null; +} + +function buildRemote( + org: string, + project: string, + repo: string, +): AzureRemote { + const orgUrl = `https://dev.azure.com/${org}`; + return { + organization: org, + project, + repository: repo, + orgUrl, + repoWebUrl: `${orgUrl}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repo)}`, + }; +} + +/** + * Build the web URL for an Azure DevOps "Create PR" page, preselecting source + target. + */ +export function buildAzureCreatePRWebUrl(args: { + remote: AzureRemote; + branch: string; + baseBranch: string; +}): string { + const { remote, branch, baseBranch } = args; + return ( + `${remote.repoWebUrl}/pullrequestcreate` + + `?sourceRef=${encodeURIComponent(branch)}` + + `&targetRef=${encodeURIComponent(baseBranch)}` + ); +} diff --git a/src/main/lib/git/providers/azure/types.ts b/src/main/lib/git/providers/azure/types.ts new file mode 100644 index 000000000..61e9e1267 --- /dev/null +++ b/src/main/lib/git/providers/azure/types.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +/** + * Narrow zod schemas for az-CLI JSON output. Azure returns a lot more fields than + * we care about — these schemas only validate what we read, everything else is + * permissively ignored. + */ + +export const AzureReviewerSchema = z.object({ + vote: z.number(), + isRequired: z.boolean().optional(), + hasDeclined: z.boolean().optional(), + displayName: z.string().optional(), +}); + +export const AzurePRSchema = z.object({ + pullRequestId: z.number(), + title: z.string(), + status: z.enum(["active", "completed", "abandoned"]), + isDraft: z.boolean().optional(), + mergeStatus: z + .enum([ + "succeeded", + "conflicts", + "queued", + "rejectedByPolicy", + "notSet", + "failure", + ]) + .optional(), + closedDate: z.string().nullable().optional(), + reviewers: z.array(AzureReviewerSchema).optional(), + url: z.string().optional(), +}); + +/** `az repos pr policy list` entry — the interesting bits for check rendering. */ +export const AzurePolicyEvalSchema = z.object({ + status: z.string(), // "approved" | "queued" | "running" | "rejected" | "notApplicable" | "broken" + configuration: z + .object({ + type: z + .object({ + displayName: z.string().optional(), + }) + .optional(), + }) + .optional(), +}); + +export type AzurePR = z.infer; +export type AzurePolicyEval = z.infer; diff --git a/src/main/lib/git/providers/index.ts b/src/main/lib/git/providers/index.ts new file mode 100644 index 000000000..245d2c243 --- /dev/null +++ b/src/main/lib/git/providers/index.ts @@ -0,0 +1,221 @@ +/** + * Provider dispatcher for PR operations. + * + * Resolves the git host from the worktree's origin remote, then delegates to + * the matching provider implementation. GitHub calls are pure forwarders so + * existing behavior stays byte-identical; Azure calls go through the az CLI. + * + * Unknown providers (gitlab, bitbucket, on-prem, etc.) return null / [] from + * queries and throw PROVIDER_UNSUPPORTED from mutations — the renderer handles + * null gracefully (PR widget empty state) and throws as toasts. + */ + +import { getGitRemoteInfo } from "../index"; +import { + fetchGitHubPRStatus, + fetchGitHubPRComments, + invalidateGitHubPRCache, + invalidateGitHubPRCommentsCache, +} from "../github"; +import { + fetchAzurePRStatus, + fetchAzurePRComments, + invalidateAzurePRCache, + invalidateAzurePRCommentsCache, + mergeAzurePR, + updateAzurePRTitle, +} from "./azure/azure"; +import { buildAzureCreatePRWebUrl, parseAzureRemoteUrl } from "./azure/parse-url"; +import type { PullRequestStatus, SupportedProvider, PRComment } from "./types"; + +export type { PullRequestStatus, SupportedProvider, PRComment }; + +// 60s resolver cache — git remotes effectively never change within a session +// and we don't want to shell out to `git remote get-url` on every 10s poll. +const providerCache = new Map< + string, + { value: SupportedProvider | null; timestamp: number } +>(); +const PROVIDER_CACHE_TTL_MS = 60_000; + +/** + * Resolve the supported provider for a worktree. Returns null for gitlab, + * bitbucket, on-prem, or any unrecognized remote. + */ +export async function resolveProvider( + worktreePath: string, +): Promise { + const cached = providerCache.get(worktreePath); + if (cached && Date.now() - cached.timestamp < PROVIDER_CACHE_TTL_MS) { + return cached.value; + } + const info = await getGitRemoteInfo(worktreePath); + const value: SupportedProvider | null = + info.provider === "github" || info.provider === "azure" + ? info.provider + : null; + providerCache.set(worktreePath, { value, timestamp: Date.now() }); + return value; +} + +export function invalidateProviderCache(worktreePath?: string): void { + if (worktreePath) { + providerCache.delete(worktreePath); + } else { + providerCache.clear(); + } +} + +/** + * Fetch PR status for the current branch. Null on any failure — never throws. + * Safe to call from a 10s polling hook. + */ +export async function fetchPRStatus( + worktreePath: string, +): Promise { + const provider = await resolveProvider(worktreePath); + if (provider === "github") return fetchGitHubPRStatus(worktreePath); + if (provider === "azure") return fetchAzurePRStatus(worktreePath); + return null; +} + +/** + * Fetch PR comments for the current branch. Returns [] on failure. Azure v1 + * always returns [] (thread-model stub — see plan "Out of scope"). + */ +export async function fetchPRComments( + worktreePath: string, +): Promise { + const provider = await resolveProvider(worktreePath); + if (provider === "github") return fetchGitHubPRComments(worktreePath); + if (provider === "azure") return fetchAzurePRComments(worktreePath); + return []; +} + +/** + * Merge (complete) a PR. Throws with MERGE_CONFLICT: prefix on conflicts — + * the renderer already recognizes that contract. + */ +export async function mergePR(args: { + worktreePath: string; + prNumber: number; + method: "merge" | "squash" | "rebase"; +}): Promise<{ success: true }> { + const provider = await resolveProvider(args.worktreePath); + if (provider === "azure") return mergeAzurePR(args); + if (provider === "github") { + // Forward to the existing inline gh logic in chats.ts via a tiny helper. + return mergeGitHubPR(args); + } + throw new Error("PROVIDER_UNSUPPORTED: This repository's host is not supported for PR merge."); +} + +/** Update PR title. Throws on failure. */ +export async function updatePRTitle(args: { + worktreePath: string; + title: string; + prNumber?: number; +}): Promise<{ success: true; title: string }> { + const provider = await resolveProvider(args.worktreePath); + if (provider === "azure") return updateAzurePRTitle(args); + if (provider === "github") return updateGitHubPRTitle(args); + throw new Error("PROVIDER_UNSUPPORTED: This repository's host is not supported for PR title updates."); +} + +/** Invalidate PR status cache in BOTH providers (safe no-op per provider). */ +export function invalidatePRCache(worktreePath?: string): void { + invalidateGitHubPRCache(worktreePath); + invalidateAzurePRCache(worktreePath); +} + +/** Invalidate PR comments cache in BOTH providers. */ +export function invalidatePRCommentsCache(worktreePath?: string): void { + invalidateGitHubPRCommentsCache(worktreePath); + invalidateAzurePRCommentsCache(worktreePath); +} + +/** + * Build the browser URL for the "Create PR" page on the given provider. + */ +export function buildCreatePRWebUrl(args: { + provider: SupportedProvider; + remoteUrl: string; + branch: string; + baseBranch: string; +}): string { + if (args.provider === "github") { + // Preserves existing behavior from git-operations.createPR — a compare + // URL for the branch. `baseBranch` isn't needed in the GitHub form; + // GitHub's UI defaults to the repo's default branch. + const match = args.remoteUrl.match( + /github\.com[:/]([^/]+)\/([^/.]+)/, + ); + if (!match) { + throw new Error("Could not parse GitHub remote URL"); + } + const [, owner, repo] = match; + return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(args.branch)}?expand=1`; + } + + const remote = parseAzureRemoteUrl(args.remoteUrl); + if (!remote) { + throw new Error("Could not parse Azure DevOps remote URL"); + } + return buildAzureCreatePRWebUrl({ + remote, + branch: args.branch, + baseBranch: args.baseBranch, + }); +} + +// ---------- GitHub mutation adapters ---------- +// These wrap the existing gh-CLI calls currently inlined in chats.ts, so +// the tRPC router can call a single dispatcher regardless of provider. + +import { execWithShellEnv } from "../shell-env"; + +async function mergeGitHubPR(args: { + worktreePath: string; + prNumber: number; + method: "merge" | "squash" | "rebase"; +}): Promise<{ success: true }> { + const flag = `--${args.method}`; + try { + await execWithShellEnv( + "gh", + ["pr", "merge", String(args.prNumber), flag, "--delete-branch"], + { cwd: args.worktreePath }, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/conflict/i.test(msg) || /not mergeable/i.test(msg) || /CONFLICTING/.test(msg)) { + throw new Error(`MERGE_CONFLICT: ${msg}`); + } + throw new Error(`GitHub PR merge failed: ${msg}`); + } + invalidateGitHubPRCache(args.worktreePath); + invalidateGitHubPRCommentsCache(args.worktreePath); + return { success: true }; +} + +async function updateGitHubPRTitle(args: { + worktreePath: string; + title: string; + prNumber?: number; +}): Promise<{ success: true; title: string }> { + const cmd = + args.prNumber != null + ? ["pr", "edit", String(args.prNumber), "--title", args.title] + : ["pr", "edit", "--title", args.title]; + try { + await execWithShellEnv("gh", cmd, { cwd: args.worktreePath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/no pull request/i.test(msg)) { + throw new Error("No pull request exists for the current branch."); + } + throw new Error(`GitHub PR title update failed: ${msg}`); + } + invalidateGitHubPRCache(args.worktreePath); + return { success: true, title: args.title }; +} diff --git a/src/main/lib/git/providers/types.ts b/src/main/lib/git/providers/types.ts new file mode 100644 index 000000000..7e9127c59 --- /dev/null +++ b/src/main/lib/git/providers/types.ts @@ -0,0 +1,12 @@ +import type { GitHubStatus, PRComment } from "../github/types"; + +/** + * The provider-agnostic PR status shape. Aliased to GitHubStatus for v1 since + * the GitHub provider defined the canonical shape and the renderer types + * already import it everywhere. Renaming later is a mechanical follow-up. + */ +export type PullRequestStatus = GitHubStatus; + +export type { PRComment }; + +export type SupportedProvider = "github" | "azure"; diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index 49330d926..d33dcba2b 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -15,17 +15,20 @@ import { chats, getDatabase, projects, subChats } from "../../db" import { computeFileStatsFromMessages } from "../../file-stats" import { createWorktreeForChat, - fetchGitHubPRComments, - fetchGitHubPRStatus, getWorktreeDiff, - invalidateGitHubPRCache, removeWorktree, sanitizeProjectName, } from "../../git" +import { + fetchPRStatus, + fetchPRComments, + invalidatePRCache, + mergePR, + updatePRTitle, +} from "../../git/providers" import type { WorktreeSetupResult } from "../../git/worktree-config" import { computeContentHash, gitCache } from "../../git/cache" import { splitUnifiedDiffByFile } from "../../git/diff-parser" -import { execWithShellEnv } from "../../git/shell-env" import { applyRollbackStash } from "../../git/stash" import { checkInternetConnection, checkOllamaStatus } from "../../ollama" import { terminalManager } from "../../terminal/manager" @@ -1534,6 +1537,12 @@ export const chatsRouter = router({ return null } + const project = db + .select() + .from(projects) + .where(eq(projects.id, chat.projectId)) + .get() + try { const git = simpleGit(chat.worktreePath) const status = await git.status() @@ -1551,11 +1560,31 @@ export const chatsRouter = router({ hasUpstream = false } + // Provider info for agent prompt generation. Null/undefined for + // unsupported providers keeps the renderer on the GitHub default. + const provider = + project?.gitProvider === "github" || project?.gitProvider === "azure" + ? project.gitProvider + : null + const azure = + provider === "azure" && + project?.gitOwner && + project?.gitProject && + project?.gitRepo + ? { + organization: project.gitOwner, + project: project.gitProject, + repository: project.gitRepo, + } + : undefined + return { branch: chat.branch || status.current || "unknown", baseBranch: chat.baseBranch || "main", uncommittedCount: status.files.length, hasUpstream, + provider, + azure, } } catch (error) { console.error("[getPrContext] Error:", error) @@ -1616,7 +1645,7 @@ export const chatsRouter = router({ return null } - const status = await fetchGitHubPRStatus(chat.worktreePath) + const status = await fetchPRStatus(chat.worktreePath) // Back-fill DB so the sidebar badge can render from cached fields const pr = status?.pr @@ -1659,8 +1688,8 @@ export const chatsRouter = router({ throw new Error("No PR to merge") } - // Check PR mergeability before attempting merge - const prStatus = await fetchGitHubPRStatus(chat.worktreePath) + // Check PR mergeability before attempting merge (provider-agnostic) + const prStatus = await fetchPRStatus(chat.worktreePath) if (prStatus?.pr?.mergeable === "CONFLICTING") { throw new Error( "MERGE_CONFLICT: This PR has merge conflicts with the base branch. " + @@ -1669,32 +1698,28 @@ export const chatsRouter = router({ } try { - await execWithShellEnv( - "gh", - [ - "pr", - "merge", - String(chat.prNumber), - `--${input.method}`, - "--delete-branch", - ], - { cwd: chat.worktreePath }, - ) - return { success: true } + return await mergePR({ + worktreePath: chat.worktreePath, + prNumber: chat.prNumber, + method: input.method, + }) } catch (error) { console.error("[mergePr] Error:", error) - const errorMsg = error instanceof Error ? error.message : "Failed to merge PR" + const errorMsg = + error instanceof Error ? error.message : "Failed to merge PR" - // Check for conflict-related error messages from gh CLI + // Normalize non-prefixed conflict messages to the MERGE_CONFLICT: contract + // so the renderer surfaces the "Sync with Main" action. if ( - errorMsg.includes("not mergeable") || - errorMsg.includes("merge conflict") || - errorMsg.includes("cannot be cleanly created") || - errorMsg.includes("CONFLICTING") + !errorMsg.startsWith("MERGE_CONFLICT:") && + (errorMsg.includes("not mergeable") || + errorMsg.includes("merge conflict") || + errorMsg.includes("cannot be cleanly created") || + errorMsg.includes("CONFLICTING")) ) { throw new Error( "MERGE_CONFLICT: This PR has merge conflicts with the base branch. " + - "Please sync your branch with the latest changes from main to resolve conflicts." + "Please sync your branch with the latest changes from main to resolve conflicts." ) } @@ -1716,7 +1741,7 @@ export const chatsRouter = router({ .get() if (!chat?.worktreePath) return [] - return await fetchGitHubPRComments(chat.worktreePath) + return await fetchPRComments(chat.worktreePath) }), /** @@ -1746,14 +1771,14 @@ export const chatsRouter = router({ throw new Error("No worktree path for this chat") } - const args = input.prNumber - ? ["pr", "edit", String(input.prNumber), "--title", input.title] - : ["pr", "edit", "--title", input.title] - try { - await execWithShellEnv("gh", args, { cwd: chat.worktreePath }) - invalidateGitHubPRCache(chat.worktreePath) - return { success: true, title: input.title } + const result = await updatePRTitle({ + worktreePath: chat.worktreePath, + title: input.title, + prNumber: input.prNumber, + }) + invalidatePRCache(chat.worktreePath) + return result } catch (error) { const errorMsg = error instanceof Error ? error.message : "Failed to update PR title" diff --git a/src/main/lib/trpc/routers/projects.ts b/src/main/lib/trpc/routers/projects.ts index 5c15be890..5611471c1 100644 --- a/src/main/lib/trpc/routers/projects.ts +++ b/src/main/lib/trpc/routers/projects.ts @@ -98,6 +98,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .where(eq(projects.id, existing.id)) .returning() @@ -122,6 +123,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() @@ -167,6 +169,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() @@ -264,6 +267,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .where(eq(projects.id, input.id)) .returning() @@ -343,6 +347,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() @@ -374,6 +379,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() @@ -456,6 +462,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .where(eq(projects.id, existing.id)) .returning() @@ -473,6 +480,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() diff --git a/src/main/lib/trpc/routers/sandbox-import.ts b/src/main/lib/trpc/routers/sandbox-import.ts index e8fbf4db6..737246888 100644 --- a/src/main/lib/trpc/routers/sandbox-import.ts +++ b/src/main/lib/trpc/routers/sandbox-import.ts @@ -482,6 +482,7 @@ export const sandboxImportRouter = router({ let finalRepo = gitInfo.repo; let finalRemoteUrl = gitInfo.remoteUrl; let finalProvider = gitInfo.provider; + const finalProject = gitInfo.project; if (!finalOwner || !finalRepo) { const repoFromMeta = remoteChatData.meta?.repository; @@ -544,6 +545,7 @@ export const sandboxImportRouter = router({ gitProvider: finalProvider, gitOwner: finalOwner, gitRepo: finalRepo, + gitProject: finalProject, }) .where(eq(projects.id, existingProject.id)) .returning() @@ -557,6 +559,7 @@ export const sandboxImportRouter = router({ gitProvider: finalProvider, gitOwner: finalOwner, gitRepo: finalRepo, + gitProject: finalProject, }) .returning() .get(); diff --git a/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx index 079b05008..48a8b2e8c 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx @@ -386,7 +386,9 @@ function ProjectDetail({ projectId }: { projectId: string }) {
Repository

- {project.gitOwner}/{project.gitRepo} + {project.gitProvider === "azure" + ? `${project.gitOwner}/${project.gitProject}/${project.gitRepo}` + : `${project.gitOwner}/${project.gitRepo}`}

{project.gitProvider === "github" && ( @@ -405,6 +407,20 @@ function ProjectDetail({ projectId }: { projectId: string }) { GitHub )} + {project.gitProvider === "azure" && project.gitRemoteUrl && ( + + )} )} diff --git a/src/renderer/features/agents/utils/git-activity.ts b/src/renderer/features/agents/utils/git-activity.ts index 65620556f..4434559b7 100644 --- a/src/renderer/features/agents/utils/git-activity.ts +++ b/src/renderer/features/agents/utils/git-activity.ts @@ -63,12 +63,20 @@ function extractCommitInfo( } /** - * Extract PR info from a gh pr create command and its output. + * Extract PR info from a `gh pr create` or `az repos pr create` command + * and its output. */ function extractPrInfo(command: string, stdout: string): GitPrInfo | null { - if (!/gh\s+pr\s+create/.test(command)) return null + if (/gh\s+pr\s+create/.test(command)) { + return extractGithubPrInfo(command, stdout) + } + if (/az\s+repos\s+pr\s+create/.test(command)) { + return extractAzurePrInfo(command, stdout) + } + return null +} - // Extract URL from stdout +function extractGithubPrInfo(command: string, stdout: string): GitPrInfo | null { const urlMatch = stdout.match( /(https:\/\/github\.com\/[^\s]+\/pull\/\d+)/, ) @@ -78,12 +86,9 @@ function extractPrInfo(command: string, stdout: string): GitPrInfo | null { const numberMatch = url.match(/\/pull\/(\d+)/) const number = numberMatch ? parseInt(numberMatch[1]!, 10) : undefined - // Extract title from --title flag in command const titleMatch = command.match(/--title\s+["']([^"']+)["']/) const title = titleMatch?.[1] || `PR #${number || ""}` - // Extract branch: either an explicit --head flag, or the branch gh reports - // in its "Creating pull request for into " preamble. const headFlagMatch = command.match(/--head\s+["']?([^\s"']+)["']?/) const preambleMatch = stdout.match( /Creating (?:draft )?pull request for ([^\s]+) into /, @@ -93,6 +98,35 @@ function extractPrInfo(command: string, stdout: string): GitPrInfo | null { return { type: "pr", title, url, number, branch } } +function extractAzurePrInfo(command: string, stdout: string): GitPrInfo | null { + // `az repos pr create --output json` prints a single JSON object. + // If the user ran without --output json (human-readable form), bail — + // the activity badge just won't appear for that turn. + try { + const parsed = JSON.parse(stdout) + const prId = + typeof parsed?.pullRequestId === "number" + ? parsed.pullRequestId + : null + const webUrl = + typeof parsed?.repository?.webUrl === "string" + ? parsed.repository.webUrl + : null + if (prId == null || !webUrl) return null + + const url = `${webUrl}/pullrequest/${prId}` + const titleFromCmd = command.match(/--title\s+["']([^"']+)["']/)?.[1] + const title = + titleFromCmd || + (typeof parsed?.title === "string" ? parsed.title : `PR #${prId}`) + const branch = command.match(/--source-branch\s+["']?([^\s"']+)["']?/)?.[1] + + return { type: "pr", title, url, number: prId, branch } + } catch { + return null + } +} + /** * Scan message parts and return the most significant git activity. * Priority: last PR > last commit (PR is more significant). diff --git a/src/renderer/features/agents/utils/pr-message.ts b/src/renderer/features/agents/utils/pr-message.ts index a3dd4f47f..7cff37aa1 100644 --- a/src/renderer/features/agents/utils/pr-message.ts +++ b/src/renderer/features/agents/utils/pr-message.ts @@ -3,6 +3,14 @@ export interface PrContext { baseBranch: string uncommittedCount: number hasUpstream: boolean + /** Git host provider for this workspace. Null/undefined → treat as GitHub. */ + provider?: "github" | "azure" | null + /** Populated when provider === "azure" so the agent can target the right org/project/repo. */ + azure?: { + organization: string + project: string + repository: string + } } /** @@ -38,9 +46,20 @@ export function generatePrMessage(context: PrContext): string { } steps.push(`Use git diff origin/${baseBranch}... to review the PR diff`) - steps.push( - `Use gh pr create --base ${baseBranch} to create a PR. Keep the title under 80 characters and description under five sentences.` - ) + if (context.provider === "azure" && context.azure) { + const { organization, project, repository } = context.azure + steps.push( + `Use az repos pr create --source-branch ${branch} --target-branch ${baseBranch} ` + + `--repository ${repository} --project "${project}" ` + + `--organization https://dev.azure.com/${organization} ` + + `--title "" --description "<summary>" --output json ` + + `to create a PR. Keep the title under 80 characters and description under five sentences.`, + ) + } else { + steps.push( + `Use gh pr create --base ${baseBranch} to create a PR. Keep the title under 80 characters and description under five sentences.`, + ) + } steps.push("If any of these steps fail, ask the user for help.") // Add numbered steps diff --git a/src/renderer/features/details-sidebar/sections/pr-widget.tsx b/src/renderer/features/details-sidebar/sections/pr-widget.tsx index 6f1d3c7d3..2c6f94580 100644 --- a/src/renderer/features/details-sidebar/sections/pr-widget.tsx +++ b/src/renderer/features/details-sidebar/sections/pr-widget.tsx @@ -117,7 +117,7 @@ export const PrWidget = memo(function PrWidget({ chatId }: PrWidgetProps) { </Button> </TooltipTrigger> <TooltipContent side="left" className="text-xs"> - Open on GitHub + Open pull request </TooltipContent> </Tooltip> </div> diff --git a/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx b/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx index 37c758050..a41ec534d 100644 --- a/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx +++ b/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx @@ -60,7 +60,7 @@ export function RenamePrTitleDialog({ <DialogHeader> <DialogTitle>Rename PR #{prNumber}</DialogTitle> <DialogDescription> - Update the title of this pull request on GitHub. + Update the title of this pull request. </DialogDescription> </DialogHeader> <Input