feat(ack): add Feishu bug intake
This commit is contained in:
+12
-3
@@ -96,14 +96,18 @@ description: >-
|
||||
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <project-root>`
|
||||
校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目
|
||||
缺少可选交付配置而宣称失败。
|
||||
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
||||
5. 若存在 `project.bugIntake`,运行
|
||||
`python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml`。
|
||||
它只接受 `feishu-base` 和显式 profile;详细的飞书配置、凭据初始化和读取方式见
|
||||
`references/feishu-bug-intake.md`。
|
||||
6. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
||||
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
|
||||
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
||||
7. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
||||
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
|
||||
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
||||
必须同时为空或同时填写。
|
||||
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
||||
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||
8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||
|
||||
## 工作
|
||||
|
||||
@@ -121,6 +125,11 @@ description: >-
|
||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
||||
不全量注入知识库。
|
||||
配置了 `project.bugIntake` 时,先按 `references/feishu-bug-intake.md` 运行 check,
|
||||
再运行 plan 获取标准化记录及 `create` / `refresh` / `unchanged` / `drift` 整理动作。
|
||||
按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述;
|
||||
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
||||
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
||||
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
||||
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
|
||||
步骤放入同一份计划。确认前不派发实现,也不执行交付。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.11.0
|
||||
0.12.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||
|
||||
> 本项目基于 ack v0.11.0。
|
||||
> 本项目基于 ack v0.12.0。
|
||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
version: 1
|
||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "0.11.0"
|
||||
ackVersion: "0.12.0"
|
||||
project:
|
||||
name: "notes-web"
|
||||
repoPath: "/home/dev/notes-web"
|
||||
@@ -104,6 +104,12 @@ tasks:
|
||||
evidence: "服务实例、worktree 与 commit 9f8e7d6 一致"
|
||||
checkedBy: "test-worker-1"
|
||||
checkedAt: "2026-07-06T09:36:00+08:00"
|
||||
# 飞书导入时使用不透明 digest;不要在 source.ref 中放 profile、Base、table 或 record ID。
|
||||
source:
|
||||
kind: "feishu-base"
|
||||
ref: "feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
recordId: "recExample"
|
||||
updatedAt: "2026-07-06T09:00:00Z"
|
||||
description: >
|
||||
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
|
||||
stepsToReproduce:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 飞书 Base Bug 收件箱
|
||||
|
||||
这是可选的只读接入。项目在 `docs/ack/tasks.yaml` 的 `project.bugIntake` 声明
|
||||
`provider: feishu-base`、显式 `profile`、`baseToken`、`tableId`、ACK Ready 的 `viewId`
|
||||
和八个字段映射;不要保存 App Secret、access token 或任何 profile 凭据。
|
||||
|
||||
## 一次性安装与 profile 设置
|
||||
|
||||
在账号级可信工具目录安装官方 CLI;ACK 读取器只会搜索账号的 `~/.local/bin`、mise/cargo
|
||||
shim 目录和固定系统目录,绝不会采用项目 `PATH` 中的同名文件。它同时识别官方 npm
|
||||
包生成的 `lark-cli -> @larksuite/cli/scripts/run.js` wrapper,并校验 package 名与 bin
|
||||
映射,然后直接执行该官方包下载的 native binary;这样不依赖 nvm PATH,也不会让 Node
|
||||
运行时变量进入凭据边界。缺少 native binary 或同名仿冒 wrapper 都会被拒绝:
|
||||
|
||||
```bash
|
||||
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
|
||||
```
|
||||
|
||||
固定 `--prefix "$HOME/.local"` 是接入契约的一部分,确保 npm wrapper 落在读取器会检查的
|
||||
账号级可信目录;不要依赖 nvm 或其它由当前 shell 动态注入的 PATH 位置。
|
||||
|
||||
随后在受控终端中用 stdin 提供 App Secret,选择 Feishu brand,避免 secret 进入 shell
|
||||
history、进程参数或项目文件:
|
||||
|
||||
```bash
|
||||
printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \
|
||||
--name project-feishu --app-id "$FEISHU_APP_ID" \
|
||||
--app-secret-stdin --brand feishu
|
||||
```
|
||||
|
||||
随后在 project 配置里只填写 `profile: project-feishu`。不要执行 `profile use`,也不要
|
||||
依赖 active profile;每次读取和附件下载都由 adapter 显式传 `--profile project-feishu`。
|
||||
为读取记录和下载附件,profile 必须具有 `base:record:read` 和
|
||||
`docs:document.media:download`。请在飞书开发者后台的应用权限中为该 app 授予这两个
|
||||
scope;不要把 `lark-cli auth check` 当作 app/bot scope 的证明,因为它检查的是当前用户的
|
||||
stored user token。
|
||||
|
||||
## 使用
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml
|
||||
tmpdir=$(mktemp -d)
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py fetch docs/ack/tasks.yaml \
|
||||
--output-dir "$tmpdir"
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py plan docs/ack/tasks.yaml \
|
||||
--output-dir "$tmpdir"
|
||||
```
|
||||
|
||||
`fetch` 只调用官方 `base +record-list`(限定配置的 view 和字段)和按配置附件字段的
|
||||
`base +record-download-attachment`。它输出单一 JSON,验证行矩阵、分页和下载路径;CLI、
|
||||
profile、JSON、分页、附件或路径任一异常都会失败且不输出伪成功结果。
|
||||
|
||||
`plan` 在同一批标准化记录上读取现有 `tasks`,只输出整理计划而不修改文件:新来源为
|
||||
`create`,同来源且现有任务为 `open`、来源时间有变化时为 `refresh`,未变化为
|
||||
`unchanged`,其它 ACK 状态发生来源变化时为 `drift`。任务板或读取结果出现重复
|
||||
`sourceRef` 会直接失败。
|
||||
|
||||
子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的
|
||||
`LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入,避免环境凭据或运行时
|
||||
注入绕过项目 profile。每条记录最多 10 个附件、单批最多 100 个,单个附件最多
|
||||
20 MiB、合计最多 200 MiB,整批附件下载最多 5 分钟,并校验声明大小与落盘大小;
|
||||
请始终使用新的临时目录作为 `--output-dir`。
|
||||
|
||||
`profile list` 只作存在性检查,不会输出 profile 内容。官方 record-list JSON 使用
|
||||
`data.fields`、`data.record_id_list` 与同长度的 `data.data` 行矩阵;`ok: true` 或
|
||||
`code: 0` 是唯一可接受的成功 envelope。
|
||||
|
||||
## Coordinator 整理
|
||||
|
||||
读取结果的每条 `sourceRef` 是稳定且不透明的键,例如
|
||||
`feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef`。
|
||||
导入时写入 task 的
|
||||
`source.kind: feishu-base`、`source.ref`、`source.recordId` 与 `source.updatedAt`;先按
|
||||
`source.ref` 查重。相同来源只更新 `open` 任务;`dispatched`、`fixed_by_dev`、
|
||||
`retesting`、`failed_retest`、`verified`、`blocked` 和 `leftover` 任务只告警来源漂移,
|
||||
由用户决定是否新建任务;飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留。
|
||||
@@ -0,0 +1,583 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read an ACK-ready Feishu Base view through the official lark-cli.
|
||||
|
||||
This is deliberately a small, non-mutating adapter. It never reads the
|
||||
active profile and emits one JSON document only on success.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import resource
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader
|
||||
|
||||
REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt")
|
||||
MAX_PAGES = 100
|
||||
MAX_RECORDS = 10_000
|
||||
PAGE_SIZE = 100
|
||||
MAX_CLI_STDOUT = 1024 * 1024
|
||||
MAX_CLI_STDERR = 64 * 1024
|
||||
MAX_ATTACHMENTS_PER_RECORD = 10
|
||||
MAX_TOTAL_ATTACHMENTS = 100
|
||||
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
MAX_TOTAL_ATTACHMENT_BYTES = 200 * 1024 * 1024
|
||||
MAX_ATTACHMENT_BATCH_SECONDS = 300
|
||||
SAFE_VALUE = re.compile(r"^[^\s\x00-\x1f]{1,256}$")
|
||||
PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||
SOURCE_REF = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class IntakeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def account_home() -> Path:
|
||||
"""Return the actual account home, never a caller-controlled HOME value."""
|
||||
try:
|
||||
home = Path(pwd.getpwuid(os.getuid()).pw_dir).resolve(strict=True)
|
||||
except (KeyError, OSError) as exc:
|
||||
raise IntakeError("cannot resolve current account home") from exc
|
||||
if not home.is_dir():
|
||||
raise IntakeError("current account home is not a directory")
|
||||
return home
|
||||
|
||||
|
||||
def trusted_lark_cli_dirs() -> list[Path]:
|
||||
"""Fixed account and system locations; intentionally never consult PATH."""
|
||||
home = account_home()
|
||||
candidates = (
|
||||
home / ".local" / "bin",
|
||||
home / ".local" / "share" / "mise" / "shims",
|
||||
home / ".cargo" / "bin",
|
||||
Path("/home/linuxbrew/.linuxbrew/bin"),
|
||||
Path("/usr/local/go/bin"),
|
||||
Path("/usr/local/bin"),
|
||||
Path("/usr/bin"),
|
||||
Path("/bin"),
|
||||
)
|
||||
result: list[Path] = []
|
||||
for candidate in candidates:
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
continue
|
||||
if resolved.is_dir() and resolved not in result:
|
||||
result.append(resolved)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_lark_cli() -> Path:
|
||||
"""Resolve a safe lark-cli from the fixed trusted locations only."""
|
||||
for directory in trusted_lark_cli_dirs():
|
||||
candidate = directory / "lark-cli"
|
||||
try:
|
||||
candidate_metadata = os.lstat(candidate)
|
||||
resolved = candidate.resolve(strict=True)
|
||||
metadata = resolved.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if not (stat.S_ISREG(candidate_metadata.st_mode) or stat.S_ISLNK(candidate_metadata.st_mode)):
|
||||
continue
|
||||
if candidate_metadata.st_uid not in {0, os.getuid()}:
|
||||
continue
|
||||
if not stat.S_ISREG(metadata.st_mode) or not os.access(resolved, os.X_OK):
|
||||
continue
|
||||
if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||
continue
|
||||
if resolved.name == "lark-cli":
|
||||
return resolved
|
||||
if not stat.S_ISLNK(candidate_metadata.st_mode):
|
||||
continue
|
||||
official_binary = official_npm_binary(resolved)
|
||||
if official_binary is not None:
|
||||
return official_binary
|
||||
raise IntakeError("lark-cli is not installed in a trusted account or system directory")
|
||||
|
||||
|
||||
def official_npm_binary(path: Path) -> Path | None:
|
||||
"""Resolve a validated npm wrapper to its downloaded native CLI binary."""
|
||||
if path.name != "run.js" or path.parent.name != "scripts":
|
||||
return None
|
||||
manifest = path.parent.parent / "package.json"
|
||||
try:
|
||||
metadata = manifest.stat()
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
return None
|
||||
if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||
return None
|
||||
package = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if not (
|
||||
isinstance(package, dict)
|
||||
and package.get("name") == "@larksuite/cli"
|
||||
and isinstance(package.get("bin"), dict)
|
||||
and package["bin"].get("lark-cli") == "scripts/run.js"
|
||||
):
|
||||
return None
|
||||
native = path.parent.parent / "bin" / "lark-cli"
|
||||
try:
|
||||
native_lstat = os.lstat(native)
|
||||
resolved = native.resolve(strict=True)
|
||||
metadata = resolved.stat()
|
||||
except OSError:
|
||||
return None
|
||||
if not stat.S_ISREG(native_lstat.st_mode) or resolved.name != "lark-cli":
|
||||
return None
|
||||
if not stat.S_ISREG(metadata.st_mode) or not os.access(resolved, os.X_OK):
|
||||
return None
|
||||
if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
def cli_environment() -> dict[str, str]:
|
||||
"""Build a minimal environment so env credentials cannot override `--profile`."""
|
||||
environment = {
|
||||
"HOME": str(account_home()),
|
||||
"PATH": os.pathsep.join(str(path) for path in trusted_lark_cli_dirs()),
|
||||
}
|
||||
for name in ("LANG", "LC_ALL", "LC_CTYPE"):
|
||||
value = os.environ.get(name)
|
||||
if value and "\x00" not in value and len(value) <= 256:
|
||||
environment[name] = value
|
||||
return environment
|
||||
|
||||
|
||||
def load_board(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if path.suffix.lower() == ".json":
|
||||
value = load_json_unique(text)
|
||||
else:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
value = yaml.load(text, Loader=make_unique_pyyaml_loader(yaml))
|
||||
except ImportError:
|
||||
value = load_yaml_subset(text)
|
||||
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
|
||||
raise IntakeError(f"cannot read task board: {exc}") from exc
|
||||
except Exception as exc: # PyYAML errors are intentionally not exposed verbatim.
|
||||
raise IntakeError("cannot parse task board") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise IntakeError("task board must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def config_from_board(board: dict[str, Any]) -> dict[str, Any]:
|
||||
project = board.get("project")
|
||||
if not isinstance(project, dict) or "bugIntake" not in project:
|
||||
raise IntakeError("project.bugIntake is not configured")
|
||||
config = project["bugIntake"]
|
||||
if not isinstance(config, dict):
|
||||
raise IntakeError("project.bugIntake must be an object")
|
||||
allowed = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"}
|
||||
unknown = sorted(set(config) - allowed)
|
||||
if unknown:
|
||||
raise IntakeError("bugIntake has unknown fields")
|
||||
if config.get("provider") != "feishu-base":
|
||||
raise IntakeError("bugIntake.provider must be feishu-base")
|
||||
profile = config.get("profile")
|
||||
if not isinstance(profile, str) or not PROFILE.fullmatch(profile):
|
||||
raise IntakeError("bugIntake.profile is invalid")
|
||||
for key in ("baseToken", "tableId", "viewId"):
|
||||
value = config.get(key)
|
||||
if not isinstance(value, str) or not SAFE_VALUE.fullmatch(value):
|
||||
raise IntakeError(f"bugIntake.{key} is invalid")
|
||||
fields = config.get("fields")
|
||||
if not isinstance(fields, dict) or set(fields) != set(REQUIRED_FIELDS):
|
||||
raise IntakeError("bugIntake.fields must map exactly the required logical fields")
|
||||
if any(not isinstance(value, str) or not SAFE_VALUE.fullmatch(value) for value in fields.values()):
|
||||
raise IntakeError("bugIntake.fields values are invalid")
|
||||
if len(set(fields.values())) != len(fields):
|
||||
raise IntakeError("bugIntake.fields values must be unique")
|
||||
return config
|
||||
|
||||
|
||||
def limit_child_file_size(limit: int) -> None:
|
||||
"""Bound regular-file writes by the CLI and any child spawned by its wrapper."""
|
||||
_, hard = resource.getrlimit(resource.RLIMIT_FSIZE)
|
||||
bounded = limit if hard == resource.RLIM_INFINITY else min(limit, hard)
|
||||
resource.setrlimit(resource.RLIMIT_FSIZE, (bounded, bounded))
|
||||
|
||||
|
||||
def run_cli(
|
||||
args: list[str], *, allow_profile_list: bool = False, max_file_bytes: int = 0,
|
||||
timeout: float = 60, cwd: Path | None = None,
|
||||
) -> Any:
|
||||
"""Run the official CLI and accept only explicit successful JSON shapes."""
|
||||
executable = resolve_lark_cli()
|
||||
file_limit = max(MAX_CLI_STDOUT, MAX_CLI_STDERR, max_file_bytes)
|
||||
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[str(executable), *args],
|
||||
shell=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
env=cli_environment(),
|
||||
cwd=cwd,
|
||||
start_new_session=True,
|
||||
preexec_fn=lambda: limit_child_file_size(file_limit),
|
||||
)
|
||||
try:
|
||||
returncode = process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
raise IntakeError("lark-cli failed to execute") from exc
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise IntakeError("lark-cli failed to execute") from exc
|
||||
stdout_size = os.fstat(stdout_file.fileno()).st_size
|
||||
stderr_size = os.fstat(stderr_file.fileno()).st_size
|
||||
if stdout_size > MAX_CLI_STDOUT or stderr_size > MAX_CLI_STDERR:
|
||||
raise IntakeError("lark-cli output exceeded the safety limit")
|
||||
if returncode:
|
||||
raise IntakeError("lark-cli command failed")
|
||||
stdout_file.seek(0)
|
||||
try:
|
||||
stdout = stdout_file.read(MAX_CLI_STDOUT + 1).decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise IntakeError("lark-cli returned malformed JSON") from exc
|
||||
try:
|
||||
value = json.loads(
|
||||
stdout,
|
||||
parse_constant=lambda value: (_ for _ in ()).throw(
|
||||
ValueError(f"non-finite JSON constant: {value}")
|
||||
),
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise IntakeError("lark-cli returned malformed JSON") from exc
|
||||
if isinstance(value, list):
|
||||
if allow_profile_list:
|
||||
return value
|
||||
raise IntakeError("lark-cli returned an unexpected JSON array")
|
||||
if not isinstance(value, dict):
|
||||
raise IntakeError("lark-cli returned an invalid JSON response")
|
||||
if "ok" in value and value["ok"] is not True:
|
||||
raise IntakeError("lark-cli returned an error response")
|
||||
if "code" in value and (not isinstance(value["code"], int) or isinstance(value["code"], bool) or value["code"] != 0):
|
||||
raise IntakeError("lark-cli returned an error response")
|
||||
if "ok" not in value and "code" not in value:
|
||||
raise IntakeError("lark-cli returned an ambiguous JSON response")
|
||||
return value
|
||||
|
||||
|
||||
def profile_check(config: dict[str, Any]) -> None:
|
||||
# `profile list` is the official non-mutating profile inspection command.
|
||||
value = run_cli(["profile", "list"], allow_profile_list=True)
|
||||
if isinstance(value, list):
|
||||
profiles = value
|
||||
else:
|
||||
# Compatibility wrapper: only a successful envelope with a direct list
|
||||
# is accepted. Do not loosen this into arbitrary nested objects.
|
||||
profiles = value.get("data")
|
||||
if not isinstance(profiles, list):
|
||||
raise IntakeError("profile check returned an invalid response")
|
||||
matching_profile: dict[str, Any] | None = None
|
||||
for item in profiles:
|
||||
# Match the official profile-list item shape. `user` and
|
||||
# `tokenStatus` are optional and deliberately never propagated.
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or not isinstance(item.get("name"), str)
|
||||
or not PROFILE.fullmatch(item["name"])
|
||||
or not isinstance(item.get("appId"), str)
|
||||
or not isinstance(item.get("brand"), str)
|
||||
or not isinstance(item.get("active"), bool)
|
||||
):
|
||||
raise IntakeError("profile check returned an invalid profile entry")
|
||||
if item["name"] == config["profile"]:
|
||||
matching_profile = item
|
||||
if matching_profile is None:
|
||||
raise IntakeError("configured lark-cli profile does not exist")
|
||||
if matching_profile["brand"] != "feishu":
|
||||
raise IntakeError("configured lark-cli profile must use the feishu brand")
|
||||
|
||||
|
||||
def text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return " ".join(value.split())
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise IntakeError("text field contains a non-finite number")
|
||||
if isinstance(value, (int, float, bool)):
|
||||
return str(value)
|
||||
if isinstance(value, list):
|
||||
return "\n".join(part for part in (text(item) for item in value) if part)
|
||||
if isinstance(value, dict):
|
||||
for key in ("text", "name", "value"):
|
||||
if key in value:
|
||||
return text(value[key])
|
||||
raise IntakeError("text field contains an unsupported object")
|
||||
raise IntakeError("text field contains an unsupported value")
|
||||
|
||||
|
||||
def attachment_items(value: Any) -> list[tuple[dict[str, Any], str]]:
|
||||
if value in (None, ""):
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise IntakeError("attachments cell must be a list")
|
||||
if len(value) > MAX_ATTACHMENTS_PER_RECORD:
|
||||
raise IntakeError("record exceeded the attachment count limit")
|
||||
attachments: list[tuple[dict[str, Any], str]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
raise IntakeError("attachment metadata must be an object")
|
||||
token = item.get("file_token", item.get("token"))
|
||||
if not isinstance(token, str) or not SAFE_VALUE.fullmatch(token):
|
||||
raise IntakeError("attachment token is invalid")
|
||||
metadata = {"name": text(item.get("name")), "type": text(item.get("type", item.get("mime_type"))), "size": item.get("size")}
|
||||
if (
|
||||
not isinstance(metadata["size"], int)
|
||||
or isinstance(metadata["size"], bool)
|
||||
or metadata["size"] < 0
|
||||
or metadata["size"] > MAX_ATTACHMENT_BYTES
|
||||
):
|
||||
raise IntakeError("attachment size is invalid")
|
||||
attachments.append((metadata, token))
|
||||
return attachments
|
||||
|
||||
|
||||
def matrix_from_response(response: dict[str, Any], field_ids: list[str]) -> tuple[list[str], list[list[Any]]]:
|
||||
data = response.get("data", response)
|
||||
if not isinstance(data, dict):
|
||||
raise IntakeError("record list data is invalid")
|
||||
fields = data.get("fields")
|
||||
ids = data.get("record_id_list", data.get("recordIds"))
|
||||
rows = data.get("data", data.get("records", data.get("items", data.get("rows"))))
|
||||
if not isinstance(fields, list) or not all(isinstance(x, str) for x in fields):
|
||||
raise IntakeError("record list fields are invalid")
|
||||
if fields != field_ids:
|
||||
raise IntakeError("record list fields do not match configured projection")
|
||||
if not isinstance(ids, list) or not all(isinstance(x, str) and RECORD_ID.fullmatch(x) for x in ids):
|
||||
raise IntakeError("record list record_id_list is invalid")
|
||||
if not isinstance(rows, list) or len(rows) != len(ids) or any(not isinstance(row, list) or len(row) != len(fields) for row in rows):
|
||||
raise IntakeError("record list matrix does not match fields and record_id_list")
|
||||
return ids, rows
|
||||
|
||||
|
||||
def fetch_pages(config: dict[str, Any]) -> list[tuple[str, list[Any]]]:
|
||||
field_ids = [config["fields"][logical] for logical in REQUIRED_FIELDS]
|
||||
all_rows: list[tuple[str, list[Any]]] = []
|
||||
offset = 0
|
||||
for _ in range(MAX_PAGES):
|
||||
args = ["base", "+record-list", "--profile", config["profile"], "--base-token", config["baseToken"], "--table-id", config["tableId"], "--view-id", config["viewId"], "--format", "json", "--offset", str(offset), "--limit", str(PAGE_SIZE)]
|
||||
for field_id in field_ids:
|
||||
args.extend(["--field-id", field_id])
|
||||
response = run_cli(args)
|
||||
ids, rows = matrix_from_response(response, field_ids)
|
||||
if len(ids) > PAGE_SIZE:
|
||||
raise IntakeError("record list exceeded requested page size")
|
||||
all_rows.extend(zip(ids, rows))
|
||||
if len(all_rows) > MAX_RECORDS:
|
||||
raise IntakeError("record list exceeded record limit")
|
||||
data = response.get("data", response)
|
||||
has_more = data.get("has_more", data.get("hasMore", False))
|
||||
if not isinstance(has_more, bool):
|
||||
raise IntakeError("record list pagination marker is invalid")
|
||||
if not has_more:
|
||||
return all_rows
|
||||
if not ids:
|
||||
raise IntakeError("record list pagination made no progress")
|
||||
offset += len(ids)
|
||||
raise IntakeError("record list exceeded page limit")
|
||||
|
||||
|
||||
def download(
|
||||
config: dict[str, Any], record_id: str, file_token: str,
|
||||
output_dir: Path, expected_size: int, timeout: float,
|
||||
) -> str:
|
||||
try:
|
||||
output_dir.mkdir(parents=True, exist_ok=False)
|
||||
except OSError as exc:
|
||||
raise IntakeError("attachment output directory is unsafe") from exc
|
||||
if not output_dir.is_dir() or output_dir.is_symlink():
|
||||
raise IntakeError("attachment output directory is unsafe")
|
||||
run_cli(
|
||||
["base", "+record-download-attachment", "--profile", config["profile"], "--base-token", config["baseToken"], "--table-id", config["tableId"], "--record-id", record_id, "--file-token", file_token, "--output", output_dir.name],
|
||||
max_file_bytes=expected_size,
|
||||
timeout=timeout,
|
||||
cwd=output_dir.parent,
|
||||
)
|
||||
try:
|
||||
created = list(output_dir.iterdir())
|
||||
except OSError as exc:
|
||||
raise IntakeError("attachment download output is unreadable") from exc
|
||||
if len(created) != 1 or not created[0].is_file() or created[0].is_symlink():
|
||||
raise IntakeError("attachment download did not produce one safe file")
|
||||
root = output_dir.resolve()
|
||||
resolved = created[0].resolve()
|
||||
if root not in resolved.parents:
|
||||
raise IntakeError("attachment download escaped output directory")
|
||||
if resolved.stat().st_size != expected_size:
|
||||
raise IntakeError("attachment download size did not match metadata")
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def source_ref(config: dict[str, Any], record_id: str) -> str:
|
||||
"""Return a stable opaque identity without serializing configured identifiers."""
|
||||
identity = "\x1f".join(("ack-feishu-base-source-ref-v1", config["profile"], config["baseToken"], config["tableId"], record_id))
|
||||
return f"feishu-base:sha256:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}"
|
||||
|
||||
|
||||
def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
|
||||
profile_check(config)
|
||||
prepared: list[tuple[dict[str, Any], list[tuple[dict[str, Any], str]]]] = []
|
||||
total_attachments = 0
|
||||
total_attachment_bytes = 0
|
||||
for record_id, row in fetch_pages(config):
|
||||
cells = dict(zip(REQUIRED_FIELDS, row))
|
||||
attachment_data = attachment_items(cells["attachments"])
|
||||
total_attachments += len(attachment_data)
|
||||
total_attachment_bytes += sum(metadata["size"] for metadata, _ in attachment_data)
|
||||
if total_attachments > MAX_TOTAL_ATTACHMENTS:
|
||||
raise IntakeError("batch exceeded the attachment count limit")
|
||||
if total_attachment_bytes > MAX_TOTAL_ATTACHMENT_BYTES:
|
||||
raise IntakeError("batch exceeded the attachment byte limit")
|
||||
record = {"sourceRef": source_ref(config, record_id), "recordId": record_id, "updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]), "actual": text(cells["actual"]), "expected": text(cells["expected"]), "steps": text(cells["stepsToReproduce"]), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []}
|
||||
for field in ("title", "actual", "expected", "steps", "acceptance", "priority", "updatedAt"):
|
||||
if not record[field]:
|
||||
raise IntakeError(f"record {field} must not be empty")
|
||||
prepared.append((record, attachment_data))
|
||||
download_root: Path | None = None
|
||||
if output_dir is not None:
|
||||
try:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
if not output_dir.is_dir() or output_dir.is_symlink():
|
||||
raise OSError("unsafe output directory")
|
||||
download_root = output_dir.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise IntakeError("attachment output directory is unsafe") from exc
|
||||
records = []
|
||||
attachment_deadline = time.monotonic() + MAX_ATTACHMENT_BATCH_SECONDS
|
||||
for record, attachment_data in prepared:
|
||||
if download_root is not None:
|
||||
for index, (attachment, file_token) in enumerate(attachment_data, start=1):
|
||||
remaining = attachment_deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise IntakeError("attachment batch exceeded the time limit")
|
||||
attachment_dir = download_root / record["recordId"] / f"attachment-{index:02d}"
|
||||
attachment["localPath"] = download(
|
||||
config, record["recordId"], file_token, attachment_dir,
|
||||
attachment["size"], min(60, remaining),
|
||||
)
|
||||
records.append(record)
|
||||
return {"provider": "feishu-base", "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records}
|
||||
|
||||
|
||||
def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||
"""Plan idempotent Coordinator actions without mutating the task board."""
|
||||
tasks = board.get("tasks")
|
||||
if not isinstance(tasks, list):
|
||||
raise IntakeError("task board tasks must be a list")
|
||||
existing: dict[str, dict[str, Any]] = {}
|
||||
for task in tasks:
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
source = task.get("source")
|
||||
if not isinstance(source, dict) or source.get("kind") != "feishu-base":
|
||||
continue
|
||||
ref = source.get("ref")
|
||||
task_id = task.get("id")
|
||||
status = task.get("status")
|
||||
updated_at = source.get("updatedAt")
|
||||
if (
|
||||
not isinstance(ref, str) or SOURCE_REF.fullmatch(ref) is None
|
||||
or not isinstance(task_id, str) or not task_id
|
||||
or not isinstance(status, str) or not status
|
||||
or not isinstance(updated_at, str) or not updated_at
|
||||
):
|
||||
raise IntakeError("existing Feishu task source is invalid")
|
||||
if ref in existing:
|
||||
raise IntakeError("task board contains duplicate Feishu source references")
|
||||
existing[ref] = task
|
||||
|
||||
actions: list[dict[str, str]] = []
|
||||
seen_records: set[str] = set()
|
||||
for record in records:
|
||||
ref = record.get("sourceRef")
|
||||
record_id = record.get("recordId")
|
||||
updated_at = record.get("updatedAt")
|
||||
if (
|
||||
not isinstance(ref, str) or SOURCE_REF.fullmatch(ref) is None
|
||||
or not isinstance(record_id, str) or RECORD_ID.fullmatch(record_id) is None
|
||||
or not isinstance(updated_at, str) or not updated_at
|
||||
):
|
||||
raise IntakeError("normalized Feishu record identity is invalid")
|
||||
if ref in seen_records:
|
||||
raise IntakeError("fetched records contain a duplicate source reference")
|
||||
seen_records.add(ref)
|
||||
task = existing.get(ref)
|
||||
if task is None:
|
||||
actions.append({"sourceRef": ref, "recordId": record_id, "action": "create"})
|
||||
continue
|
||||
source = task["source"]
|
||||
if source["updatedAt"] == updated_at:
|
||||
action = "unchanged"
|
||||
elif task["status"] == "open":
|
||||
action = "refresh"
|
||||
else:
|
||||
action = "drift"
|
||||
actions.append({
|
||||
"sourceRef": ref,
|
||||
"recordId": record_id,
|
||||
"taskId": task["id"],
|
||||
"status": task["status"],
|
||||
"action": action,
|
||||
})
|
||||
return actions
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Read a configured Feishu Base bug intake")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
for name in ("check", "fetch", "plan"):
|
||||
command = sub.add_parser(name)
|
||||
command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board")
|
||||
if name in {"fetch", "plan"}:
|
||||
command.add_argument("--output-dir", type=Path, help="explicit directory for downloaded attachments")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
board = load_board(args.tasks)
|
||||
config = config_from_board(board)
|
||||
if args.command == "check":
|
||||
profile_check(config)
|
||||
output = {"provider": "feishu-base", "profile": config["profile"], "ok": True}
|
||||
elif args.command == "fetch":
|
||||
output = fetch(config, args.output_dir)
|
||||
else:
|
||||
output = fetch(config, args.output_dir)
|
||||
output["actions"] = plan_actions(board, output["records"])
|
||||
except IntakeError as exc:
|
||||
sys.stderr.write(f"Feishu bug intake failed: {exc}\n")
|
||||
return 1
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
sys.stderr.write("Feishu bug intake failed: local I/O failed\n")
|
||||
return 1
|
||||
print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -113,6 +113,15 @@ DELIVERY_STATUSES = {
|
||||
}
|
||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||
FEISHU_REQUIRED_FIELDS = {
|
||||
"title", "actual", "expected", "stepsToReproduce", "acceptance", "priority",
|
||||
"attachments", "updatedAt",
|
||||
}
|
||||
FEISHU_CONFIG_FIELDS = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"}
|
||||
FEISHU_SOURCE_FIELDS = {"kind", "ref", "recordId", "updatedAt"}
|
||||
FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||
DISPATCH_FIELDS = {
|
||||
"taskId",
|
||||
"dispatchId",
|
||||
@@ -654,6 +663,28 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
{"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"},
|
||||
"project",
|
||||
)
|
||||
if "bugIntake" in project:
|
||||
intake = project["bugIntake"]
|
||||
if not isinstance(intake, dict):
|
||||
errors.append("project.bugIntake 必须是对象")
|
||||
else:
|
||||
reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors)
|
||||
if intake.get("provider") != "feishu-base":
|
||||
errors.append("project.bugIntake.provider 必须是 feishu-base")
|
||||
profile = intake.get("profile")
|
||||
if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None:
|
||||
errors.append("project.bugIntake.profile 非法")
|
||||
for key in ("baseToken", "tableId", "viewId"):
|
||||
value = intake.get(key)
|
||||
if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
|
||||
errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串")
|
||||
fields = intake.get("fields")
|
||||
if not isinstance(fields, dict) or set(fields) != FEISHU_REQUIRED_FIELDS:
|
||||
errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段")
|
||||
elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()):
|
||||
errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串")
|
||||
elif len(set(fields.values())) != len(fields):
|
||||
errors.append("project.bugIntake.fields 字段值不能重复")
|
||||
if (
|
||||
"knowledgeFile" in project
|
||||
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
||||
@@ -710,6 +741,7 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
return errors
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
seen_source_refs: set[str] = set()
|
||||
for i, task in enumerate(tasks):
|
||||
where = f"tasks[{i}]"
|
||||
if not isinstance(task, dict):
|
||||
@@ -756,6 +788,25 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
)
|
||||
validate_object_fields(task, {"evidence", "verification"}, where)
|
||||
|
||||
if "source" in task:
|
||||
source = task["source"]
|
||||
# `source` was historically an open extension point. Preserve
|
||||
# non-Feishu strings/objects and tighten only the namespaced shape.
|
||||
if isinstance(source, dict) and source.get("kind") == "feishu-base":
|
||||
reject_unknown_fields(source, FEISHU_SOURCE_FIELDS, f"{where}.source", errors)
|
||||
ref = source.get("ref")
|
||||
if not isinstance(ref, str) or FEISHU_SOURCE_REF_RE.fullmatch(ref) is None:
|
||||
errors.append(f"{where}.source.ref: 必须是不透明 feishu-base SHA-256 引用")
|
||||
else:
|
||||
if ref in seen_source_refs:
|
||||
errors.append(f"{where}.source.ref: 来源引用重复")
|
||||
seen_source_refs.add(ref)
|
||||
record_id = source.get("recordId")
|
||||
if not isinstance(record_id, str) or FEISHU_RECORD_ID_RE.fullmatch(record_id) is None:
|
||||
errors.append(f"{where}.source.recordId: 必须是合法飞书记录 ID")
|
||||
if not _nonempty_string(source.get("updatedAt")):
|
||||
errors.append(f"{where}.source.updatedAt: 必须是非空字符串")
|
||||
|
||||
validate_knowledge_fields(task, where, status, errors)
|
||||
|
||||
if "dispatch" not in task:
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"const": "docs/ack/delivery.yaml",
|
||||
"description": "可选项目交付契约的唯一权威路径"
|
||||
},
|
||||
"bugIntake": { "$ref": "#/definitions/feishuBugIntake" },
|
||||
"orchestration": {
|
||||
"$ref": "#/definitions/orchestration"
|
||||
}
|
||||
@@ -164,6 +165,50 @@
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"feishuBugIntake": {
|
||||
"type": "object",
|
||||
"required": ["provider", "profile", "baseToken", "tableId", "viewId", "fields"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"provider": { "const": "feishu-base" },
|
||||
"profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
|
||||
"baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"tableId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"viewId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"fields": {
|
||||
"type": "object",
|
||||
"required": ["title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"actual": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"expected": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"stepsToReproduce": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"acceptance": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
|
||||
"updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"feishuTaskSource": {
|
||||
"type": "object",
|
||||
"required": ["kind", "ref", "recordId", "updatedAt"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": { "const": "feishu-base" },
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^feishu-base:sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"recordId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$"
|
||||
},
|
||||
"updatedAt": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||
}
|
||||
},
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,63}$"
|
||||
@@ -940,6 +985,16 @@
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/knowledgeCheck" }
|
||||
},
|
||||
"source": {
|
||||
"if": {
|
||||
"type": "object",
|
||||
"required": ["kind"],
|
||||
"properties": { "kind": { "const": "feishu-base" } }
|
||||
},
|
||||
"then": {
|
||||
"$ref": "#/definitions/feishuTaskSource"
|
||||
}
|
||||
},
|
||||
"description": { "type": "string" },
|
||||
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
|
||||
"expected": { "type": "string" },
|
||||
|
||||
@@ -11,6 +11,22 @@ project:
|
||||
overlayFile: "docs/ack/project.md"
|
||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||
deliveryFile: "docs/ack/delivery.yaml"
|
||||
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
||||
# bugIntake:
|
||||
# provider: "feishu-base"
|
||||
# profile: "project-feishu"
|
||||
# baseToken: "<base_token>"
|
||||
# tableId: "<table_id>"
|
||||
# viewId: "<ack_ready_view_id>"
|
||||
# fields:
|
||||
# title: "<field_id>"
|
||||
# actual: "<field_id>"
|
||||
# expected: "<field_id>"
|
||||
# stepsToReproduce: "<field_id>"
|
||||
# acceptance: "<field_id>"
|
||||
# priority: "<field_id>"
|
||||
# attachments: "<field_id>"
|
||||
# updatedAt: "<field_id>"
|
||||
orchestration:
|
||||
profileVersion: 1
|
||||
mode: "orca"
|
||||
@@ -95,6 +111,13 @@ tasks:
|
||||
knowledgeCandidates: []
|
||||
knowledgeChecks: []
|
||||
|
||||
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
|
||||
# source:
|
||||
# kind: "feishu-base"
|
||||
# ref: "feishu-base:sha256:<64-lowercase-hex>"
|
||||
# recordId: "<record-id>"
|
||||
# updatedAt: "<source-updated-at>"
|
||||
|
||||
description: >
|
||||
<What is wrong, in user-visible terms.>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user