PMFlow/extensions/pmflow-ide/src/extension.ts

527 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
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<PMFlowTaskItem> {
private readonly emitter = new vscode.EventEmitter<PMFlowTaskItem | undefined | void>();
readonly onDidChangeTreeData: vscode.Event<PMFlowTaskItem | undefined | void> = this.emitter.event;
constructor(private readonly context: vscode.ExtensionContext) {}
refresh(): void {
this.emitter.fire();
}
getTreeItem(element: PMFlowTaskItem): vscode.TreeItem {
return element;
}
async getChildren(): Promise<PMFlowTaskItem[]> {
const tasks = await fetchDevTasks(this.context);
return tasks.map((task) => new PMFlowTaskItem(task));
}
}
async function fetchJson<T>(context: vscode.ExtensionContext, path: string, init?: RequestInit): Promise<T> {
const serverUrl = context.globalState.get<string>(SERVER_KEY);
const token = context.globalState.get<string>(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<DevTask[]> {
return await fetchJson<DevTask[]>(context, "/me/dev-tasks");
}
async function fetchAvailableGithubRepos(
context: vscode.ExtensionContext,
projectId: string
): Promise<GitHubAvailableRepository[]> {
return await fetchJson<GitHubAvailableRepository[]>(
context,
`/integrations/github/repos/available?project_id=${encodeURIComponent(projectId)}`
);
}
async function login(context: vscode.ExtensionContext): Promise<void> {
const serverUrl = await vscode.window.showInputBox({
title: "PMFlow Server URL",
prompt: "输入 PMFlow API 地址,例如 http://localhost:8000/api/v1",
value: context.globalState.get<string>(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<void> {
if (!task) {
vscode.window.showWarningMessage("请先从 PMFlow Tasks 面板选择任务");
return;
}
const payload = await fetchJson<{
package_text: string;
package_payload: Record<string, unknown>;
}>(
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<BranchTaskBinding[]>(BRANCH_BINDINGS_KEY) || [];
}
async function saveBranchBindings(context: vscode.ExtensionContext, bindings: BranchTaskBinding[]): Promise<void> {
await context.globalState.update(BRANCH_BINDINGS_KEY, bindings);
}
async function upsertBranchBinding(context: vscode.ExtensionContext, binding: BranchTaskBinding): Promise<void> {
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<GitRepositoryInfo | null> {
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<DevTask | null> {
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<RepoBindingSummary>(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<ProgressEventResponse> {
return await fetchJson<ProgressEventResponse>(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<void> {
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<void> {
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<void> {
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<void> {
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;
}