Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ indent_size = 2
indent_style = space
indent_size = 4

# Go 由 gofmt 统一格式化:缩进恒用 tab(非空格),此处仅约定 tab 的显示宽度为 4。
[*.go]
indent_style = tab
tab_width = 4

[go.mod]
indent_style = tab
tab_width = 4

[{Makefile,makefile,GNUmakefile,*.mk}]
indent_style = tab
tab_width = 4
9 changes: 6 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,14 @@ jobs:
VERSION="${GITHUB_REF_NAME#v}"
BIN="meebox${{ matrix.ext }}"
ARCHIVE="meebox-cli-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}"
cp ../../LICENSE .
# Bundle LICENSE + README + SKILL.md so the archive is a drop-in agent skill
# directory (unzip into a skills dir → SKILL.md beside the binary it drives).
cp ../../LICENSE ../README.md ../SKILL.md .
FILES=("${BIN}" LICENSE README.md SKILL.md)
if [ "${{ matrix.archive }}" = "zip" ]; then
zip -q "${ARCHIVE}.zip" "${BIN}" LICENSE
zip -q "${ARCHIVE}.zip" "${FILES[@]}"
else
tar -czf "${ARCHIVE}.tar.gz" "${BIN}" LICENSE
tar -czf "${ARCHIVE}.tar.gz" "${FILES[@]}"
fi
for f in "${ARCHIVE}".zip "${ARCHIVE}".tar.gz; do
[ -e "$f" ] && sha256sum "$f" > "$f.sha256"
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ npm --prefix apps/desktop run prepare:pragent # 对齐嵌入式 pr-agent 运
- **独立 Go module,不入 npm/Nx**:自带 `cli/go.mod`(纯 Go、无 CGO),非 workspace 成员、不进 Nx——根 `lint/typecheck/test/build` 不覆盖它,CLI 自成一套。
- **本地命令**(在 `cli/`):`go vet ./...` → `go test ./...` → `go build ./...`,改完 CLI 三步过了再收尾。`go.sum` 入库(锁校验和);构建产物(`bin/` / `meebox` 等)已 gitignore(见 `cli/.gitignore`)。
- **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本经 `-ldflags -X …/cmd.version` 注入、与应用同 tag。
- **只读边界**:CLI 只做浏览与评审操作,**不提供评论发送等写操作**;写工具(approve/needswork/publish)在 CLI 与服务端双重硬拒绝。新增命令先确认对应 API 端点已存在且只读——CLI 不得绕过 API 直连应用内部。
- **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向),`--output json` 供机器;配置走环境变量 / flag / `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 隔离),代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。
- **压缩包即 skill 目录**:CLI 压缩包除二进制外一并打包 `LICENSE` + `cli/README.md` + `cli/SKILL.md`(frontmatter `name: meebox`)——解压投放到 agent 的 skills 目录即得可用 skill(面向 agent 交付的主形态)。改命令树 / 边界时同步更新 `SKILL.md` 与 `README.md`。
- **写边界**:CLI 做浏览 + **评审写动作**——approve / needswork(远端评审决断)与 comment(发评论),经服务端专用端点(复用 GUI 同源 controller)。仍**不开放**:merge(合并)与 pr-agent 变更类工具(publish 等,`instruct` 只读白名单 describe/review/ask/improve 在 CLI 与服务端双重把关)。新增命令先确认对应 API 端点已存在;放开新写端点须评估远端副作用。CLI 不得绕过 API 直连应用内部。
- **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向、保序)、`--output json` 供机器(亦保序);PR 列表返回精简投影、PR 标识对外为 `id`、PR 关联命令用 `--pr <id>`。连接配置走 flag / 环境变量(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)/ `~/.code-meeseeks/cli.yaml`,**不读 GUI 的 `config.yaml`**(避免越权触达连接层机密);代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。

## 约定

Expand Down
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
- **外部集成 · 本地 API 服务**:设置新增「集成」分区,可开启一个本机 API 服务,将 PR 浏览与评审 Agent 操作以接口形式开放给外部 agent / 工具 / 脚本集成。
- 默认关闭;开启即强制访问令牌鉴权,令牌可一键生成 / 显示 / 复制 / 重新生成。
- 监听地址可自定义:默认仅本机可达,按需可开放到局域网(开放时给出安全提示)。
- 仅开放浏览与评审操作(PR 列表 / 详情 / diff / 动态 / 提交 / 评审人审批,以及评审 Agent 的状态 / 历史 / 自动评审 / 指令 / 对话),不提供评论发送等写操作
- **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR 与操作评审 Agent,便于脚本与外部 agent 集成;与本地 API 一致,只读取向、不含写操作
- 开放浏览(当前身份 / PR 列表 / 详情 / diff / 动态 / 提交 / 评审人)、评审 Agent(状态 / 历史 / 自动评审 / 指令 / 对话 / 中断)与评审写动作(通过 / 需修改 / 发评论);不开放合并与变更类 Agent 工具(publish 等)
- **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR、操作评审 Agent 并执行评审写动作(approve / needswork / comment),便于脚本与外部 agent 集成。PR 列表精简且支持分页;PR 关联命令用 `--pr <id>`;连接信息须显式提供(flag / 环境变量 / cli.yaml),不读 GUI 主配置
- **PR 列表发现分类未读圆点**:某发现分类(待我评审 / 我创建 等)下有新的待处理 PR 时,在该分类标签后加未读圆点,一眼看出哪类有新进展;圆点始终基于活跃 PR,即便当前处于「已关闭」视图也正确反映活跃分类的未读。

### ♻️ 变更
Expand Down
103 changes: 92 additions & 11 deletions apps/desktop/src/main/services/api-server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ import * as agentCtl from '../../controllers/agent.js';
import * as prCtl from '../../controllers/pr.js';
import { getContext } from '../context.js';
import { HttpError } from './http.js';
import { toPrAgentRuns, toPrListItem } from './views.js';

/**
* 本地 API 的路由表与处理器。处理器**复用 IPC controller 同源逻辑**——controller 形态为
* `(event, req)` 且只读路径不触碰 event,故以 NO_EVENT 占位调用,避免在 HTTP 侧另起一套实现。
* 只读边界:写工具一律不暴露(见 agent/instruct)。见 docs/arch/04-integration/01-service-api.md。
* `(event, req)` 且这些路径不触碰 event,故以 NO_EVENT 占位调用,避免在 HTTP 侧另起一套实现。
*
* 写边界:开放**评审写操作**——approve / needswork(远端评审决断)与顶层 comment(发评论),
* 均复用 GUI 同源 controller。仍**不**暴露:merge(合并)、pr-agent 的变更类工具(publish 等,
* 见 agent/instruct 的只读白名单)。见 docs/arch/04-integration/01-service-api.md。
*/

// controller 形参 event 在被复用的只读 / 队列路径中均未使用,占位即可。
Expand Down Expand Up @@ -48,35 +52,66 @@ function seg(path: string): string[] {
return path.split('/').filter(Boolean);
}

/** 当前启用平台下可用的分类标签:一级(平台发现分类)+ 二级(状态 / 合并态筛选)。 */
/** 列表分页默认页大小(`limit` 缺省 / 非法 / ≤0 时取此值)。 */
const DEFAULT_LIMIT = 100;

/** 当前启用平台下可用的分类标签:`categories`(平台发现分类)+ `statuses`(状态 / 合并态筛选)。 */
const categories: RouteHandler = () => {
const ctx = getContext();
const activeId = ctx.bootstrap.config.active_connection_id;
const built = activeId
? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId)
: undefined;
const caps = built?.adapter.connection.capabilities();
const primary: PrDiscoveryFilter[] = caps?.discoveryFilters
const categoryList: PrDiscoveryFilter[] = caps?.discoveryFilters
? [...caps.discoveryFilters]
: ['review-requested'];
return {
platform: built?.adapter.kind ?? null,
primary,
secondary: [...PR_SECONDARY_FILTERS],
categories: categoryList,
statuses: [...PR_SECONDARY_FILTERS],
};
};

/**
* 当前身份与集成平台:活动连接的 PAT 所属用户(name / displayName / slug)+ 平台种类 +
* 连接显示名。无活动连接时各项为 null。刻意收窄——不带 capabilities(那是 GUI 降级用的大对象)。
*/
const whoami: RouteHandler = () => {
const ctx = getContext();
const activeId = ctx.bootstrap.config.active_connection_id;
const built = activeId
? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId)
: undefined;
if (!activeId || !built) {
return { platform: null, connectionId: null, displayName: null, user: null };
}
const conn = ctx.bootstrap.config.connections.find((c) => c.id === activeId);
const user = built.adapter.connection.getCurrentUser();
return {
platform: built.adapter.kind,
connectionId: activeId,
displayName: conn?.display_name ?? activeId,
user: user ? { name: user.name, displayName: user.displayName, slug: user.slug ?? null } : null,
};
};

/**
* PR 列表(不分页)+ 一级 / 二级分类过滤 + 检索。过滤语义复用 @meebox/shared 的纯谓词
* (与渲染层侧栏同源),此处仅做查询参数解析 + 委派。
* PR 列表:`category`(一级发现分类)+ `status`(二级状态 / 合并态)过滤 + `q` 检索 +
* `skip`/`limit` 分页(默认 limit 100)。过滤语义复用 @meebox/shared 的纯谓词(与渲染层侧栏同源);
* 返回**精简列表投影**({@link toPrListItem},去 description 明细、人员仅 slug),此处仅解析参数 + 委派。
*/
const listPrs: RouteHandler = async ({ query }) => {
const all = await prCtl.listPrs(NO_EVENT, undefined);
return filterPullRequests(all, {
primary: (query.get('primary') as PrDiscoveryFilter) || undefined,
secondary: (query.get('secondary') as PrSecondaryFilter) || undefined,
const filtered = filterPullRequests(all, {
primary: (query.get('category') as PrDiscoveryFilter) || undefined,
secondary: (query.get('status') as PrSecondaryFilter) || undefined,
query: query.get('q') ?? undefined,
});
const skip = Math.max(0, Number.parseInt(query.get('skip') ?? '', 10) || 0);
const limitRaw = Number.parseInt(query.get('limit') ?? '', 10);
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : DEFAULT_LIMIT;
return filtered.slice(skip, skip + limit).map(toPrListItem);
};

const showPr: RouteHandler = ({ params }) => getContext().pr.findPrOrThrow(params.id);
Expand Down Expand Up @@ -129,8 +164,48 @@ const agentChat: RouteHandler = ({ params, body }) => {
return agentCtl.enqueueMessage(NO_EVENT, { localId: params.id, message: b.message });
};

/** 中断该 PR 正在运行的 Agent(思考 / 执行任意阶段即时停)。PR 级停,非按单个工具 run。 */
const agentStop: RouteHandler = ({ params }) =>
agentCtl.stopAgent(NO_EVENT, { localId: params.id });

/** 该 PR 在运行队列里的 pr-agent runs(active + waiting),供按 run 取消前的发现。 */
const agentRuns: RouteHandler = async ({ params }) => {
const snapshot = await agentCtl.getQueue(NO_EVENT, undefined);
return toPrAgentRuns(snapshot, params.id);
};

/** 取消该 PR 的某个 pr-agent run(active SIGKILL / waiting 出队)。先校验 run 归属该 PR。 */
const agentRunCancel: RouteHandler = async ({ params }) => {
const snapshot = await agentCtl.getQueue(NO_EVENT, undefined);
const belongs = [...snapshot.active, ...snapshot.waiting].some(
(r) => r.runId === params.runId && r.prLocalId === params.id,
);
if (!belongs) {
throw new HttpError(404, ERROR_CODES.SV_NOT_FOUND, { runId: params.runId, localId: params.id });
}
return agentCtl.cancelPragent(NO_EVENT, { runId: params.runId });
};

/** 评审决断「通过」:先写远端评审状态、再落本地(复用 GUI 同源 setPrStatus)。 */
const approve: RouteHandler = ({ params }) =>
prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'approved' });

/** 评审决断「需修改」:先写远端评审状态、再落本地。 */
const needswork: RouteHandler = ({ params }) =>
prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'needs_work' });

/** 发一条顶层(不锚文件)评论到远端 PR。body.body 为评论正文,空则 400。 */
const comment: RouteHandler = ({ params, body }) => {
const b = (body ?? {}) as { body?: string };
if (!b.body?.trim()) {
throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'comment body required' });
}
return prCtl.createComment(NO_EVENT, { localId: params.id, body: b.body });
};

export const routes: Route[] = [
{ method: 'GET', segments: seg('/api/v1/categories'), handler: categories },
{ method: 'GET', segments: seg('/api/v1/whoami'), handler: whoami },
{ method: 'GET', segments: seg('/api/v1/prs'), handler: listPrs },
{ method: 'GET', segments: seg('/api/v1/prs/:id'), handler: showPr },
{ method: 'GET', segments: seg('/api/v1/prs/:id/diff'), handler: diff },
Expand All @@ -142,6 +217,12 @@ export const routes: Route[] = [
{ method: 'POST', segments: seg('/api/v1/prs/:id/agent/review'), handler: agentReview },
{ method: 'POST', segments: seg('/api/v1/prs/:id/agent/instruct'), handler: agentInstruct },
{ method: 'POST', segments: seg('/api/v1/prs/:id/agent/chat'), handler: agentChat },
{ method: 'POST', segments: seg('/api/v1/prs/:id/agent/stop'), handler: agentStop },
{ method: 'GET', segments: seg('/api/v1/prs/:id/agent/runs'), handler: agentRuns },
{ method: 'POST', segments: seg('/api/v1/prs/:id/agent/runs/:runId/cancel'), handler: agentRunCancel },
{ method: 'POST', segments: seg('/api/v1/prs/:id/approve'), handler: approve },
{ method: 'POST', segments: seg('/api/v1/prs/:id/needswork'), handler: needswork },
{ method: 'POST', segments: seg('/api/v1/prs/:id/comment'), handler: comment },
];

/** 按方法 + 路径匹配路由,提取 `:param` 路径参数;无匹配返回 null。 */
Expand Down
104 changes: 104 additions & 0 deletions apps/desktop/src/main/services/api-server/views.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { PragentRunInfo } from '@meebox/ipc';
import type {
LocalPrStatus,
PlatformKind,
PrDiscoveryFilter,
ReviewRunTool,
ReviewerStatus,
StoredPullRequest,
} from '@meebox/shared';

/**
* PR 列表视图项:`GET /prs` 对外暴露的**精简投影**。这是「请求接口视图层的树结构约束方法」——
* 单一投影函数 {@link toPrListItem} 定义列表返回的字段集合与次序,避免直接把整条
* StoredPullRequest(含 description 明细、完整人员对象等)泄给列表消费方。
*
* 收窄原则:
* - 只给标识与概览,**去掉 description 明细**(详情走 `GET /prs/{id}`);
* - **人员信息只留 slug**(reviewer 另带 status);头像 / 展示名等留给详情;
* - **字段顺序即输出顺序**:id / title / author / createdAt 优先,再给其余概览字段。
*/
export interface PrListItem {
/** PR 的本地稳定标识(== StoredPullRequest.localId);写操作与详情端点均按此定位。 */
id: string;
title: string;
/** 作者 slug(缺失时回退 name);不含展示名 / 头像。 */
author: string;
createdAt: string;
/** 本人评审决断(pending / approved / needs_work)。 */
status: LocalPrStatus;
state: 'open' | 'merged' | 'declined';
draft: boolean;
platform: PlatformKind;
/** `projectKey/repoSlug`。 */
repo: string;
/** 远端平台 PR 编号。 */
remoteId: string;
updatedAt: string;
hasConflict: boolean;
/** 远端判定可直接合并(== mergeStatus.canMerge)。 */
mergeable: boolean;
/** 命中的发现分类(一级 category)。 */
categories: PrDiscoveryFilter[];
/** 评审人:仅 slug + status。 */
reviewers: Array<{ slug: string; status: ReviewerStatus }>;
unread: boolean;
unreadMentionCount: number;
}

/**
* 某 PR 在运行队列里的一个 pr-agent run 视图项:`GET /prs/{id}/agent/runs` 的投影。用于让调用方
* 发现可取消的 run(runId + tool + 运行 / 排队态),配合 `…/runs/{runId}/cancel` 做按 run 取消。
*/
export interface PrAgentRunItem {
runId: string;
tool: ReviewRunTool;
/** active = 正在执行;waiting = 排队中。 */
state: 'active' | 'waiting';
/** 开始执行时间(ISO);waiting 为 null。 */
startedAt: string | null;
enqueuedAt: string;
question?: string;
}

/** 从队列快照筛出属于该 PR 的 run(active 在前、waiting 在后),投影为精简项。 */
export function toPrAgentRuns(
queue: { active: PragentRunInfo[]; waiting: PragentRunInfo[] },
prId: string,
): PrAgentRunItem[] {
const pick = (r: PragentRunInfo, state: 'active' | 'waiting'): PrAgentRunItem => ({
runId: r.runId,
tool: r.tool,
state,
startedAt: r.startedAt,
enqueuedAt: r.enqueuedAt,
...(r.question ? { question: r.question } : {}),
});
return [
...queue.active.filter((r) => r.prLocalId === prId).map((r) => pick(r, 'active')),
...queue.waiting.filter((r) => r.prLocalId === prId).map((r) => pick(r, 'waiting')),
];
}

/** 把存储态 PR 投影为列表视图项。对象字面量的键序即 JSON 输出顺序(CLI 视图层据此渲染)。 */
export function toPrListItem(pr: StoredPullRequest): PrListItem {
return {
id: pr.localId,
title: pr.title,
author: pr.author.slug ?? pr.author.name,
createdAt: pr.createdAt,
status: pr.localStatus,
state: pr.state,
draft: pr.draft,
platform: pr.platform,
repo: `${pr.repo.projectKey}/${pr.repo.repoSlug}`,
remoteId: pr.remoteId,
updatedAt: pr.updatedAt,
hasConflict: pr.hasConflict,
mergeable: pr.mergeStatus?.canMerge === true,
categories: pr.discoveryFilters,
reviewers: pr.reviewers.map((r) => ({ slug: r.slug ?? r.name, status: r.status })),
unread: pr.unread ?? false,
unreadMentionCount: pr.unreadMentionCount ?? 0,
};
}
19 changes: 19 additions & 0 deletions apps/desktop/src/renderer/src/styles/base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,25 @@ textarea {
}
}

// 文本型按钮:无底 / 无边框的链接态动作(工具栏里的轻量操作,如「全选 / 取消全选」),
// 强调蓝、hover 加下划线;不占按钮实底,读作行内链接。
.btn-link {
background: none;
border: none;
padding: 0;
color: $color-accent;
font: inherit;
cursor: pointer;

&:hover:not(:disabled) {
text-decoration: underline;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}

.count-pill {
background: $bg-white-fade;
padding: 0 $space-3;
Expand Down
Loading
Loading