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
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,27 @@ podman rm $(podman ps -a --filter name=forge- -q)

Skip-gate commands are only active at CI stages (`wait_for_ci_gate`, `ci_evaluator`, `attempt_ci_fix`). Rebase works from any workflow stage.

## PRD Approval via GitHub PR

Opt-in per project via Jira project property. When configured, Forge opens a PR in the proposals repo instead of posting the PRD to Jira. Reviewer feedback triggers regeneration; merging the PR signals approval.

**Per-project config (Jira project property):**

| Property | Example | Description |
|----------|---------|-------------|
| `forge.prd_proposals_repo` | `org/enhancement-proposals` | Enables PR-based PRD approval for this project |

Set via: `jira project-property set <PROJECT> forge.prd_proposals_repo "owner/repo"`

**Global fallbacks (`.env`, used when `FORGE_REQUIRE_PROJECT_CONFIG=false`):**

| Setting | Default | Description |
|---------|---------|-------------|
| `PRD_PROPOSALS_REPO` | (empty) | Fallback `owner/repo` for projects without the property |
| `PRD_PROPOSALS_PATH` | `proposals` | Directory in the repo for PRD files |

Branch naming convention: `forge/prd/{ticket-key}` (e.g., `forge/prd/proj-123`).

## Container Execution

Tasks are implemented in ephemeral Podman containers:
Expand Down
15 changes: 15 additions & 0 deletions src/forge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ def atlassian_auth_base64(self) -> str:
),
)

# PRD Approval Configuration (global fallbacks — per-project config via
# Jira project property forge.prd_proposals_repo takes precedence)
prd_proposals_repo: str = Field(
default="",
description=(
"Global fallback GitHub repo (owner/repo) for enhancement proposals. "
"Per-project config via Jira project property forge.prd_proposals_repo "
"takes precedence. Only used when forge_require_project_config is False."
),
)
prd_proposals_path: str = Field(
default="proposals",
description="Directory in the proposals repo where PRD files are stored.",
)

@property
def known_repos(self) -> list[str]:
"""Get list of known repositories."""
Expand Down
111 changes: 111 additions & 0 deletions src/forge/integrations/github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,3 +695,114 @@ async def get_fork_owner(self) -> str:
return self.settings.github_fork_owner
user = await self.get_authenticated_user()
return user["login"]

async def create_branch(
self,
owner: str,
repo: str,
branch_name: str,
base: str = "main",
) -> dict[str, Any]:
"""Create a new branch from a base ref.

Args:
owner: Repository owner.
repo: Repository name.
branch_name: New branch name.
base: Base branch to branch from.

Returns:
API response with ref details.
"""
client = await self._get_client()

ref_response = await client.get(f"/repos/{owner}/{repo}/git/ref/heads/{base}")
ref_response.raise_for_status()
sha = ref_response.json()["object"]["sha"]

try:
response = await client.post(
f"/repos/{owner}/{repo}/git/refs",
json={"ref": f"refs/heads/{branch_name}", "sha": sha},
)
response.raise_for_status()
data = response.json()
logger.info(f"Created branch {branch_name} in {owner}/{repo}")
return data
except httpx.HTTPStatusError as e:
if e.response.status_code == 422:
logger.info(f"Branch {branch_name} already exists in {owner}/{repo}")
return {"ref": f"refs/heads/{branch_name}", "object": {"sha": sha}}
raise

async def create_or_update_file(
self,
owner: str,
repo: str,
path: str,
content: str,
message: str,
branch: str,
sha: str | None = None,
) -> dict[str, Any]:
"""Create or update a file via the Contents API.

Args:
owner: Repository owner.
repo: Repository name.
path: File path in the repository.
content: File content (plain text, will be base64-encoded).
message: Commit message.
branch: Target branch.
sha: Existing file SHA (required for updates, omit for creates).

Returns:
API response with content details.
"""
import base64 as b64

client = await self._get_client()
body: dict[str, Any] = {
"message": message,
"content": b64.b64encode(content.encode()).decode(),
"branch": branch,
}
if sha:
body["sha"] = sha

response = await client.put(f"/repos/{owner}/{repo}/contents/{path}", json=body)
response.raise_for_status()
data = response.json()
logger.info(f"{'Updated' if sha else 'Created'} file {path} on {branch} in {owner}/{repo}")
return data

