Skip to content
Open
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
132 changes: 117 additions & 15 deletions src/forge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,20 +636,84 @@ async def cmd_project_setup(args: argparse.Namespace) -> int:
await jira.set_project_property(project_key, "forge.skills", skill_entries)
print(f"[OK] forge.skills = {len(skill_entries)} entries")

# forge.references property processing
ref_desc = getattr(args, "ref_description", None)
ref_desc_arg_name = "--ref-description"
if ref_desc is None:
ref_desc = getattr(args, "description", None)
ref_desc_arg_name = "--description"

add_reference = getattr(args, "add_reference", None)
remove_reference = getattr(args, "remove_reference", None)
list_references = getattr(args, "list_references", False)

if ref_desc and (not add_reference or len(ref_desc) != len(add_reference)):
print(
f"Error: {ref_desc_arg_name} requires matching number of --add-reference items.",
file=sys.stderr,
)
return 1

from forge.workflow.utils.references import normalize_url

references_updated = False
current_references = await jira.get_project_references(project_key)

if add_reference:
for i, url in enumerate(add_reference):
desc = ref_desc[i] if ref_desc and i < len(ref_desc) else ""
# Normalise URL for lookup
norm_url = normalize_url(url)
existing = next(
(r for r in current_references if normalize_url(r["url"]) == norm_url),
None,
)
if existing:
existing["description"] = desc
existing["url"] = norm_url
else:
current_references.append({"url": norm_url, "description": desc})
references_updated = True

if remove_reference:
for url in remove_reference:
norm_remove_url = normalize_url(url)
current_references = [
r for r in current_references if normalize_url(r["url"]) != norm_remove_url
]
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:
print(f"Standing references for project {project_key}:")
if not current_references:
print(" (none)")
else:
for ref in current_references:
desc_str = f" - {ref.get('description')}" if ref.get("description") else ""
print(f" {ref.get('url')}{desc_str}")

if not any(
[
args.repo,
args.default_repo,
args.prd_proposals_repo is not None,
args.prd_proposals_path is not None,
args.skills_config,
args.add_skill,
getattr(args, "repo", None),
getattr(args, "default_repo", None),
getattr(args, "prd_proposals_repo", None) is not None,
getattr(args, "prd_proposals_path", None) is not None,
getattr(args, "skills_config", None),
getattr(args, "add_skill", None),
add_reference,
remove_reference,
list_references,
]
):
print(
"Nothing to set — specify at least one of: "
"--repo, --default-repo, --prd-proposals-repo, "
"--prd-proposals-path, --skills-config, --add-skill"
"--prd-proposals-path, --skills-config, --add-skill, "
"--add-reference, --remove-reference, --list-references"
)
return 1

Expand Down Expand Up @@ -680,7 +744,8 @@ async def cmd_get_config(args: argparse.Namespace) -> int:
discovered_keys = await jira.list_project_properties(project_key)
except httpx.HTTPStatusError as e:
print(
f"Error: Jira API request failed for project '{project_key}': {e}", file=sys.stderr
f"Error: Jira API request failed for project '{project_key}': {e}",
file=sys.stderr,
)
return 1
except Exception as e:
Expand Down Expand Up @@ -764,7 +829,10 @@ async def cmd_get_config(args: argparse.Namespace) -> int:
"source": "global",
}
else:
effective_config["forge.repos"] = {"value": None, "source": "unset/required"}
effective_config["forge.repos"] = {
"value": None,
"source": "unset/required",
}

# 2. forge.default_repo
default_repo_val = project_properties.get("forge.default_repo")
Expand All @@ -781,7 +849,10 @@ async def cmd_get_config(args: argparse.Namespace) -> int:
"source": "global" if val else "unset/required",
}
else:
effective_config["forge.default_repo"] = {"value": None, "source": "unset/required"}
effective_config["forge.default_repo"] = {
"value": None,
"source": "unset/required",
}

# 3. forge.prd_proposals_repo
prd_repo_val = project_properties.get("forge.prd_proposals_repo")
Expand Down Expand Up @@ -825,14 +896,20 @@ async def cmd_get_config(args: argparse.Namespace) -> int:
# 5. forge.skills
skills_val = project_properties.get("forge.skills")
if skills_val is not None:
effective_config["forge.skills"] = {"value": skills_val, "source": "project"}
effective_config["forge.skills"] = {
"value": skills_val,
"source": "project",
}
else:
effective_config["forge.skills"] = {"value": None, "source": "unset"}

# 6. forge.references
refs_val = project_properties.get("forge.references")
if refs_val is not None:
effective_config["forge.references"] = {"value": refs_val, "source": "project"}
effective_config["forge.references"] = {
"value": refs_val,
"source": "project",
}
else:
effective_config["forge.references"] = {"value": None, "source": "unset"}

