import * as vscode from "vscode"; import { matchAuthorizedRepository, parseGitHubRemote, type GitHubAvailableRepository } from "./shared"; type DevTask = { id: string; title: string; description?: string; status: string; priority: string; project_id: string; project_title: string; charter_id: string; charter_title: string; prompt_package_count: number; model_binding?: { display_name: string; provider: string; } | null; }; type RepoBindingSummary = { id: string; project_id: string; provider: string; owner: string; repo_name: string; full_name: string; default_branch: string; installation_id?: string | null; installation_account_login?: string | null; app_state?: string | null; status: string; }; type ProgressEventResponse = { id: string; task_id: string; project_id: string; event_type: string; workflow_state: string; derived_task_status: string; task_status: string; payload: Record; delivery_id?: string | null; event_source_id?: string | null; }; type BranchTaskBinding = { rootPath: string; branchName: string; taskId: string; projectId: string; repoBindingId: string; lastCommitSha?: string; }; type GitRepositoryInfo = { rootPath: string; branchName: string; defaultBranch: string; commitSha?: string; owner: string; repoName: string; }; const SERVER_KEY = "pmflow.serverUrl"; const TOKEN_KEY = "pmflow.token"; const BRANCH_BINDINGS_KEY = "pmflow.branchTaskBindings"; const OUTPUT_CHANNEL = vscode.window.createOutputChannel("PMFlow IDE"); function logSync(message: string): void { OUTPUT_CHANNEL.appendLine(`[PMFlow] ${message}`); } class PMFlowTaskItem extends vscode.TreeItem { constructor(public readonly task: DevTask) { super(task.title, vscode.TreeItemCollapsibleState.None); this.description = `${task.project_title} · ${task.status} · ${task.priority}`; this.tooltip = `${task.title}\n${task.description || ""}`; this.contextValue = "pmflowTask"; this.command = { command: "pmflow.generatePromptPackage", title: "Generate Prompt Package", arguments: [task], }; } } class PMFlowTaskProvider implements vscode.TreeDataProvider { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this.emitter.event; constructor(private readonly context: vscode.ExtensionContext) {} refresh(): void { this.emitter.fire(); } getTreeItem(element: PMFlowTaskItem): vscode.TreeItem { return element; } async getChildren(): Promise { const tasks = await fetchDevTasks(this.context); return tasks.map((task) => new PMFlowTaskItem(task)); } } async function fetchJson(context: vscode.ExtensionContext, path: string, init?: RequestInit): Promise { const serverUrl = context.globalState.get(SERVER_KEY); const token = context.globalState.get(TOKEN_KEY); if (!serverUrl || !token) { throw new Error("请先执行 PMFlow: Login"); } const response = await fetch(`${serverUrl.replace(/\/$/, "")}${path}`, { ...init, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...(init?.headers || {}), }, }); if (!response.ok) { const text = await response.text(); throw new Error(text || `请求失败: ${response.status}`); } const payload = (await response.json()) as { data?: T } | T; return (payload && typeof payload === "object" && "data" in payload ? payload.data : payload) as T; } async function fetchDevTasks(context: vscode.ExtensionContext): Promise { return await fetchJson(context, "/me/dev-tasks"); } async function fetchAvailableGithubRepos( context: vscode.ExtensionContext, projectId: string ): Promise { return await fetchJson( context, `/integrations/github/repos/available?project_id=${encodeURIComponent(projectId)}` ); } async function login(context: vscode.ExtensionContext): Promise { const serverUrl = await vscode.window.showInputBox({ title: "PMFlow Server URL", prompt: "输入 PMFlow API 地址,例如 http://localhost:8000/api/v1", value: context.globalState.get(SERVER_KEY) || "http://localhost:8000/api/v1", }); if (!serverUrl) return; const token = await vscode.window.showInputBox({ title: "PMFlow Access Token", prompt: "输入 PMFlow 登录后的 Bearer Token", password: true, }); if (!token) return; await context.globalState.update(SERVER_KEY, serverUrl); await context.globalState.update(TOKEN_KEY, token); vscode.window.showInformationMessage("PMFlow 登录信息已保存"); } async function generatePromptPackage(context: vscode.ExtensionContext, task?: DevTask): Promise { if (!task) { vscode.window.showWarningMessage("请先从 PMFlow Tasks 面板选择任务"); return; } const payload = await fetchJson<{ package_text: string; package_payload: Record; }>( context, `/projects/${task.project_id}/charters/${task.charter_id}/prompt-packages`, { method: "POST", body: JSON.stringify({ task_id: Number(task.id) }), } ); const document = await vscode.workspace.openTextDocument({ content: payload.package_text, language: "markdown", }); await vscode.window.showTextDocument(document, { preview: false }); } function getBranchBindings(context: vscode.ExtensionContext): BranchTaskBinding[] { return context.globalState.get(BRANCH_BINDINGS_KEY) || []; } async function saveBranchBindings(context: vscode.ExtensionContext, bindings: BranchTaskBinding[]): Promise { await context.globalState.update(BRANCH_BINDINGS_KEY, bindings); } async function upsertBranchBinding(context: vscode.ExtensionContext, binding: BranchTaskBinding): Promise { const bindings = getBranchBindings(context); const next = bindings.filter( (item) => !(item.rootPath === binding.rootPath && item.branchName === binding.branchName) ); next.push(binding); await saveBranchBindings(context, next); } function getGitApi(): any | null { const extension = vscode.extensions.getExtension("vscode.git"); if (!extension) return null; const exported = extension.isActive ? extension.exports : undefined; const apiProvider = exported || extension.exports; if (!apiProvider?.getAPI) return null; return apiProvider.getAPI(1); } async function pickGitRepository(): Promise { const git = getGitApi(); const repositories: any[] = git?.repositories || []; if (!repositories.length) { vscode.window.showWarningMessage("未检测到 Git 仓库,请先在 VS Code / Cursor 中打开项目仓库。"); return null; } const picked = repositories.length === 1 ? repositories[0] : await vscode.window.showQuickPick( repositories.map((repo) => ({ label: repo.rootUri?.fsPath || "未知仓库", description: repo.state?.HEAD?.name || "未命名分支", repo, })), { placeHolder: "选择要绑定的本地仓库" } ); const repository = repositories.length === 1 ? picked : picked?.repo; if (!repository) return null; const rootPath = String(repository.rootUri?.fsPath || "").trim(); const branchName = String(repository.state?.HEAD?.name || "").trim(); const commitSha = String(repository.state?.HEAD?.commit || "").trim() || undefined; const defaultBranch = String(repository.state?.remotes?.[0]?.fetchUrl || "").includes("github.com") ? "main" : "main"; const remotes: any[] = repository.state?.remotes || []; const remoteUrl = remotes.map((remote) => remote.fetchUrl || remote.pushUrl).find(Boolean); const parsed = parseGitHubRemote(String(remoteUrl || "")); if (!rootPath || !branchName || !parsed) { vscode.window.showWarningMessage("当前仓库缺少可识别的 GitHub 远端或分支信息。"); return null; } return { rootPath, branchName, commitSha, defaultBranch, owner: parsed.owner, repoName: parsed.repoName, }; } async function pickTask(context: vscode.ExtensionContext, preferredTask?: DevTask): Promise { if (preferredTask) return preferredTask; const tasks = await fetchDevTasks(context); if (!tasks.length) { vscode.window.showInformationMessage("当前账号没有可同步的开发任务。"); return null; } const picked = await vscode.window.showQuickPick( tasks.map((task) => ({ label: task.title, description: `${task.project_title} · ${task.status}`, detail: `${task.charter_title} · ${task.priority}`, task, })), { placeHolder: "选择要绑定的 PMFlow 任务" } ); return picked?.task || null; } async function ensureRepoBinding(context: vscode.ExtensionContext, task: DevTask): Promise<{ repoBinding: RepoBindingSummary; repoInfo: GitRepositoryInfo; } | null> { const repoInfo = await pickGitRepository(); if (!repoInfo) return null; const availableRepos = await fetchAvailableGithubRepos(context, task.project_id); if (!availableRepos.length) { vscode.window.showWarningMessage("当前项目下没有 GitHub App 已授权仓库,请先在 PMFlow 的 AI 配置里同步安装信息。"); return null; } const matchedRepo = matchAuthorizedRepository(availableRepos, { owner: repoInfo.owner, repoName: repoInfo.repoName, }); if (!matchedRepo) { vscode.window.showWarningMessage( `当前远端 ${repoInfo.owner}/${repoInfo.repoName} 不在 GitHub App 已授权仓库列表中,请先在 PMFlow 绑定授权仓库。` ); return null; } const repoBinding = await fetchJson(context, "/integrations/github/repos/bind", { method: "POST", body: JSON.stringify({ project_id: Number(task.project_id), github_repository_id: Number(matchedRepo.id), }), }); logSync(`仓库已绑定:${matchedRepo.full_name} -> project ${task.project_id}`); return { repoBinding, repoInfo }; } async function sendProgressEvent( context: vscode.ExtensionContext, task: DevTask, args: { repoBindingId: string; eventType: string; summary: string; branchName?: string; commitSha?: string; prNumber?: number; } ): Promise { return await fetchJson(context, `/tasks/${task.id}/progress-events`, { method: "POST", body: JSON.stringify({ project_id: Number(task.project_id), event_type: args.eventType, source: "ide_extension", summary: args.summary, repo_binding_id: Number(args.repoBindingId), payload: { branch_name: args.branchName, commit_sha: args.commitSha, pr_number: args.prNumber, }, }), }); } async function bindCurrentRepo(context: vscode.ExtensionContext, task?: DevTask): Promise { const pickedTask = await pickTask(context, task); if (!pickedTask) return; const binding = await ensureRepoBinding(context, pickedTask); if (!binding) return; vscode.window.showInformationMessage( `已绑定仓库 ${binding.repoBinding.full_name} -> ${pickedTask.project_title}` ); } async function startTaskOnCurrentBranch(context: vscode.ExtensionContext, task?: DevTask): Promise { const pickedTask = await pickTask(context, task); if (!pickedTask) return; const binding = await ensureRepoBinding(context, pickedTask); if (!binding) return; const result = await sendProgressEvent(context, pickedTask, { repoBindingId: binding.repoBinding.id, eventType: "commit", summary: `IDE 已在分支 ${binding.repoInfo.branchName} 启动任务`, branchName: binding.repoInfo.branchName, commitSha: binding.repoInfo.commitSha, }); await upsertBranchBinding(context, { rootPath: binding.repoInfo.rootPath, branchName: binding.repoInfo.branchName, taskId: pickedTask.id, projectId: pickedTask.project_id, repoBindingId: binding.repoBinding.id, lastCommitSha: binding.repoInfo.commitSha, }); vscode.window.showInformationMessage( `任务已绑定到分支 ${binding.repoInfo.branchName},当前状态:${result.task_status}` ); logSync(`任务 ${pickedTask.id} 已绑定到分支 ${binding.repoInfo.branchName},状态 ${result.task_status}`); } async function markPrOpened(context: vscode.ExtensionContext, task?: DevTask): Promise { const pickedTask = await pickTask(context, task); if (!pickedTask) return; const binding = await ensureRepoBinding(context, pickedTask); if (!binding) return; const prInput = await vscode.window.showInputBox({ title: "Pull Request 编号", prompt: "输入当前分支对应的 PR 编号", validateInput: (value) => (/^\d+$/.test(value.trim()) ? undefined : "请输入数字 PR 编号"), }); if (!prInput) return; const prNumber = Number(prInput); const result = await sendProgressEvent(context, pickedTask, { repoBindingId: binding.repoBinding.id, eventType: "pr_opened", summary: `PR #${prNumber} 已打开`, branchName: binding.repoInfo.branchName, commitSha: binding.repoInfo.commitSha, prNumber, }); await upsertBranchBinding(context, { rootPath: binding.repoInfo.rootPath, branchName: binding.repoInfo.branchName, taskId: pickedTask.id, projectId: pickedTask.project_id, repoBindingId: binding.repoBinding.id, lastCommitSha: binding.repoInfo.commitSha, }); vscode.window.showInformationMessage(`PR 已回写到 PMFlow,当前状态:${result.workflow_state || result.task_status}`); logSync(`PR #${prNumber} 已回写,状态 ${result.workflow_state || result.task_status}`); } async function markTaskAccepted(context: vscode.ExtensionContext, task?: DevTask): Promise { const pickedTask = await pickTask(context, task); if (!pickedTask) return; const bindings = getBranchBindings(context); const existing = bindings.find((item) => item.taskId === pickedTask.id); if (!existing) { vscode.window.showWarningMessage("当前任务还没有分支绑定,请先执行 Start Task On Current Branch。"); return; } const result = await sendProgressEvent(context, pickedTask, { repoBindingId: existing.repoBindingId, eventType: "accepted", summary: "验收确认通过", branchName: existing.branchName, commitSha: existing.lastCommitSha, }); vscode.window.showInformationMessage(`任务已验收完成,当前状态:${result.task_status}`); logSync(`任务 ${pickedTask.id} 已验收完成,状态 ${result.task_status}`); } function registerGitAutoSync(context: vscode.ExtensionContext): void { const git = getGitApi(); const repositories: any[] = git?.repositories || []; if (!repositories.length) return; for (const repository of repositories) { const disposable = repository.state.onDidChange(async () => { const branchName = String(repository.state?.HEAD?.name || "").trim(); const commitSha = String(repository.state?.HEAD?.commit || "").trim(); const rootPath = String(repository.rootUri?.fsPath || "").trim(); if (!branchName || !commitSha || !rootPath) return; const bindings = getBranchBindings(context); const binding = bindings.find( (item) => item.rootPath === rootPath && item.branchName === branchName ); if (!binding || binding.lastCommitSha === commitSha) return; logSync(`检测到新 commit ${commitSha},分支 ${branchName}`); try { const task = (await fetchDevTasks(context)).find((item) => item.id === binding.taskId); if (!task) return; await sendProgressEvent(context, task, { repoBindingId: binding.repoBindingId, eventType: "commit", summary: `本地提交已同步:${branchName}`, branchName, commitSha, }); await upsertBranchBinding(context, { ...binding, lastCommitSha: commitSha }); logSync(`commit 已同步到任务 ${task.id},状态回写完成`); } catch (error) { logSync(`commit 自动同步失败: ${String(error)}`); } }); context.subscriptions.push(disposable); } } export function activate(context: vscode.ExtensionContext) { const provider = new PMFlowTaskProvider(context); context.subscriptions.push(vscode.window.registerTreeDataProvider("pmflowTasks", provider)); context.subscriptions.push(OUTPUT_CHANNEL); context.subscriptions.push( vscode.commands.registerCommand("pmflow.login", async () => { await login(context); provider.refresh(); }) ); context.subscriptions.push( vscode.commands.registerCommand("pmflow.refreshTasks", async () => { provider.refresh(); }) ); context.subscriptions.push( vscode.commands.registerCommand("pmflow.generatePromptPackage", async (task?: DevTask) => { await generatePromptPackage(context, task); }) ); context.subscriptions.push( vscode.commands.registerCommand("pmflow.bindCurrentRepo", async (task?: DevTask) => { await bindCurrentRepo(context, task); provider.refresh(); }) ); context.subscriptions.push( vscode.commands.registerCommand("pmflow.startTaskOnCurrentBranch", async (task?: DevTask) => { await startTaskOnCurrentBranch(context, task); provider.refresh(); }) ); context.subscriptions.push( vscode.commands.registerCommand("pmflow.markPrOpened", async (task?: DevTask) => { await markPrOpened(context, task); provider.refresh(); }) ); context.subscriptions.push( vscode.commands.registerCommand("pmflow.markTaskAccepted", async (task?: DevTask) => { await markTaskAccepted(context, task); provider.refresh(); }) ); registerGitAutoSync(context); } export function deactivate() { return undefined; }