527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
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;
|
||
}
|