Expand Down Expand Up @@ -871,9 +948,9 @@ async def cmd_get_config(args: argparse.Namespace) -> int:
"GITHUB_KNOWN_REPOS": settings.known_repos,
"GITHUB_DEFAULT_REPO": settings.github_default_repo or None,
"PRD_PROPOSALS_REPO": settings.prd_proposals_repo or None,
"PRD_PROPOSALS_PATH": settings.prd_proposals_path.strip("/")
if settings.prd_proposals_path
else None,
"PRD_PROPOSALS_PATH": (
settings.prd_proposals_path.strip("/") if settings.prd_proposals_path else None
),
}
output_data = {
"project": project_key,
Expand Down Expand Up @@ -1253,6 +1330,31 @@ def main(argv: list[str] | None = None) -> int:
metavar="JSON",
help="Full forge.skills value as a JSON array of SkillEntry objects",
)
setup_parser.add_argument(
"--add-reference",
action="append",
metavar="URL",
help="Add a project-level standing reference by its URL (repeatable).",
)
setup_parser.add_argument(
"--ref-description",
"--description",

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.

Nit: Arg name is overly generic

--description as a top-level project-setup argument could collide with future options. --ref-description would be more specific and self-documenting about its pairing with --add-reference.

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.

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 verified this feedback; no additional code change was needed.

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.

Thank you! The top-level setup argument has been renamed to --ref-description as suggested, with --description retained as a deprecated alias.

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.

Thank you! The top-level setup argument has been renamed to --ref-description as suggested, with --description retained as a deprecated alias.

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.

Thank you! The top-level setup argument has been renamed to --ref-description as suggested, with --description retained as a deprecated alias, which is already fully implemented and verified.

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.

Thank you! The top-level setup argument has been renamed to --ref-description as suggested, with --description retained as a deprecated alias, which is already fully implemented and verified.

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.

Thank you! The top-level setup argument has been renamed to --ref-description as suggested, with --description retained as a deprecated alias, which is already fully implemented and verified.

dest="ref_description",
action="append",
metavar="TEXT",
help="Description for the standing reference. Positionally pairs with --add-reference flags. (Note: --description is a deprecated alias)",
)
setup_parser.add_argument(
"--remove-reference",
action="append",
metavar="URL",
help="Remove a project-level standing reference by its URL (repeatable).",
)
setup_parser.add_argument(
"--list-references",
action="store_true",
help="List all project-level standing references.",
)