async def get_file_contents(
self,
owner: str,
repo: str,
path: str,
ref: str,
) -> dict[str, Any] | None:
"""Get file contents and metadata from a repository.

Args:
owner: Repository owner.
repo: Repository name.
path: File path in the repository.
ref: Git ref (branch, tag, or SHA).

Returns:
File metadata including sha, or None if not found.
"""
client = await self._get_client()
try:
response = await client.get(
f"/repos/{owner}/{repo}/contents/{path}",
params={"ref": ref},
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
raise
23 changes: 23 additions & 0 deletions src/forge/integrations/jira/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,29 @@ async def get_project_default_repo(self, project_key: str) -> str:
logger.info(f"Project {project_key}: default repo: {value}")
return value

async def get_prd_proposals_repo(self, project_key: str) -> str | None:
"""Fetch the forge.prd_proposals_repo project property.

When set, enables PRD approval via GitHub PR for this project.
The value is a GitHub repo in "owner/repo" format.

Args:
project_key: The Jira project key.

Returns:
Repo string in "owner/repo" format, or None if not configured.
"""
value = await self.get_project_property(project_key, "forge.prd_proposals_repo")
if value is None:
return None
if not isinstance(value, str) or "/" not in value:
logger.warning(
f"forge.prd_proposals_repo for project {project_key} is malformed: {value!r}"
)
return None
logger.info(f"Project {project_key}: PRD proposals repo: {value}")
return value

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

Expand Down
121 changes: 120 additions & 1 deletion src/forge/orchestrator/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from forge.integrations.github.client import GitHubClient
from forge.integrations.jira.client import JiraClient
from forge.models.events import EventSource
from forge.models.workflow import TicketType
from forge.models.workflow import ForgeLabel, TicketType
from forge.orchestrator.checkpointer import get_checkpointer, get_ticket_from_pr_index
from forge.queue.consumer import QueueConsumer
from forge.queue.models import QueueMessage
Expand All @@ -39,6 +39,8 @@ def _is_workflow_errored(state: dict) -> bool:
return not state.get("is_paused") and state.get("last_error") is not None


_PRD_GATE_NODES = ("prd_approval_gate", "generate_prd", "regenerate_prd")

# Matches >option N anywhere in comment (case-insensitive, first match wins)
# Supports both start-of-line usage (>option 2) and in-prose usage (let's go with >option 2)
_OPTION_PATTERN = re.compile(r"(?mi)>option\s+(\d+)")
Expand Down Expand Up @@ -150,6 +152,23 @@ async def _resolve_ticket_from_pr_index(self, message: QueueMessage) -> QueueMes

return message

def _is_prd_pr_event(self, message: QueueMessage, current_state: dict[str, Any]) -> bool:
"""Check if a GitHub event targets the PRD proposals PR."""
if message.source != EventSource.GITHUB:
return False
prd_pr_number = current_state.get("prd_pr_number")
prd_pr_repo = current_state.get("prd_pr_repo")
if not prd_pr_number or not prd_pr_repo:
return False

payload = message.payload
repo_full = payload.get("repository", {}).get("full_name", "")
event_pr_number = payload.get("pull_request", {}).get("number") or payload.get(
"issue", {}
).get("number")

return repo_full == prd_pr_repo and event_pr_number == prd_pr_number

async def _process_workflow(self, message: QueueMessage) -> None:
"""Process a message through the workflow.

Expand Down Expand Up @@ -558,8 +577,16 @@ async def _handle_resume_event(
# Check for rejection comment (contains feedback)
# Determine if comment is on Epic/Task (child) vs Feature (parent)
# based on current workflow phase
#
# Skip Jira comment feedback when PRD review happens on a GitHub PR —
# feedback should come from the PR, not Jira.
comment_ticket_key = None
comment_ticket_type = None # "epic" or "task"
if comment and current_state.get("prd_pr_number") and current_node in _PRD_GATE_NODES:
logger.info(
f"Ignoring Jira comment for {message.ticket_key} — PRD review is on GitHub PR"
)
comment = {}
if comment:
comment_body = comment.get("body", "")
# Extract text from ADF if needed
Expand Down Expand Up @@ -690,6 +717,98 @@ async def _handle_resume_event(
else:
logger.info(f"Detected Feature-level comment: {feedback[:100]}...")

# GitHub events targeting the PRD proposals PR — handled at prd_approval_gate.
# Merge = approval. Review with feedback = revision. Comment = feedback/question.
if self._is_prd_pr_event(message, current_state) and current_node in _PRD_GATE_NODES:
event = message.event_type

if "pull_request_review" in event:
review = payload.get("review", {})
review_state = review.get("state", "").lower()
review_body = review.get("body", "") or ""

# Merge-only approval: review approval is intentionally ignored
if review_state in ("changes_requested", "commented"):
repo_full = payload.get("repository", {}).get("full_name", "")
pr_number = payload.get("pull_request", {}).get("number")
inline_comments = []
if repo_full and pr_number:
_owner, _repo = repo_full.split("/", 1)
gh = GitHubClient()
try:
inline_comments = await gh.get_pull_request_review_comments(
_owner, _repo, pr_number
)
finally:
await gh.close()

parts = []
if review_body.strip():
parts.append(review_body.strip())
if inline_comments:
inline_text = "\n\n".join(
f"**{c['path']}** (line {c['position']}):\n{c['body']}"
for c in inline_comments
)
parts.append(f"Inline comments:\n{inline_text}")

if parts:
feedback = "\n\n".join(parts)
is_rejected = True
logger.info(
f"PRD PR review ({review_state}) for {message.ticket_key}: "
f"body={'yes' if review_body.strip() else 'no'}, "
f"inline={len(inline_comments)}"
)
else:
logger.info(
f"PRD PR review ({review_state}) for {message.ticket_key} "
"with no content — ignoring"
)
return current_state

elif "pull_request" in event and payload.get("pull_request", {}).get("merged") is True:
is_approved = True
logger.info(f"PRD PR merged for {message.ticket_key}")
# Sync Jira label
jira = JiraClient()
try:
await jira.set_workflow_label(message.ticket_key, ForgeLabel.PRD_APPROVED)
finally:
await jira.close()

elif "issue_comment" in event:
gh_comment = payload.get("comment", {})
comment_body = gh_comment.get("body", "").strip()
sender_login = payload.get("sender", {}).get("login", "")

if comment_body and sender_login:
# Skip self-comments
gh = GitHubClient()
try:
forge_user = await gh.get_authenticated_user()
forge_login = forge_user.get("login", "")
finally:
await gh.close()

if sender_login == forge_login:
logger.debug(f"Ignoring self-comment on PRD PR for {message.ticket_key}")
return current_state

comment_type = classify_comment(comment_body)
if comment_type == CommentType.QUESTION:
is_question = True
feedback = comment_body
logger.info(
f"PRD PR question for {message.ticket_key}: {comment_body[:100]}..."
)
else:
is_rejected = True
feedback = comment_body
logger.info(
f"PRD PR feedback for {message.ticket_key}: {comment_body[:100]}..."
)

# GitHub pull_request_review events — handled when at human_review_gate.
# A review submission is the primary signal for the human review stage.
if (
Expand Down
12 changes: 12 additions & 0 deletions src/forge/workflow/feature/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ class FeatureState(
generation_context: dict[str, Any] # Stored context from generation
is_question: bool # Current comment is a question (not feedback)

# PRD PR tracking (enhancement proposal flow)
prd_pr_url: str | None
prd_pr_number: int | None
prd_pr_repo: str | None
prd_pr_branch: str | None
prd_pr_file_path: str | None


def create_initial_feature_state(ticket_key: str, **kwargs: Any) -> FeatureState:
"""Create initial state for a new Feature workflow run."""
Expand Down Expand Up @@ -103,6 +110,11 @@ def create_initial_feature_state(ticket_key: str, **kwargs: Any) -> FeatureState
"qa_history": [],
"generation_context": {},
"is_question": False,
"prd_pr_url": None,
"prd_pr_number": None,
"prd_pr_repo": None,
"prd_pr_branch": None,
"prd_pr_file_path": None,
}

# Merge with kwargs, letting kwargs override defaults
Expand Down
Loading
Loading