
Forge App Builder
- 223 installs
- 19 repo stars
- Updated August 1, 2026
- atlassian/forge-skills
Guides building, deploying, troubleshooting, and installing Atlassian Forge apps — custom extensions built with the Forge CLI (forge create, forge deploy, forge install).
About
Guides building, deploying, troubleshooting, and installing Atlassian Forge apps — custom extensions built with the Forge CLI (forge create, forge deploy, forge install). Use when the user wants to create a Forge app (issue panels, dashboard gadgets, Confluence macros, global pages), is encountering Forge CLI errors or deployment issues (e.g. forge install failures, environment errors), or needs help with Forge-specific concepts like resolvers, UI Kit, manifest scopes, or developer spaces. Do not use for general Jira configuration, automation rules, JQL queries, or Atlassian REST API usage outside of a Forge app context. **When building a Forge app, the agent MUST complete this workflow in order. Do not skip steps. Do not substitute manual instructions for running the scripts below.**
- **When building a Forge app, the agent MUST complete this workflow in order. Do not skip steps. Do not substitute manual
- **Always use `forge create`** to scaffold apps — it registers the app and generates a valid app ID
- **Never manually scaffold** — apps without valid app IDs cannot be deployed
- **If `forge create` fails, STOP** — inform the user and provide the manual command
- 4. **Never ask for API tokens in chat** — direct users to run `forge login` in their terminal and enter credentials ther
Forge App Builder by the numbers
- 223 all-time installs (skills.sh)
- Ranked #383 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
forge-app-builder capabilities & compatibility
- Capabilities
- **when building a forge app, the agent must comp · **always use `forge create`** to scaffold apps — · **never manually scaffold** — apps without valid · **if `forge create` fails, stop** — inform the u
- Use cases
- documentation
What forge-app-builder says it does
Guides building, deploying, troubleshooting, and installing Atlassian Forge apps — custom extensions built with the Forge CLI (forge create, forge deploy, forge install). Use when the user wants to cr
npx skills add https://github.com/atlassian/forge-skills --skill forge-app-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 223 |
|---|---|
| repo stars | ★ 19 |
| Last updated | August 1, 2026 |
| Repository | atlassian/forge-skills ↗ |
How do I apply forge-app-builder using the workflow in its SKILL.md?
Guides building, deploying, troubleshooting, and installing Atlassian Forge apps — custom extensions built with the Forge CLI (forge create, forge deploy, forge install). Use when the use...
Who is it for?
Developers following the forge-app-builder skill for the tasks it documents.
Skip if: Tasks outside the forge-app-builder scope described in SKILL.md.
When should I use this skill?
User mentions forge-app-builder or related triggers from the skill description.
What you get
Working forge-app-builder setup aligned with the documented patterns and constraints.
Files
Forge App Builder
When building a Forge app, the agent MUST complete this workflow in order. Do not skip steps. Do not substitute manual instructions for running the scripts below.
Critical Rules
1. Always use `forge create` to scaffold apps — it registers the app and generates a valid app ID 2. Never manually scaffold — apps without valid app IDs cannot be deployed 3. If `forge create` fails, STOP — inform the user and provide the manual command 4. Never ask for API tokens in chat — direct users to run forge login in their terminal and enter credentials there 5. Always ask the user to choose when multiple options exist (developer spaces, sites) — never pick on their behalf 6. Always ask the user for their Atlassian site URL during installation — never try to discover it from other apps, environment variables, or any other source 7. Always run the deploy script for deploy and install — do not give the user only manual forge deploy / forge install commands as the primary outcome; run scripts.deploy_forge_app.py yourself 8. Set `ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-app-builder` on `forge` commands run for this skill — prefix forge invocations with this env var: ones you run in the shell (e.g. forge developer-spaces list, forge lint) 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 Server Prerequisites
This skill works best with the following MCP servers. The scripts and CLI workflow function without them, but the agent will lack access to up-to-date Forge documentation for template selection, module configuration, and code patterns.
| MCP Server | Required? | Purpose |
|---|---|---|
| Forge MCP | Strongly recommended | Template lookup, module discovery, manifest syntax, UI Kit/backend guides |
| ADS MCP | Optional | Atlaskit component/token/icon lookup (Custom UI apps only) |
If MCP servers are not connected, inform the user that code guidance may be based on the model's training data and could be outdated. Recommend they verify against developer.atlassian.com/platform/forge. |
Agent Workflow
Complete steps 0–5 in order. Run the scripts yourself; do not only instruct the user to run them.
Step 0: Prerequisites (Install Automatically If Missing)
Before any other steps, call the forge-development-guide tool to get the current Node.js version requirement and CLI setup instructions. Then check and install prerequisites:
1. Node.js — Run node -v. If missing or below the version specified in the development guide:
- macOS (Homebrew):
brew install node - nvm:
nvm install <version>thennvm use <version> - fnm:
fnm install <version>thenfnm use <version> - Other: https://nodejs.org (LTS)
2. Forge CLI — Run forge --version. If missing:
npm install -g @forge/cli3. Forge login — Run forge whoami. If not logged in:
- Never ask for or accept API tokens in chat — tokens are sensitive; the user must enter them only in their terminal
- Direct the user to create an API token: https://id.atlassian.com/manage/api-tokens
- Tell the user to run
forge loginin their own terminal (not via the agent). The CLI will prompt for: - Atlassian email
- API token (paste only in the terminal when prompted)
- Example message: "You need to log in to Forge. Create an API token at [https://id.atlassian.com/manage/api-tokens](https://id.atlassian.com/manage/api-tokens), then run `forge login` in your terminal. Enter your email and token when prompted — do not paste them here."
- After the user confirms they've logged in, retry the workflow
Install in order: Node.js first (required for npm), then Forge CLI, then login. Retry the workflow after installing.
Step 1: Discover Developer Spaces
ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-app-builder forge developer-spaces list --jsonStep 2: Ask User to Choose Developer Space
Do not proceed to Step 3 until the user has selected a developer space. Present the list from Step 1 (names and IDs) and ask which space to use. If only one space exists, you may use it and briefly inform the user. Never pick one of multiple spaces on the user's behalf.
Step 3: Create App
All python3 -m scripts.* commands must be run from the skill directory (the directory containing this SKILL.md file). Derive it from the SKILL.md path provided in the system prompt. Use python3 on macOS if python is not available.
Run from the skill directory. The --directory flag sets the parent directory under which the app folder (named after --name) will be created. The script cds into that directory before running forge create, so the app appears as a subdirectory (e.g. <parent-directory>/<app-name>/). If omitted, the app is created under the current directory.
python3 -m scripts.create_forge_app \
--template <template> \
--name <app-name> \
--dev-space-id <selected-id> \
--directory <parent-directory>To find the right template for the user's needs:
- Call
list-forge-modulesto identify the appropriate module type - Call
search-forge-docswith a query like "template for " to find the matching template name
Validate a template: python3 -m scripts.list_templates --validate <name> List all templates: python3 -m scripts.list_templates --list
Step 4: Customize Code
After forge create succeeds:
cd <app-name>
npm installUI Kit vs Custom UI — Choose the Right Tools
Before writing any UI code, determine which approach the app uses. Getting this wrong causes import errors and broken builds.
- UI Kit (most
forge createtemplates): manifest usesrender: nativeor code imports from@forge/react. Useforge-ui-kit-developer-guideas the ONLY UI reference. Do NOT suggest@atlaskit/* imports — they won't work. - Custom UI: manifest has
resourcepointing to astatic/directory. Use ADS MCP tools (ads_plan,ads_get_components,ads_get_all_icons) for component discovery. Do NOT useforge-ui-kit-developer-guide— it describes a different API.
Knowledge tools for implementation
forge-ui-kit-developer-guide— Frontend components (UI Kit only)ads_plan/ads_get_components— Component and token lookup (Custom UI only)forge-backend-developer-guide— Backend resolvers and APIsforge-app-manifest-guide— Manifest configurationsearch-forge-docs— Search for specific APIs or props
Step 5: Deploy and Install (run the deploy script)
You MUST run the deploy script — do not only paste manual forge deploy / forge install commands for the user to run. Execute the script from the skill directory.
- If you have the user's Atlassian site URL (e.g. they provided it earlier or in the request), run in one go:
python3 -m scripts.deploy_forge_app \
--app-dir <app-directory> \
--site <site-url> \
--product <jira|confluence>- If you do not have the site URL yet: run deploy only, then ask for the site, then run again to install:
# 1) Deploy only
python3 -m scripts.deploy_forge_app \
--app-dir <app-directory> \
--product <jira|confluence> \
--deploy-only
# 2) Ask the user: "What is your Atlassian site URL (e.g. yourcompany.atlassian.net)?"
# 3) After they reply, run again with their site to complete install
python3 -m scripts.deploy_forge_app \
--app-dir <app-directory> \
--site <site-url> \
--product <jira|confluence> \
--skip-depsAlways ask the user for their site URL when needed for install; never try to discover it. If scopes changed from a previous install, add --upgrade to the install (or run the script again with --site and the script will handle it; for manual upgrade use forge install ... --upgrade).
Cross-product installation
When an app uses scopes from multiple products (e.g. a Confluence macro that also reads Jira data), the deploy script automatically detects the required products from the manifest scopes and installs on all of them. The --product flag sets the primary product; the script adds any additional products found in the scopes.
If you need to install manually, run forge install once per product:
forge install --non-interactive --site <site-url> --product confluence -e development
forge install --non-interactive --site <site-url> --product jira -e developmentHandling forge create Failures
When forge create fails, never attempt workarounds or manual scaffolding.
| Error | Action |
|---|---|
| Prerequisites missing (Node.js, Forge CLI) | Run Step 0 install commands, then retry |
| "Prompts can not be meaningfully rendered" | Ask user to run forge create in an interactive terminal |
| "No developer spaces found" | Direct user to https://developer.atlassian.com/console/ |
| "directory already exists" | A folder named <app-name> already exists inside the parent directory. Choose a different name or remove the existing folder |
| Network/auth issues, not logged in | Direct user to https://id.atlassian.com/manage/api-tokens, then run forge login in their terminal |
| Any other error | Show error, ask user for guidance |
Example response when it fails:
forge create needs an interactive terminal. Please run:
ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-app-builder forge create --template jira-dashboard-gadget my-app-name
Once created, let me know and I'll help customize it.Module Selection
Call list-forge-modules for a comprehensive, up-to-date list of all available modules organized by product. Then use search-forge-docs with the module name for configuration details and YAML examples.
Scripts
| Script | Purpose |
|---|---|
scripts/create_forge_app.py | Create app with dev space selection and template validation. --directory sets the parent dir (script cd's into it). Run: python3 -m scripts.create_forge_app |
scripts/list_templates.py | List/validate all Forge templates from Atlassian registry. Run: python3 -m scripts.list_templates |
scripts/deploy_forge_app.py | Deploy and install app (prerequisites check, npm install, lint, deploy, install). Auto-detects cross-product scopes and installs on all required products. Run: python3 -m scripts.deploy_forge_app |
Completion checklist
Before considering the workflow done, confirm:
- User selected developer space when more than one existed (or only one existed)
- App was created via
create_forge_app(or user ranforge createafter a failure) - Code was customized and
npm installrun in the app directory - Deploy script was executed by the agent (not only manual commands given to the user)
- If install was needed, user was asked for site URL and the script was run with
--site(or user provided site and install completed) - If the app uses cross-product scopes (e.g. Confluence app reading Jira data), it was installed on all required products (the deploy script handles this automatically)
Common agent mistakes (avoid these)
- Picking a developer space when multiple exist — always ask the user to choose.
- Skipping the deploy script — giving only "run
forge deployandforge install" instructions instead of runningdeploy_forge_app.pyyourself. - Not asking for site URL — when install is required, ask the user for their Atlassian site URL; do not guess or skip install.
- Proceeding to create app before user chooses a developer space — wait for their selection when there are multiple spaces.
Troubleshooting
For CLI commands, debugging techniques, and common error patterns, call forge-development-guide. For quick checks:
- Not logged in / auth failed: Create API token at id.atlassian.com/manage/api-tokens, then run
forge loginin your terminal (never paste token in chat) - App not appearing after install: Check
forge logs -e development --limit 50, verify manifest withforge lint, re-install with--upgradeif scopes changed - "forge: command not found":
npm install -g @forge/cli
__pycache__/
*.pyc
.DS_Store
# Node
node_modules/
# Eval run outputs (ephemeral artifacts from skill testing iterations)
forge-app-builder-workspace/
{
"skill_name": "forge-app-builder",
"evals": [
{
"id": 1,
"prompt": "I want to build a Jira issue panel that shows related customer support tickets from an external API. It should appear on every issue and display a list of tickets with their status. Can you create the Forge app for me?",
"expected_output": "Agent creates a Forge app with a Jira issue panel module, writes working frontend and backend code, handles deployment, and interacts properly with the user throughout (asking for choices, not guessing).",
"files": [],
"expectations": [
"The app uses a Jira issue panel module type (not a different module like a page or global action)",
"The agent asks the user to choose a developer space rather than picking one automatically",
"The agent describes or provides frontend code/plan for rendering a list of tickets with their status",
"The agent describes or provides backend code/plan that fetches data from an external API (e.g. a resolver)",
"The agent asks the user for their Atlassian site URL before attempting installation",
"The agent does not ask the user for API tokens or credentials in the chat",
"The agent specifies or describes appropriate Jira scopes needed for the app to function",
"The agent verifies that prerequisite tools (Node.js, Forge CLI) are available before proceeding",
"The agent does not use @atlaskit/ imports in a UI Kit app",
"The agent plans to create the app using forge create (or equivalent script) rather than manually writing app files from scratch"
]
},
{
"id": 2,
"prompt": "I need a Confluence macro that lets users embed an interactive chart. The macro should accept some configuration for chart type (bar, line, pie) and data source URL. Please set everything up as a Forge app.",
"expected_output": "Agent creates a Forge app with a Confluence macro module that accepts configuration parameters for chart type and data source URL, implements the chart rendering logic, and deploys the app.",
"files": [],
"expectations": [
"The app uses a Confluence macro module type",
"The macro accepts a configuration parameter for chart type (supporting bar, line, and pie)",
"The macro accepts a configuration parameter for data source URL",
"The agent asks the user to choose a developer space rather than picking one automatically",
"The agent describes or provides frontend code/plan for rendering a chart based on the configuration",
"The agent asks the user for their Atlassian site URL before attempting installation",
"The agent verifies prerequisite tools are available before proceeding",
"The agent specifies or describes appropriate Confluence scopes needed for the app",
"The agent plans to create the app using forge create (or equivalent script) rather than manually writing app files from scratch"
]
},
{
"id": 3,
"prompt": "Can you create a Forge app that adds a dashboard gadget to Jira? I want it to show a summary of open issues grouped by priority. I already have forge installed and I'm logged in.",
"expected_output": "Agent creates a Jira dashboard gadget that fetches open issues and groups them by priority. Even though the user says prerequisites are installed, the agent still verifies before proceeding.",
"files": [],
"expectations": [
"The app uses a Jira dashboard gadget module type",
"The agent still verifies prerequisites even though the user claims they are already installed",
"The agent describes or provides backend code/plan that fetches Jira issues using JQL or the Jira REST API",
"The agent describes or provides a plan for grouping or categorizing issues by priority level",
"The agent asks the user to choose a developer space rather than picking one automatically",
"The agent asks the user for their Atlassian site URL before attempting installation",
"The agent specifies or describes a scope that allows reading Jira issues (e.g. read:jira-work)",
"The agent plans to create the app using forge create (or equivalent script) rather than manually writing app files from scratch"
]
},
{
"id": 4,
"prompt": "I want to make a Confluence app that reads data from Jira issues and displays it on a Confluence page — like a cross-product integration. The Confluence macro should show a table of Jira issues assigned to the current user.",
"expected_output": "Agent creates a Confluence macro that integrates with Jira, including proper scopes for both products, and handles the cross-product deployment correctly.",
"files": [],
"expectations": [
"The app uses a Confluence macro module type",
"The agent specifies or describes both Confluence and Jira scopes since it's a cross-product app",
"The agent describes or provides backend code/plan that calls the Jira REST API to fetch issues assigned to the current user",
"The agent describes or provides frontend code/plan that displays issues in a table format",
"The agent recognizes and explains the cross-product nature of the app (needs access to both Jira and Confluence)",
"The agent asks the user to choose a developer space rather than picking one automatically",
"The agent asks the user for their Atlassian site URL before attempting installation",
"The agent plans to create the app using forge create (or equivalent script) rather than manually writing app files from scratch"
]
},
{
"id": 5,
"prompt": "forge create keeps failing with 'Prompts can not be meaningfully rendered'. I've been trying to create a jira-issue-panel-ui-kit app but it just won't work. Help!",
"expected_output": "Agent identifies the error and tells the user to run forge create in an interactive terminal. Does not try to manually scaffold the app or work around the error.",
"files": [],
"expectations": [
"The agent identifies the error as a known or expected issue (not something novel to debug)",
"The agent tells the user to run forge create in an interactive terminal as the primary solution",
"The agent does NOT attempt to manually create the app files as a workaround",
"The agent provides the correct forge create command with the template name for the user to run",
"The agent offers to help with customization and deployment after the user successfully creates the app",
"The agent does not ask the user for API tokens or credentials in the chat"
]
}
]
}
{
"name": "forge-app-builder-skill",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@playwright/test": "^1.58.2"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://packages.atlassian.com/api/npm/npm-remote/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://packages.atlassian.com/api/npm/npm-remote/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://packages.atlassian.com/api/npm/npm-remote/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://packages.atlassian.com/api/npm/npm-remote/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
{
"dependencies": {
"@playwright/test": "^1.58.2"
}
}
Forge App Builder Skill
Guides creation, deployment, and installation of Atlassian Forge apps (Jira widgets, Confluence macros, issue panels, dashboard gadgets, etc.). Use when building any Forge app. Provides automated forge create workflow, module selection, CLI commands, and deployment scripts.
Installation
This skill ships inside the [Forge Skills](https://github.com/atlassian/forge-skills) plugin bundle (skills/forge-app-builder/). Prefer installing that repo as a plugin in your editor or CLI so you get the skill plus Forge and ADS MCP configuration. See the forge-skills README for Cursor, Claude Code, Gemini, Codex, and Copilot CLI.
Using only this skill folder (advanced)
If your host can load a skill from an arbitrary path, point it at skills/forge-app-builder inside a checkout of forge-skills. For example, clone the repo and symlink into a global skills directory:
git clone https://github.com/atlassian/forge-skills.git ~/dev/forge-skills
ln -s ~/dev/forge-skills/skills/forge-app-builder ~/.cursor/skills/forge-app-builderAdjust the target path for your tool (.claude/skills/, .agents/skills/, etc.). You will not get .mcp.json from the symlink alone; install the full plugin or add MCP servers yourself if you want them.
---
What This Skill Provides
- Automated workflow — Discovers dev spaces, creates apps with
forge create, deploys and installs - Module selection — Guidance for Jira panels, Confluence macros, dashboard gadgets, Rovo agents, and more
- Helper scripts — Python scripts for dev spaces, templates, and deployment
- Reference docs — CLI workflow, module selector, project setup
See SKILL.md for the full skill content.
# Forge app creation scripts (run from skill dir: python -m scripts.create_forge_app, etc.)
#!/usr/bin/env python3
"""
Automated Forge App Creation Script
Wraps 'forge create' command with non-interactive mode and better error handling.
Run from the skill directory: python -m scripts.create_forge_app
"""
import subprocess
import sys
import argparse
import os
import re
import json
# Relative imports — scripts/ is a package; run as python -m scripts.create_forge_app from skill dir
from . import list_templates as list_templates_module
from .forge_env import forge_env
# Environment for every Forge CLI invocation this script spawns.
_FORGE_ENV = forge_env("forge-app-builder")
def validate_prerequisites():
"""Check if Forge CLI and Node.js are available"""
try:
subprocess.run(['forge', '--version'], capture_output=True, check=True, env=_FORGE_ENV)
subprocess.run(['node', '-v'], capture_output=True, check=True, env=_FORGE_ENV)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def validate_template(template_name):
"""
Validate that a template name is valid by checking against the official registry
Returns (is_valid, suggestions) tuple
"""
try:
templates = list_templates_module.fetch_templates()
template_names = [t['name'] for t in templates]
if template_name in template_names:
return True, None
# Find similar templates for suggestion
words = template_name.lower().replace('-', ' ').split()
suggestions = []
for valid_template in template_names:
valid_words = valid_template.lower().replace('-', ' ').split()
if any(word in valid_words for word in words):
suggestions.append(valid_template)
return False, suggestions[:5] if suggestions else template_names[:5]
except Exception as e:
print(f"⚠️ Could not validate template: {e}")
return True, None # Assume valid if validation fails
def discover_dev_spaces():
"""
Discover available developer spaces via the Forge CLI.
Returns a list of {'id': str, 'name': str} dictionaries.
"""
try:
result = subprocess.run(
['forge', 'developer-spaces', 'list', '--json'],
capture_output=True, text=True, timeout=30, env=_FORGE_ENV,
)
if result.returncode != 0:
print(f"⚠️ forge developer-spaces list failed: {result.stderr.strip()}")
return []
raw = json.loads(result.stdout)
spaces = raw if isinstance(raw, list) else raw.get("data", raw.get("spaces", []))
return [
{"id": entry.get("id") or entry.get("developerSpaceId"), "name": entry.get("name", "")}
for entry in spaces
if entry.get("id") or entry.get("developerSpaceId")
]
except Exception as e:
print(f"⚠️ Could not discover developer spaces: {e}")
return []
def create_app(template, app_name, output_dir=None, dev_space_id=None):
"""
Create a Forge app using 'forge create'
Args:
template: Template name (e.g., 'jira-issue-panel-ui-kit')
app_name: Name for the new app
output_dir: Parent directory where the app folder will be created.
The script cd's into this directory before running forge create
so the app folder is created as a subdirectory.
dev_space_id: Developer space ID to use (required)
Returns:
True if successful, False otherwise
"""
if not validate_prerequisites():
print("❌ Prerequisites missing. Ensure Forge CLI and Node.js v22+ are installed.")
print(" Install: npm install -g @forge/cli")
return False
# Validate template
is_valid, suggestions = validate_template(template)
if not is_valid:
print(f"❌ Template '{template}' is not recognized.")
print(f"\n📋 Did you mean one of these?")
for suggestion in suggestions:
print(f" - {suggestion}")
print(f"\n💡 To see all available templates, run:")
print(f" python -m scripts.list_templates --list")
return False
if not dev_space_id:
print("❌ Developer space ID is required. Please use --dev-space-id flag.")
return False
# Resolve the working directory for forge create.
# We cd into output_dir (the parent) and let forge create the app subfolder,
# instead of using forge's --directory flag which treats the path as the
# full output path and fails if it already exists.
cwd = os.path.abspath(output_dir) if output_dir else os.getcwd()
if not os.path.isdir(cwd):
print(f"❌ Parent directory does not exist: {cwd}")
return False
app_path = os.path.join(cwd, app_name)
if os.path.exists(app_path):
print(f"❌ Directory already exists: {app_path}")
print(f" Choose a different app name or remove the existing folder.")
return False
# Build command — no --directory flag; we run from the parent dir instead
cmd = ['forge', 'create', '--template', template, app_name]
if dev_space_id:
cmd.extend(['--developer-space-id', dev_space_id])
cmd.append('--accept-terms')
try:
print(f"\n📦 Creating Forge app: {app_name}")
print(f"📋 Template: {template}")
print(f"📂 Location: {cwd}")
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=_FORGE_ENV)
if result.returncode != 0:
stderr = result.stderr.strip()
stdout = result.stdout.strip()
print(f"❌ Failed to create app (exit code {result.returncode})")
if stdout:
print(f"\n--- stdout ---\n{stdout}")
if stderr:
print(f"\n--- stderr ---\n{stderr}")
return False
print(f"✅ App created successfully at: {app_path}")
print(f"📝 Next steps:")
print(f" 1. cd {app_path}")
print(f" 2. npm install")
print(f" 3. Customize the code")
print(f" 4. Deploy with: forge deploy --non-interactive -e development")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Failed to create app: {e}")
if e.stdout:
print(f"\n--- stdout ---\n{e.stdout}")
if e.stderr:
print(f"\n--- stderr ---\n{e.stderr}")
return False
def main():
parser = argparse.ArgumentParser(
description='Automated Forge app creation with developer space selection'
)
parser.add_argument('--template', required=True, help='Forge template (e.g., jira-issue-panel-ui-kit)')
parser.add_argument('--name', required=True, help='App name')
parser.add_argument('--directory', help='Output directory (defaults to current directory)')
parser.add_argument('--dev-space-id', help='Developer space ID (if not provided, will prompt for selection)')
args = parser.parse_args()
# Step 1: Get developer space ID (either from arg or by discovery + prompt)
dev_space_id = args.dev_space_id
if not dev_space_id:
# Step 1a: Discover developer spaces
print("🔍 Step 1: Discovering your Developer Spaces...\n")
dev_spaces = discover_dev_spaces()
if not dev_spaces:
print("\n❌ Could not discover any developer spaces.")
print("\n📋 Please create a developer space at:")
print(" https://developer.atlassian.com/console/")
print("\nThen run this script again, or use 'forge create' interactively:")
print(f" ATL_FORGE_ATTRIBUTION_SKILL_NAME=forge-app-builder forge create --template {args.template} {args.name}")
sys.exit(1)
# Step 1b: Ask user which dev space to use
print(f"✅ Found {len(dev_spaces)} developer space(s):\n")
for i, space in enumerate(dev_spaces, 1):
print(f" {i}. {space['name']}")
print(f" ID: {space['id']}\n")
if len(dev_spaces) == 1:
choice = input(f"Use '{dev_spaces[0]['name']}'? [Y/n]: ").strip().lower()
if choice in ['', 'y', 'yes']:
dev_space_id = dev_spaces[0]['id']
print(f"✅ Selected: {dev_spaces[0]['name']}\n")
else:
print("❌ Cancelled by user")
sys.exit(1)
else:
while True:
try:
choice = input(f"Select a Developer Space (1-{len(dev_spaces)}): ").strip()
idx = int(choice) - 1
if 0 <= idx < len(dev_spaces):
dev_space_id = dev_spaces[idx]['id']
print(f"✅ Selected: {dev_spaces[idx]['name']}\n")
break
else:
print(f"❌ Please enter a number between 1 and {len(dev_spaces)}")
except ValueError:
print("❌ Please enter a valid number")
except KeyboardInterrupt:
print("\n❌ Cancelled by user")
sys.exit(1)
# Step 2: Create the app with the selected developer space
print(f"📦 Step 2: Creating Forge app with selected developer space...\n")
success = create_app(args.template, args.name, args.directory, dev_space_id)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Forge App Deployment Automation Script
This script automates the complete Forge app deployment process:
1. Validates prerequisites (Node.js, Forge CLI)
2. Installs dependencies
3. Validates manifest
4. Registers app (if needed)
5. Deploys to Forge
6. Installs on Jira site
Usage:
python deploy_forge_app.py --app-dir /path/to/app --site https://company.atlassian.net
"""
import os
import sys
import subprocess
import json
import argparse
import time
from pathlib import Path
from .forge_env import forge_env
# Environment for every Forge CLI invocation this script spawns.
_FORGE_ENV = forge_env("forge-app-builder")
class Colors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def print_header(message):
print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 80}{Colors.ENDC}")
print(f"{Colors.HEADER}{Colors.BOLD}{message.center(80)}{Colors.ENDC}")
print(f"{Colors.HEADER}{Colors.BOLD}{'=' * 80}{Colors.ENDC}\n")
def print_step(step_num, message):
print(f"{Colors.OKCYAN}{Colors.BOLD}Step {step_num}: {message}{Colors.ENDC}")
def print_success(message):
print(f"{Colors.OKGREEN}✓ {message}{Colors.ENDC}")
def print_error(message):
print(f"{Colors.FAIL}✗ {message}{Colors.ENDC}")
def print_warning(message):
print(f"{Colors.WARNING}⚠ {message}{Colors.ENDC}")
def run_command(cmd, cwd=None, capture_output=True, check=True):
"""Run a shell command and return the result."""
try:
result = subprocess.run(
cmd,
shell=True,
cwd=cwd,
capture_output=capture_output,
text=True,
check=check,
env=_FORGE_ENV,
)
return result
except subprocess.CalledProcessError as e:
print_error(f"Command failed: {cmd}")
if e.stdout and e.stdout.strip():
print(f"\n--- stdout ---\n{e.stdout.strip()}")
if e.stderr and e.stderr.strip():
print(f"\n--- stderr ---\n{e.stderr.strip()}")
raise
def check_node():
"""Check if Node.js is installed and get version."""
try:
result = run_command("node -v")
version = result.stdout.strip()
print_success(f"Node.js {version} found")
return True
except:
print_error("Node.js not found. Please install Node.js 22 LTS")
return False
def check_forge_cli():
"""Check if Forge CLI is installed and get version."""
try:
result = run_command("forge --version")
version = result.stdout.strip().split('\n')[0] # Get first line (version)
print_success(f"Forge CLI {version} found")
return True
except:
print_error("Forge CLI not found. Install with: npm install -g @forge/cli")
return False
def check_forge_login():
"""Check if user is logged into Forge."""
try:
result = run_command("forge whoami")
# Parse output to get email
lines = result.stdout.strip().split('\n')
for line in lines:
if '@' in line and 'Logged in as' in line:
print_success(f"Logged in to Forge: {line}")
return True
return False
except:
print_error("Not logged into Forge. Run: forge login")
return False
def install_dependencies(app_dir):
"""Install npm dependencies."""
print("Installing dependencies (this may take a few minutes)...")
result = run_command("npm install", cwd=app_dir)
print_success("Dependencies installed")
return True
def validate_manifest(app_dir):
"""Validate manifest.yml using forge lint."""
print("Validating manifest.yml...")
result = run_command("forge lint", cwd=app_dir)
print_success("Manifest is valid")
return True
def check_app_registered(app_dir):
"""Check if app is already registered (has an app ID in manifest)."""
manifest_path = Path(app_dir) / "manifest.yml"
if not manifest_path.exists():
return False
with open(manifest_path, 'r') as f:
content = f.read()
# Check if there's a valid app ID (not placeholder)
if 'id:' in content and 'will-be-generated' not in content.lower() and 'your-app-id' not in content.lower():
return True
return False
def register_app(app_dir, developer_space_id=None):
"""
Register the app with Forge.
Note: This is the tricky part - forge register is interactive.
We'll try to use environment variables or pass space ID if available.
"""
print_warning("App needs to be registered with Forge")
if developer_space_id:
# Try non-interactive registration with space ID
print(f"Attempting registration with Developer Space ID: {developer_space_id}")
try:
# This may not work - forge register might not support --space-id
cmd = f"forge register --developer-space-id {developer_space_id}"
result = run_command(cmd, cwd=app_dir, check=False)
if result.returncode == 0:
print_success("App registered successfully")
return True
except:
pass
print_warning("forge register requires interactive input")
print_warning("Please run this command manually in your terminal:")
print(f" cd {app_dir}")
print(f" forge register")
print_warning("Then run this script again.")
return False
def deploy_app(app_dir, environment="development"):
"""Deploy the app to Forge."""
print(f"Deploying to {environment} environment...")
cmd = f"forge deploy --non-interactive -e {environment}"
result = run_command(cmd, cwd=app_dir)
print_success(f"Deployed to {environment}")
return True
def install_app(app_dir, site_url, product="jira", environment="development"):
"""Install the app on the specified site."""
print(f"Installing app on {site_url}...")
cmd = f"forge install --non-interactive --site {site_url} --product {product} -e {environment}"
result = run_command(cmd, cwd=app_dir)
print_success(f"Installed on {site_url}")
return True
def detect_required_products(app_dir):
"""
Parse manifest.yml scopes to determine which Atlassian products
the app needs to be installed on.
Returns a set of product names (e.g. {'jira', 'confluence'}).
"""
manifest_path = Path(app_dir) / "manifest.yml"
if not manifest_path.exists():
return set()
with open(manifest_path, 'r') as f:
content = f.read()
products = set()
jira_patterns = [
'read:jira-work', 'write:jira-work',
'read:jira-user', 'manage:jira-project',
'manage:jira-configuration', 'manage:jira-data-provider',
'manage:jira-webhook',
]
confluence_patterns = [
'read:confluence-content', 'write:confluence-content',
'read:confluence-space', 'write:confluence-space',
'read:confluence-user', 'read:confluence-groups',
'read:page:confluence', 'write:page:confluence',
'read:space:confluence',
]
for pat in jira_patterns:
if pat in content:
products.add('jira')
break
for pat in confluence_patterns:
if pat in content:
products.add('confluence')
break
# Also detect from module keys
if 'jira:' in content or 'jira:issuePanel' in content or 'jira:projectPage' in content:
products.add('jira')
if 'macro:' in content or 'confluence:' in content:
products.add('confluence')
return products
def get_app_logs(app_dir, environment="development", limit=20):
"""Get recent app logs."""
print("Fetching recent logs...")
cmd = f"forge logs -e {environment} --limit {limit}"
result = run_command(cmd, cwd=app_dir, capture_output=True)
print(result.stdout)
def main():
parser = argparse.ArgumentParser(
description="Automate Forge app deployment",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Deploy to development environment
python deploy_forge_app.py --app-dir ./my-forge-app --site https://company.atlassian.net
# Deploy to production
python deploy_forge_app.py --app-dir ./my-forge-app --site https://company.atlassian.net --env production
# Skip installation step (deploy only)
python deploy_forge_app.py --app-dir ./my-forge-app --deploy-only
"""
)
parser.add_argument("--app-dir", required=True, help="Path to Forge app directory")
parser.add_argument("--site", help="Atlassian site URL (e.g., https://company.atlassian.net)")
parser.add_argument("--product", default="jira", help="Product to install on (default: jira)")
parser.add_argument("--env", default="development", help="Environment (default: development)")
parser.add_argument("--developer-space-id", help="Developer Space ID for registration")
parser.add_argument("--deploy-only", action="store_true", help="Deploy only, skip installation")
parser.add_argument("--skip-deps", action="store_true", help="Skip npm install")
parser.add_argument("--show-logs", action="store_true", help="Show logs after deployment")
args = parser.parse_args()
app_dir = Path(args.app_dir).resolve()
if not app_dir.exists():
print_error(f"App directory not found: {app_dir}")
sys.exit(1)
print_header("Forge App Deployment Automation")
print(f"App Directory: {app_dir}")
print(f"Environment: {args.env}")
if not args.deploy_only:
print(f"Target Site: {args.site}")
print(f"Product: {args.product}")
# Step 1: Check prerequisites
print_step(1, "Checking Prerequisites")
if not check_node():
sys.exit(1)
if not check_forge_cli():
sys.exit(1)
if not check_forge_login():
sys.exit(1)
# Step 2: Install dependencies
if not args.skip_deps:
print_step(2, "Installing Dependencies")
try:
install_dependencies(app_dir)
except Exception as e:
print_error(f"Failed to install dependencies: {e}")
sys.exit(1)
else:
print_step(2, "Skipping dependency installation")
# Step 3: Validate manifest
print_step(3, "Validating Manifest")
try:
validate_manifest(app_dir)
except Exception as e:
print_error(f"Manifest validation failed: {e}")
sys.exit(1)
# Step 4: Register app (if needed)
print_step(4, "Checking App Registration")
if check_app_registered(app_dir):
print_success("App is already registered")
else:
if not register_app(app_dir, args.developer_space_id):
print_error("App registration required. Please register manually.")
sys.exit(1)
# Step 5: Deploy
print_step(5, "Deploying to Forge")
try:
deploy_app(app_dir, args.env)
except Exception as e:
print_error(f"Deployment failed: {e}")
sys.exit(1)
# Step 6: Install (if not deploy-only)
installed_products = []
if not args.deploy_only:
# Always get site URL - prompt if not provided
if args.site:
print_success(f"Using site: {args.site}")
else:
print("\n⚠️ Site URL is required for installation")
args.site = input(f"Enter {args.product.capitalize()} site URL (e.g., https://company.atlassian.net): ").strip()
if not args.site:
print_error("Site URL is required for installation")
sys.exit(1)
# Detect all products required by manifest scopes/modules
required_products = detect_required_products(app_dir)
# Ensure the explicitly requested product is included
required_products.add(args.product)
print_step(6, "Installing on Atlassian Site")
if len(required_products) > 1:
print(f" Detected cross-product scopes — installing on: {', '.join(sorted(required_products))}")
for product in sorted(required_products):
try:
install_app(app_dir, args.site, product, args.env)
installed_products.append(product)
except Exception as e:
print_error(f"Installation on {product} failed: {e}")
sys.exit(1)
# Show logs if requested
if args.show_logs:
print_step(7, "Recent Logs")
get_app_logs(app_dir, args.env)
# Success!
print_header("Deployment Complete!")
if args.deploy_only:
print_success("Your Forge app is now deployed!")
print(f"\n{Colors.BOLD}Next step:{Colors.ENDC}")
print(f" Install the app by re-running with --site <your-site-url>")
else:
products_str = ' and '.join(installed_products) if installed_products else args.product
print_success(f"Your Forge app is deployed and installed on {products_str}!")
print(f"\n{Colors.BOLD}Next steps:{Colors.ENDC}")
print(f" 1. Go to {args.site}")
print(f" 2. Open your app in {products_str.capitalize()}")
print(f" 3. Look for your app!")
print(f"\n{Colors.BOLD}Useful commands:{Colors.ENDC}")
print(f" View logs: forge logs -e {args.env} --limit 50")
print(f" Tunnel: forge tunnel")
site_flag = f" --site {args.site}" if args.site else ""
print(f" Redeploy: python {sys.argv[0]} --app-dir {app_dir}{site_flag} --skip-deps")
print()
if __name__ == "__main__":
main()
"""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-app-builder", 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
"""
List all available Forge templates.
Fetches the official template list from Atlassian's template registry.
Includes 154 templates across all Atlassian products.
Source: https://forge-templates.us-west-2.prod.public.atl-paas.net/templates.json
Last verified: February 2025
"""
import json
import sys
import urllib.request
from typing import List, Dict, Optional
TEMPLATE_REGISTRY_URL = "https://forge-templates.us-west-2.prod.public.atl-paas.net/templates.json"
def fetch_templates() -> List[Dict]:
"""Fetch the latest template list from Atlassian's registry"""
try:
with urllib.request.urlopen(TEMPLATE_REGISTRY_URL, timeout=10) as response:
data = response.read()
templates = json.loads(data)
return templates
except Exception as e:
print(f"❌ Failed to fetch templates: {e}", file=sys.stderr)
print(f" URL: {TEMPLATE_REGISTRY_URL}", file=sys.stderr)
sys.exit(1)
def categorize_templates(templates: List[Dict]) -> Dict[str, List[str]]:
"""Categorize templates by product"""
categories = {
'Jira': [],
'Jira Service Management': [],
'Confluence': [],
'Bitbucket': [],
'Compass': [],
'Rovo': [],
'Automation': [],
'Dashboards': [],
'Teamwork Graph': [],
'Other': []
}
for template in templates:
name = template['name']
if name.startswith('jira-service-management-'):
categories['Jira Service Management'].append(name)
elif name.startswith('jira-'):
categories['Jira'].append(name)
elif name.startswith('confluence-'):
categories['Confluence'].append(name)
elif name.startswith('bitbucket-'):
categories['Bitbucket'].append(name)
elif name.startswith('compass-'):
categories['Compass'].append(name)
elif 'rovo' in name.lower():
categories['Rovo'].append(name)
elif name.startswith('automation-'):
categories['Automation'].append(name)
elif name.startswith('dashboards-'):
categories['Dashboards'].append(name)
elif name.startswith('teamwork-graph-'):
categories['Teamwork Graph'].append(name)
else:
categories['Other'].append(name)
# Remove empty categories
return {k: sorted(v) for k, v in categories.items() if v}
def validate_template(template_name: str, templates: List[Dict]) -> bool:
"""
Validate if a template exists in the official registry.
Returns True if valid, False otherwise.
"""
template_names = [t['name'] for t in templates]
print(f"Validating template: {template_name}...", end=" ", flush=True)
if template_name in template_names:
print("✅ Valid")
return True
else:
print("❌ Invalid")
print(f"\nTemplate '{template_name}' not found in the registry.")
print("\nDid you mean one of these?")
# Find similar templates
similar = []
for name in template_names:
if template_name.lower() in name.lower() or name.lower() in template_name.lower():
similar.append(name)
if similar:
for s in sorted(similar)[:10]:
print(f" • {s}")
else:
# Show templates from the same product
if '-' in template_name:
product = template_name.split('-')[0]
product_templates = [n for n in template_names if n.startswith(f"{product}-")]
if product_templates:
print(f"\nAvailable {product} templates:")
for t in sorted(product_templates)[:10]:
print(f" • {t}")
return False
def list_templates(format='text') -> str:
"""List all templates in specified format"""
templates = fetch_templates()
if format == 'json':
output = {
"templates": sorted([t['name'] for t in templates]),
"count": len(templates),
"source": TEMPLATE_REGISTRY_URL
}
return json.dumps(output, indent=2)
else:
# Text format
categories = categorize_templates(templates)
output = []
output.append("=" * 80)
output.append(f"FORGE TEMPLATES ({len(templates)} total)")
output.append("=" * 80)
output.append(f"Source: {TEMPLATE_REGISTRY_URL}")
output.append("")
for category, template_list in categories.items():
output.append(f"{category.upper()} ({len(template_list)}):")
for t in template_list:
# Find description if available
desc = next((tmpl.get('description', '') for tmpl in templates if tmpl['name'] == t), '')
if desc:
output.append(f" • {t} - {desc}")
else:
output.append(f" • {t}")
output.append("")
return "\n".join(output)
def main():
import argparse
parser = argparse.ArgumentParser(
description='Forge template manager - fetches templates from Atlassian registry',
epilog=f'Template registry: {TEMPLATE_REGISTRY_URL}'
)
parser.add_argument('--list', action='store_true', help='List all templates (default)')
parser.add_argument('--validate', metavar='TEMPLATE', help='Validate a specific template')
parser.add_argument('--json', action='store_true', help='Output as JSON')
parser.add_argument('--count', action='store_true', help='Show only the template count')
args = parser.parse_args()
if args.count:
templates = fetch_templates()
print(f"{len(templates)} templates available")
return
if args.validate:
templates = fetch_templates()
is_valid = validate_template(args.validate, templates)
sys.exit(0 if is_valid else 1)
else:
format_type = 'json' if args.json else 'text'
print(list_templates(format=format_type))
if __name__ == '__main__':
main()
"""
Tests for scripts/create_forge_app.py
"""
import os
import subprocess
import unittest
from unittest.mock import patch, MagicMock, call
from scripts import create_forge_app as cfa
SAMPLE_TEMPLATES = [
{"name": "jira-issue-panel-ui-kit", "description": "Jira issue panel"},
{"name": "confluence-macro-ui-kit", "description": "Confluence macro"},
]
class TestValidatePrerequisites(unittest.TestCase):
@patch("scripts.create_forge_app.subprocess.run")
def test_returns_true_when_both_present(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
self.assertTrue(cfa.validate_prerequisites())
@patch("scripts.create_forge_app.subprocess.run")
def test_returns_false_when_forge_missing(self, mock_run):
mock_run.side_effect = FileNotFoundError("forge not found")
self.assertFalse(cfa.validate_prerequisites())
@patch("scripts.create_forge_app.subprocess.run")
def test_returns_false_when_command_fails(self, mock_run):
mock_run.side_effect = subprocess.CalledProcessError(1, "forge")
self.assertFalse(cfa.validate_prerequisites())
class TestValidateTemplate(unittest.TestCase):
@patch("scripts.create_forge_app.list_templates_module.fetch_templates",
return_value=SAMPLE_TEMPLATES)
def test_valid_template_returns_true(self, mock_fetch):
is_valid, suggestions = cfa.validate_template("jira-issue-panel-ui-kit")
self.assertTrue(is_valid)
self.assertIsNone(suggestions)
@patch("scripts.create_forge_app.list_templates_module.fetch_templates",
return_value=SAMPLE_TEMPLATES)
def test_invalid_template_returns_false_with_suggestions(self, mock_fetch):
is_valid, suggestions = cfa.validate_template("jira-nonexistent")
self.assertFalse(is_valid)
self.assertIsNotNone(suggestions)
@patch("scripts.create_forge_app.list_templates_module.fetch_templates",
return_value=SAMPLE_TEMPLATES)
def test_suggestions_contain_similar_templates(self, mock_fetch):
is_valid, suggestions = cfa.validate_template("jira-panel")
self.assertFalse(is_valid)
# "jira" keyword matches "jira-issue-panel-ui-kit"
self.assertTrue(any("jira" in s for s in suggestions))
@patch("scripts.create_forge_app.list_templates_module.fetch_templates",
side_effect=Exception("network error"))
def test_assumes_valid_if_validation_fails(self, mock_fetch):
# When we can't fetch templates, assume valid to avoid blocking the user
is_valid, suggestions = cfa.validate_template("any-template")
self.assertTrue(is_valid)
class TestDiscoverDevSpaces(unittest.TestCase):
def _mock_cli(self, json_output, returncode=0, stderr=""):
import json as _json
return MagicMock(
returncode=returncode,
stdout=_json.dumps(json_output) if isinstance(json_output, (list, dict)) else json_output,
stderr=stderr,
)
@patch("scripts.create_forge_app.subprocess.run")
def test_returns_spaces_list(self, mock_run):
mock_run.return_value = self._mock_cli([{"id": "abc", "name": "My Space"}])
result = cfa.discover_dev_spaces()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["id"], "abc")
@patch("scripts.create_forge_app.subprocess.run")
def test_returns_empty_list_when_no_spaces(self, mock_run):
mock_run.return_value = self._mock_cli([])
result = cfa.discover_dev_spaces()
self.assertEqual(result, [])
@patch("scripts.create_forge_app.subprocess.run")
def test_returns_empty_list_on_cli_failure(self, mock_run):
mock_run.return_value = self._mock_cli("", returncode=1, stderr="not logged in")
result = cfa.discover_dev_spaces()
self.assertEqual(result, [])
@patch("scripts.create_forge_app.subprocess.run",
side_effect=Exception("error"))
def test_returns_empty_list_on_exception(self, mock_run):
result = cfa.discover_dev_spaces()
self.assertEqual(result, [])
class TestCreateApp(unittest.TestCase):
@patch("scripts.create_forge_app.subprocess.run")
@patch("scripts.create_forge_app.validate_template", return_value=(True, None))
@patch("scripts.create_forge_app.validate_prerequisites", return_value=True)
def test_creates_app_successfully(self, mock_prereqs, mock_validate, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("scripts.create_forge_app.os.path.isdir", return_value=True), \
patch("scripts.create_forge_app.os.path.exists", return_value=False):
result = cfa.create_app(
template="jira-issue-panel-ui-kit",
app_name="my-app",
output_dir="/tmp",
dev_space_id="abc-123"
)
self.assertTrue(result)
# Verify forge create was called with correct args
call_args = mock_run.call_args[0][0]
self.assertIn("forge", call_args)
self.assertIn("create", call_args)
self.assertIn("--template", call_args)
self.assertIn("jira-issue-panel-ui-kit", call_args)
self.assertIn("my-app", call_args)
self.assertIn("--developer-space-id", call_args)
self.assertIn("abc-123", call_args)
@patch("scripts.create_forge_app.validate_prerequisites", return_value=False)
def test_returns_false_when_prerequisites_missing(self, mock_prereqs):
result = cfa.create_app("jira-issue-panel-ui-kit", "my-app", dev_space_id="abc")
self.assertFalse(result)
@patch("scripts.create_forge_app.validate_template", return_value=(False, ["jira-issue-panel-ui-kit"]))
@patch("scripts.create_forge_app.validate_prerequisites", return_value=True)
def test_returns_false_for_invalid_template(self, mock_prereqs, mock_validate):
result = cfa.create_app("bad-template", "my-app", dev_space_id="abc")
self.assertFalse(result)
@patch("scripts.create_forge_app.validate_template", return_value=(True, None))
@patch("scripts.create_forge_app.validate_prerequisites", return_value=True)
def test_returns_false_without_dev_space_id(self, mock_prereqs, mock_validate):
result = cfa.create_app("jira-issue-panel-ui-kit", "my-app", dev_space_id=None)
self.assertFalse(result)
@patch("scripts.create_forge_app.validate_template", return_value=(True, None))
@patch("scripts.create_forge_app.validate_prerequisites", return_value=True)
def test_returns_false_when_output_dir_missing(self, mock_prereqs, mock_validate):
with patch("scripts.create_forge_app.os.path.isdir", return_value=False):
result = cfa.create_app(
"jira-issue-panel-ui-kit", "my-app",
output_dir="/nonexistent",
dev_space_id="abc"
)
self.assertFalse(result)
@patch("scripts.create_forge_app.validate_template", return_value=(True, None))
@patch("scripts.create_forge_app.validate_prerequisites", return_value=True)
def test_returns_false_when_app_dir_already_exists(self, mock_prereqs, mock_validate):
with patch("scripts.create_forge_app.os.path.isdir", return_value=True), \
patch("scripts.create_forge_app.os.path.exists", return_value=True):
result = cfa.create_app(
"jira-issue-panel-ui-kit", "my-app",
output_dir="/tmp",
dev_space_id="abc"
)
self.assertFalse(result)
@patch("scripts.create_forge_app.subprocess.run")
@patch("scripts.create_forge_app.validate_template", return_value=(True, None))
@patch("scripts.create_forge_app.validate_prerequisites", return_value=True)
def test_returns_false_on_forge_create_failure(self, mock_prereqs, mock_validate, mock_run):
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="forge error")
with patch("scripts.create_forge_app.os.path.isdir", return_value=True), \
patch("scripts.create_forge_app.os.path.exists", return_value=False):
result = cfa.create_app(
"jira-issue-panel-ui-kit", "my-app",
output_dir="/tmp",
dev_space_id="abc"
)
self.assertFalse(result)
@patch("scripts.create_forge_app.subprocess.run")
@patch("scripts.create_forge_app.validate_template", return_value=(True, None))
@patch("scripts.create_forge_app.validate_prerequisites", return_value=True)
def test_runs_from_parent_directory(self, mock_prereqs, mock_validate, mock_run):
"""forge create should be run from the parent directory, not the app directory."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("scripts.create_forge_app.os.path.isdir", return_value=True), \
patch("scripts.create_forge_app.os.path.exists", return_value=False), \
patch("scripts.create_forge_app.os.path.abspath", return_value="/parent"):
cfa.create_app(
"jira-issue-panel-ui-kit", "my-app",
output_dir="/parent",
dev_space_id="abc"
)
call_kwargs = mock_run.call_args[1]
self.assertEqual(call_kwargs.get("cwd"), "/parent")
@patch("scripts.create_forge_app.subprocess.run")
@patch("scripts.create_forge_app.validate_template", return_value=(True, None))
@patch("scripts.create_forge_app.validate_prerequisites", return_value=True)
def test_stamps_skill_name_env_var(self, mock_prereqs, mock_validate, mock_run):
"""forge create must carry the skill-name attribution env var."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("scripts.create_forge_app.os.path.isdir", return_value=True), \
patch("scripts.create_forge_app.os.path.exists", return_value=False):
cfa.create_app(
"jira-issue-panel-ui-kit", "my-app",
output_dir="/tmp", dev_space_id="abc-123",
)
env = mock_run.call_args[1].get("env")
self.assertIsNotNone(env)
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
if __name__ == "__main__":
unittest.main()
"""
Tests for scripts/deploy_forge_app.py
"""
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock, mock_open
from scripts import deploy_forge_app as dfa
class TestCheckNode(unittest.TestCase):
@patch("scripts.deploy_forge_app.run_command")
def test_returns_true_when_node_present(self, mock_run):
mock_run.return_value = MagicMock(stdout="v22.0.0\n")
self.assertTrue(dfa.check_node())
@patch("scripts.deploy_forge_app.run_command")
def test_returns_false_when_node_missing(self, mock_run):
mock_run.side_effect = Exception("node not found")
self.assertFalse(dfa.check_node())
class TestCheckForgeCli(unittest.TestCase):
@patch("scripts.deploy_forge_app.run_command")
def test_returns_true_when_forge_present(self, mock_run):
mock_run.return_value = MagicMock(stdout="10.0.0\nsome other line\n")
self.assertTrue(dfa.check_forge_cli())
@patch("scripts.deploy_forge_app.run_command")
def test_returns_false_when_forge_missing(self, mock_run):
mock_run.side_effect = Exception("forge not found")
self.assertFalse(dfa.check_forge_cli())
class TestCheckForgeLogin(unittest.TestCase):
@patch("scripts.deploy_forge_app.run_command")
def test_returns_true_when_logged_in(self, mock_run):
mock_run.return_value = MagicMock(stdout="Logged in as user@example.com\n")
self.assertTrue(dfa.check_forge_login())
@patch("scripts.deploy_forge_app.run_command")
def test_returns_false_when_not_logged_in(self, mock_run):
mock_run.side_effect = Exception("not logged in")
self.assertFalse(dfa.check_forge_login())
@patch("scripts.deploy_forge_app.run_command")
def test_returns_false_when_output_has_no_email(self, mock_run):
mock_run.return_value = MagicMock(stdout="Some output without email\n")
self.assertFalse(dfa.check_forge_login())
class TestCheckAppRegistered(unittest.TestCase):
def test_returns_true_when_app_id_present(self):
manifest_content = "app:\n id: ari:cloud:ecosystem::app/abc-123\n"
with patch("builtins.open", mock_open(read_data=manifest_content)), \
patch("scripts.deploy_forge_app.Path.exists", return_value=True):
result = dfa.check_app_registered("/fake/app")
self.assertTrue(result)
def test_returns_false_when_manifest_missing(self):
with patch("scripts.deploy_forge_app.Path.exists", return_value=False):
result = dfa.check_app_registered("/fake/app")
self.assertFalse(result)
def test_returns_false_when_placeholder_id(self):
manifest_content = "app:\n id: will-be-generated\n"
with patch("builtins.open", mock_open(read_data=manifest_content)), \
patch("scripts.deploy_forge_app.Path.exists", return_value=True):
result = dfa.check_app_registered("/fake/app")
self.assertFalse(result)
class TestDetectRequiredProducts(unittest.TestCase):
def _run_detect(self, manifest_content):
with patch("builtins.open", mock_open(read_data=manifest_content)), \
patch("scripts.deploy_forge_app.Path.exists", return_value=True):
return dfa.detect_required_products("/fake/app")
def test_detects_jira_from_scope(self):
manifest = "permissions:\n scopes:\n - read:jira-work\n"
products = self._run_detect(manifest)
self.assertIn("jira", products)
def test_detects_confluence_from_scope(self):
manifest = "permissions:\n scopes:\n - read:confluence-content\n"
products = self._run_detect(manifest)
self.assertIn("confluence", products)
def test_detects_both_products(self):
manifest = (
"permissions:\n scopes:\n"
" - read:jira-work\n"
" - write:confluence-content\n"
)
products = self._run_detect(manifest)
self.assertIn("jira", products)
self.assertIn("confluence", products)
def test_detects_jira_from_module_key(self):
manifest = "modules:\n jira:issuePanel:\n - key: my-panel\n"
products = self._run_detect(manifest)
self.assertIn("jira", products)
def test_detects_confluence_from_module_key(self):
manifest = "modules:\n confluence:spacePage:\n - key: my-macro\n"
products = self._run_detect(manifest)
self.assertIn("confluence", products)
def test_returns_empty_set_when_manifest_missing(self):
with patch("scripts.deploy_forge_app.Path.exists", return_value=False):
products = dfa.detect_required_products("/fake/app")
self.assertEqual(products, set())
def test_returns_empty_set_for_no_product_scopes(self):
manifest = "permissions:\n scopes:\n - storage:app\n"
products = self._run_detect(manifest)
self.assertEqual(products, set())
class TestDeployApp(unittest.TestCase):
@patch("scripts.deploy_forge_app.run_command")
def test_deploy_runs_correct_command(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
dfa.deploy_app("/fake/app", environment="development")
cmd = mock_run.call_args[0][0]
self.assertIn("forge deploy", cmd)
self.assertIn("--non-interactive", cmd)
self.assertIn("development", cmd)
@patch("scripts.deploy_forge_app.run_command")
def test_deploy_uses_specified_environment(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
dfa.deploy_app("/fake/app", environment="staging")
cmd = mock_run.call_args[0][0]
self.assertIn("staging", cmd)
class TestInstallApp(unittest.TestCase):
@patch("scripts.deploy_forge_app.run_command")
def test_install_runs_correct_command(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
dfa.install_app("/fake/app", "mysite.atlassian.net", product="jira")
cmd = mock_run.call_args[0][0]
self.assertIn("forge install", cmd)
self.assertIn("--non-interactive", cmd)
self.assertIn("mysite.atlassian.net", cmd)
self.assertIn("jira", cmd)
@patch("scripts.deploy_forge_app.run_command")
def test_install_uses_specified_product(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
dfa.install_app("/fake/app", "mysite.atlassian.net", product="confluence")
cmd = mock_run.call_args[0][0]
self.assertIn("confluence", cmd)
class TestRunCommand(unittest.TestCase):
@patch("scripts.deploy_forge_app.subprocess.run")
def test_returns_result_on_success(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
result = dfa.run_command("echo hello")
self.assertEqual(result.stdout, "ok")
@patch("scripts.deploy_forge_app.subprocess.run")
def test_raises_on_failure(self, mock_run):
import subprocess
mock_run.side_effect = subprocess.CalledProcessError(1, "bad-cmd")
with self.assertRaises(subprocess.CalledProcessError):
dfa.run_command("bad-cmd")
@patch("scripts.deploy_forge_app.subprocess.run")
def test_stamps_skill_name_env_var(self, mock_run):
"""Every command run by the deploy script carries the attribution env var."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
dfa.run_command("forge deploy")
env = mock_run.call_args[1].get("env")
self.assertIsNotNone(env)
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
if __name__ == "__main__":
unittest.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-app-builder", base={})
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
def test_preserves_base_environment(self):
env = forge_env("forge-app-builder", base={"PATH": "/usr/bin", "HOME": "/home/x"})
self.assertEqual(env["PATH"], "/usr/bin")
self.assertEqual(env["HOME"], "/home/x")
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
def test_defaults_to_current_process_environment(self):
# Without an explicit base, the current environment is copied (and not mutated).
import os
env = forge_env("forge-app-builder")
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-app-builder", 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-app-builder",
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-app-builder")
def test_ambient_wildcard_vars_pass_through(self):
# Wildcard vars the agent host already set must reach the CLI untouched.
env = forge_env(
"forge-app-builder",
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-app-builder")
def test_invalid_extra_value_is_dropped(self):
env = forge_env("forge-app-builder", extra={"run_id": "bad value!"}, base={})
self.assertNotIn("ATL_FORGE_ATTRIBUTION_RUN_ID", env)
self.assertEqual(env["ATL_FORGE_ATTRIBUTION_SKILL_NAME"], "forge-app-builder")
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)
def test_empty_value_is_dropped(self):
env = forge_env("", base={})
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
def test_non_string_value_is_dropped(self):
env = forge_env(None, base={}) # type: ignore[arg-type]
self.assertNotIn("ATL_FORGE_ATTRIBUTION_SKILL_NAME", env)
if __name__ == "__main__":
unittest.main()
"""
Tests for scripts/list_templates.py
"""
import json
import sys
import unittest
from unittest.mock import patch, MagicMock
from io import StringIO
from scripts import list_templates as lt
SAMPLE_TEMPLATES = [
{"name": "jira-issue-panel-ui-kit", "description": "Jira issue panel using UI Kit"},
{"name": "jira-dashboard-gadget", "description": "Jira dashboard gadget"},
{"name": "jira-service-management-portal-footer", "description": "JSM portal footer"},
{"name": "confluence-macro-ui-kit", "description": "Confluence macro using UI Kit"},
{"name": "bitbucket-pipeline-extension", "description": "Bitbucket pipeline extension"},
{"name": "compass-component-page", "description": "Compass component page"},
{"name": "rovo-agent", "description": "Rovo agent"},
{"name": "automation-rule", "description": "Automation rule"},
{"name": "dashboards-overview-gadget", "description": "Dashboards gadget"},
{"name": "teamwork-graph-extension", "description": "Teamwork Graph extension"},
{"name": "custom-app", "description": "Generic custom app"},
]
class TestFetchTemplates(unittest.TestCase):
@patch("scripts.list_templates.urllib.request.urlopen")
def test_fetch_templates_success(self, mock_urlopen):
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(SAMPLE_TEMPLATES).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_response
result = lt.fetch_templates()
self.assertEqual(len(result), len(SAMPLE_TEMPLATES))
self.assertEqual(result[0]["name"], "jira-issue-panel-ui-kit")
@patch("scripts.list_templates.urllib.request.urlopen")
def test_fetch_templates_network_error(self, mock_urlopen):
mock_urlopen.side_effect = Exception("Network error")
with self.assertRaises(SystemExit) as cm:
lt.fetch_templates()
self.assertEqual(cm.exception.code, 1)
class TestCategorizeTemplates(unittest.TestCase):
def test_jira_templates_categorized(self):
categories = lt.categorize_templates(SAMPLE_TEMPLATES)
self.assertIn("Jira", categories)
self.assertIn("jira-issue-panel-ui-kit", categories["Jira"])
self.assertIn("jira-dashboard-gadget", categories["Jira"])
def test_jsm_templates_separate_from_jira(self):
categories = lt.categorize_templates(SAMPLE_TEMPLATES)
self.assertIn("Jira Service Management", categories)
self.assertIn("jira-service-management-portal-footer", categories["Jira Service Management"])
self.assertNotIn("jira-service-management-portal-footer", categories.get("Jira", []))
def test_confluence_templates_categorized(self):
categories = lt.categorize_templates(SAMPLE_TEMPLATES)
self.assertIn("Confluence", categories)
self.assertIn("confluence-macro-ui-kit", categories["Confluence"])
def test_bitbucket_templates_categorized(self):
categories = lt.categorize_templates(SAMPLE_TEMPLATES)
self.assertIn("Bitbucket", categories)
self.assertIn("bitbucket-pipeline-extension", categories["Bitbucket"])
def test_rovo_templates_categorized(self):
categories = lt.categorize_templates(SAMPLE_TEMPLATES)
self.assertIn("Rovo", categories)
self.assertIn("rovo-agent", categories["Rovo"])
def test_other_category_for_unrecognized(self):
categories = lt.categorize_templates(SAMPLE_TEMPLATES)
self.assertIn("Other", categories)
self.assertIn("custom-app", categories["Other"])
def test_empty_categories_excluded(self):
# Only Jira templates — no Confluence category should appear
jira_only = [{"name": "jira-issue-panel-ui-kit", "description": ""}]
categories = lt.categorize_templates(jira_only)
self.assertNotIn("Confluence", categories)
def test_categories_are_sorted(self):
categories = lt.categorize_templates(SAMPLE_TEMPLATES)
for category, templates in categories.items():
self.assertEqual(templates, sorted(templates),
f"Templates in '{category}' are not sorted")
class TestValidateTemplate(unittest.TestCase):
def test_valid_template(self):
captured = StringIO()
with patch("sys.stdout", captured):
result = lt.validate_template("jira-issue-panel-ui-kit", SAMPLE_TEMPLATES)
self.assertTrue(result)
def test_invalid_template(self):
captured = StringIO()
with patch("sys.stdout", captured):
result = lt.validate_template("nonexistent-template", SAMPLE_TEMPLATES)
self.assertFalse(result)
def test_invalid_template_suggests_similar(self):
captured = StringIO()
with patch("sys.stdout", captured):
lt.validate_template("jira-panel", SAMPLE_TEMPLATES)
output = captured.getvalue()
# Should suggest jira templates since "jira" matches
self.assertIn("jira", output.lower())
def test_invalid_template_suggests_product_match(self):
captured = StringIO()
with patch("sys.stdout", captured):
lt.validate_template("confluence-nonexistent", SAMPLE_TEMPLATES)
output = captured.getvalue()
self.assertIn("confluence", output.lower())
class TestListTemplates(unittest.TestCase):
@patch("scripts.list_templates.fetch_templates", return_value=SAMPLE_TEMPLATES)
def test_list_templates_text_format(self, mock_fetch):
output = lt.list_templates(format="text")
self.assertIn("FORGE TEMPLATES", output)
self.assertIn("jira-issue-panel-ui-kit", output)
self.assertIn("confluence-macro-ui-kit", output)
@patch("scripts.list_templates.fetch_templates", return_value=SAMPLE_TEMPLATES)
def test_list_templates_json_format(self, mock_fetch):
output = lt.list_templates(format="json")
parsed = json.loads(output)
self.assertIn("templates", parsed)
self.assertIn("count", parsed)
self.assertEqual(parsed["count"], len(SAMPLE_TEMPLATES))
self.assertEqual(parsed["templates"], sorted(t["name"] for t in SAMPLE_TEMPLATES))
@patch("scripts.list_templates.fetch_templates", return_value=SAMPLE_TEMPLATES)
def test_list_templates_includes_source_url(self, mock_fetch):
output = lt.list_templates(format="text")
self.assertIn(lt.TEMPLATE_REGISTRY_URL, output)
if __name__ == "__main__":
unittest.main()
Related skills
FAQ
What does forge-app-builder do?
Guides building, deploying, troubleshooting, and installing Atlassian Forge apps — custom extensions built with the Forge CLI (forge create, forge deploy, forge install). Use when the use...
When should I use forge-app-builder?
Invoke when Guides building, deploying, troubleshooting, and installing Atlassian Forge apps — custom extensions built with the Forge CLI (forge create,.
Is forge-app-builder safe to install?
Review the Security Audits panel on this page before installing in production.