Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,42 @@ curl -X PUT \
-d '"org/repo1"'
```

### Setting up via the Forge CLI

Instead of using raw `curl` requests, you can use the `forge project-setup` CLI command to configure these properties. When running in automation, use the `--json` flag to retrieve structured mutation output:

```bash
# Set default repository and output changes as pretty-printed JSON
forge project-setup MYPROJ --default-repo org/repo1 --json
```

Output format on success:
```json
{
"project": "MYPROJ",
"mutations": {
"forge.default_repo": {
"operation": "set",
"value": "org/repo1"
}
}
}
```

This JSON output is easily consumable with tools like `jq`:
```bash
# Get the mutated value of a specific property using jq
forge project-setup MYPROJ --default-repo org/repo1 --json | jq '.mutations["forge.default_repo"].value'
# Output: "org/repo1"
```

### Error Handling in JSON Mode

When running with `--json`, if an error or exception occurs during execution:
- Standard output (`stdout`) is completely suppressed and remains empty.
- The error description is written directly to standard error (`stderr`).
- The command exits with a non-zero exit code (`1`).

Repository labels on managed tickets use `repo:<owner>/<repo>`. Forge validates
that assignment against the project's configured repositories before workspace
setup or implementation. A missing or invalid assignment blocks the workflow
Expand Down
133 changes: 112 additions & 21 deletions src/forge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,14 +528,29 @@ async def cmd_skills_update(_args: argparse.Namespace) -> int:

async def cmd_project_setup(args: argparse.Namespace) -> int:
"""Configure Jira project properties for Forge."""
import io
import json
import sys

from forge.integrations.jira.client import JiraClient

project_key = args.project_key.upper()
jira = JiraClient()
is_json = getattr(args, "json", False)

jira = None
original_stdout = sys.stdout
exit_code = 1
mutations = {}
current_references = []

try:
if is_json:
sys.stdout = io.StringIO()

jira = JiraClient()

# [AISOS-2527] Suppress human-readable stdout (silent success execution) during JSON mode.
# Any success or informational prints must be conditionalized with "if not getattr(args, 'json', False):"

def parse_repo(raw: str) -> str | dict:
if raw.startswith("{"):
Expand Down Expand Up @@ -601,7 +616,9 @@ def repo_name(repo: str | dict) -> str | None:
return 1

await jira.set_project_property(project_key, "forge.repos", parsed_repos)
print(f"[OK] forge.repos = {parsed_repos}")
mutations["forge.repos"] = {"operation": "set", "value": parsed_repos}
if not is_json:
print(f"[OK] forge.repos = {parsed_repos}")

# forge.default_repo
remove_default_repo = getattr(args, "remove_default_repo", False)
Expand All @@ -613,7 +630,9 @@ def repo_name(repo: str | dict) -> str | None:
return 1
if remove_default_repo:
await jira.delete_project_property(project_key, "forge.default_repo")
print("[OK] forge.default_repo removed")
mutations["forge.default_repo"] = {"operation": "remove", "value": None}
if not is_json:
print("[OK] forge.default_repo removed")
elif args.default_repo:
if "/" not in args.default_repo:
print(
Expand All @@ -622,7 +641,9 @@ def repo_name(repo: str | dict) -> str | None:
)
return 1
await jira.set_project_property(project_key, "forge.default_repo", args.default_repo)
print(f"[OK] forge.default_repo = {args.default_repo!r}")
mutations["forge.default_repo"] = {"operation": "set", "value": args.default_repo}
if not is_json:
print(f"[OK] forge.default_repo = {args.default_repo!r}")

# forge.prd_proposals_repo — opt-in / opt-out for PRD approval via GitHub PR
remove_prd_repo = getattr(args, "remove_prd_proposals_repo", False)
Expand All @@ -634,11 +655,15 @@ def repo_name(repo: str | dict) -> str | None:
return 1
if remove_prd_repo:
await jira.delete_project_property(project_key, "forge.prd_proposals_repo")
print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)")
mutations["forge.prd_proposals_repo"] = {"operation": "remove", "value": None}
if not is_json:
print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)")
elif args.prd_proposals_repo is not None:
if args.prd_proposals_repo == "":
await jira.delete_project_property(project_key, "forge.prd_proposals_repo")
print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)")
mutations["forge.prd_proposals_repo"] = {"operation": "remove", "value": None}
if not is_json:
print("[OK] forge.prd_proposals_repo removed (PRD approval via Jira labels)")
else:
if "/" not in args.prd_proposals_repo:
print(
Expand All @@ -649,7 +674,12 @@ def repo_name(repo: str | dict) -> str | None:
await jira.set_project_property(
project_key, "forge.prd_proposals_repo", args.prd_proposals_repo
)
print(f"[OK] forge.prd_proposals_repo = {args.prd_proposals_repo!r}")
mutations["forge.prd_proposals_repo"] = {
"operation": "set",
"value": args.prd_proposals_repo,
}
if not is_json:
print(f"[OK] forge.prd_proposals_repo = {args.prd_proposals_repo!r}")

# forge.prd_proposals_path — base directory for enhancement folders
remove_prd_path = getattr(args, "remove_prd_proposals_path", False)
Expand All @@ -661,15 +691,21 @@ def repo_name(repo: str | dict) -> str | None:
return 1
if remove_prd_path:
await jira.delete_project_property(project_key, "forge.prd_proposals_path")
print("[OK] forge.prd_proposals_path removed (reset to default: repo root)")
mutations["forge.prd_proposals_path"] = {"operation": "remove", "value": None}
if not is_json:
print("[OK] forge.prd_proposals_path removed (reset to default: repo root)")
elif args.prd_proposals_path is not None:
if args.prd_proposals_path == "":
await jira.delete_project_property(project_key, "forge.prd_proposals_path")
print("[OK] forge.prd_proposals_path removed (reset to default: repo root)")
mutations["forge.prd_proposals_path"] = {"operation": "remove", "value": None}
if not is_json:
print("[OK] forge.prd_proposals_path removed (reset to default: repo root)")
else:
path = args.prd_proposals_path.strip("/")
await jira.set_project_property(project_key, "forge.prd_proposals_path", path)
print(f"[OK] forge.prd_proposals_path = {path!r}")
mutations["forge.prd_proposals_path"] = {"operation": "set", "value": path}
if not is_json:
print(f"[OK] forge.prd_proposals_path = {path!r}")

# forge.skills — built from --add-skill flags and/or --skills-config JSON
skill_entries: list[dict] = []
Expand Down Expand Up @@ -724,7 +760,9 @@ def repo_name(repo: str | dict) -> str | None:
return 1
if remove_skills:
await jira.delete_project_property(project_key, "forge.skills")
print("[OK] forge.skills removed")
mutations["forge.skills"] = {"operation": "remove", "value": None}
if not is_json:
print("[OK] forge.skills removed")
elif skill_entries:
from forge.skills.models import SkillEntry

Expand All @@ -738,7 +776,9 @@ def repo_name(repo: str | dict) -> str | None:
skill_entries = validated

await jira.set_project_property(project_key, "forge.skills", skill_entries)
print(f"[OK] forge.skills = {len(skill_entries)} entries")
mutations["forge.skills"] = {"operation": "set", "value": skill_entries}
if not is_json:
print(f"[OK] forge.skills = {len(skill_entries)} entries")

# forge.model_policy — exact per-stage project overrides. User
# workstations validate syntax only; the Forge runtime owns and
Expand All @@ -764,7 +804,9 @@ def repo_name(repo: str | dict) -> str | None:
return 1
if clear_model_policy:
await jira.delete_project_property(project_key, "forge.model_policy")
print("[OK] forge.model_policy deleted")
mutations["forge.model_policy"] = {"operation": "remove", "value": None}
if not is_json:
print("[OK] forge.model_policy deleted")
elif model_policy_arg:
try:
model_policy = json.loads(model_policy_arg)
Expand Down Expand Up @@ -820,10 +862,14 @@ def repo_name(repo: str | dict) -> str | None:
return 1
if model_policy:
await jira.set_project_property(project_key, "forge.model_policy", model_policy)
print(f"[OK] forge.model_policy = {len(model_policy)} overrides")
mutations["forge.model_policy"] = {"operation": "set", "value": model_policy}
if not is_json:
print(f"[OK] forge.model_policy = {len(model_policy)} overrides")
else:
await jira.delete_project_property(project_key, "forge.model_policy")
print("[OK] forge.model_policy deleted (no overrides remain)")
mutations["forge.model_policy"] = {"operation": "remove", "value": None}
if not is_json:
print("[OK] forge.model_policy deleted (no overrides remain)")

if model_all:
from forge.models.model_policy import ModelTarget
Expand All @@ -839,10 +885,14 @@ def repo_name(repo: str | dict) -> str | None:
print(f"Error: invalid model default: {exc}", file=sys.stderr)
return 1
await jira.set_project_property(project_key, "forge.model_default", model_default)
print("[OK] forge.model_default set")
mutations["forge.model_default"] = {"operation": "set", "value": model_default}
if not is_json:
print("[OK] forge.model_default set")
elif clear_model_default:
await jira.delete_project_property(project_key, "forge.model_default")
print("[OK] forge.model_default deleted")
mutations["forge.model_default"] = {"operation": "remove", "value": None}
if not is_json:
print("[OK] forge.model_default deleted")

# forge.references property processing
# --ref-description and its deprecated alias --description share the same
Expand Down Expand Up @@ -892,8 +942,10 @@ def repo_name(repo: str | dict) -> str | None:
references_updated = True
if references_updated:
await jira.set_project_references(project_key, current_references)
print(f"[OK] forge.references = {current_references}")
if list_references:
mutations["forge.references"] = {"operation": "set", "value": current_references}
if not is_json:
print(f"[OK] forge.references = {current_references}")
if list_references and not is_json:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With forge project-setup MYPROJ --list-references --json, this branch skips the only reference-list output and the final JSON contains mutations: {}. Please include the requested references in the JSON response (without presenting a read as a mutation), and test the list-only case.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forge implemented this feedback in the latest pushed revision.

print(f"Standing references for project {project_key}:")
if not current_references:
print(" (none)")
Expand Down Expand Up @@ -927,7 +979,7 @@ def repo_name(repo: str | dict) -> str | None:
list_references,
]
):
print(
msg = (
"Nothing to set — specify at least one of: "
"--repo, --add-repo, --remove-repo, --default-repo, --remove-default-repo, "
"--prd-proposals-repo, --remove-prd-proposals-repo, "
Expand All @@ -938,15 +990,35 @@ def repo_name(repo: str | dict) -> str | None:
", --clear-model-default"
", --add-reference, --remove-reference, --list-references"
)
if is_json:
print(msg, file=sys.stderr)
else:
print(msg)
return 1

if jira is not None:
await jira.close()
jira = None
exit_code = 0
return 0

except Exception as e:
print(f"Error: {e}", file=sys.stderr)
exit_code = 1
return 1
finally:
await jira.close()
if is_json:
sys.stdout = original_stdout
if exit_code == 0:
out_payload = {"project": project_key, "mutations": mutations}
if getattr(args, "list_references", False):
out_payload["references"] = current_references
print(json.dumps(out_payload, indent=2))
if jira is not None:
import contextlib

with contextlib.suppress(Exception):
await jira.close()


async def cmd_get_config(args: argparse.Namespace) -> int:
Expand Down Expand Up @@ -1824,6 +1896,20 @@ def main(argv: list[str] | None = None) -> int:
# Set skills from a JSON array
forge project-setup MYPROJ \\
--skills-config '[{"source":"https://github.com/acme/skills","ref":"v1.0","path":""}]'

# Output mutation details as structured JSON for automation and parse with jq
forge project-setup MYPROJ --default-repo owner/repo1 --json | jq .

Representative output:
{
"project": "MYPROJ",
"mutations": {
"forge.default_repo": {
"operation": "set",
"value": "owner/repo1"
}
}
}
""",
)
setup_parser.add_argument("project_key", help="Jira project key (e.g., MYPROJ)")
Expand Down Expand Up @@ -1970,6 +2056,11 @@ def main(argv: list[str] | None = None) -> int:
action="store_true",
help="List all project-level standing references.",
)
setup_parser.add_argument(
"--json",
action="store_true",
help="Output mutation details as structured JSON for scripting",
)

# get-config command
get_config_parser = subparsers.add_parser(
Expand Down
Loading
Loading