mcpField
F
forge-connector

forge-connector

github.com/atlassian开发27418 次安装1 周前更新
代码仓库

关于 forge-connector

forge-connector 是用于创建 Atlassian Forge graph:connector 应用的技能,可将外部数据写入 Teamwork Graph,使其可在 Rovo Search 和 Rovo Chat 中检索。它面向构建 Forge 连接器的开发者,提供从数据类型与权限梳理到脚手架和同步实现的分步指导。

主要功能

  1. 01生成连接器应用脚手架
  2. 02梳理 Teamwork Graph 对象类型
  3. 03指导凭据与表单配置
  4. 04规划访问控制与数据权限
  5. 05配置定期重新摄取任务

适用场景

  1. 01将外部系统内容接入 Rovo 搜索
  2. 02按来源权限控制检索可见性
  3. 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.md
scripts/
__init__.py
forge_env.py
scaffold_connector.py
tests/
__init__.py
test_forge_env.py
test_scaffold_connector.py

详细介绍

Forge Connector

构建一个 graph:connector Forge 应用,将外部数据导入 Atlassian 的 Teamwork Graph,使其显示在 Rovo Search 和 Rovo Chat 中。

关键规则

  1. 必须安装在 Jira 中 — 使用 Teamwork Graph modules 的应用必须安装到 Jira 站点。仅安装在 Confluence 中无法运行。
  2. 绝不要在聊天中索要凭据 — 引导用户在自己的终端中运行 forge login。
  3. 始终亲自运行脚手架脚本 — 不要只提供手动操作说明;运行 scripts/scaffold_connector.py 以生成样板代码。
  4. 需要安装时,始终向用户询问其 Atlassian 站点 URL — 绝不要自行查找或猜测。
  5. Atlassian 会在断开连接时删除数据 — 当 action = 'DELETED' 时,应用只需清理本地状态;Atlassian 会自动删除 Teamwork Graph 数据。
  6. 处理程序参数会直接传入 — Forge 会将请求对象作为第一个参数传给处理程序,而不是嵌套在 event.payload 下。配置值位于 request.configProperties,而不是 event.payload.config。这是导致 TypeError: Cannot destructure property of undefined 错误的最常见原因。
  7. 使用 @forge/kvs 存储数据 — 从 @forge/kvs 导入 kvs。不要使用 @forge/storage — 其 storage 导出在连接器函数运行时为 undefined。
  8. 使用 @forge/teamwork-graph 的 graph 命名导出 — 正确的导入方式是 const { graph } = require('@forge/teamwork-graph')。调用 graph.setObjects({ objects, connectionId })。不要直接将 setObjects 作为命名导入。
  9. validateConnectionHandler 必须返回 { success, message } — 不要抛出 Error。返回 { success: false, message: '...' } 以拒绝,返回 { success: true } 以接受。
  10. function 声明应放在 modules 下 — 在 manifest.yml 中,function: 是 modules: 下的键,不是顶层键。将其放在顶层会导致 lint 错误。
  11. formConfiguration 使用带有 type: header 的 form 数组 — 不要使用 fields: 或 beforeYouBegin:。正确格式为 form: [{ key, type: header, title, description, properties: [...] }]。
  12. 权限范围应为 read/write/delete:object:jira — 使用 read:object:jira、write:object:jira、delete:object:jira。read:graph:teamwork 和 write:graph:teamwork 权限范围无效,会导致 forge lint 失败。
  13. 为此技能运行的 forge 命令设置 ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-connector — 在 forge 命令前加上此环境变量:包括你在 shell 中运行的命令(例如 forge lint、forge logs、forge deploy),以及作为备用方案提供给用户的交互式 forge create 命令。捆绑脚本会自动设置此变量;为简洁起见,此技能中展示的其他命令省略了它 — 运行时请添加。仅 forge login 和 forge tunnel(由用户运行的身份验证/实时开发命令)除外。
  14. 绝不要忽略 setObjects/setUsers/setGroups 的响应 — 不要将数据导入调用视为即发即弃。始终检查 response.success 和 response.results?.rejected。调用可能部分失败:部分对象被拒绝,其他对象被接受;即使 rejected.length > 0,response.success 也可能为 true。
  15. 保持访问控制 ID 一致 — 在 permissions 中使用 USER 或 GROUP 主体时,id 值必须与对应 graph.setUsers()/graph.setGroups() 调用中使用的 externalId 完全一致。ID 不匹配(例如 user-42 与 accountId:user-42)会导致本应有权访问的用户无法看到对象,且不会显示任何错误。
  16. 使用编排功能安排刷新任务 — 用户需要定期重新摄取数据时,请使用 graph.scheduleOrUpdateTask 和 taskRunner 处理程序(平台托管的方式),而不是 scheduledTrigger。编排功能会原生处理执行频率、重试和任务扇出。只有用户明确需要不带平台任务跟踪的轻量级 cron 风格触发器时,才改用 scheduledTrigger。
  17. taskId 必须是有效的 UUID — scheduleOrUpdateTask 和 scheduleChildTask 都要求 task.taskId 是 UUID 字符串。根任务使用 uuid()(v4),子任务使用 uuidv5('<scanId>:<parentTaskId>:<itemId>', APP_NAMESPACE)。将根任务 ID 持久化到 KVS,以便重新运行 onConnectionChange 时复用相同 ID(从而更新现有任务,而不是创建相互竞争的计划)。
  18. 安排任务前先将 TaskInfo 写入 KVS;完成后绝不要删除 — 调用 scheduleOrUpdateTask 或 scheduleChildTask 前,始终将 TaskInfo 记录以 taskId 为键写入 KVS。成功后,在记录上设置 completed: true — 不要删除记录。删除记录会导致重复投递返回 ENTITY_NOT_FOUND,并覆盖先前的成功状态。
  19. 根据编排响应中的 response.error 分支处理 — scheduleOrUpdateTask 和 scheduleChildTask 成功时返回 { status: 'ACCEPTED' },而不是 { success: true }。通过检查 if (response.error) 来检测失败 — 不要检查 if (!response.success)。

