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
13 changes: 9 additions & 4 deletions src/forge/orchestrator/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,15 +467,15 @@ async def _handle_resume_event(

current_node = current_state.get("current_node", "")

# GitHub check_run/check_suite events are the explicit signal for wait_for_ci_gate.
# GitHub check_run/check_suite events are the explicit signal for CI evaluation.
# They don't carry Jira labels or comments, so handle them before the label loop.
# For check_suite and check_run events (both real GitHub webhooks and poller
# forwarded ones), only wake up CI evaluation when the suite is completed.
# GitHub fires check_suite webhooks for created/in_progress/completed — evaluating
# on the earlier actions would see a partial set of check runs and could
# prematurely declare success. Other event types (push, pull_request) always wake up.
if (
current_node in ("wait_for_ci_gate", "ci_evaluator")
current_node in ("ci_evaluator", "human_review_gate")
and message.source == EventSource.GITHUB
):
event = message.event_type
Expand All @@ -498,7 +498,7 @@ async def _handle_resume_event(

# GitHub issue_comment events: detect /forge skip-gate and /forge unskip-gate
# commands posted as PR comments.
_CI_STAGES = ("wait_for_ci_gate", "ci_evaluator", "attempt_ci_fix")
_CI_STAGES = ("ci_evaluator", "attempt_ci_fix", "human_review_gate")
if message.source == EventSource.GITHUB and "issue_comment" in message.event_type:
gh_comment_body = payload.get("comment", {}).get("body", "").strip()
repo_full = payload.get("repository", {}).get("full_name", "")
Expand Down Expand Up @@ -1205,6 +1205,12 @@ async def _handle_resume_event(
elif is_ci_webhook:
# GitHub CI event — unpause the gate and let ci_evaluator check the results
updated_state["is_paused"] = False

if current_node == "human_review_gate":
# Keep current_node as human_review_gate so review webhooks arriving
# during the CI cycle are still accepted from the queue.
updated_state["pending_ci_event"] = True

elif is_yolo:
updated_state["yolo_mode"] = True
updated_state["is_paused"] = False
Expand Down Expand Up @@ -1297,7 +1303,6 @@ async def _handle_resume_event(
"ci_evaluator",
"attempt_ci_fix",
"human_review_gate",
"wait_for_ci_gate",
)
if (
not current_state.get("is_paused", True)
Expand Down
50 changes: 50 additions & 0 deletions src/forge/prompts/v1/ci-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
You are analyzing CI failures to determine whether they are caused by changes
in this pull request or by external factors.

## CI Failure Information

The failed checks are described in:
{failures_file_path}

All available log files are in `.forge/logs/`.

## Your Task

Read the failure logs carefully. Compare the failing test names, file paths,
and error messages against the files changed in this PR.

To see which files this PR touches, run:
```
git diff --name-only HEAD~1 HEAD
```
or inspect the most recent commit:
```
git show --stat HEAD
```

Determine whether each failing check is caused by changes introduced in this PR,
or by external factors such as:
- Flaky or intermittently failing tests unrelated to the diff
- Broken test infrastructure (container images, network, environment config)
- Pre-existing failures in files not touched by this PR
- Tests that fail because of unrelated upstream changes

Write your verdict to `.forge/ci-attribution.json` in exactly this format:

```json
{
"attributable": true,
"reason": "one sentence explaining why",
"confidence": "high"
}
```

Set `attributable` to `true` if the failure logs reference files, functions,
or test cases that appear in the PR diff. Set it to `false` if the failures
are clearly unrelated to the diff.

When confidence is low, set `attributable` to `true` (fail-safe: the fix
pipeline will attempt a fix, and the human can always use `/forge skip-gate`
to bypass a stuck check).

Do not attempt to fix anything. Write only the attribution JSON file.
1 change: 1 addition & 0 deletions src/forge/workflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class CIIntegrationState(TypedDict, total=False):
ci_skipped_checks: list[str]
ci_fix_attempt: int
ci_fix_max_attempts: int
pending_ci_event: bool


class ReviewIntegrationState(TypedDict, total=False):
Expand Down
157 changes: 14 additions & 143 deletions src/forge/workflow/bug/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,8 @@
from langgraph.graph import END, StateGraph

from forge.workflow.bug.state import BugState
from forge.workflow.nodes.ci_evaluator import (
attempt_ci_fix,
escalate_to_blocked,
evaluate_ci_status,
wait_for_ci_gate,
)
from forge.workflow.nodes.docs_updater import update_documentation
from forge.workflow.nodes.human_review import (
human_review_gate,
route_human_review,
)
from forge.workflow.nodes.implement_review import (
implement_review,
review_response_gate,
route_review_response,
)
from forge.workflow.nodes.human_review import route_human_review
from forge.workflow.nodes.implementation import implement_task
from forge.workflow.nodes.local_reviewer import local_review_changes
from forge.workflow.nodes.plan_bug_fix import (
Expand All @@ -44,9 +30,13 @@
regenerate_rca,
route_rca_option,
)
from forge.workflow.nodes.rebase import rebase_pr
from forge.workflow.nodes.triage import route_triage_gate, triage_check, triage_gate
from forge.workflow.nodes.workspace_setup import setup_workspace
from forge.workflow.post_pr import (
add_post_pr_edges,
add_post_pr_nodes,
route_after_pr_creation,
)
from forge.workflow.utils import resolve_shared_resume_node

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -134,8 +124,6 @@ def route_entry(state: BugState) -> str:
return "create_pr"
elif current_node == "teardown_workspace":
return "teardown_workspace"
elif current_node == "wait_for_ci_gate":
return "wait_for_ci_gate"
elif current_node == "ai_review":
return "human_review_gate"
elif current_node == "escalate_blocked":
Expand Down Expand Up @@ -359,47 +347,6 @@ def _route_after_implementation(
return "local_review"


def _route_after_pr_creation(
state: BugState,
) -> Literal["teardown_workspace", "escalate_blocked"]:
"""Route after PR creation attempt."""
last_error = state.get("last_error")
pr_urls = state.get("pr_urls", [])

if last_error and not pr_urls:
return "escalate_blocked"

return "teardown_workspace"


def _route_after_teardown(state: BugState) -> str:
"""Route after workspace teardown.

If more repos remain in repos_to_process, loop back to setup_workspace.
Otherwise proceed to wait_for_ci_gate (matching feature workflow pattern)
so that Jira labels are updated and the workflow pauses until CI fires.
"""
repos_to_process = state.get("repos_to_process", [])
repos_completed = state.get("repos_completed", [])
remaining = [r for r in repos_to_process if r not in repos_completed]
if remaining:
return "setup_workspace"
return "wait_for_ci_gate"


def _route_ci_evaluation(
state: BugState,
) -> Literal["human_review_gate", "attempt_ci_fix", "escalate_blocked", "__end__"]:
"""Route based on CI evaluation results."""
ci_status = state.get("ci_status", "")
routes = {
"passed": "human_review_gate",
"fixing": "attempt_ci_fix",
"pending": "__end__",
}
return routes.get(ci_status, "escalate_blocked")


def build_bug_graph() -> StateGraph:
"""Create the Bug workflow graph.

Expand Down Expand Up @@ -455,16 +402,8 @@ def build_bug_graph() -> StateGraph:
graph.add_node("create_pr", create_pull_request)
graph.add_node("teardown_workspace", teardown_and_route)

# ── CI/CD ──
graph.add_node("wait_for_ci_gate", wait_for_ci_gate)
graph.add_node("ci_evaluator", evaluate_ci_status)
graph.add_node("attempt_ci_fix", attempt_ci_fix)
graph.add_node("escalate_blocked", escalate_to_blocked)

# ── Review ──
graph.add_node("human_review_gate", human_review_gate)
graph.add_node("implement_review", implement_review)
graph.add_node("review_response_gate", review_response_gate)
# ── Post-PR nodes (CI/review) - shared across all workflows ──
add_post_pr_nodes(graph)

# ── Set entry point ──
graph.set_entry_point("route_entry")
Expand All @@ -490,7 +429,6 @@ def build_bug_graph() -> StateGraph:
"update_documentation": "update_documentation",
"create_pr": "create_pr",
"teardown_workspace": "teardown_workspace",
"wait_for_ci_gate": "wait_for_ci_gate",
"ci_evaluator": "ci_evaluator",
"human_review_gate": "human_review_gate",
"implement_review": "implement_review",
Expand Down Expand Up @@ -644,86 +582,20 @@ def build_bug_graph() -> StateGraph:
graph.add_edge("update_documentation", "create_pr")
graph.add_conditional_edges(
"create_pr",
_route_after_pr_creation,
route_after_pr_creation,
{
"teardown_workspace": "teardown_workspace",
"escalate_blocked": "escalate_blocked",
},
)
graph.add_conditional_edges(
"teardown_workspace",
_route_after_teardown,
{
"setup_workspace": "setup_workspace", # multi-repo loop-back
"wait_for_ci_gate": "wait_for_ci_gate",
},
)

# ── CI/CD flow ──
graph.add_conditional_edges(
"wait_for_ci_gate",
lambda s: END if s.get("is_paused") else "ci_evaluator",
{END: END, "ci_evaluator": "ci_evaluator"},
)
graph.add_conditional_edges(
"ci_evaluator",
_route_ci_evaluation,
{
"human_review_gate": "human_review_gate",
"attempt_ci_fix": "attempt_ci_fix",
"escalate_blocked": "escalate_blocked",
END: END,
},
)
graph.add_conditional_edges(
"attempt_ci_fix",
lambda s: s.get("current_node", "wait_for_ci_gate"),
{
"wait_for_ci_gate": "wait_for_ci_gate",
"escalate_blocked": "escalate_blocked",
"ci_evaluator": "ci_evaluator",
"attempt_ci_fix": "escalate_blocked",
},
)
graph.add_edge("escalate_blocked", END)

# ── Review flow (merge path → post_merge_summary) ──
# "complete_tasks" is the feature-workflow merge return from route_human_review;
# in the bug graph it should never be reached (pr_merged check intercepts first),
# but map it to post_merge_summary defensively.
graph.add_conditional_edges(
"human_review_gate",
_route_human_review_bug,
{
"implement_review": "implement_review",
"post_merge_summary": "post_merge_summary",
"complete_tasks": "post_merge_summary",
END: END,
},
)
graph.add_conditional_edges(
"implement_review",
lambda s: s.get("current_node", "wait_for_ci_gate"),
{
"wait_for_ci_gate": "wait_for_ci_gate",
"review_response_gate": "review_response_gate",
"implement_review": "implement_review",
"human_review_gate": "human_review_gate",
"escalate_blocked": "escalate_blocked",
},
)
graph.add_conditional_edges(
"review_response_gate",
route_review_response,
{
"implement_review": "implement_review",
"human_review_gate": "human_review_gate",
END: END,
},
# ── Post-PR edges (CI/review) - shared across all workflows ──
add_post_pr_edges(
graph,
on_complete_node="post_merge_summary",
human_review_routing_fn=_route_human_review_bug,
)

# ── Rebase (merge conflict resolution, triggered by /forge rebase) ──
graph.add_node("rebase_pr", rebase_pr)
graph.add_conditional_edges(
"rebase_pr",
lambda s: s.get("current_node", END),
Expand All @@ -737,7 +609,6 @@ def build_bug_graph() -> StateGraph:
"update_documentation": "update_documentation",
"create_pr": "create_pr",
"teardown_workspace": "teardown_workspace",
"wait_for_ci_gate": "wait_for_ci_gate",
"ci_evaluator": "ci_evaluator",
"attempt_ci_fix": "ci_evaluator",
"human_review_gate": "human_review_gate",
Expand Down
Loading
Loading