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
17 changes: 16 additions & 1 deletion core/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,21 @@ class RespondWorkflow(BaseWorkflow):
workflow = WORKFLOW_RESPOND_TO_PR_COMMENT
config_name = WORKFLOW_RESPOND_TO_PR_COMMENT

def _should_dispatch_run(self, context: Mapping[str, Any]) -> bool:
if context.get("branch_strategy") == "blocked":
return False
if (
bool(context.get("is_cross_repository"))
and not bool(context.get("trigger_actor_is_trusted"))
):
return False
if (
not bool(context.get("is_cross_repository"))
and context.get("can_push_to_head_branch") is False
):
return False
return True

def build_dispatch(self, payload: Mapping[str, Any], *, github_client: Any, workspace_path: Path | None = None) -> WorkflowDispatch | None:
from workflows.respond_to_pr_comment import build_pr_comment_prompt, gather_pr_comment_context # type: ignore[import-not-found]

Expand All @@ -225,7 +240,7 @@ def build_dispatch(self, payload: Mapping[str, Any], *, github_client: Any, work
client=github_client,
pr=pr,
)
if context.get("can_push_to_head_branch") is False:
if not self._should_dispatch_run(context):
return None
return WorkflowDispatch(
workflow=self.workflow,
Expand Down
322 changes: 297 additions & 25 deletions core/workflows/respond_to_pr_comment.py

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Create the App (organization-owned or user-owned), grant it these permissions, a
- **Issues** — Read & Write (apply labels, post comments, manage assignees)
- **Pull requests** — Read & Write (open PRs, post reviews)

**Organization permissions (optional)**

- **Members** — Read-only. Required only when `respond-to-pr-comment` should allow fork PR requests from members whose webhook `author_association` is not already `OWNER`, `MEMBER`, or `COLLABORATOR`. Set `OZ_TRUSTED_GITHUB_ORG` to the GitHub organization slug to enable this membership probe.

**Webhook events**

- `issues`, `issue_comment`, `pull_request`, `pull_request_review`, `pull_request_review_comment`
Expand Down Expand Up @@ -39,11 +43,14 @@ vercel deploy
| `WARP_REVIEW_TRIAGE_ENVIRONMENT_ID` | Optional override used by review/triage runs. Falls back to `WARP_ENVIRONMENT_ID` when empty. |
| `CRON_SECRET` | Required random secret used to authenticate Vercel cron requests. Local development can opt out with `OZ_ALLOW_UNAUTHENTICATED_CRON=true`. |
| `GITHUB_API_BASE_URL` | Optional. Defaults to `https://api.github.com`. Override for GitHub Enterprise. |
| `OZ_TRUSTED_GITHUB_ORG` | Optional. GitHub organization slug used to verify trusted fork-PR comment triggers when webhook author association is insufficient. |

Provision a Vercel KV resource on the project. Vercel injects `KV_REST_API_URL` / `KV_REST_API_TOKEN` automatically; the cron handler reads them at runtime through `upstash-redis`.

Finally, point the GitHub App's webhook URL at `https://<vercel-project>.vercel.app/api/webhook`. The webhook handler returns `202` for every delivery so the App's "Recent deliveries" UI stays green even when the cron tick is busy.

Fork PR caveat: when a trusted fork PR comment cannot be applied directly to the contributor's head branch, Oz pushes a branch to the base repository and then attempts to open a follow-up PR against the contributor's fork branch. That fallback requires the GitHub App token to have enough access to create a PR in the fork repository; otherwise Oz will report the pushed branch and explain that fork access is needed.

## 3. Configure shared Oz workflow settings (optional)

Repositories can commit `.github/oz/config.yml` to make workflow-level defaults visible and reviewable in source control. Oz resolves that file from the consuming repository first and falls back to the bundled [`../.github/oz/config.yml`](../.github/oz/config.yml) when absent. Discovery stops at the first existing file — the two locations are not merged. The settings live under `self_improvement` and `triage`:
Expand Down
8 changes: 8 additions & 0 deletions oz/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ def get_login(item: Any) -> str:
return str(getattr(item, "login", "") or "")


def split_repo_full_name(full_name: str) -> tuple[str, str]:
"""Split an ``owner/repo`` slug, returning empty parts when malformed."""
if "/" not in full_name:
return "", ""
owner, repo = full_name.split("/", 1)
return owner, repo


def is_automation_user(user: Any) -> bool:
"""Return whether *user* is an automation account that should not trigger workflows."""
login = get_login(user).strip().lower()
Expand Down
62 changes: 61 additions & 1 deletion tests/test_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ def test_returns_dispatch_request_for_review_body(self) -> None:
self.assertEqual(kwargs["trigger_kind"], "review_body")
self.assertEqual(kwargs["trigger_comment_id"], 1234)

def test_skips_dispatch_when_head_branch_is_not_safe_to_push(self) -> None:
def test_skips_dispatch_for_untrusted_fork_pr_comment(self) -> None:
from core.builders import build_respond_request

respond_module = sys.modules["workflows.respond_to_pr_comment"]
Expand All @@ -349,6 +349,8 @@ def test_skips_dispatch_when_head_branch_is_not_safe_to_push(self) -> None:
"is_cross_repository": True,
"head_branch_exists_in_base": False,
"can_push_to_head_branch": False,
"branch_strategy": "fallback-pr-to-fork",
"trigger_actor_is_trusted": False,
"pr_title": "feat: add",
"requester": "alice",
"trigger_kind": "review",
Expand Down Expand Up @@ -380,6 +382,64 @@ def test_skips_dispatch_when_head_branch_is_not_safe_to_push(self) -> None:

self.assertIsNone(request)
respond_module.build_pr_comment_prompt.assert_not_called() # type: ignore[attr-defined]
def test_returns_dispatch_request_for_trusted_fork_pr_comment_with_fallback(self) -> None:
from core.builders import build_respond_request
from core.routing import WORKFLOW_RESPOND_TO_PR_COMMENT

respond_module = sys.modules["workflows.respond_to_pr_comment"]
respond_module.gather_pr_comment_context.return_value = { # type: ignore[attr-defined]
"owner": "acme",
"repo": "widgets",
"pr_number": 7,
"head_branch": "feature",
"head_repo_full_name": "contributor/widgets",
"base_branch": "main",
"base_repo_full_name": "acme/widgets",
"is_cross_repository": True,
"head_branch_exists_in_base": False,
"can_push_to_head_branch": False,
"branch_strategy": "fallback-pr-to-fork",
"trigger_actor_is_trusted": True,
"pr_title": "feat: add",
"requester": "alice",
"trigger_kind": "review",
"trigger_comment_id": 999,
"review_reply_target_id": 999,
"has_spec_context": False,
"spec_context_text": "No spec context.",
"coauthor_line": "",
"coauthor_directives": "- foo",
"progress_start_line": "I'm starting",
}
github_client = MagicMock()
repo = MagicMock(name="repo")
github_client.get_repo.return_value = repo
pr = MagicMock(name="pr")
repo.get_pull.return_value = pr

payload = {
"repository": {"full_name": "acme/widgets"},
"installation": {"id": 1},
"pull_request": {"number": 7},
"comment": {
"id": 999,
"author_association": "MEMBER",
"user": {"login": "alice"},
},
}

request = build_respond_request(
payload,
github_client=github_client,
workspace_path=Path("/tmp/ws"),
)

self.assertIsNotNone(request)
assert request is not None
self.assertEqual(request.workflow, WORKFLOW_RESPOND_TO_PR_COMMENT)
self.assertEqual(request.prompt, "RESPOND_PROMPT_BODY")
self.assertEqual(request.payload_subset["branch_strategy"], "fallback-pr-to-fork")
self.assert_deferred_progress(request, start_line="I'm starting")


class BuildVerifyRequestTest(_BuilderTestBase):
Expand Down
Loading
Loading