MCP 前置条件

MCP 服务用途
Forge MCP清单语法、模块配置、部署指南
ADS MCPAtlaskit 组件(仅在添加 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。

要询问用户的问题
  1. 你要连接哪个外部系统或工具? 例如:Google Drive、ServiceNow、Salesforce、GitHub、Confluence、Slack、Figma、Zendesk

  2. 你想让 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
  3. 内容是单一类型还是混合类型? 如果是混合类型(例如,包含任务和文档的项目管理工具),请计划将每种内容作为独立的对象类型进行摄取。脚手架支持一种主要类型——你之后可以在 manifest.yml 中添加更多 objectTypes 条目。

  4. 管理员是否需要提供凭据(API 密钥、URL、OAuth 令牌)才能连接? 是 → 在脚手架命令中使用 --has-form-config。 否(数据完全来自 Atlassian 内部)→ 省略此标志。

  5. 源数据多久会变化一次? 这决定同步频率。所有由平台管理的刷新都使用编排(scheduleOrUpdateTask + taskRunner)——它原生支持频率控制、重试和扇出。只有在明确需要不跟踪任务的轻量级 cron 时,才使用旧版 scheduledTrigger 方案。

    频率编排间隔
    每小时scheduleInterval: { value: 1, timeUnit: 'hour' }
    每天scheduleInterval: { value: 1, timeUnit: 'day' }
    每 N 分钟(1–60)scheduleInterval: { value: N, timeUnit: 'minute' }
    静态 / 一次性无需定时刷新——在 onConnectionChange 中摄取一次

    继续执行步骤 2 前,记录所选间隔。

  6. 谁应该能够在 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.jsfetchExternalData() — 替换为你的 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.documentDOCUMENT
application/vnd.google-apps.spreadsheetSPREADSHEET
application/vnd.google-apps.presentationPRESENTATION
application/vnd.google-apps.folderFOLDER
application/pdfPDF
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 连接

部署完成后,告知用户:

  1. 前往 Atlassian Administration → Apps → [site] → Connected apps
  2. 找到应用 → View app details → Connections 标签页
  3. 点击连接器下方的 Connect
  4. 填写所有配置字段(如果已定义 formConfiguration)
  5. 点击 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 中可见
  1. 在 Jira 站点中打开 Rovo Search(摄取后允许最多 5 分钟进行索引)
  2. 搜索至少一个已摄取对象的 displayName 或内容文本中出现的词语
  3. 按连接器的昵称进行筛选(由管理员在连接时设置)

连接器应至少显示一个结果。如果 5 分钟后仍无结果:

  • 确认 setObjects 日志显示 N accepted,且 N > 0(检查 3)
  • 确认对象的 permissions 与已登录用户匹配(EVERYONE 主体,或包含测试用户的 user/group 主体)
  • 重新检查 manifest.yml 中是否包含 write:object:jira 范围,以及任何范围变更后是否重新部署了应用
检查 5 — 权限边界(仅当使用了 EVERYONE 时跳过)

如果连接器使用 user 或 group 主体:

  1. 以应该拥有访问权限的用户身份登录 → 确认该对象出现在 Rovo Search 中
  2. 以不应该拥有访问权限的用户身份登录 → 确认该对象不会出现

如果未经授权的用户能看到受限对象,请重新检查 setObjects 中的 accessControls 主体,并重新部署。

检查 6 — Rovo Chat 引用连接器数据

向 Rovo Chat 提出一个答案仅存在于已摄取内容中的问题,例如:

“[已摄取项目的标题] 的状态是什么?”

Rovo Chat 应将连接器列为来源。如果找不到内容,检查 3 和检查 4 可能存在尚未解决的问题。

检查 7 — 计划重新摄取已触发(如果已配置)

如果添加了 scheduledTrigger:

  1. 暂时在 manifest.yml 中设置 interval: fiveMinutes,重新部署并等待一个周期
  2. 确认 forge logs 显示 refreshIngestionHandler 启动了新的摄取任务
  3. 恢复原来的间隔,并在投入生产前重新部署
生产就绪门槛

如果用户选择运行验证,只有在所有适用的检查均通过后,才能继续部署到生产环境(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 触发后,请等待几分钟以完成索引

验证摄取是否正常:

  1. 在 Jira 站点中打开 Rovo Search
  2. 搜索已摄取对象的 name 或 properties 中包含的文本
  3. 按连接器昵称筛选以缩小结果范围

大型数据集的分批模式

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.pyskills/forge-connector/(本技能)搭建新的连接器应用 — 生成 manifest.yml、src/index.ts,并安装 SDK。运行:python3 -m scripts.scaffold_connector
scripts/deploy_forge_app.pyskills/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_FOUNDTaskInfo 删除得过早 — 改用 completed: true 标志,并对重复投递提前返回,而不是删除记录
taskRunner 中的 taskMetadata 为空scheduleChildTask 上的 temporalTaskMetadata / persistentTaskMetadata 无法可靠地传回 — 通过 TaskInfo.metadata 将处理程序所需的全部内容存入 KVS
scheduleInterval.value 超出范围错误值必须介于 1 和 60 之间;调整后重新部署


名称和徽标指南

  • 使用官方服务名称作为连接器名称(例如,使用 Google Drive,而不是 Acme 的 Drive 连接器)
  • 图标使用官方服务徽标,不要修改徽标或将其与自有品牌标识组合
  • 这些指南仅适用于 graph:connector 模块;你的 Forge 应用本身可以使用自有品牌标识

评论

支持 Markdown