关于 forge-connector
forge-connector 是用于创建 Atlassian Forge graph:connector 应用的技能,可将外部数据写入 Teamwork Graph,使其可在 Rovo Search 和 Rovo Chat 中检索。它面向构建 Forge 连接器的开发者,提供从数据类型与权限梳理到脚手架和同步实现的分步指导。
主要功能
- 01生成连接器应用脚手架
- 02梳理 Teamwork Graph 对象类型
- 03指导凭据与表单配置
- 04规划访问控制与数据权限
- 05配置定期重新摄取任务
适用场景
- 01将外部系统内容接入 Rovo 搜索
- 02按来源权限控制检索可见性
- 03为数据连接设置周期性刷新
MCP 相关
该技能列出 Forge MCP,用于查阅清单语法、模块配置和部署指南;添加 Custom UI 时可使用 ADS MCP 查询 Atlaskit 组件。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ atlassian/ forge-skills --skill forge-connector安装后在 Claude Code 中输入/forge-connector确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.mdscripts/__init__.pyforge_env.pyscaffold_connector.pytests/__init__.pytest_forge_env.pytest_scaffold_connector.py详细介绍
Forge Connector
构建一个 graph:connector Forge 应用,将外部数据导入 Atlassian 的 Teamwork Graph,使其显示在 Rovo Search 和 Rovo Chat 中。
关键规则
- 必须安装在 Jira 中 — 使用 Teamwork Graph modules 的应用必须安装到 Jira 站点。仅安装在 Confluence 中无法运行。
- 绝不要在聊天中索要凭据 — 引导用户在自己的终端中运行
forge login。 - 始终亲自运行脚手架脚本 — 不要只提供手动操作说明;运行
scripts/scaffold_connector.py以生成样板代码。 - 需要安装时,始终向用户询问其 Atlassian 站点 URL — 绝不要自行查找或猜测。
- Atlassian 会在断开连接时删除数据 — 当
action = 'DELETED'时,应用只需清理本地状态;Atlassian 会自动删除 Teamwork Graph 数据。 - 处理程序参数会直接传入 — Forge 会将请求对象作为第一个参数传给处理程序,而不是嵌套在
event.payload下。配置值位于request.configProperties,而不是event.payload.config。这是导致TypeError: Cannot destructure property of undefined错误的最常见原因。 - 使用
@forge/kvs存储数据 — 从@forge/kvs导入kvs。不要使用@forge/storage— 其storage导出在连接器函数运行时为undefined。 - 使用
@forge/teamwork-graph的graph命名导出 — 正确的导入方式是const { graph } = require('@forge/teamwork-graph')。调用graph.setObjects({ objects, connectionId })。不要直接将setObjects作为命名导入。 validateConnectionHandler必须返回{ success, message }— 不要抛出 Error。返回{ success: false, message: '...' }以拒绝,返回{ success: true }以接受。function声明应放在modules下 — 在manifest.yml中,function:是modules:下的键,不是顶层键。将其放在顶层会导致 lint 错误。formConfiguration使用带有type: header的form数组 — 不要使用fields:或beforeYouBegin:。正确格式为form: [{ key, type: header, title, description, properties: [...] }]。- 权限范围应为
read/write/delete:object:jira— 使用read:object:jira、write:object:jira、delete:object:jira。read:graph:teamwork和write:graph:teamwork权限范围无效,会导致forge lint失败。 - 为此技能运行的
forge命令设置ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-connector— 在forge命令前加上此环境变量:包括你在 shell 中运行的命令(例如forge lint、forge logs、forge deploy),以及作为备用方案提供给用户的交互式forge create命令。捆绑脚本会自动设置此变量;为简洁起见,此技能中展示的其他命令省略了它 — 运行时请添加。仅forge login和forge tunnel(由用户运行的身份验证/实时开发命令)除外。 - 绝不要忽略
setObjects/setUsers/setGroups的响应 — 不要将数据导入调用视为即发即弃。始终检查response.success和response.results?.rejected。调用可能部分失败:部分对象被拒绝,其他对象被接受;即使rejected.length > 0,response.success也可能为true。 - 保持访问控制 ID 一致 — 在
permissions中使用USER或GROUP主体时,id值必须与对应graph.setUsers()/graph.setGroups()调用中使用的externalId完全一致。ID 不匹配(例如user-42与accountId:user-42)会导致本应有权访问的用户无法看到对象,且不会显示任何错误。 - 使用编排功能安排刷新任务 — 用户需要定期重新摄取数据时,请使用
graph.scheduleOrUpdateTask和taskRunner处理程序(平台托管的方式),而不是scheduledTrigger。编排功能会原生处理执行频率、重试和任务扇出。只有用户明确需要不带平台任务跟踪的轻量级 cron 风格触发器时,才改用scheduledTrigger。 taskId必须是有效的 UUID —scheduleOrUpdateTask和scheduleChildTask都要求task.taskId是 UUID 字符串。根任务使用uuid()(v4),子任务使用uuidv5('<scanId>:<parentTaskId>:<itemId>', APP_NAMESPACE)。将根任务 ID 持久化到 KVS,以便重新运行onConnectionChange时复用相同 ID(从而更新现有任务,而不是创建相互竞争的计划)。- 安排任务前先将
TaskInfo写入 KVS;完成后绝不要删除 — 调用scheduleOrUpdateTask或scheduleChildTask前,始终将TaskInfo记录以taskId为键写入 KVS。成功后,在记录上设置completed: true— 不要删除记录。删除记录会导致重复投递返回ENTITY_NOT_FOUND,并覆盖先前的成功状态。 - 根据编排响应中的
response.error分支处理 —scheduleOrUpdateTask和scheduleChildTask成功时返回{ status: 'ACCEPTED' },而不是{ success: true }。通过检查if (response.error)来检测失败 — 不要检查if (!response.success)。
MCP 前置条件
| MCP 服务 | 用途 |
|---|---|
| Forge MCP | 清单语法、模块配置、部署指南 |
| ADS MCP | Atlaskit 组件(仅在添加 Custom UI 时使用) |
Agent 工作流 — 按顺序完成步骤 0–7
步骤 0:前置条件
检查 Node.js(node -v,需要 22+)、Forge CLI(forge --version)和登录状态(forge whoami)。安装缺少的工具:
npm install -g @forge/cli
如果用户尚未登录,请告知他们在终端中运行 forge login。
步骤 1:发现 Developer Spaces
注意: Forge CLI 12.x 中不存在
forge developer-spaces list。你无法以非交互方式列出 Developer Spaces。
forge create 需要交互式 TTY 来选择 Developer Space。请让用户自行运行:
Tell the user:
cd <parent-directory>
ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-connector forge create --template blank <app-name>
When prompted, select a Developer Space and let it complete.
Come back when done.
脚手架脚本中的 --dev-space-id 标志是可选的,可以省略 — 脚本已更新为在未提供该参数时跳过它。
步骤 1.5:发现数据并映射到对象类型
请在创建脚手架之前完成此步骤。 向用户提出以下问题,以确定正确的 Teamwork Graph 对象类型。不要假设或默认使用 atlassian:document。
要询问用户的问题
-
你要连接哪个外部系统或工具? 例如:Google Drive、ServiceNow、Salesforce、GitHub、Confluence、Slack、Figma、Zendesk
-
你想让 Rovo 搜索哪类内容? 提供示例,帮助用户确定类型:
- 文件、页面、Wiki 文章、报告、PDF → 可能是
atlassian:document - 任务、工单、问题、缺陷、故事 → 可能是
atlassian:work-item - 聊天消息、电子邮件、评论 → 可能是
atlassian:message或atlassian:comment - 项目、工作区、看板 → 可能是
atlassian:project - 代码仓库 → 可能是
atlassian:repository - 拉取请求 / 合并请求 → 可能是
atlassian:pull-request - Git 提交 → 可能是
atlassian:commit - 设计文件(Figma、Sketch)→ 可能是
atlassian:design - 视频录像 → 可能是
atlassian:video - 日历事件、会议 → 可能是
atlassian:calendar-event - 话题串、频道 → 可能是
atlassian:conversation - 客户账户或组织 → 可能是
atlassian:customer-organization - 团队空间或组织单位 → 可能是
atlassian:space
- 文件、页面、Wiki 文章、报告、PDF → 可能是
-
内容是单一类型还是混合类型? 如果是混合类型(例如,包含任务和文档的项目管理工具),请计划将每种内容作为独立的对象类型进行摄取。脚手架支持一种主要类型——你之后可以在
manifest.yml中添加更多objectTypes条目。 -
管理员是否需要提供凭据(API 密钥、URL、OAuth 令牌)才能连接? 是 → 在脚手架命令中使用
--has-form-config。 否(数据完全来自 Atlassian 内部)→ 省略此标志。 -
源数据多久会变化一次? 这决定同步频率。所有由平台管理的刷新都使用编排(
scheduleOrUpdateTask+taskRunner)——它原生支持频率控制、重试和扇出。只有在明确需要不跟踪任务的轻量级 cron 时,才使用旧版scheduledTrigger方案。频率 编排间隔 每小时 scheduleInterval: { value: 1, timeUnit: 'hour' }每天 scheduleInterval: { value: 1, timeUnit: 'day' }每 N 分钟(1–60) scheduleInterval: { value: N, timeUnit: 'minute' }静态 / 一次性 无需定时刷新——在 onConnectionChange中摄取一次继续执行步骤 2 前,记录所选间隔。
-
谁应该能够在 Rovo Search 中查看已摄取的内容? 这决定了每个对象上的
permissions.accessControls。询问:- “这些内容是否都可公开访问,还是源系统会限制不同用户的查看权限?”
- “你希望 Rovo Search 结果遵循源系统的权限设置吗?”
根据回答映射到正确的主体模型:
源系统访问模型 要使用的 accessControls公开访问,无限制 principals: [{ type: 'EVERYONE' }]指定的用户有访问权限 principals: [{ type: 'user', id: '<atlassian-account-id>' }]— 每位用户一个条目基于团队或群组(例如 Confluence 空间、Google Workspace 群组) principals: [{ type: 'group', id: '<group-id>' }]— 每个群组一个条目私有 / 仅所有者可访问 使用所有者 Atlassian 账户 ID 的单个 user主体混合(源系统中的每个对象都有 ACL) 在摄取过程中获取每个项目的 ACL,并将其映射到 user或group主体除非用户明确确认内容可公开访问,否则切勿默认使用
EVERYONE。 对受限内容使用EVERYONE,会将数据泄露给不应在 Rovo Search 中查看这些内容的用户。继续执行步骤 2 前,记录所选权限模型。编写步骤 3 中的
setObjects调用时,请参考此模型。
映射决策
根据回答,从下方的对象类型表中选择最匹配的类型。只有在内容确实没有更合适的匹配项时(例如任意文件附件),才回退到 atlassian:document。对于“在 Rovo 中编入索引”列标记为 ❌ 的类型(atlassian:build、atlassian:deployment、atlassian:test),请提醒用户,这些对象不会显示在 Rovo Search 或 Rovo Chat 中。
继续执行步骤 2 之前,记录所选的对象类型和权限模型。
步骤 2:搭建 Connector App
从技能目录(包含此 SKILL.md 的目录)运行。将 <object-type> 替换为步骤 1.5 中确定的类型。--dev-space-id 为可选项:
python3 -m scripts.scaffold_connector \
--name <app-name> \
--connector-name "<Human Readable Name>" \
--object-type <object-type> \
--directory <parent-directory>
只有在前面步骤中已获取 ID 时,才添加 --dev-space-id <id>。
对象类型 — 使用步骤 1.5 中选定的类型。请勿在完成上述探索问题之前默认使用 atlassian:document。
表单配置标志 — 如果管理员必须提供 API 凭据或连接详情(由步骤 1.5 的问题 4 确定),请添加 --has-form-config。对于完全在 Atlassian 内运行的应用(不需要外部凭据),则省略此标志。
如果由于
forge create需要 TTY 而导致搭建失败: 搭建脚本会打印一个手动回退命令。请让用户以交互方式运行forge create,然后继续执行步骤 3 — 目录存在后,搭建脚本只需写入manifest.yml和src/index.js。
步骤 3:自定义生成的代码
完成搭建后(或用户以交互方式运行 forge create 后):
cd <app-name>
npm install
空白模板会生成 src/index.js(JavaScript,而非 TypeScript)。编辑该文件以添加 API 调用。搭建脚本会生成可正常工作的处理程序骨架;请填入业务逻辑。
要编辑的关键文件
| 文件 | 修改内容 |
|---|---|
src/index.js | fetchExternalData() — 替换为你的 API 调用 |
manifest.yml | 为任何外部 API 添加 permissions.external.fetch.backend URL |
package.json | 将 @forge/api、@forge/kvs、@forge/teamwork-graph 添加为依赖项 |
setObjects — 将数据写入 Teamwork Graph
使用名为 graph 的导出项 — 请勿直接解构 setObjects:
const { graph } = require('@forge/teamwork-graph');
const result = await graph.setObjects({
connectionId, // required — the connectionId from the handler request
objects: [
{
schemaVersion: '1.0',
id: 'unique-id-from-source', // unique per connectionId
updateSequenceNumber: 1,
displayName: 'My Document Title',
url: 'https://source-system.example.com/doc/123',
createdAt: '2024-01-15T10:00:00Z', // ISO 8601
lastUpdatedAt: '2024-01-20T14:30:00Z',
// Use the permission model chosen in Step 1.5 question 6.
// EVERYONE only if content is confirmed publicly accessible.
// For user-restricted content: { type: 'USER', id: '<externalId from setUsers>' }
// For group-restricted content: { type: 'GROUP', id: '<externalId from setGroups>' }
permissions: [{
accessControls: [{
principals: [{ type: 'EVERYONE' }],
}],
}],
'atlassian:document': {
type: {
category: 'DOCUMENT', // see Document Categories table below
mimeType: 'application/vnd.google-apps.document',
},
content: {
mimeType: 'application/vnd.google-apps.document',
text: 'document title or snippet for search indexing',
},
},
},
],
});
// Always check both success AND rejected — a call can partially fail.
const rejected = result.results?.rejected ?? [];
if (!result.success || rejected.length > 0) {
console.error('setObjects ingestion failed', {
connectionId,
acceptedCount: result.results?.accepted?.length ?? 0,
rejectedCount: rejected.length,
rejected,
error: result.error,
});
}
- 每次调用最多 100 个对象 — 使用循环对大型数据集分批处理
- 每个
connectionId下的id必须唯一 - 每次调用
graph.setObjects()都必须提供connectionId
文档类别(用于 atlassian:document.type.category)
| MIME 类型 | 类别 |
|---|---|
application/vnd.google-apps.document | DOCUMENT |
application/vnd.google-apps.spreadsheet | SPREADSHEET |
application/vnd.google-apps.presentation | PRESENTATION |
application/vnd.google-apps.folder | FOLDER |
application/pdf | PDF |
image/* | IMAGE |
video/* | VIDEO |
audio/* | AUDIO |
| 其他 | OTHER |
getObjectByExternalId — 查找单个对象
const { graph } = require('@forge/teamwork-graph');
const data = await graph.getObjectByExternalId({
externalId: 'unique-id-from-source',
objectType: 'atlassian:document',
connectionId,
});
if (data.success) console.log(data.object);
步骤 4:部署和安装
你必须运行部署脚本 — 不要只向用户提供手动运行 forge deploy 的命令。
部署脚本位于 forge-app-builder 技能中,而非此技能中。根据此 SKILL.md 的路径确定其目录:向上两级(skills/forge-connector/ → skills/),然后进入 forge-app-builder/。从该目录运行下方所有命令。
# Derive forge-app-builder skill dir from this SKILL.md's path:
# e.g. if this file is at /path/to/skills/forge-connector/SKILL.md
# then the deploy script dir is: /path/to/skills/forge-app-builder/
# If you have the site URL:
python3 -m scripts.deploy_forge_app \
--app-dir <app-directory> \
--site <site-url> \
--product jira
# If you don't have the site URL yet, deploy first then ask:
python3 -m scripts.deploy_forge_app \
--app-dir <app-directory> \
--product jira \
--deploy-only
# Ask: "What is your Atlassian site URL (e.g. yourcompany.atlassian.net)?"
python3 -m scripts.deploy_forge_app \
--app-dir <app-directory> \
--site <site-url> \
--product jira \
--skip-deps
步骤 5:通过 Atlassian Administration 连接
部署完成后,告知用户:
- 前往 Atlassian Administration → Apps → [site] → Connected apps
- 找到应用 → View app details → Connections 标签页
- 点击连接器下方的 Connect
- 填写所有配置字段(如果已定义
formConfiguration) - 点击 Connect — 这会触发
onConnectionChange,其action: CREATED,并开始数据写入
步骤 6:使用 forge tunnel 监控
在开发期间使用 forge tunnel,将连接器函数执行时的实时日志直接输出到终端。这是发现 onConnectionChangeHandler、validateConnectionHandler 和 setObjects 调用中的错误最快的方式,无需等待 forge logs。
告诉用户在自己的终端中运行此命令(它需要交互式会话):
cd <app-directory>
forge tunnel
隧道启动后,每次调用连接器函数(例如在 Atlassian Admin 中点击“Connect”,或触发定时重新摄取)都会立即输出结果。请留意:
[connector] Fetched N items— 确认fetchExternalData()已运行[connector] Batch 1: N accepted, 0 rejected— 确认setObjects已成功validateConnectionHandler中的任何未捕获错误或抛出的异常
如果隧道未运行,请改用 forge logs 检查以往的调用:
# Most recent 50 log lines from development environment
forge logs -e development --limit 50
# Production logs for a specific site
forge logs -e production --site <your-site> --limit 50
隧道与日志——何时使用哪一种:
| 情况 | 使用 |
|---|---|
| 正在开发/测试连接流程 | forge tunnel — 实时流式输出 |
| 调试过往调用或生产问题 | forge logs |
| 连接器函数在隧道捕获前超时 | 使用 --limit 100 的 forge logs |
注意:
forge tunnel必须由用户在交互式终端中运行——不要尝试通过 Agent 运行。
步骤 7:端到端验证(可选)
运行任何检查前,先询问用户:
“是否要在部署到生产环境前运行端到端验证检查?这可以确认连接、摄取、Rovo Search 可见性和权限边界均正常工作。”
如果用户回答否或想跳过,请继续下一步——不要运行或描述这些检查。 如果用户回答是,请按顺序完成以下所有检查。
检查 1 — 已建立连接
在 Atlassian Administration → Apps → Connected apps 中,连接器应显示状态 Connected。如果显示错误或待处理状态,请返回步骤 6,检查 forge tunnel 或 forge logs 的输出。
检查 2 — validateConnection 已通过(如果已配置)
如果应用包含 validateConnection 函数,请确认管理员点击 Connect 时看到了成功消息。如果没有,请检查日志中的返回值——它必须是 { success: true },而不是抛出的错误。
检查 3 — onConnectionChange 已触发且已运行摄取
在 forge logs 或隧道输出中确认:
forge logs -e development --limit 50
请检查以下三个信号:
- 处理程序已调用:来自
onConnectionChangeHandler的日志行包含action: CREATED - 数据已获取:例如
[connector] Fetched N items setObjects已成功:例如[connector] Batch 1: N accepted, 0 rejected
如果 setObjects 返回 { success: false },错误详情在 result.error 中——请将其告知用户,并在继续前修复。
检查 4 — 对象在 Rovo Search 中可见
- 在 Jira 站点中打开 Rovo Search(摄取后允许最多 5 分钟进行索引)
- 搜索至少一个已摄取对象的
displayName或内容文本中出现的词语 - 按连接器的昵称进行筛选(由管理员在连接时设置)
连接器应至少显示一个结果。如果 5 分钟后仍无结果:
- 确认
setObjects日志显示N accepted,且 N > 0(检查 3) - 确认对象的
permissions与已登录用户匹配(EVERYONE主体,或包含测试用户的user/group主体) - 重新检查
manifest.yml中是否包含write:object:jira范围,以及任何范围变更后是否重新部署了应用
检查 5 — 权限边界(仅当使用了 EVERYONE 时跳过)
如果连接器使用 user 或 group 主体:
- 以应该拥有访问权限的用户身份登录 → 确认该对象出现在 Rovo Search 中
- 以不应该拥有访问权限的用户身份登录 → 确认该对象不会出现
如果未经授权的用户能看到受限对象,请重新检查 setObjects 中的 accessControls 主体,并重新部署。
检查 6 — Rovo Chat 引用连接器数据
向 Rovo Chat 提出一个答案仅存在于已摄取内容中的问题,例如:
“[已摄取项目的标题] 的状态是什么?”
Rovo Chat 应将连接器列为来源。如果找不到内容,检查 3 和检查 4 可能存在尚未解决的问题。
检查 7 — 计划重新摄取已触发(如果已配置)
如果添加了 scheduledTrigger:
- 暂时在
manifest.yml中设置interval: fiveMinutes,重新部署并等待一个周期 - 确认
forge logs显示refreshIngestionHandler启动了新的摄取任务 - 恢复原来的间隔,并在投入生产前重新部署
生产就绪门槛
如果用户选择运行验证,只有在所有适用的检查均通过后,才能继续部署到生产环境(forge deploy -e production):
| 检查 | 生产环境部署要求 |
|---|---|
| 1 — 已建立连接 | 始终需要 |
| 2 — validateConnection 已通过 | 仅当配置了 validateConnection 时需要 |
| 3 — 摄取运行无错误 | 始终需要 |
| 4 — 对象在 Rovo Search 中可见 | 始终需要 |
| 5 — 权限边界 | 仅当使用 user/group 主体时需要 |
| 6 — Rovo Chat 引用了连接器 | 始终需要 |
| 7 — 计划重新摄取已触发 | 仅当配置了 scheduledTrigger 时需要 |
清单参考
关键规则:
- 作用域为
read:object:jira、write:object:jira、delete:object:jira— 不是read:graph:teamwork/write:graph:teamwork(这些会导致forge lint失败)function:声明在modules:下方,而不是顶层- 出站请求使用
address:,而不是裸字符串(运行forge lint --fix可自动修正)formConfiguration使用form: [{ type: header, properties: [...] }]— 不是fields:或beforeYouBegin:
最简连接器(无管理员配置、无 OAuth)
当应用完全在 Atlassian 内运行且不需要外部凭据时使用。
app:
id: <generated-by-forge-create>
runtime:
name: nodejs24.x
memoryMB: 256
architecture: arm64
permissions:
scopes:
- read:object:jira
- write:object:jira
- delete:object:jira
- storage:app
modules:
graph:connector:
- key: my-connector
name: My Service
capabilities:
replicatesPermissions: false # true if mirroring source-system ACLs into Teamwork Graph
syncFidelity: mirror # append | upsert | mirror (mirror is recommended)
supportsIncrementalSync: false
icons:
light: https://cdn.example.com/logo.png
dark: https://cdn.example.com/logo.png
objectTypes:
- atlassian:document
datasource:
onConnectionChange:
function: on-connection-change
function:
- key: on-connection-change
handler: index.onConnectionChangeHandler
带编排的连接器(平台管理的刷新)
在任何连接器变体中添加 orchestration.taskRunner 块,以启用平台管理的定期重新摄取。将其与现有的 onConnectionChange 处理程序一起接入。
modules:
graph:connector:
- key: my-connector
name: My Service
capabilities:
replicatesPermissions: false
syncFidelity: mirror
supportsIncrementalSync: false
icons:
light: https://cdn.example.com/logo.png
dark: https://cdn.example.com/logo.png
objectTypes:
- atlassian:document
orchestration:
taskRunner:
function: task-runner-fn # references the function key below
datasource:
onConnectionChange:
function: on-connection-change
function:
- key: on-connection-change
handler: index.onConnectionChangeHandler
- key: task-runner-fn
handler: index.taskRunner
timeoutSeconds: 900 # optional: extend to 15 min for large datasets
timeoutSeconds仅在被使用方模块(例如orchestration.taskRunner)引用的函数上有效。在未被引用的函数上设置它会导致清单验证失败。
带管理员表单配置的连接器(API 密钥 / URL)
当管理员必须提供凭据才能连接到外部系统时使用。
app:
id: <generated-by-forge-create>
runtime:
name: nodejs24.x
memoryMB: 256
architecture: arm64
permissions:
scopes:
- read:object:jira
- write:object:jira
- delete:object:jira
- storage:app
external:
fetch:
backend:
- address: 'https://api.your-service.com' # note: address: not a bare string
modules:
graph:connector:
- key: my-connector
name: My Service
capabilities:
replicatesPermissions: false # true if mirroring source-system ACLs into Teamwork Graph
syncFidelity: mirror # append | upsert | mirror (mirror is recommended)
supportsIncrementalSync: false
icons:
light: https://cdn.example.com/logo.png
dark: https://cdn.example.com/logo.png
objectTypes:
- atlassian:document
datasource:
formConfiguration:
form: # use form:, NOT fields: or beforeYouBegin:
- key: connectionDetails
type: header
title: Connection Details
description: >
Provide your My Service API credentials.
Find them in My Service → Settings → API.
properties:
- key: apiKey # camelCase keys — accessed as request.configProperties.apiKey
label: API Key
type: string
isRequired: true
- key: apiUrl
label: API URL
type: string
isRequired: true
validateConnection:
function: validate-connection
onConnectionChange:
function: on-connection-change
function: # function: is under modules:, NOT top-level
- key: on-connection-change
handler: index.onConnectionChangeHandler
- key: validate-connection
handler: index.validateConnectionHandler
处理程序签名
重要: Forge 会将请求直接作为第一个参数传入 — 不会将其包装在
event.payload下。表单配置值位于request.configProperties,而不是event.payload.config。如果弄错,会导致TypeError: Cannot destructure property of undefined。
onConnectionChange
const { kvs } = require('@forge/kvs');
const { graph } = require('@forge/teamwork-graph');
exports.onConnectionChangeHandler = async (request) => {
// request.action, request.connectionId, request.configProperties
const { action, connectionId, configProperties } = request;
if (action === 'DELETED') {
// Atlassian removes Teamwork Graph data automatically on disconnect.
// Only clean up locally stored credentials.
await kvs.deleteSecret(connectionId);
return { success: true };
}
// CREATED or UPDATED — persist credentials and ingest data
await kvs.setSecret(connectionId, configProperties);
await ingestAllData(connectionId, configProperties);
return { success: true };
};
validateConnection
const { fetch } = require('@forge/api');
exports.validateConnectionHandler = async (request) => {
// request.configProperties — NOT event.payload.config
const { configProperties } = request;
// Return { success: false, message } to reject — do NOT throw an Error.
// Return { success: true } to accept.
const response = await fetch(`${configProperties['apiUrl']}/health`);
if (!response.ok) {
return { success: false, message: 'Invalid API credentials. Please check your settings.' };
}
return { success: true, message: 'Connection validated successfully.' };
};
refreshIngestion(计划触发器)
exports.refreshIngestionHandler = async () => {
const activeConnections = await kvs.get('active-connections') ?? [];
for (const connectionId of activeConnections) {
const config = await kvs.getSecret(connectionId);
if (config) await ingestAllData(connectionId, config);
}
};
对象类型
粗体对象会在 Rovo Search 和 Rovo Chat 中建立索引。
| 对象类型 | 是否在 Rovo 中建立索引 | 最适用于 |
|---|---|---|
atlassian:document | ✅ | 文件、页面、Wiki 文章、报告 |
atlassian:message | ✅ | 聊天消息、电子邮件、评论 |
atlassian:work-item | ✅ | 任务、工单、问题 |
atlassian:project | ✅ | 项目、工作区 |
atlassian:space | ✅ | 团队空间、组织单元 |
atlassian:design | ✅ | 设计文件(Figma 等) |
atlassian:repository | ✅ | 代码仓库 |
atlassian:pull-request | ✅ | PR、合并请求 |
atlassian:commit | ✅ | Git 提交 |
atlassian:branch | ✅ | Git 分支 |
atlassian:conversation | ✅ | 讨论串、频道 |
atlassian:video | ✅ | 视频录制 |
atlassian:calendar-event | ✅ | 会议、活动 |
atlassian:comment | ✅ | 审查评论 |
atlassian:customer-organization | ✅ | 客户账户、组织 |
atlassian:build | ❌ | CI/CD 构建 |
atlassian:deployment | ❌ | 部署 |
atlassian:test | ❌ | 测试用例 |
在 Rovo Search / Rovo Chat 中显示
完成摄取后:
- 对象会显示在 Rovo Search 中,位于以连接器昵称命名的子筛选条件下(由管理员在连接时设置)
- 当用户询问与已摄取内容相关的主题时,Rovo Chat 可以在回复中引用连接器对象并标注出处
- 数据不会立即可用——
onConnectionChange触发后,请等待几分钟以完成索引
验证摄取是否正常:
- 在 Jira 站点中打开 Rovo Search
- 搜索已摄取对象的
name或properties中包含的文本 - 按连接器昵称筛选以缩小结果范围
大型数据集的分批模式
const { graph } = require('@forge/teamwork-graph');
const BATCH_SIZE = 100;
async function ingestAllData(connectionId, config) {
const items = await fetchExternalData(config);
for (let i = 0; i < items.length; i += BATCH_SIZE) {
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
const batch = items.slice(i, i + BATCH_SIZE);
const result = await graph.setObjects({
connectionId, // required in every call
objects: batch.map(item => ({
schemaVersion: '1.0',
id: item.id, // unique per connectionId
updateSequenceNumber: 1,
displayName: item.title,
url: item.url,
createdAt: item.createdAt,
lastUpdatedAt: item.updatedAt,
// Replace with USER/GROUP principals if source system has access controls.
// The id must exactly match the externalId used in graph.setUsers() / graph.setGroups().
permissions: [{
accessControls: [{ principals: [{ type: 'EVERYONE' }] }],
}],
'atlassian:document': {
type: { category: 'DOCUMENT', mimeType: item.mimeType },
content: { mimeType: item.mimeType, text: item.title },
},
})),
});
// Always check both success AND rejected — a call can partially fail.
const rejected = result.results?.rejected ?? [];
if (!result.success || rejected.length > 0) {
console.error(`[connector] setObjects batch ${batchNum} failed`, {
connectionId,
acceptedCount: result.results?.accepted?.length ?? 0,
rejectedCount: rejected.length,
rejected,
error: result.error,
});
} else {
console.log(`[connector] Batch ${batchNum}: ${result.results?.accepted?.length ?? batch.length} accepted, 0 rejected`);
}
}
}
编排式重新摄取(推荐)
当用户选择刷新计划时,请使用编排功能。平台会按每个连接配置的频率调用 taskRunner,无需外部调度器。完整参考请参阅编排概念。
安装 uuid 包以生成任务 ID:
npm install uuid
每次 taskRunner 调用都会收到的 3 个 ID
| ID | 生命周期 | 用途 |
|---|---|---|
taskId | 在根任务的多次调用间保持稳定 | TaskInfo 的 KVS 键 |
scanId | 每次根任务的计划运行生成一个 | 原样传递给 updateTaskStatus;贯穿所有子任务 |
taskExecutionId | 每次调用尝试都唯一 | 原样传递给 updateTaskStatus |
onConnectionChange — 计划根任务
在 CREATED 和 UPDATED 时都调用 scheduleOrUpdateTask。重用相同的 taskId,即可就地更新现有计划。
const { graph, types } = require('@forge/teamwork-graph');
const { kvs } = require('@forge/kvs');
const { v4: uuid } = require('uuid');
exports.onConnectionChangeHandler = async (request) => {
const { action, connectionId, configProperties } = request;
if (action === 'DELETED') {
await kvs.deleteSecret(connectionId);
await kvs.delete(`rootTaskId:${connectionId}`);
// TaskInfo rows for in-flight tasks are cleaned up here or in a maintenance pass.
return { success: true };
}
await kvs.setSecret(connectionId, configProperties);
// Mint once, reuse on UPDATED — same taskId makes scheduleOrUpdateTask an update.
let rootTaskId = await kvs.get(`rootTaskId:${connectionId}`);
if (!rootTaskId) {
rootTaskId = uuid();
await kvs.set(`rootTaskId:${connectionId}`, rootTaskId);
}
// Write TaskInfo BEFORE scheduling (taskRunner reads this to dispatch).
await kvs.set(`taskInfo:${rootTaskId}`, {
taskType: types.FORGE_TASK_TYPES.ENTITY_INGESTION_FULL,
connectionId,
createdAt: new Date().toISOString(),
});
const response = await graph.scheduleOrUpdateTask({
connectionId,
task: {
taskId: rootTaskId,
taskType: types.FORGE_TASK_TYPES.ENTITY_INGESTION_FULL,
scheduleInterval: { value: 1, timeUnit: 'day' }, // value: 1–60; timeUnit: minute|hour|day
},
});
// Branch on response.error — NOT response.success (orchestration responses don't set a boolean success field).
if (response.error) {
console.error('[connector] scheduleOrUpdateTask failed:', response.error);
return { success: false, message: response.error };
}
return { success: true };
};
taskRunner — 使用幂等性运行摄取
exports.taskRunner = async (request) => {
const { taskId, scanId, taskExecutionId, connectionId } = request;
// Read TaskInfo — dispatch key and idempotency state.
const taskInfo = await kvs.get(`taskInfo:${taskId}`);
if (!taskInfo) {
await graph.updateTaskStatus({
scanId, taskExecutionId, connectionId,
status: 'failure',
failureReason: 'ENTITY_NOT_FOUND',
task: { taskId },
});
return { success: false, message: 'Task not found' };
}
// Short-circuit duplicate deliveries — the platform delivers at-least-once.
if (taskInfo.completed) {
await graph.updateTaskStatus({ scanId, taskExecutionId, connectionId, status: 'success', task: { taskId } });
return { success: true, message: 'Already completed (duplicate delivery)' };
}
try {
const config = await kvs.getSecret(connectionId);
await ingestAllData(connectionId, config);
// Mark completed BEFORE updateTaskStatus so a crash between the two
// doesn't cause a duplicate invocation to re-run the full pipeline.
await kvs.set(`taskInfo:${taskId}`, { ...taskInfo, completed: true });
await graph.updateTaskStatus({ scanId, taskExecutionId, connectionId, status: 'success', task: { taskId } });
return { success: true };
} catch (err) {
console.error('[connector] taskRunner failed:', err);
await graph.updateTaskStatus({
scanId, taskExecutionId, connectionId,
status: 'failure',
failureReason: 'INTERNAL_ERROR',
task: { taskId },
});
return { success: false, message: err.message };
}
};
任务类型(types.FORGE_TASK_TYPES)
taskType 是封闭枚举——请使用 SDK 常量,切勿使用原始字符串。
const { types } = require('@forge/teamwork-graph');
types.FORGE_TASK_TYPES.ENTITY_INGESTION_FULL // full re-sync of objects
types.FORGE_TASK_TYPES.ENTITY_INGESTION_INCREMENTAL // delta sync (changes only)
types.FORGE_TASK_TYPES.USER_INGESTION_FULL
types.FORGE_TASK_TYPES.USER_INGESTION_INCREMENTAL
types.FORGE_TASK_TYPES.GROUP_INGESTION_FULL
types.FORGE_TASK_TYPES.GROUP_INGESTION_INCREMENTAL
types.FORGE_TASK_TYPES.RELATIONSHIP_INGESTION_FULL
types.FORGE_TASK_TYPES.RELATIONSHIP_INGESTION_INCREMENTAL
使用子任务进行扇出(大型数据集)
对于大型数据集,请从根 taskRunner 扇出,为每个条目创建子任务。对照任务使用确定性的 UUID(uuidv5),以便重复投递解析到同一条 TaskInfo 记录。
const { v5: uuidv5 } = require('uuid');
const APP_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // any stable UUID
// Inside taskRunner, after fetching item list:
for (const item of items) {
const childTaskId = uuidv5(`${scanId}:${taskId}:${item.id}`, APP_NAMESPACE);
await kvs.set(`taskInfo:${childTaskId}`, {
taskType: types.FORGE_TASK_TYPES.ENTITY_INGESTION_FULL,
connectionId,
createdAt: new Date().toISOString(),
metadata: { itemId: item.id },
});
const response = await graph.scheduleChildTask({
connectionId,
scanId, // must be the current scan's scanId
task: {
taskId: childTaskId,
taskType: types.FORGE_TASK_TYPES.ENTITY_INGESTION_FULL,
},
});
if (response.error) {
console.error('[connector] scheduleChildTask failed:', response.error);
}
}
计划重新摄取(旧版 — 优先使用编排)
优先使用编排(上文所述),适用于用户需要由平台管理的定期同步。只有在不需要平台任务跟踪的简单 cron 风格触发场景中,才使用
scheduledTrigger。
若要通过基本的计划触发器保持数据最新:
# In manifest.yml — under modules:
scheduledTrigger:
- key: refresh-trigger
function: refresh-ingestion
interval: day # prefer 'day' or 'hour'; avoid 'fiveMinutes'
# Under function:
- key: refresh-ingestion
handler: index.refreshIngestionHandler
const { kvs } = require('@forge/kvs');
// Track active connections in onConnectionChangeHandler:
// await kvs.set('active-connections', [...activeConnections, connectionId]);
// await kvs.setSecret(connectionId, configProperties); // store credentials securely
exports.refreshIngestionHandler = async () => {
const activeConnections = await kvs.get('active-connections') ?? [];
for (const connectionId of activeConnections) {
const config = await kvs.getSecret(connectionId); // retrieve stored credentials
if (config) await ingestAllData(connectionId, config);
}
};
脚本
| 脚本 | 技能目录 | 用途 |
|---|---|---|
scripts/scaffold_connector.py | skills/forge-connector/(本技能) | 搭建新的连接器应用 — 生成 manifest.yml、src/index.ts,并安装 SDK。运行:python3 -m scripts.scaffold_connector |
scripts/deploy_forge_app.py | skills/forge-app-builder/(不同的技能) | 在 Jira 上部署并安装。从 forge-app-builder 目录运行:python3 -m scripts.deploy_forge_app |
脚手架脚本位于本技能的目录中。部署脚本位于 forge-app-builder 技能目录中 — 运行前务必先 cd 到该目录(或根据此 SKILL.md 的位置推导路径)。
开发最佳实践
遵循这些实践以避免常见问题。完整指南请参阅官方连接器要求。
不要忽略摄取错误
务必检查 setObjects、setUsers 或 setGroups 的响应。调用可能完全失败,也可能只拒绝部分条目 — 即使部分对象遭到拒绝,response.success 也可能为 true。
const response = await graph.setObjects({ connectionId, objects });
const rejected = response.results?.rejected ?? [];
if (!response.success || rejected.length > 0) {
console.error('Teamwork Graph object ingestion failed', {
connectionId,
acceptedCount: response.results?.accepted?.length ?? 0,
rejectedCount: rejected.length,
rejected,
error: response.error,
});
}
保持访问控制 ID 一致
在对象 permissions 中使用 USER 或 GROUP 主体时,务必先通过 graph.setUsers() / graph.setGroups() 摄取相应的用户或组。accessControls 中的 id 必须与传入这些调用的 externalId 完全一致。ID 不匹配会导致本应有权访问的用户无法看到对象,且不会显示任何提示。
// Ingest user with externalId 'user-42'
await graph.setUsers({ connectionId, users: [{ externalId: 'user-42', displayName: 'Alex Chen', emails: [{ value: '[email protected]', primary: true }] }] });
// Use the same ID 'user-42' in accessControls — NOT 'accountId:user-42' or any other variant
await graph.setObjects({
connectionId,
objects: [{
// ...
permissions: [{ accessControls: [{ principals: [{ type: 'USER', id: 'user-42' }] }] }],
}],
});
声明功能
在 graph:connector 模块中添加 capabilities 块。若未添加,管理员会在配置屏幕上看到 "Capabilities not declared by developer",从而降低信任度。请设置准确反映连接器行为的值:
| 属性 | 类型 | 值 | 描述 |
|---|---|---|---|
replicatesPermissions | 布尔值 | true / false | 是否镜像源系统 ACL。若为 false,工作区中的所有人都能看到摄取的所有数据。 |
syncFidelity | 枚举 | append / upsert / mirror | 建议使用 mirror — 同步新对象、更新和删除。 |
supportsIncrementalSync | 布尔值 | true / false | 连接器是否仅同步自上次运行以来的变更。 |
数据删除职责
| 方式 | 行为 | 要求 |
|---|---|---|
| 镜像(推荐) | 从源同步删除操作——Teamwork Graph 始终反映当前状态 | 实现删除传播 |
| 仅断开连接 | 不同步删除操作——数据会一直保留,直到管理员断开连接 | 在连接器文档和 Marketplace 列表中醒目说明此限制 |
管理员断开连接时,Atlassian 会自动移除所有已摄取的数据。你的 onConnectionChange 处理程序只需在 action: DELETED 时清理应用端资源(停止同步任务、移除 webhook、删除已存储的凭据)。
故障排除
| 问题 | 操作 |
|---|---|
清单中无法识别 graph:connector | 运行 forge lint — 它会指出导致错误的确切字段 |
TypeError: Cannot destructure property 'config' of 'event.payload' | 处理程序使用了 event.payload.config — 改为 request.configProperties。Forge 会直接传递 request,不会将其嵌套在 event.payload 下 |
TypeError: Cannot read properties of undefined (reading 'set') | 使用了来自 @forge/storage 的 storage — 改用来自 @forge/kvs 的 kvs |
graph.setObjects is not a function | 导入错误 — 使用 const { graph } = require('@forge/teamwork-graph'),然后调用 graph.setObjects({ objects, connectionId }) |
forge lint:无效的 scopes read/write:graph:teamwork | 替换为 read:object:jira、write:object:jira、delete:object:jira |
forge lint:document should NOT have additional property 'function' | function: 位于顶层 — 将其移入 modules: |
forge lint:formConfiguration must have required property 'form' | 将 fields: / beforeYouBegin: 替换为 form: [{ type: header, properties: [...] }] |
forge lint 警告:egress 条目已弃用 | 运行 forge lint --fix,自动将裸 URL 字符串转换为 { address: 'url' } |
找不到 forge developer-spaces list 命令 | Forge CLI 12.x 中不存在此命令。请让用户交互式运行 forge create 来选择 developer space |
forge create 因 non-TTY 错误而失败 | forge create 需要交互式终端 — 请让用户运行该命令;然后将清单和源文件写入创建的目录 |
未触发 onConnectionChange | 确认管理员已在 Atlassian Administration → Connected apps 中点击 "Connect";运行 forge tunnel 确认函数已触发 |
| 对象未显示在 Rovo Search 中 | 等待约 5 分钟以完成索引;运行 forge logs -e development --since 15m 检查是否存在 setObjects 错误 |
setObjects 返回 success: true,但对象未显示在 Rovo 中 | 检查 response.results?.rejected — 即使整个调用成功,单个项目仍可能被拒绝;记录 rejected 数组以查看每个项目的错误详情 |
| 有访问权限的用户无法在 Rovo Search 中看到对象 | 确认 accessControls 主体中的 id 与 graph.setUsers() / graph.setGroups() 使用的 externalId 完全匹配 — ID 不匹配(例如 user-42 与 accountId:user-42)会导致访问权限无声失效 |
调用 @forge/teamwork-graph 时出现 403 | 确保清单 scopes 中包含 read:object:jira、write:object:jira、delete:object:jira,然后重新部署并运行 forge install --upgrade |
需要运行 forge login | 在 https://id.atlassian.com/manage/api-tokens 创建 API token,然后运行 forge login |
scheduleOrUpdateTask 返回 { status: 'ACCEPTED' },但代码报告失败 | 根据 response.error 而非 response.success 进行分支判断 — 编排成功响应不会设置布尔字段 success |
SDK 返回 task.taskType must be one of: ... | taskType 是封闭枚举 — 使用来自 @forge/teamwork-graph 的 types.FORGE_TASK_TYPES.* 常量;客户端会拒绝任意字符串 |
task.taskId must be a valid UUID format | 根任务使用 uuid()(v4),子任务使用 uuidv5(...) — "my-app:sync" 这类自由格式字符串会被拒绝 |
Failed to schedule child tasks: Not Found - Scan not found | 传给 scheduleChildTask 的 scanId 与活动 scan 不匹配 — 确保从活动的 taskRunner 调用中调用它,并原样传递 request.scanId |
成功运行后 taskRunner 报告 ENTITY_NOT_FOUND | TaskInfo 删除得过早 — 改用 completed: true 标志,并对重复投递提前返回,而不是删除记录 |
taskRunner 中的 taskMetadata 为空 | scheduleChildTask 上的 temporalTaskMetadata / persistentTaskMetadata 无法可靠地传回 — 通过 TaskInfo.metadata 将处理程序所需的全部内容存入 KVS |
scheduleInterval.value 超出范围错误 | 值必须介于 1 和 60 之间;调整后重新部署 |
名称和徽标指南
- 使用官方服务名称作为连接器名称(例如,使用
Google Drive,而不是Acme 的 Drive 连接器) - 图标使用官方服务徽标,不要修改徽标或将其与自有品牌标识组合
- 这些指南仅适用于
graph:connector模块;你的 Forge 应用本身可以使用自有品牌标识