
Forge Connector
- 151 installs
- 19 repo stars
- Updated August 1, 2026
- atlassian/forge-skills
forge-connector is a Claude skill that scaffolds and deploys Atlassian Forge graph:connector apps to ingest external data into the Teamwork Graph for Rovo Search and Chat.
About
This skill guides building and deploying an Atlassian Forge connector app that ingests external data into Atlassian's Teamwork Graph. A developer uses it when connecting a third-party tool like Google Drive, ServiceNow, or Salesforce so its content becomes searchable in Rovo Search and Rovo Chat. It runs a scaffold script to generate the graph:connector boilerplate and encodes the Forge SDK rules that avoid common runtime errors.
- Scaffolds a graph:connector Forge app that ingests external data into Atlassian's Teamwork Graph
- Makes ingested content searchable in Rovo Search and Rovo Chat
- Encodes Forge-specific gotchas: @forge/kvs storage, graph.setObjects, validateConnection return shape
Forge Connector by the numbers
- 151 all-time installs (skills.sh)
- Ranked #2,506 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
forge-connector capabilities & compatibility
- Capabilities
- forge connector · data ingestion · teamwork graph
- Works with
- atlassian · jira · confluence · salesforce · servicenow · google drive
- Use cases
- api development
What forge-connector says it does
Builds a `graph:connector` Forge app that ingests external data into Atlassian's Teamwork Graph so it appears in **Rovo Search** and **Rovo Chat**.
npx skills add https://github.com/atlassian/forge-skills --skill forge-connectorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 151 |
|---|---|
| repo stars | ★ 19 |
| Last updated | August 1, 2026 |
| Repository | atlassian/forge-skills ↗ |
What it does
Build a Forge connector app that ingests external data into Atlassian's Teamwork Graph so it appears in Rovo Search and Chat.
Who is it for?
developers building an Atlassian Forge connector to bring external tool data into Rovo Search and Chat
Skip if: Confluence-only installs, which the skill states will not work
When should I use this skill?
the user wants to build a Forge connector, ingest external data into Atlassian, or make third-party content searchable in Rovo
What you get
A deployed graph:connector Forge app whose external data is searchable in Rovo Search and Chat.
- a scaffolded graph:connector Forge app
- deployed connector ingesting data into Teamwork Graph
By the numbers
- 13 critical rules for Forge connector development
Files
Forge Connector
Builds a graph:connector Forge app that ingests external data into Atlassian's Teamwork Graph so it appears in Rovo Search and Rovo Chat.
Critical Rules
1. Must install in Jira — Apps using Teamwork Graph modules must be installed on a Jira site. Confluence-only installs will not work. 2. Never ask for credentials in chat — Direct users to run forge login in their own terminal. 3. Always run the scaffold script yourself — Do not only give manual instructions; run scripts/scaffold_connector.py to generate the boilerplate. 4. Always ask the user for their Atlassian site URL when install is needed — never discover or guess it. 5. Atlassian deletes data on disconnect — When action = 'DELETED', the app only needs to clean up local state; Atlassian removes the Teamwork Graph data automatically. 6. Handler arguments are passed directly — Forge passes the request object as the first argument to handlers, NOT nested under event.payload. Config values are at request.configProperties, NOT event.payload.config. This is the most common source of TypeError: Cannot destructure property of undefined errors. 7. Use `@forge/kvs` for storage — Import kvs from @forge/kvs. Do NOT use @forge/storage — its storage export is undefined at runtime in connector functions. 8. Use `graph` named export from `@forge/teamwork-graph` — The correct import is const { graph } = require('@forge/teamwork-graph'). Call graph.setObjects({ objects, connectionId }). Do NOT import setObjects as a named export directly. 9. `validateConnectionHandler` must return `{ success, message }` — Do NOT throw an Error. Return { success: false, message: '...' } to reject, { success: true } to accept. 10. `function` declarations belong under `modules` — In manifest.yml, function: is a key under modules:, not a top-level key. Placing it at the top level causes a lint error. 11. `formConfiguration` uses `form` array with `type: header` — Do NOT use fields: or beforeYouBegin:. The correct format uses form: [{ key, type: header, title, description, properties: [...] }]. 12. Scopes are `read/write/delete:object:jira` — Use read:object:jira, write:object:jira, delete:object:jira. The scopes read:graph:teamwork and write:graph:teamwork are invalid and will fail forge lint. 13. Set `ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-connector` on `forge` commands run for this skill — prefix forge invocations with this env var: ones you run in the shell (e.g. forge lint, forge logs, forge deploy) and the interactive `forge create` command you hand the user as a fallback. The bundled scripts set it automatically; other commands shown in this skill omit it for brevity — add it when you run them. The only exclusions are forge login and forge tunnel (user-run auth / live-dev commands).
MCP Prerequisites
| MCP Server | Purpose |
|---|---|
| Forge MCP | Manifest syntax, module config, deployment guides |
| ADS MCP | Atlaskit components (only if adding Custom UI) |
---
Agent Workflow — Complete Steps 0–7 in Order
Step 0: Prerequisites
Check Node.js (node -v, requires 22+), Forge CLI (forge --version), and login (forge whoami). Install missing tools:
npm install -g @forge/cliTell the user to run forge login in their terminal if not authenticated.
Step 1: Discover Developer Spaces
Note: forge developer-spaces list does NOT exist in Forge CLI 12.x. You cannot list developer spaces non-interactively.forge create requires an interactive TTY to select a developer space. Ask the user to run it themselves:
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.The --dev-space-id flag in the scaffold script is optional and can be omitted — the script has been updated to skip it when not provided.
Step 1.5: Discover Data & Map to Object Types
Do this before scaffolding. Ask the user the following questions to determine the correct Teamwork Graph object type(s). Do not assume or default to atlassian:document.
Questions to ask the user
1. What external system or tool are you connecting? e.g. Google Drive, ServiceNow, Salesforce, GitHub, Confluence, Slack, Figma, Zendesk
2. What kind of content do you want to make searchable in Rovo? Prompt with examples to help them identify it:
- Files, pages, wiki articles, reports, PDFs → likely
atlassian:document - Tasks, tickets, issues, bugs, stories → likely
atlassian:work-item - Chat messages, emails, comments → likely
atlassian:messageoratlassian:comment - Projects, workspaces, boards → likely
atlassian:project - Code repositories → likely
atlassian:repository - Pull requests / merge requests → likely
atlassian:pull-request - Git commits → likely
atlassian:commit - Design files (Figma, Sketch) → likely
atlassian:design - Video recordings → likely
atlassian:video - Calendar events, meetings → likely
atlassian:calendar-event - Threads, channels → likely
atlassian:conversation - Customer accounts or organisations → likely
atlassian:customer-organization - Team spaces or org units → likely
atlassian:space
3. Is the content a single type or a mix? If mixed (e.g. a project management tool with tasks and documents), plan to ingest each as its own object type. The scaffold supports one primary type — you can add more objectTypes entries in manifest.yml later.
4. Does the admin need to supply credentials (API key, URL, OAuth token) to connect? Yes → use --has-form-config in the scaffold command. No (data comes entirely from within Atlassian) → omit the flag.
5. How often does the source data change? Frequently (hourly) → plan a scheduledTrigger with interval: hour. Daily or less → interval: day. Static / one-off → no scheduled trigger needed.
6. Who should be able to see the ingested content in Rovo Search? This determines the permissions.accessControls on each object. Ask:
- "Is all this content publicly accessible, or does the source system restrict who can see what?"
- "Do you want Rovo Search results to respect those source-system permissions?"
Map the answer to the correct principal model:
| Source system access model | accessControls to use |
|---|---|
| Publicly accessible, no restrictions | principals: [{ type: 'EVERYONE' }] |
| Specific named users have access | principals: [{ type: 'user', id: '<atlassian-account-id>' }] — one entry per user |
| Team or group based (e.g. Confluence space, Google Workspace group) | principals: [{ type: 'group', id: '<group-id>' }] — one entry per group |
| Private / owner only | single user principal with the owner's Atlassian account ID |
| Mixed (per-object ACLs from the source) | fetch ACLs per item during ingestion and map each to a user or group principal |
Do NOT default to `EVERYONE` unless the user explicitly confirms content is publicly accessible. Using EVERYONE on restricted content leaks data to users who shouldn't see it in Rovo Search.
Record the chosen permission model before proceeding to Step 2. Reference it when writing the setObjects call in Step 3.
Mapping decision
Based on the answers, select the best-fit type from the Object Types table below. Only fall back to atlassian:document if the content genuinely has no better match (e.g. arbitrary file attachments). For types marked ❌ in the "Indexed in Rovo" column (atlassian:build, atlassian:deployment, atlassian:test), warn the user that those objects will not appear in Rovo Search or Rovo Chat.
Record the chosen object type(s) and permission model before proceeding to Step 2.
Step 2: Scaffold the Connector App
Run from the skill directory (the directory containing this SKILL.md). Replace <object-type> with the type determined in Step 1.5. --dev-space-id is optional:
python3 -m scripts.scaffold_connector \
--name <app-name> \
--connector-name "<Human Readable Name>" \
--object-type <object-type> \
--directory <parent-directory>Add --dev-space-id <id> only if you have the ID from a previous step.
Object type — use the type chosen in Step 1.5. Do NOT default to atlassian:document without first completing the discovery questions above.
Form config flag — add --has-form-config if the admin must provide API credentials or connection details (determined in Step 1.5 question 4). Omit it for apps that operate entirely within Atlassian (no external credentials needed).
If scaffold fails because `forge create` needs a TTY: The scaffold script will print a manual fallback command. Have the user runforge createinteractively, then continue from Step 3 — the scaffold script only needs to writemanifest.ymlandsrc/index.jsafter the directory exists.
Step 3: Customize the Generated Code
After scaffolding (or after the user runs forge create interactively):
cd <app-name>
npm installThe blank template generates src/index.js (JavaScript, not TypeScript). Edit it to add your API calls. The scaffold generates working handler skeletons; fill in your business logic.
Key files to edit
| File | What to change |
|---|---|
src/index.js | fetchExternalData() — replace with your API calls |
manifest.yml | Add permissions.external.fetch.backend URLs for any external APIs |
package.json | Add @forge/api, @forge/kvs, @forge/teamwork-graph as dependencies |
setObjects — ingest data into Teamwork Graph
Use the graph named export — do NOT destructure setObjects directly:
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: '<atlassian-account-id>' }
// For group-restricted content: { type: 'group', id: '<group-id>' }
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',
},
},
},
],
});
if (!result.success) {
console.error('setObjects error:', result.error);
}- Max 100 objects per call — batch large datasets with a loop
idmust be unique perconnectionIdconnectionIdis required in everygraph.setObjects()call
Document Categories (for atlassian:document.type.category)
| MIME type | Category |
|---|---|
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 | OTHER |
getObjectByExternalId — look up a single object
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);Step 4: Deploy and Install
You MUST run the deploy script — do not only give the user manual forge deploy commands.
The deploy script lives in the forge-app-builder skill, not in this skill. Derive its directory from the path of this SKILL.md: go up two levels (skills/forge-connector/ → skills/) then into forge-app-builder/. Run all commands below from that directory.
# 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-depsStep 5: Connect via Atlassian Administration
After deployment, tell the user to:
1. Go to Atlassian Administration → Apps → [site] → Connected apps 2. Find the app → View app details → Connections tab 3. Click Connect under the connector 4. Fill in any configuration fields (if formConfiguration was defined) 5. Click Connect — this triggers onConnectionChange with action: CREATED and starts data ingestion
Step 6: Monitor with forge tunnel
Use forge tunnel during development to stream live logs directly to your terminal as the connector functions execute. This is the fastest way to catch errors in onConnectionChangeHandler, validateConnectionHandler, and setObjects calls without waiting for forge logs.
Tell the user to run this in their own terminal (it requires an interactive session):
cd <app-directory>
forge tunnelWith the tunnel active, any invocation of the connector functions (e.g. clicking "Connect" in Atlassian Admin, or triggering a scheduled re-ingestion) will stream output immediately. Look for:
[connector] Fetched N items— confirmsfetchExternalData()ran[connector] Batch 1: N accepted, 0 rejected— confirmssetObjectssucceeded- Any uncaught errors or thrown exceptions from
validateConnectionHandler
If the tunnel is not running, use forge logs instead to inspect past invocations:
# 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 50Tunnel vs logs — when to use which:
| Situation | Use |
|---|---|
| Actively developing / testing the connection flow | forge tunnel — live streaming |
| Debugging a past invocation or production issue | forge logs |
| Connector function timed out before tunnel caught it | forge logs with --limit 100 |
Note: forge tunnel must be run by the user in an interactive terminal — do not attempt to run it via the agent.Step 7: End-to-End Verification (optional)
Before running any checks, ask the user:
"Would you like to run end-to-end verification checks before deploying to production? This confirms the connection, ingestion, Rovo Search visibility, and permission boundaries are all working correctly."
If the user says no or wants to skip, move on — do not run or describe the checks. If the user says yes, work through every check below in order.
Check 1 — Connection established
In Atlassian Administration → Apps → Connected apps, the connector should show status Connected. If it shows an error or pending state, go back to Step 6 and inspect forge tunnel or forge logs output.
Check 2 — validateConnection passed (if configured)
If the app has a validateConnection function, confirm the admin saw a success message when clicking Connect. If not, check logs for the return value — it must be { success: true }, not a thrown error.
Check 3 — onConnectionChange fired and ingestion ran
In forge logs or the tunnel output, confirm:
forge logs -e development --limit 50Look for all three signals:
- Handler was invoked: log line from
onConnectionChangeHandlerwithaction: CREATED - Data was fetched: e.g.
[connector] Fetched N items setObjectssucceeded: e.g.[connector] Batch 1: N accepted, 0 rejected
If setObjects returned { success: false }, the error detail is in result.error — surface it to the user and fix before continuing.
Check 4 — Objects visible in Rovo Search
1. Open Rovo Search on the Jira site (allow up to 5 minutes for indexing after ingestion) 2. Search for a word that appears in at least one ingested object's displayName or content text 3. Filter by the connector's nickname (set by admin at connection time)
At least one result from the connector should appear. If nothing shows up after 5 minutes:
- Confirm
setObjectsloggedN acceptedwith N > 0 (Check 3) - Confirm the object's
permissionsmatch the logged-in user (anEVERYONEprincipal or auser/groupprincipal that includes the test user) - Re-check that
write:object:jirascope is present inmanifest.ymland the app was redeployed after any scope change
Check 5 — Permission boundary (skip only if EVERYONE was used)
If the connector uses user or group principals: 1. Log in as a user who should have access → confirm the object appears in Rovo Search 2. Log in as a user who should not have access → confirm the object does not appear
If a restricted object is visible to an unauthorised user, re-check the accessControls principals in setObjects and redeploy.
Check 6 — Rovo Chat references connector data
Ask Rovo Chat a question whose answer exists only in the ingested content, e.g.:
"What is the status of [title of an ingested item]?"
Rovo Chat should cite the connector as a source. If it cannot find the content, Checks 3 and 4 likely have an unresolved issue.
Check 7 — Scheduled re-ingestion fires (if configured)
If a scheduledTrigger was added: 1. Temporarily set interval: fiveMinutes in manifest.yml, redeploy, and wait one cycle 2. Confirm forge logs shows a fresh ingestion run from refreshIngestionHandler 3. Restore the original interval and redeploy before going to production
Production readiness gate
If the user chose to run verification, only proceed to a production deploy (forge deploy -e production) when all applicable checks above pass:
| Check | Required for production |
|---|---|
| 1 — Connection established | Always |
| 2 — validateConnection passed | Only if validateConnection is configured |
| 3 — Ingestion ran without errors | Always |
| 4 — Objects visible in Rovo Search | Always |
| 5 — Permission boundary | Only if using user/group principals |
| 6 — Rovo Chat cites connector | Always |
| 7 — Scheduled re-ingestion fires | Only if scheduledTrigger is configured |
---
Manifest Reference
Key rules:
- Scopes areread:object:jira,write:object:jira,delete:object:jira— NOTread:graph:teamwork/write:graph:teamwork(those failforge lint)
- function: is declared under `modules:`, not at the top level- Egress usesaddress:not a bare string (runforge lint --fixto auto-correct)
-formConfigurationusesform: [{ type: header, properties: [...] }]— NOTfields:orbeforeYouBegin:
Minimal connector (no admin config, no OAuth)
Use when the app operates entirely within Atlassian — no external credentials needed.
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
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.onConnectionChangeHandlerConnector with admin form config (API key / URL)
Use when the admin must provide credentials to connect to an external system.
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
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---
Handler Signatures
Critical: Forge passes the request directly as the first argument — it is NOT wrapped underevent.payload. Config form values are atrequest.configProperties, notevent.payload.config. Getting this wrong causesTypeError: 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 (scheduled trigger)
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);
}
};---
Object Types
Objects in bold are indexed in Rovo Search and Rovo Chat.
| Object Type | Indexed in Rovo | Best for |
|---|---|---|
atlassian:document | ✅ | Files, pages, wiki articles, reports |
atlassian:message | ✅ | Chat messages, emails, comments |
atlassian:work-item | ✅ | Tasks, tickets, issues |
atlassian:project | ✅ | Projects, workspaces |
atlassian:space | ✅ | Team spaces, org units |
atlassian:design | ✅ | Design files (Figma, etc.) |
atlassian:repository | ✅ | Code repositories |
atlassian:pull-request | ✅ | PRs, merge requests |
atlassian:commit | ✅ | Git commits |
atlassian:branch | ✅ | Git branches |
atlassian:conversation | ✅ | Threads, channels |
atlassian:video | ✅ | Video recordings |
atlassian:calendar-event | ✅ | Meetings, events |
atlassian:comment | ✅ | Review comments |
atlassian:customer-organization | ✅ | Customer accounts, orgs |
atlassian:build | ❌ | CI/CD builds |
atlassian:deployment | ❌ | Deployments |
atlassian:test | ❌ | Test cases |
---
Rovo Search / Rovo Chat Surfacing
Once ingested:
- Objects appear in Rovo Search under a subfilter named after the connector's nickname (set by admin at connection time)
- Rovo Chat can reference and cite connector objects in responses when queried about topics related to the ingested content
- Data is not available immediately — allow a few minutes for indexing after
onConnectionChangefires
To verify ingestion is working:
1. Open Rovo Search on the Jira site 2. Search for text that appears in an ingested object's name or properties 3. Filter by the connector nickname to narrow results
---
Batching Pattern for Large Datasets
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 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.
permissions: [{
accessControls: [{ principals: [{ type: 'EVERYONE' }] }],
}],
'atlassian:document': {
type: { category: 'DOCUMENT', mimeType: item.mimeType },
content: { mimeType: item.mimeType, text: item.title },
},
})),
});
if (!result.success) {
console.error(`[connector] setObjects error in batch ${Math.floor(i / BATCH_SIZE) + 1}:`, result.error);
}
}
}---
Scheduled Re-Ingestion (optional)
To keep data fresh, add a scheduled trigger that re-runs ingestion periodically:
# 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.refreshIngestionHandlerconst { 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
| Script | Skill directory | Purpose |
|---|---|---|
scripts/scaffold_connector.py | skills/forge-connector/ (this skill) | Scaffold a new connector app — generates manifest.yml, src/index.ts, installs SDK. Run: python3 -m scripts.scaffold_connector |
scripts/deploy_forge_app.py | skills/forge-app-builder/ (different skill) | Deploy and install on Jira. Run from the forge-app-builder directory: python3 -m scripts.deploy_forge_app |
The scaffold script is in this skill's directory. The deploy script is in the forge-app-builder skill directory — always cd there (or derive the path from this SKILL.md's location) before running it.
---
Troubleshooting
| Problem | Action |
|---|---|
graph:connector not recognized in manifest | Run forge lint — it will identify the exact field causing the error |
TypeError: Cannot destructure property 'config' of 'event.payload' | Handler using event.payload.config — change to request.configProperties. Forge passes request directly, not nested under event.payload |
TypeError: Cannot read properties of undefined (reading 'set') | Using storage from @forge/storage — switch to kvs from @forge/kvs |
graph.setObjects is not a function | Wrong import — use const { graph } = require('@forge/teamwork-graph') then call graph.setObjects({ objects, connectionId }) |
forge lint: invalid scopes read/write:graph:teamwork | Replace with read:object:jira, write:object:jira, delete:object:jira |
forge lint: document should NOT have additional property 'function' | function: is at the top level — move it inside modules: |
forge lint: formConfiguration must have required property 'form' | Replace fields: / beforeYouBegin: with form: [{ type: header, properties: [...] }] |
forge lint warning: deprecated egress entries | Run forge lint --fix to auto-convert bare URL strings to { address: 'url' } |
forge developer-spaces list command not found | Does not exist in Forge CLI 12.x. Have user run forge create interactively to select a developer space |
forge create fails with non-TTY error | forge create needs an interactive terminal — ask the user to run it; then write manifest and source files into the created directory |
onConnectionChange not triggered | Verify admin clicked "Connect" in Atlassian Administration → Connected apps; run forge tunnel to confirm the function fires |
| Objects not appearing in Rovo Search | Wait ~5 minutes for indexing; run forge logs -e development --since 15m to check for setObjects errors |
403 on @forge/teamwork-graph calls | Ensure read:object:jira, write:object:jira, delete:object:jira are in manifest scopes, then redeploy and forge install --upgrade |
forge login required | Create API token at https://id.atlassian.com/manage/api-tokens, then run forge login |
---
---
Naming and Logo Guidelines
- Use the official service name as the connector name (e.g.
Google Drive, notDrive Connector by Acme) - Use the official service logo for icons — do not modify or combine with your own branding
- These guidelines apply only to the
graph:connectormodule; your Forge app itself may use your own branding
Forge Connector Skill
Build Atlassian Forge apps that ingest external data into the Teamwork Graph, making it searchable in Rovo Search and referenceable in Rovo Chat.
What This Skill Does
Guides the agent through the full connector workflow:
1. Scaffold a graph:connector Forge app from scratch 2. Configure manifest.yml with correct module structure, scopes, and function keys 3. Implement onConnectionChangeHandler and validateConnectionHandler using @forge/teamwork-graph 4. Deploy and install on a Jira site 5. Connect via Atlassian Administration → Connected apps 6. Verify ingested data appears in Rovo Search
Prerequisites
- Node.js 22+ —
node -v - Forge CLI —
npm install -g @forge/cli - Forge login —
forge login - Jira site — connector apps must be installed in Jira (not Confluence-only)
Quick Start
# From the forge-connector skill directory:
python3 -m scripts.scaffold_connector \
--name my-service-connector \
--connector-name "My Service" \
--object-type atlassian:document \
--dev-space-id <your-dev-space-id> \
--directory ~/projects \
--has-form-config \
--api-url https://api.myservice.comThen deploy (using the forge-app-builder deploy script):
python3 -m scripts.deploy_forge_app \
--app-dir ~/projects/my-service-connector \
--site yourcompany.atlassian.net \
--product jiraObject Types Indexed in Rovo Search
atlassian:document · atlassian:message · atlassian:work-item · atlassian:project · atlassian:space · atlassian:design · atlassian:repository · atlassian:pull-request · atlassian:commit · atlassian:branch · atlassian:conversation · atlassian:video · atlassian:calendar-event · atlassian:comment · atlassian:customer-organization
SDK Reference
import { setObjects, deleteObjectsByExternalId, getObjectByExternalId } from '@forge/teamwork-graph';
// Ingest (up to 100 objects per call)
await setObjects({ objects: [...] });
// Delete
await deleteObjectsByExternalId({ objectType: 'atlassian:document', externalIds: ['id-1'] });
// Fetch single object
const obj = await getObjectByExternalId({ externalId: 'id-1', objectType: 'atlassian:document' });Scripts
| Script | Purpose |
|---|---|
scripts/scaffold_connector.py | Scaffold complete connector app boilerplate |
Further Reading
# Forge connector scripts (run from skill dir: python3 -m scripts.scaffold_connector, etc.)
"""Environment helper for spawning the Forge CLI.
The Forge CLI reads a reserved ``ATL_FORGE_ATTRIBUTION_*`` namespace from
its environment and forwards every key in it to the backend. This helper
returns an environment mapping with the skill identifier stamped in, so
that every ``forge`` command a skill spawns carries it.
The same namespace is open-ended, so the helper supports arbitrary
wildcard fields beyond the skill name:
* ``extra=`` stamps additional keys programmatically, e.g.
``forge_env("forge-connector", extra={"run_id": "abc"})`` →
``ATL_FORGE_ATTRIBUTION_RUN_ID=abc``.
* any ``ATL_FORGE_ATTRIBUTION_*`` var already present in the environment
(e.g. set by the agent host) is preserved and forwarded as-is.
Values the helper stamps follow the CLI's contract — short tokens
matching ``[A-Za-z0-9._-]`` and at most 128 characters; values that
don't match are dropped silently rather than raising.
"""
import os
import re
_ATTRIBUTION_PREFIX = "ATL_FORGE_ATTRIBUTION_"
_VALUE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
_MAX_LEN = 128
def _is_valid_value(value):
"""True if ``value`` satisfies the Forge CLI value contract."""
return (
isinstance(value, str)
and 0 < len(value) <= _MAX_LEN
and _VALUE_RE.match(value) is not None
)
def forge_env(skill_name, extra=None, base=None):
"""Return an environment dict for spawning the Forge CLI.
Starts from a copy of the current process environment (or ``base`` if
provided) and stamps ``ATL_FORGE_ATTRIBUTION_SKILL_NAME=<skill_name>``.
``extra`` may supply additional ``ATL_FORGE_ATTRIBUTION_*`` fields as a
mapping of unprefixed keys to values (e.g. ``{"SESSION_ID": "abc"}`` →
``ATL_FORGE_ATTRIBUTION_SESSION_ID=abc``). Keys are upper-cased and
prefixed; entries whose value fails validation are skipped.
Any ``ATL_FORGE_ATTRIBUTION_*`` vars already in the source environment
are left untouched, so wildcard fields set by the caller's environment
pass through to the CLI unchanged.
"""
# Copying the source env preserves ambient ATL_FORGE_ATTRIBUTION_* vars.
env = dict(os.environ if base is None else base)
fields = {"SKILL_NAME": skill_name}
if extra:
fields.update(extra)
for key, value in fields.items():
if not _is_valid_value(value):
continue
env[_ATTRIBUTION_PREFIX + key.upper()] = value
return env
#!/usr/bin/env python3
"""
Forge Connector Scaffold Script
Creates a new Forge app with graph:connector module boilerplate:
- manifest.yml with graph:connector module, scopes, and functions
- src/index.ts with onConnectionChangeHandler and validateConnectionHandler
- package.json updated with @forge/teamwork-graph dependency
Usage:
python3 -m scripts.scaffold_connector \
--name my-connector \
--connector-name "My Service" \
--object-type atlassian:document \
--dev-space-id <id> \
--directory /path/to/parent \
[--has-form-config] \
[--api-url https://api.example.com]
Run from the skill directory (the directory containing SKILL.md).
"""
import argparse
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
from .forge_env import forge_env
# Environment for every Forge CLI invocation this script spawns.
_FORGE_ENV = forge_env("forge-connector")
VALID_OBJECT_TYPES = [
"atlassian:document",
"atlassian:message",
"atlassian:work-item",
"atlassian:project",
"atlassian:space",
"atlassian:design",
"atlassian:repository",
"atlassian:pull-request",
"atlassian:commit",
"atlassian:branch",
"atlassian:conversation",
"atlassian:video",
"atlassian:calendar-event",
"atlassian:comment",
"atlassian:customer-organization",
"atlassian:build",
"atlassian:deployment",
"atlassian:test",
"atlassian:test-execution",
"atlassian:test-plan",
"atlassian:test-run",
]
ROVO_INDEXED_TYPES = {
"atlassian:document", "atlassian:message", "atlassian:work-item",
"atlassian:project", "atlassian:space", "atlassian:design",
"atlassian:repository", "atlassian:pull-request", "atlassian:commit",
"atlassian:branch", "atlassian:conversation", "atlassian:video",
"atlassian:calendar-event", "atlassian:comment", "atlassian:customer-organization",
}
def check_prerequisites():
for tool in ["node", "forge"]:
try:
subprocess.run([tool, "--version"], capture_output=True, check=True, env=_FORGE_ENV)
except (subprocess.CalledProcessError, FileNotFoundError):
print(f"❌ '{tool}' not found. Install Node.js 22+ and Forge CLI (npm install -g @forge/cli).")
return False
return True
def run_forge_create(app_name: str, cwd: str, dev_space_id: str | None) -> bool:
"""Run forge create with the blank template."""
cmd = [
"forge", "create",
"--template", "blank",
app_name,
"--accept-terms",
]
if dev_space_id:
cmd += ["--developer-space-id", dev_space_id]
print(f"\n📦 Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=_FORGE_ENV)
if result.returncode != 0:
print(f"❌ forge create failed (exit {result.returncode})")
if result.stdout.strip():
print(f"\n--- stdout ---\n{result.stdout.strip()}")
if result.stderr.strip():
print(f"\n--- stderr ---\n{result.stderr.strip()}")
return False
print(f"✅ forge create succeeded")
return True
def write_manifest(app_dir: str, connector_key: str, connector_name: str,
object_type: str, has_form_config: bool, api_domain: str) -> None:
"""Write manifest.yml with graph:connector module."""
validate_fn_block = ""
validate_function_entry = ""
form_config_block = ""
if has_form_config:
form_config_block = textwrap.dedent(f"""\
formConfiguration:
beforeYouBegin: |
Provide your {connector_name} API credentials below.
fields:
- key: api-url
type: string
label: API URL
isRequired: true
- key: api-key
type: string
label: API Key
isRequired: true
validateConnection:
function: validate-connection
""")
# indent form config under datasource (6 spaces)
form_config_block = "\n".join(
" " + line if line.strip() else line
for line in form_config_block.splitlines()
) + "\n"
validate_function_entry = " - key: validate-connection\n handler: index.validateConnectionHandler\n"
egress_block = ""
if api_domain:
egress_block = f" external:\n fetch:\n backend:\n - '{api_domain}'\n"
manifest = f"""\
app:
id: "{{}}"
permissions:
scopes:
- read:graph:teamwork
- write:graph:teamwork
{egress_block}
modules:
graph:connector:
- key: {connector_key}
name: {connector_name}
objectTypes:
- {object_type}
datasource:
{form_config_block} onConnectionChange:
function: on-connection-change
function:
- key: on-connection-change
handler: index.onConnectionChangeHandler
{validate_function_entry}"""
# forge create generates a manifest.yml with the app id already filled in.
# We read the existing id and slot it back in.
manifest_path = Path(app_dir) / "manifest.yml"
existing_id = ""
if manifest_path.exists():
for line in manifest_path.read_text().splitlines():
if "id:" in line:
existing_id = line.split("id:")[-1].strip().strip('"')
break
manifest = manifest.replace('"{}"', f'"{existing_id}"')
manifest_path.write_text(manifest)
print(f"✅ Wrote manifest.yml")
def write_index_ts(app_dir: str, object_type: str, has_form_config: bool,
connector_name: str) -> None:
"""Write src/index.ts with complete handler boilerplate."""
validate_handler = ""
if has_form_config:
validate_handler = textwrap.dedent("""\
/**
* Called by Atlassian when the admin submits the connection form.
* Throw an Error to reject the connection with a user-visible message.
* Return (any value) to accept.
*/
export async function validateConnectionHandler(event: {
context: { cloudId: string; moduleKey: string };
payload: { config: Record<string, string> };
}): Promise<void> {
const { config } = event.payload;
const apiUrl = config['api-url'];
const apiKey = config['api-key'];
if (!apiUrl || !apiKey) {
throw new Error('API URL and API Key are required.');
}
// TODO: replace with a real health-check call to your service
const response = await api.fetch(`${apiUrl}/health`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
throw new Error(
`Could not connect to ${apiUrl} (HTTP ${response.status}). ` +
'Please check your API URL and key.'
);
}
}
""")
index_ts = textwrap.dedent(f"""\
import api, {{ storage }} from '@forge/api';
import {{ setObjects, deleteObjectsByExternalId }} from '@forge/teamwork-graph';
const OBJECT_TYPE = '{object_type}' as const;
const BATCH_SIZE = 100;
// ---------------------------------------------------------------------------
// onConnectionChange — called by Atlassian when a connection is created,
// updated, or deleted.
// ---------------------------------------------------------------------------
export async function onConnectionChangeHandler(event: {{
context: {{ cloudId: string; moduleKey: string }};
payload: {{
action: 'CREATED' | 'UPDATED' | 'DELETED';
connectionId: string;
config: Record<string, string>;
}};
}}): Promise<void> {{
const {{ action, connectionId, config }} = event.payload;
if (action === 'DELETED') {{
// Atlassian automatically removes Teamwork Graph data on deletion.
// Only clean up your own locally stored state.
await storage.delete(`conn:${{connectionId}}`);
const ids: string[] = (await storage.get('active-connections')) ?? [];
await storage.set('active-connections', ids.filter(id => id !== connectionId));
console.log(`[connector] Connection deleted: ${{connectionId}}`);
return;
}}
// CREATED or UPDATED — persist config and start ingestion
await storage.set(`conn:${{connectionId}}`, config);
const ids: string[] = (await storage.get('active-connections')) ?? [];
if (!ids.includes(connectionId)) {{
await storage.set('active-connections', [...ids, connectionId]);
}}
console.log(`[connector] Connection ${{action}}: ${{connectionId}} — starting ingestion`);
await ingestAllData(connectionId, config);
}}
{validate_handler}
// ---------------------------------------------------------------------------
// Ingestion helpers
// ---------------------------------------------------------------------------
async function ingestAllData(
connectionId: string,
config: Record<string, string>,
): Promise<void> {{
const items = await fetchExternalData(config);
console.log(`[connector] Fetched ${{items.length}} items for connection ${{connectionId}}`);
for (let i = 0; i < items.length; i += BATCH_SIZE) {{
const batch = items.slice(i, i + BATCH_SIZE);
const result = await setObjects({{
objects: batch.map(item => ({{
// Prefix with connectionId to guarantee global uniqueness
externalId: `${{connectionId}}:${{item.id}}`,
objectType: OBJECT_TYPE,
name: item.title ?? item.name ?? item.id,
url: item.url,
createdAt: item.createdAt,
lastModifiedAt: item.updatedAt ?? item.modifiedAt,
properties: buildProperties(item, config),
}})),
}});
const accepted = result.results.accepted.length;
const rejected = result.results.rejected.length;
console.log(`[connector] Batch ${{Math.floor(i / BATCH_SIZE) + 1}}: ${{accepted}} accepted, ${{rejected}} rejected`);
if (rejected > 0) {{
console.error('[connector] Rejected objects:', JSON.stringify(result.results.rejected));
}}
}}
}}
function buildProperties(
item: Record<string, unknown>,
config: Record<string, string>,
): Record<string, string> {{
// Properties are indexed and can be used for filtering. Max 5 key-value pairs.
// TODO: add fields that are meaningful for your data source.
return {{
source: config['api-url'] ?? '{connector_name}',
}};
}}
// ---------------------------------------------------------------------------
// TODO: replace this function with real API calls to your external system.
// ---------------------------------------------------------------------------
async function fetchExternalData(
config: Record<string, string>,
): Promise<Array<Record<string, unknown>>> {{
const apiUrl = config['api-url'];
const apiKey = config['api-key'];
if (!apiUrl) {{
console.warn('[connector] api-url not configured — returning empty dataset');
return [];
}}
const response = await api.fetch(`${{apiUrl}}/items`, {{
headers: {{
Authorization: `Bearer ${{apiKey}}`,
'Content-Type': 'application/json',
}},
}});
if (!response.ok) {{
throw new Error(`[connector] fetchExternalData failed: HTTP ${{response.status}}`);
}}
const data: {{ items: Array<Record<string, unknown>> }} = await response.json();
return data.items ?? [];
}}
""")
src_dir = Path(app_dir) / "src"
src_dir.mkdir(exist_ok=True)
(src_dir / "index.ts").write_text(index_ts)
print("✅ Wrote src/index.ts")
def install_teamwork_graph_sdk(app_dir: str) -> bool:
"""Install @forge/teamwork-graph SDK in the app directory."""
print("\n📦 Installing @forge/teamwork-graph SDK...")
result = subprocess.run(
["npm", "install", "@forge/teamwork-graph"],
cwd=app_dir,
capture_output=True,
text=True,
env=_FORGE_ENV,
)
if result.returncode != 0:
print(f"⚠️ npm install @forge/teamwork-graph failed: {result.stderr.strip()}")
print(" You can install it manually: npm install @forge/teamwork-graph")
return False
print("✅ @forge/teamwork-graph installed")
return True
def write_gitignore(app_dir: str) -> None:
"""Ensure .gitignore exists with standard Node entries."""
gitignore_path = Path(app_dir) / ".gitignore"
if not gitignore_path.exists():
gitignore_path.write_text("node_modules/\ndist/\n.forge/\n")
def main():
parser = argparse.ArgumentParser(
description="Scaffold a Forge graph:connector app with handler boilerplate",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Examples:
# Simple connector (no admin form config):
python3 -m scripts.scaffold_connector \\
--name my-connector --connector-name "My Service" \\
--object-type atlassian:document \\
--dev-space-id abc123 --directory ~/projects
# Connector with admin API key form:
python3 -m scripts.scaffold_connector \\
--name my-connector --connector-name "My Service" \\
--object-type atlassian:document \\
--dev-space-id abc123 --directory ~/projects \\
--has-form-config --api-url https://api.myservice.com
"""),
)
parser.add_argument("--name", required=True, help="App directory name (e.g. my-connector)")
parser.add_argument("--connector-name", required=True,
help="Human-readable connector name shown in Atlassian Admin UI (e.g. 'My Service')")
parser.add_argument("--object-type", default="atlassian:document",
help="Teamwork Graph object type (default: atlassian:document)")
parser.add_argument("--dev-space-id", required=False, default=None,
help="Forge developer space ID (optional)")
parser.add_argument("--directory",
help="Parent directory for the app (default: current directory)")
parser.add_argument("--has-form-config", action="store_true",
help="Generate admin configuration form (API URL + API key fields)")
parser.add_argument("--api-url",
help="Base URL of the external API (used for egress allow-list in manifest)")
args = parser.parse_args()
# Validate object type
if args.object_type not in VALID_OBJECT_TYPES:
print(f"❌ Unknown object type: {args.object_type}")
print(f"\nValid types:\n " + "\n ".join(VALID_OBJECT_TYPES))
sys.exit(1)
if args.object_type not in ROVO_INDEXED_TYPES:
print(f"⚠️ Note: '{args.object_type}' is NOT indexed in Rovo Search / Rovo Chat.")
print(" Objects will still be stored in Teamwork Graph but won't appear in search results.")
if not check_prerequisites():
sys.exit(1)
parent_dir = os.path.abspath(args.directory) if args.directory else os.getcwd()
app_dir = os.path.join(parent_dir, args.name)
if not os.path.isdir(parent_dir):
print(f"❌ Parent directory does not exist: {parent_dir}")
sys.exit(1)
if os.path.exists(app_dir):
print(f"❌ Directory already exists: {app_dir}")
print(" Choose a different --name or remove the existing folder.")
sys.exit(1)
connector_key = args.name.lower().replace(" ", "-")
# Extract domain from api-url for egress allow-list
api_domain = ""
if args.api_url:
from urllib.parse import urlparse
parsed = urlparse(args.api_url)
api_domain = f"{parsed.scheme}://{parsed.netloc}" if parsed.netloc else args.api_url
print(f"\n🔌 Scaffolding Forge Connector: {args.connector_name}")
print(f" App name: {args.name}")
print(f" Object type: {args.object_type}")
print(f" Form config: {'yes' if args.has_form_config else 'no'}")
print(f" Directory: {parent_dir}")
# Step 1: forge create with blank template
if not run_forge_create(args.name, parent_dir, args.dev_space_id):
print("\n💡 If forge create fails with 'Prompts can not be meaningfully rendered',")
print(" run forge create interactively in your terminal:")
print(f" cd {parent_dir} && ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-connector forge create --template blank {args.name}")
sys.exit(1)
# Step 2: Write connector-specific files
write_manifest(
app_dir=app_dir,
connector_key=connector_key,
connector_name=args.connector_name,
object_type=args.object_type,
has_form_config=args.has_form_config,
api_domain=api_domain,
)
write_index_ts(
app_dir=app_dir,
object_type=args.object_type,
has_form_config=args.has_form_config,
connector_name=args.connector_name,
)
write_gitignore(app_dir)
# Step 3: Install @forge/teamwork-graph
install_teamwork_graph_sdk(app_dir)
print(f"\n{'=' * 60}")
print("✅ Connector scaffolded successfully!")
print(f"\nApp location: {app_dir}")
print(f"\nNext steps:")
print(f" 1. cd {app_dir}")
print(f" 2. Open src/index.ts and replace fetchExternalData() with real API calls")
print(f" 3. Run: forge lint (verify manifest)")
print(f" 4. Deploy with the deploy script (from forge-app-builder skill):")
print(f" python3 -m scripts.deploy_forge_app --app-dir {app_dir} --site <your-site> --product jira")
print(f" 5. In Atlassian Admin → Apps → Connected apps → Connect")
print(f" 6. Verify data in Rovo Search after ~5 min")
if args.object_type not in ROVO_INDEXED_TYPES:
print(f"\n⚠️ Remember: '{args.object_type}' objects won't appear in Rovo Search.")
print()
if __name__ == "__main__":
main()
"""Tests for scripts/forge_env.py"""
import unittest
from scripts.forge_env import forge_env
class TestForgeEnv(unittest.TestCase):
def test_stamps_skill_name(self):
env = forge_env("forge-connector", base={})
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-connector")
def test_preserves_base_environment(self):
env = forge_env("forge-connector", base={"PATH": "/usr/bin"})
self.assertEqual(env["PATH"], "/usr/bin")
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-connector")
def test_defaults_to_current_process_environment(self):
import os
env = forge_env("forge-connector")
self.assertIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", os.environ)
def test_extra_keys_are_upper_cased_and_prefixed(self):
env = forge_env("forge-connector", extra={"session_id": "abc123"}, base={})
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SESSION_ID"], "abc123")
def test_multiple_wildcard_extras_are_all_stamped(self):
env = forge_env(
"forge-connector",
extra={"run_id": "r1", "session_id": "s1"},
base={},
)
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_RUN_ID"], "r1")
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SESSION_ID"], "s1")
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-connector")
def test_ambient_wildcard_vars_pass_through(self):
# Wildcard vars the agent host already set must reach the CLI untouched.
env = forge_env(
"forge-connector",
base={"ATL_FORGE_ATTRIBUTION_RUN_ID": "host-run-42", "PATH": "/usr/bin"},
)
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_RUN_ID"], "host-run-42")
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-connector")
def test_invalid_extra_value_is_dropped(self):
env = forge_env("forge-connector", extra={"run_id": "bad value!"}, base={})
self.assertNotIn("ATL_FORGE_ATTRIBUTION_RUN_ID", env)
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-connector")
def test_over_length_value_is_dropped(self):
env = forge_env("x" * 129, base={})
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
def test_invalid_charset_value_is_dropped(self):
env = forge_env("bad name!", base={})
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
if __name__ == "__main__":
unittest.main()
"""Tests for scripts/scaffold_connector.py"""
import unittest
from unittest.mock import patch, MagicMock
from scripts import scaffold_connector as sc
class TestRunForgeCreate(unittest.TestCase):
@patch("scripts.scaffold_connector.subprocess.run")
def test_stamps_skill_name_env_var(self, mock_run):
"""forge create must carry the skill-name attribution env var."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sc.run_forge_create("my-connector", "/parent", dev_space_id="abc")
env = mock_run.call_args[1].get("env")
self.assertIsNotNone(env)
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-connector")
@patch("scripts.scaffold_connector.subprocess.run")
def test_passes_developer_space_id(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
sc.run_forge_create("my-connector", "/parent", dev_space_id="space-123")
cmd = mock_run.call_args[0][0]
self.assertIn("--developer-space-id", cmd)
self.assertIn("space-123", cmd)
if __name__ == "__main__":
unittest.main()
Related skills
FAQ
Where must a Forge Teamwork Graph connector app be installed?
On a Jira site. The skill states Confluence-only installs will not work.
Which storage API does the connector use?
@forge/kvs. The skill warns @forge/storage returns undefined at runtime in connector functions.