# get-config command
get_config_parser = subparsers.add_parser(
Expand Down
20 changes: 20 additions & 0 deletions src/forge/integrations/jira/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,26 @@ async def get_proposals_path(self, project_key: str) -> str | None:
logger.info(f"Project {project_key}: proposals path: {value!r}")
return value

async def get_project_references(self, project_key: str) -> list[dict[str, str]]:
"""Fetch the forge.references project property.

Returns:
List of reference dicts, e.g., [{"url": "https://...", "description": "..."}]
"""
value = await self.get_project_property(project_key, "forge.references")
if value is None:
return []
if not isinstance(value, list):
logger.warning(f"forge.references for project {project_key} is malformed: {value!r}")
return []
return [ref for ref in value if isinstance(ref, dict) and "url" in ref]

async def set_project_references(
self, project_key: str, references: list[dict[str, str]]
) -> None:
"""Set the forge.references project property."""
await self.set_project_property(project_key, "forge.references", references)

async def get_skills_config(self, project_key: str) -> list[SkillEntry] | None:
"""Fetch and parse the forge.skills project property.

Expand Down
17 changes: 13 additions & 4 deletions src/forge/workflow/nodes/epic_decomposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from forge.workflow.utils import update_state_timestamp
from forge.workflow.utils.jira_status import post_status_comment
from forge.workflow.utils.qa_summary import post_qa_summary_if_needed
from forge.workflow.utils.references import fetch_and_inject_references

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -106,7 +107,11 @@ async def decompose_epics(state: WorkflowState) -> WorkflowState:
jira, ticket_key, _missing_repo_config_comment(project_key)
)
await jira.set_workflow_label(ticket_key, ForgeLabel.BLOCKED)
return {**state, "last_error": str(e), "current_node": "decompose_epics"}
return {
**state,
"last_error": str(e),
"current_node": "decompose_epics",
}
logger.warning(f"Project {project_key}: {e} — falling back to GITHUB_KNOWN_REPOS")
for repo in settings.known_repos:
available_repos.add(repo)
Expand All @@ -127,8 +132,10 @@ async def decompose_epics(state: WorkflowState) -> WorkflowState:
"feedback": state.get("feedback_comment", ""),
}

spec_content_with_refs = await fetch_and_inject_references(state, jira, spec_content)

# Generate Epic breakdown using the configured LLM backend - primary operation
epics_data = await agent.generate_epics(spec_content, context)
epics_data = await agent.generate_epics(spec_content_with_refs, context)

if not epics_data:
logger.warning(f"No Epics generated for {ticket_key}")
Expand Down Expand Up @@ -220,7 +227,7 @@ async def decompose_epics(state: WorkflowState) -> WorkflowState:
"revision_requested": False,
"current_epic_key": None,
"current_node": "plan_approval_gate",
"last_error": f"Partial Jira failure: {jira_error}" if jira_error else None,
"last_error": (f"Partial Jira failure: {jira_error}" if jira_error else None),
}
)
else:
Expand Down Expand Up @@ -329,9 +336,11 @@ async def update_single_epic(state: WorkflowState) -> WorkflowState:
epic_issue = await jira.get_issue(epic_key)
original_plan = epic_issue.description or ""

original_plan_with_refs = await fetch_and_inject_references(state, jira, original_plan)

# Regenerate plan with feedback
new_plan = await agent.regenerate_with_feedback(
original_content=original_plan,
original_content=original_plan_with_refs,
feedback=feedback,
content_type="epic",
ticket_key=ticket_key,
Expand Down
3 changes: 3 additions & 0 deletions src/forge/workflow/nodes/implementation.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from forge.workflow.nodes.workspace_setup import prepare_workspace
from forge.workflow.utils import merge_review_exhaustion, update_state_timestamp
from forge.workflow.utils.jira_status import post_status_comment
from forge.workflow.utils.references import fetch_and_inject_references
from forge.workspace.git_ops import GitOperations

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -194,6 +195,8 @@ async def implement_task(state: WorkflowState) -> WorkflowState:
guardrails=guardrails,
)

full_description = await fetch_and_inject_references(state, jira, full_description)

# Run implementation in container sandbox
runner = ContainerRunner(settings)

Expand Down
9 changes: 8 additions & 1 deletion src/forge/workflow/nodes/plan_bug_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@
from forge.prompts import load_prompt
from forge.sandbox import ContainerRunner
from forge.workflow.bug.state import BugState
from forge.workflow.utils import merge_review_exhaustion, set_paused, update_state_timestamp
from forge.workflow.utils import (
merge_review_exhaustion,
set_paused,
update_state_timestamp,
)
from forge.workflow.utils.jira_status import post_status_comment
from forge.workflow.utils.references import fetch_and_inject_references

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -132,6 +137,8 @@ async def _run_plan_container(
known_repos="\n".join(known_repos),
)

task_description = await fetch_and_inject_references(state, jira, task_description)

with tempfile.TemporaryDirectory() as tmpdir:
workspace_path = Path(tmpdir)
runner = ContainerRunner(settings)
Expand Down
23 changes: 15 additions & 8 deletions src/forge/workflow/nodes/prd_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from forge.workflow.utils import update_state_timestamp
from forge.workflow.utils.jira_status import post_status_comment
from forge.workflow.utils.proposal_review_threads import reply_to_proposal_decisions
from forge.workflow.utils.references import fetch_and_inject_references

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -146,6 +147,8 @@ async def generate_prd(state: WorkflowState) -> WorkflowState:
"current_node": "generate_prd",
}

raw_requirements = await fetch_and_inject_references(state, jira, raw_requirements)

# Build context from issue metadata
context: dict[str, Any] = {
"ticket_key": ticket_key,
Expand Down Expand Up @@ -209,7 +212,7 @@ async def generate_prd(state: WorkflowState) -> WorkflowState:
"prd_content": prd_content,
"generation_context": generation_context,
"current_node": "prd_approval_gate",
"last_error": f"PRD publish pending: {jira_error}" if jira_error else None,
"last_error": (f"PRD publish pending: {jira_error}" if jira_error else None),
}
)
if prd_pr_result:
Expand Down Expand Up @@ -260,9 +263,11 @@ async def regenerate_prd_with_feedback(state: WorkflowState) -> WorkflowState:
agent = ForgeAgent()

try:
original_prd_with_refs = await fetch_and_inject_references(state, jira, original_prd)

# Regenerate PRD with feedback
new_prd = await agent.regenerate_with_feedback(
original_content=original_prd,
original_content=original_prd_with_refs,
feedback=feedback,
content_type="prd",
ticket_key=ticket_key,
Expand Down Expand Up @@ -316,12 +321,14 @@ async def regenerate_prd_with_feedback(state: WorkflowState) -> WorkflowState:
if state.get("automated_review_revision_pending"):
automated_review_revision_count += 1
proposal_review_decisions = [
{
**decision,
"status": "addressed",
}
if decision.get("disposition") in ("accept", "uncertain")
else decision
(
{
**decision,
"status": "addressed",
}
if decision.get("disposition") in ("accept", "uncertain")
else decision
)
for decision in state.get("proposal_review_decisions", [])
]

Expand Down
Loading
Loading