Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a672d4a
feat: add PRD approval mode configuration settings
ItzikEzra-rh Jun 14, 2026
61d79f5
feat: add GitHub Content API methods for file and branch operations
ItzikEzra-rh Jun 14, 2026
aedc721
feat: add PRD PR tracking fields to FeatureState
ItzikEzra-rh Jun 14, 2026
bf79915
feat: create and update PRD PRs in enhancement proposals repo
ItzikEzra-rh Jun 14, 2026
2a269cb
feat: handle PRD PR events in worker for feedback and merge-based app…
ItzikEzra-rh Jun 14, 2026
de92ec9
docs: add PRD PR approval configuration and update comment classifier…
ItzikEzra-rh Jun 14, 2026
47cee64
style: auto-format worker and prd_generation with ruff
ItzikEzra-rh Jun 14, 2026
a1483b5
refactor: make PRD proposals repo a per-project opt-in via Jira property
ItzikEzra-rh Jun 14, 2026
ef0fd51
fix: ignore Jira comments when PRD review is on GitHub PR
ItzikEzra-rh Jun 14, 2026
ddf0ee7
fix: move ForgeLabel import to top, store file path in state
ItzikEzra-rh Jun 14, 2026
c95467e
fix: scope review comments to current review and use file line numbers
ItzikEzra-rh Jun 16, 2026
9abbf8d
Merge remote-tracking branch 'origin/main' into feat/prd-via-github-pr
ItzikEzra-rh Jun 17, 2026
0b8f244
fix: align test with ! prefix requirement for Jira feedback comments
ItzikEzra-rh Jun 17, 2026
7a4a10f
fix: post Q&A answers to GitHub PR and use placeholder PR body
ItzikEzra-rh Jun 17, 2026
b820891
Merge branch 'main' into feat/prd-via-github-pr
eshulman2 Jun 17, 2026
b53002f
fix: render PR link as hyperlink in Jira and copy approved PRD to des…
eshulman2 Jun 17, 2026
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 @@ -134,6 +134,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
136 changes: 136 additions & 0 deletions src/forge/integrations/github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,31 @@ async def get_pull_request_review_comments(
response.raise_for_status()
return response.json()

async def get_review_comments(
self, owner: str, repo: str, pr_number: int, review_id: int
) -> list[dict[str, Any]]:
"""Get inline comments from a specific PR review.

Scoped to a single review submission, avoiding stale comments
from prior review rounds.

Args:
owner: Repository owner.
repo: Repository name.
pr_number: Pull request number.
review_id: The review ID from the webhook payload.

Returns:
List of comment dicts with path, line, and body.
"""
client = await self._get_client()
response = await client.get(
f"/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/comments",
params={"per_page": 100},
)
response.raise_for_status()
return response.json()

async def create_issue_comment(
self, owner: str, repo: str, issue_number: int, body: str
) -> dict[str, Any]:
Expand Down Expand Up @@ -695,3 +720,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
125 changes: 125 additions & 0 deletions src/forge/orchestrator/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -159,6 +161,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 @@ -580,8 +599,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 @@ -717,6 +744,104 @@ 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")
review_id = review.get("id")
inline_comments: list[dict[str, Any]] = []
if repo_full and pr_number and review_id:
_owner, _repo = repo_full.split("/", 1)
gh = GitHubClient()
try:
inline_comments = await gh.get_review_comments(
_owner, _repo, pr_number, review_id
)
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.get('line') or c.get('original_line', '?')}):\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}")
jira = JiraClient()
try:
await jira.set_workflow_label(message.ticket_key, ForgeLabel.PRD_APPROVED)
prd_content = current_state.get("prd_content", "")
if prd_content:
await jira.update_description(message.ticket_key, prd_content)
logger.info(
f"Copied approved PRD to Jira description for {message.ticket_key}"
)
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
Loading
Loading