diff --git a/.github/actions/build-triage-image/action.yml b/.github/actions/build-triage-image/action.yml new file mode 100644 index 0000000..06a4b0d --- /dev/null +++ b/.github/actions/build-triage-image/action.yml @@ -0,0 +1,29 @@ +name: Build Oz triage image +description: >- + Build the Oz triage Docker image from the same warpdotdev/oz-for-oss revision + this composite action was loaded at. The repository is laid down on disk by + GitHub when the action is referenced via `uses:`, so no additional checkout + is needed. +inputs: + image-name: + description: Local Docker image name to build. + required: false + default: oz-for-oss-triage +runs: + using: composite + steps: + - name: Build triage Docker image + shell: bash + env: + TRIAGE_IMAGE_NAME: ${{ inputs.image-name }} + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + # ACTION_PATH points at .github/actions/build-triage-image inside the + # oz-for-oss checkout GitHub created for this action; the repo root is + # three directories above it. + repo_root="$(cd "$ACTION_PATH/../../.." && pwd)" + docker build \ + -f "$repo_root/docker/triage/Dockerfile" \ + -t "$TRIAGE_IMAGE_NAME" \ + "$repo_root" diff --git a/.github/actions/run-oz-python-script/action.yml b/.github/actions/run-oz-python-script/action.yml new file mode 100644 index 0000000..941bc1f --- /dev/null +++ b/.github/actions/run-oz-python-script/action.yml @@ -0,0 +1,47 @@ +name: Run Oz Python workflow script +description: >- + Install the shared Oz workflow Python dependencies and run a script from + warpdotdev/oz-for-oss/.github/scripts at the same revision this composite + action was loaded at. The repository is laid down on disk by GitHub when the + action is referenced via `uses:`, so no additional checkout is needed. +inputs: + script-path: + description: Path to the Python entrypoint relative to .github/scripts. + required: true +outputs: + allow_review: + description: Optional pass-through output for scripts that set allow_review. + value: ${{ steps.run-script.outputs.allow_review }} +runs: + using: composite + steps: + - name: Install uv and activate virtual environment + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + enable-cache: true + # github.action_path points at .github/actions/run-oz-python-script + # inside the oz-for-oss checkout GitHub created for this action; the + # repository root is three directories above it. + working-directory: ${{ github.action_path }}/../../.. + cache-dependency-glob: ${{ github.action_path }}/../../scripts/requirements.txt + activate-environment: true + python-version: "3.12" + - name: Install Python workflow dependencies + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + uv pip install -r "$ACTION_PATH/../../scripts/requirements.txt" + - name: Run Oz Python workflow script + id: run-script + shell: bash + env: + OZ_SCRIPT_PATH: ${{ inputs.script-path }} + ACTION_PATH: ${{ github.action_path }} + WORKFLOW_CODE_REPOSITORY: warpdotdev/oz-for-oss + run: | + set -euo pipefail + script_root="$(cd "$ACTION_PATH/../../scripts" && pwd)" + export PYTHONPATH="${script_root}${PYTHONPATH:+:${PYTHONPATH}}" + python "${script_root}/${OZ_SCRIPT_PATH}" diff --git a/.github/scripts/comment_on_unready_assigned_issue.py b/.github/scripts/comment_on_unready_assigned_issue.py new file mode 100644 index 0000000..42ca28a --- /dev/null +++ b/.github/scripts/comment_on_unready_assigned_issue.py @@ -0,0 +1,49 @@ +from __future__ import annotations +from contextlib import closing +from typing import Any, Mapping + +from github import Auth, Github + +from oz_workflows.env import load_event, repo_parts, repo_slug, require_env +from oz_workflows.helpers import WorkflowProgressComment + + +DEFAULT_ASSIGNEE_LOGIN = "oz-agent" + + +def resolve_assignee_login(event: Mapping[str, Any]) -> str: + """Return the assignee login from a webhook payload, defaulting to oz-agent. + + Guards against both a missing ``assignee`` key and an explicit ``null`` + value, which GitHub sends on unassignment events. Using ``or {}`` (rather + than the default argument to ``dict.get``) ensures we don't attempt to call + ``.get`` on ``None``. + """ + return (event.get("assignee") or {}).get("login") or DEFAULT_ASSIGNEE_LOGIN + + +def main() -> None: + owner, repo = repo_parts() + event = load_event() + issue_number = int(event["issue"]["number"]) + assignee_login = resolve_assignee_login(event) + with closing(Github(auth=Auth.Token(require_env("GH_TOKEN")))) as client: + github = client.get_repo(repo_slug()) + issue = github.get_issue(issue_number) + progress = WorkflowProgressComment( + github, + owner, + repo, + issue_number, + workflow="comment-on-unready-assigned-issue", + event_payload=event, + ) + progress.start("I'm checking whether this assignment is ready for work.") + progress.complete( + "This issue is assigned to me, but it is not labeled `ready-to-spec` or `ready-to-implement`, so there is no work to do yet.", + ) + issue.remove_from_assignees(assignee_login) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/oz_workflows/__init__.py b/.github/scripts/oz_workflows/__init__.py new file mode 100644 index 0000000..6568fc9 --- /dev/null +++ b/.github/scripts/oz_workflows/__init__.py @@ -0,0 +1 @@ +"""Shared helpers for Oz-backed GitHub workflow orchestration.""" diff --git a/.github/scripts/oz_workflows/actions.py b/.github/scripts/oz_workflows/actions.py new file mode 100644 index 0000000..ebc65e3 --- /dev/null +++ b/.github/scripts/oz_workflows/actions.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import os +import uuid + + +def _append_multiline(path: str, name: str, value: str) -> None: + """Append a multiline output or environment entry using a unique delimiter.""" + delimiter = f"oz_{uuid.uuid4()}" + with open(path, "a", encoding="utf-8") as handle: + handle.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + + +def set_output(name: str, value: str) -> None: + """Publish a GitHub Actions step output when the workflow provides a sink.""" + output_path = os.environ.get("GITHUB_OUTPUT") + if output_path: + _append_multiline(output_path, name, value) + + +def append_summary(text: str) -> None: + """Append text to the GitHub Actions step summary, preserving line endings.""" + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write(text) + if not text.endswith("\n"): + handle.write("\n") + + +def notice(message: str) -> None: + """Emit a GitHub Actions notice annotation.""" + print(f"::notice::{message}") + + +def warning(message: str) -> None: + """Emit a GitHub Actions warning annotation.""" + print(f"::warning::{message}") + + +def error(message: str) -> None: + """Emit a GitHub Actions error annotation.""" + print(f"::error::{message}") diff --git a/.github/scripts/oz_workflows/artifacts.py b/.github/scripts/oz_workflows/artifacts.py new file mode 100644 index 0000000..c47550c --- /dev/null +++ b/.github/scripts/oz_workflows/artifacts.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import json +import random +import time +from typing import Any, Protocol, TypedDict, cast + +import httpx +from oz_agent_sdk import OzAPI +from oz_agent_sdk.types import AgentGetArtifactResponse +from oz_agent_sdk.types.agent import RunItem + +from .oz_client import build_oz_client + +# Retry policy for artifact downloads. A transient CDN or S3 blip can surface as +# either a 5xx response or as a network-level exception (connection reset, DNS +# flake, read timeout, etc.). We want to retry a handful of times with +# exponential backoff + jitter so a momentary failure at the tail end of an +# otherwise successful agent run does not cause the entire workflow to fail. +_DOWNLOAD_MAX_ATTEMPTS = 5 +_DOWNLOAD_INITIAL_BACKOFF_SECONDS = 1.0 +_DOWNLOAD_MAX_BACKOFF_SECONDS = 10.0 + +# Network-level httpx exceptions that are worth retrying. These cover the +# common transient failures for signed-URL downloads. +_RETRYABLE_NETWORK_EXCEPTIONS: tuple[type[BaseException], ...] = ( + httpx.ConnectError, + httpx.ConnectTimeout, + httpx.ReadError, + httpx.ReadTimeout, + httpx.WriteError, + httpx.WriteTimeout, + httpx.PoolTimeout, + httpx.RemoteProtocolError, +) + + +class PrMetadata(TypedDict): + """Structured PR metadata produced by implementation workflows.""" + + branch_name: str + pr_title: str + pr_summary: str + + +class ResolvedReviewComment(TypedDict): + """A single PR review comment that the agent reported as resolved.""" + + comment_id: int + summary: str + + +class _FileArtifactDataLike(Protocol): + artifact_uid: str + filename: str | None + + +class _FileArtifactLike(Protocol): + artifact_type: str + data: _FileArtifactDataLike | None + + +def poll_for_artifact( + run_id: str, + *, + filename: str, + timeout_seconds: int = 120, + poll_interval_seconds: int = 5, +) -> dict[str, Any]: + """Retrieve a FILE artifact by filename from a completed Oz run. + + The caller should invoke this after ``run_agent()`` has returned + (i.e. the run has reached a terminal SUCCEEDED state). The artifact + should already be present, but we poll briefly for resilience against + propagation delay. + """ + client = build_oz_client() + artifact_uid = _poll_for_file_artifact_uid( + client, + run_id, + filename=filename, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + return _download_artifact_json(client, artifact_uid) + + +def poll_for_text_artifact( + run_id: str, + *, + filename: str, + timeout_seconds: int = 120, + poll_interval_seconds: int = 5, +) -> str: + """Retrieve a FILE artifact by filename and return its raw text content.""" + client = build_oz_client() + artifact_uid = _poll_for_file_artifact_uid( + client, + run_id, + filename=filename, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + return _download_artifact_text(client, artifact_uid) + + +def _poll_for_file_artifact_uid( + client: OzAPI, + run_id: str, + *, + filename: str, + timeout_seconds: int, + poll_interval_seconds: int, +) -> str: + """Wait for a FILE artifact by filename and return its artifact UID.""" + deadline = time.monotonic() + timeout_seconds + + while True: + run = client.agent.runs.retrieve(run_id) + artifact_uid = _find_file_artifact(run, filename) + if artifact_uid is not None: + return artifact_uid + if time.monotonic() >= deadline: + raise RuntimeError( + f"Timed out waiting for FILE artifact '{filename}' on Oz run {run_id}" + ) + time.sleep(poll_interval_seconds) + + +def _find_file_artifact(run: RunItem, filename: str) -> str | None: + """Return the artifact UID for a FILE artifact matching *filename*, or None.""" + artifacts = cast(list[_FileArtifactLike], run.artifacts or []) + for artifact in artifacts: + if artifact.artifact_type != "FILE": + continue + data = artifact.data + if data is None: + continue + if data.filename == filename: + return str(data.artifact_uid) + return None + + +def _download_artifact_json(client: OzAPI, artifact_uid: str) -> dict[str, Any]: + """Fetch a FILE artifact's signed URL and download its JSON content.""" + payload = json.loads(_download_artifact_text(client, artifact_uid)) + if not isinstance(payload, dict): + raise RuntimeError( + f"Artifact {artifact_uid} must decode to a JSON object" + ) + return payload + + +def _download_artifact_text(client: OzAPI, artifact_uid: str) -> str: + """Fetch a FILE artifact's signed URL and download its text content. + + The download is retried with exponential backoff + jitter on 5xx + responses and on transient httpx network errors (connect/read timeouts, + protocol errors, etc.). 4xx responses are not retried and surface + immediately as ``httpx.HTTPStatusError``. + """ + response: AgentGetArtifactResponse = client.agent.get_artifact(artifact_uid) + download_url = response.data.download_url + if not download_url: + raise RuntimeError( + f"Artifact {artifact_uid} did not return a download URL" + ) + with httpx.Client(timeout=30) as http: + return _download_text_with_retries(http, download_url, artifact_uid) + + +def _download_text_with_retries( + http: httpx.Client, download_url: str, artifact_uid: str +) -> str: + """GET *download_url* with retries on 5xx and transient network errors. + + Returns the response text on success. Raises the last encountered error + after ``_DOWNLOAD_MAX_ATTEMPTS`` failed attempts. + """ + last_error: Exception | None = None + for attempt in range(_DOWNLOAD_MAX_ATTEMPTS): + try: + download_response = http.get(download_url) + except _RETRYABLE_NETWORK_EXCEPTIONS as exc: + last_error = exc + else: + if download_response.status_code < 500: + # 2xx returns the body; 4xx raises a non-retryable error. + download_response.raise_for_status() + return download_response.text + last_error = httpx.HTTPStatusError( + ( + f"Server error {download_response.status_code} while " + f"downloading artifact {artifact_uid}" + ), + request=download_response.request, + response=download_response, + ) + + if attempt >= _DOWNLOAD_MAX_ATTEMPTS - 1: + break + backoff = min( + _DOWNLOAD_INITIAL_BACKOFF_SECONDS * (2**attempt), + _DOWNLOAD_MAX_BACKOFF_SECONDS, + ) + # Add jitter to avoid thundering-herd style retry storms across + # concurrently-running workflows. + time.sleep(backoff + random.uniform(0, 1)) + + # At least one attempt always runs, so last_error is set when we exit + # the loop without returning. Guard against the theoretical case where + # it isn't so we don't raise ``TypeError`` under ``python -O`` (which + # strips ``assert`` statements). + if last_error is None: + raise RuntimeError( + f"Exhausted retries downloading artifact {artifact_uid} " + "without recording an error" + ) + raise last_error + + +PR_METADATA_FILENAME = "pr-metadata.json" + +_PR_METADATA_REQUIRED_KEYS = ("branch_name", "pr_title", "pr_summary") + + +def load_pr_metadata_artifact(run_id: str) -> PrMetadata: + """Load and validate the pr-metadata.json artifact from a completed Oz run. + + The artifact must be a JSON object containing at least the keys + ``branch_name``, ``pr_title``, and ``pr_summary``. + """ + metadata = poll_for_artifact( + run_id, + filename=PR_METADATA_FILENAME, + ) + missing = [key for key in _PR_METADATA_REQUIRED_KEYS if key not in metadata] + if missing: + raise RuntimeError( + f"pr-metadata.json artifact from Oz run {run_id} is missing " + f"required key(s): {', '.join(missing)}" + ) + pr_summary = metadata.get("pr_summary", "") + if not isinstance(pr_summary, str) or not pr_summary.strip(): + raise RuntimeError( + f"pr-metadata.json artifact from Oz run {run_id} has an empty pr_summary" + ) + return cast(PrMetadata, metadata) + + +def try_load_pr_metadata_artifact( + run_id: str, + *, + timeout_seconds: int = 10, + poll_interval_seconds: int = 2, +) -> PrMetadata | None: + """Try to load the optional ``pr-metadata.json`` artifact. + + Workflows that only *sometimes* need to refresh the PR title/body (for + example, ``respond-to-pr-comment`` when the agent's changes transition + a spec-only PR into a spec + implementation PR) should use this helper + rather than ``load_pr_metadata_artifact`` so a missing or malformed + artifact degrades to ``None`` instead of aborting the workflow. + + Uses a short polling window by default because the artifact is + optional. When the artifact is absent, the agent did not intend to + refresh the PR description and callers should leave the existing + description untouched. + """ + try: + metadata = poll_for_artifact( + run_id, + filename=PR_METADATA_FILENAME, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + except (RuntimeError, ValueError, httpx.HTTPError): + # ``RuntimeError``: poll timeouts or non-object JSON payloads. + # ``ValueError``: malformed JSON (``json.JSONDecodeError`` is a subclass). + # ``httpx.HTTPError``: 4xx responses or other transport-level failures + # that survived the download retries in ``_download_artifact_text``. + return None + missing = [key for key in _PR_METADATA_REQUIRED_KEYS if key not in metadata] + if missing: + return None + pr_summary = metadata.get("pr_summary", "") + if not isinstance(pr_summary, str) or not pr_summary.strip(): + return None + pr_title = metadata.get("pr_title", "") + if not isinstance(pr_title, str) or not pr_title.strip(): + return None + return cast(PrMetadata, metadata) + + +RESOLVED_REVIEW_COMMENTS_FILENAME = "resolved_review_comments.json" + + +def _normalize_resolved_review_comment_entry( + entry: Any, *, index: int +) -> ResolvedReviewComment | None: + """Normalize a raw ``resolved_review_comments`` entry from the artifact. + + Returns ``None`` (logging a warning) when the entry cannot be coerced into + the documented ``{"comment_id": int, "summary": str}`` shape rather than + raising, so a single malformed entry does not abort the workflow. + """ + if not isinstance(entry, dict): + print( + f"[resolved-review-comments] Dropped entry {index}: expected object, got {type(entry).__name__}" + ) + return None + raw_comment_id = entry.get("comment_id") + comment_id: int | None + if isinstance(raw_comment_id, bool): + # ``bool`` is a subclass of ``int``; treat it as invalid. + comment_id = None + elif isinstance(raw_comment_id, int): + comment_id = raw_comment_id + elif isinstance(raw_comment_id, str) and raw_comment_id.strip().isdigit(): + comment_id = int(raw_comment_id.strip()) + else: + comment_id = None + if comment_id is None or comment_id <= 0: + print( + f"[resolved-review-comments] Dropped entry {index}: missing or invalid `comment_id`" + ) + return None + summary = entry.get("summary") + if not isinstance(summary, str): + summary = "" + summary = summary.strip() + if not summary: + print( + f"[resolved-review-comments] Dropped entry {index} for comment {comment_id}: missing `summary`" + ) + return None + return {"comment_id": comment_id, "summary": summary} + + +def normalize_resolved_review_comments_payload( + payload: Any, +) -> list[ResolvedReviewComment]: + """Validate and normalize a ``resolved_review_comments.json`` payload. + + Accepts either an object with a ``resolved_review_comments`` list or a + bare list of entries. Dropped entries (malformed or duplicate + ``comment_id``) are logged and skipped so the rest of the workflow + continues uninterrupted. + """ + if isinstance(payload, dict): + raw_entries = payload.get("resolved_review_comments") + else: + raw_entries = payload + if raw_entries is None: + return [] + if not isinstance(raw_entries, list): + print( + "[resolved-review-comments] Dropping payload: `resolved_review_comments` must be a list" + ) + return [] + seen: set[int] = set() + resolved: list[ResolvedReviewComment] = [] + for index, entry in enumerate(raw_entries): + normalized = _normalize_resolved_review_comment_entry(entry, index=index) + if normalized is None: + continue + if normalized["comment_id"] in seen: + print( + f"[resolved-review-comments] Dropped duplicate entry for comment {normalized['comment_id']}" + ) + continue + seen.add(normalized["comment_id"]) + resolved.append(normalized) + return resolved + + +def try_load_resolved_review_comments_artifact( + run_id: str, + *, + timeout_seconds: int = 10, + poll_interval_seconds: int = 2, +) -> list[ResolvedReviewComment]: + """Try to load the optional ``resolved_review_comments.json`` artifact. + + The artifact is emitted by the agent only when it resolved one or more + PR review comments as part of the run. When it is absent (or cannot be + parsed), this returns an empty list rather than raising so callers can + fall back to their existing completion behavior. + """ + try: + payload = poll_for_artifact( + run_id, + filename=RESOLVED_REVIEW_COMMENTS_FILENAME, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + except (RuntimeError, ValueError, httpx.HTTPError): + # ``RuntimeError``: poll timeouts or non-object JSON payloads. + # ``ValueError``: malformed JSON (``json.JSONDecodeError`` is a subclass). + # ``httpx.HTTPError``: 4xx responses or other transport-level failures + # that survived the download retries in ``_download_artifact_text``. + # Any of these should degrade to an empty list so a broken optional + # artifact never aborts the surrounding workflow's success path. + return [] + return normalize_resolved_review_comments_payload(payload) diff --git a/.github/scripts/oz_workflows/docker_agent.py b/.github/scripts/oz_workflows/docker_agent.py new file mode 100644 index 0000000..35a48d5 --- /dev/null +++ b/.github/scripts/oz_workflows/docker_agent.py @@ -0,0 +1,452 @@ +"""Run an Oz agent inside a Docker container from the GitHub Actions runner. + +This is the Docker-based counterpart to :func:`oz_workflows.oz_client.run_agent`. +Instead of dispatching the agent to a pre-defined Warp cloud environment, +the caller invokes a locally-built image (e.g. ``oz-for-oss-triage``) that +bundles the ``oz`` CLI. The container reads the consuming repo via a +read-only mount at ``/mnt/repo`` and writes its structured result into a +writable mount at ``/mnt/output``. + +The helper streams stdout line-by-line, parses each JSON event emitted by +``oz agent run --output-format json``, and surfaces the run id and +session-share link to an optional ``on_event`` callback so existing +progress-comment plumbing keeps working. + +Event schema +------------ +The serialized shape of every JSON line the CLI emits lives in the Rust +``JsonMessage`` / ``JsonSystemEvent`` enums in +``warp-internal/deep-forest/app/src/ai/agent_sdk/driver/output.rs`` +(see ``pub mod json`` around line 532). Those enums are deliberately kept +as a stable, serde-tagged interface for external consumers and are not +1:1 with the internal ``AIAgent*`` types. + +The three ``type="system"`` events this module consumes, with their +emit sites and the condition that causes each to fire, are: + +* ``event_type="run_started"``, payload ``{run_id, run_url}`` -- emitted + unconditionally on every ``oz agent run`` invocation by + ``AgentDriverRunner::setup_and_run_driver`` via ``driver::write_run_started`` + (``agent_sdk/mod.rs``, calls ``output::json::run_started`` in + ``driver.rs``'s ``write_run_started``). The CLI always assigns a task + id before the driver starts, so this event is guaranteed for every run. +* ``event_type="shared_session_established"``, payload ``{join_url}`` -- + emitted when the terminal driver reports a successful share handshake + (``AgentDriver::handle_terminal_driver_event`` -> + ``write_session_joined`` in ``driver.rs``). It is only emitted when + ``--share`` is passed; ``_build_docker_argv`` always adds + ``--share public:view`` so we rely on this event to populate + ``session_link``. +* ``event_type="conversation_started"``, payload ``{conversation_id}`` -- + emitted once per run when the first server conversation token arrives, + from the ``BlocklistAIHistoryEvent::UpdatedStreamingExchange`` handler + in ``driver.rs``. Expected exactly once for any run that reaches the + server, so a missing value signals the run failed before any model + round-trip. + +Other events the CLI may emit on stdout (``type="agent"``, +``type="agent_reasoning"``, ``type="tool_call"``, ``type="tool_result"``, +``type="skill_invoked"``, ``type="artifact_created"``, etc.) are +forwarded to ``on_event`` without being inspected, so callers can extend +parsing without modifying this module. The parser is tolerant: any event +whose ``event_type`` we do not recognize is a no-op, so additions to the +Rust enum do not break existing runs. +""" + +from __future__ import annotations + +import json +import logging +import shutil +import subprocess +import sys +import tempfile +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Iterable + +from .actions import notice +from .env import optional_env + +logger = logging.getLogger(__name__) + +# Default timeout for an agent run. The triage workflow's SDK path uses +# ``60 * 60`` seconds; keep parity so we don't tighten the limit by +# accident when moving into Docker. +DEFAULT_TIMEOUT_SECONDS = 60 * 60 + +# Mount paths inside the container. The Dockerfile's documentation and +# the ``triage-issue`` skill's Docker workflow mode reference these same +# constants; changing them requires updating the skill as well. +REPO_MOUNT = "/mnt/repo" +OUTPUT_MOUNT = "/mnt/output" + + +@dataclass +class DockerAgentRun: + """Structured result for a completed :func:`run_agent_in_docker` invocation. + + ``run_id`` and ``session_link`` mirror the ``RunItem`` fields consumed + by :func:`oz_workflows.helpers.record_run_session_link` so callers can + reuse the same progress-comment plumbing. ``output`` holds the parsed + JSON the agent wrote to ``/mnt/output/``; the helper + reads and cleans up the backing tempdir before returning, so callers + never need to touch a filesystem path. + """ + + run_id: str = "" + session_link: str = "" + conversation_id: str = "" + output: dict[str, Any] = field(default_factory=dict) + exit_code: int = 0 + + +class DockerAgentError(RuntimeError): + """Raised when the Docker-based agent run fails before reporting a result.""" + + +class DockerAgentTimeout(DockerAgentError): + """Raised when the agent container exceeds the configured timeout.""" + + +def run_agent_in_docker( + *, + prompt: str, + skill_name: str, + title: str, + image: str, + repo_dir: Path | str, + output_filename: str, + on_event: Callable[[DockerAgentRun], None] | None = None, + model: str | None = None, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + log_group: str | None = None, + repo_read_only: bool = True, + forward_env_names: Iterable[str] | None = None, +) -> DockerAgentRun: + """Run ``oz agent run`` inside *image* and return the final run state. + + The helper: + + 1. Creates a temporary output directory on the host and mounts it at + ``/mnt/output`` in the container. The agent is instructed (via the + prompt / skill) to write *output_filename* into that directory. + 2. Spawns ``docker run --rm`` with a read-only repo mount, streams + stdout to the host's stdout, and parses JSON-line events to track + the run id and session-share link. + 3. Enforces *timeout_seconds* using a ``threading.Timer`` so we don't + hang the workflow if the container stops responding. + 4. Reads and JSON-decodes *output_filename* from the output dir, + stashes the parsed payload on ``run.output``, and deletes the + tempdir before returning. Callers read ``run.output`` directly; + no filesystem state survives this call. + + Raises :class:`DockerAgentError` on a non-zero exit code, a missing + or malformed output file, or any other failure before ``run.output`` + is populated. Raises :class:`DockerAgentTimeout` when the watchdog + fires. Either way, the output directory is removed. + """ + repo_path = Path(repo_dir).resolve() + if not repo_path.is_dir(): + raise DockerAgentError( + f"Docker agent repo directory does not exist: {repo_path}" + ) + + output_dir = Path(tempfile.mkdtemp(prefix="oz-agent-output-")) + + # We only log the group banner when the caller asked for one. The + # GitHub Actions ``::group::`` annotation is idempotent - using it + # from local tools (e.g. ``scripts/local_triage.py``) is harmless. + group_label = (log_group or title).strip() + if group_label: + print(f"::group::{group_label}", flush=True) + + run = DockerAgentRun() + try: + argv = _build_docker_argv( + image=image, + repo_dir=repo_path, + output_dir=output_dir, + prompt=prompt, + skill_name=skill_name, + title=title, + model=model, + repo_read_only=repo_read_only, + forward_env_names=forward_env_names, + ) + notice(f"Launching agent container: {_format_argv_for_log(argv)}") + _run_and_stream( + argv, + run=run, + on_event=on_event, + timeout_seconds=timeout_seconds, + ) + + if run.exit_code != 0: + raise DockerAgentError( + f"Docker agent exited with code {run.exit_code} (image={image})" + ) + run.output = _read_output_json(output_dir / output_filename) + return run + finally: + if group_label: + print("::endgroup::", flush=True) + # Unconditional cleanup so callers (including + # ``scripts/local_triage.py`` and any future local tooling) never + # have to track or remove the backing tempdir themselves. + shutil.rmtree(output_dir, ignore_errors=True) + + +def _build_docker_argv( + *, + image: str, + repo_dir: Path, + output_dir: Path, + prompt: str, + skill_name: str, + title: str, + model: str | None, + repo_read_only: bool, + forward_env_names: Iterable[str] | None, +) -> list[str]: + """Build the ``docker run`` argv for the triage container. + + Environment variables that the container needs are forwarded via + ``-e `` (the host's value is inherited). We intentionally never + forward the value inline so ``WARP_API_KEY`` never appears in process + listings. + """ + argv: list[str] = ["docker", "run", "--rm"] + env_names = tuple(forward_env_names or ("WARP_API_KEY", "WARP_API_BASE_URL")) + for name in env_names: + argv.extend(["-e", name]) + repo_mount = ( + f"{repo_dir}:{REPO_MOUNT}:ro" + if repo_read_only + else f"{repo_dir}:{REPO_MOUNT}" + ) + + argv.extend( + [ + "-v", + repo_mount, + "-v", + f"{output_dir}:{OUTPUT_MOUNT}", + image, + "agent", + "run", + "--skill", + skill_name, + "--cwd", + REPO_MOUNT, + "--prompt", + prompt, + "--output-format", + "json", + "--name", + title, + "--share", + "public:view", + ] + ) + if model: + argv.extend(["--model", model]) + return argv + + +def _run_and_stream( + argv: list[str], + *, + run: DockerAgentRun, + on_event: Callable[[DockerAgentRun], None] | None, + timeout_seconds: int, +) -> None: + """Spawn the container, stream stdout, and parse events. + + stderr is merged into stdout via ``stderr=subprocess.STDOUT``. The + previous ``stderr=subprocess.PIPE`` shape could deadlock if the + container wrote more than the pipe buffer (~64KB on Linux) before + exiting, since this loop only drained stdout. Merging keeps the + drain single-threaded and gives operators one contiguous log stream + to read. + """ + proc = subprocess.Popen( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + timed_out = False + + def _kill_on_timeout() -> None: + nonlocal timed_out + timed_out = True + proc.kill() + + timer = threading.Timer(timeout_seconds, _kill_on_timeout) + timer.start() + try: + assert proc.stdout is not None # for the type checker + for line in proc.stdout: + sys.stdout.write(line) + sys.stdout.flush() + _ingest_stdout_line(line, run=run, on_event=on_event) + proc.wait() + finally: + timer.cancel() + + if timed_out: + raise DockerAgentTimeout( + f"Docker agent timed out after {timeout_seconds} seconds" + ) + + run.exit_code = int(proc.returncode or 0) + + +def _ingest_stdout_line( + line: str, + *, + run: DockerAgentRun, + on_event: Callable[[DockerAgentRun], None] | None, +) -> None: + """Parse a single stdout line and update *run* when it's a known event. + + Only ``type="system"`` events that actually change a tracked field + (``run_id``, ``session_link``, ``conversation_id``) trigger the + ``on_event`` callback. Noisier events -- ``agent``, ``tool_call``, + ``tool_result``, ``skill_invoked``, etc. -- are ignored here because + callbacks like ``_record_triage_session_link`` issue a GitHub + ``PATCH /comments/:id`` per invocation and would otherwise hit + comment-edit rate limits on chatty runs. The raw stdout is already + echoed back to the host's stdout so operators can still see + everything during the run. + """ + stripped = line.strip() + if not stripped: + return + try: + event = json.loads(stripped) + except ValueError: + return + if not isinstance(event, dict): + return + + if event.get("type") != "system": + return + if not _apply_system_event(event, run=run): + return + if on_event is None: + return + try: + on_event(run) + except Exception: + logger.exception("Docker agent on_event callback raised") + + +def _apply_system_event(event: dict[str, Any], *, run: DockerAgentRun) -> bool: + """Apply a ``{"type": "system", "event_type": ...}`` payload to *run*. + + Returns ``True`` iff the event actually changed a tracked field on + *run* (so callers can fire ``on_event`` only on real state + transitions). The three recognized ``event_type`` values come from + the ``JsonSystemEvent`` Rust enum in + ``deep-forest/app/src/ai/agent_sdk/driver/output.rs``: + ``run_started``, ``shared_session_established``, and + ``conversation_started``. See the module docstring for emit sites + and guarantees. Any other value is silently ignored (returns + ``False``) so new variants added upstream do not break the parser. + """ + event_type = event.get("event_type") + if event_type == "run_started": + run_id = str(event.get("run_id") or "").strip() + if run_id and run.run_id != run_id: + run.run_id = run_id + return True + elif event_type == "shared_session_established": + join_url = str(event.get("join_url") or "").strip() + if join_url and run.session_link != join_url: + run.session_link = join_url + return True + elif event_type == "conversation_started": + conversation_id = str(event.get("conversation_id") or "").strip() + if conversation_id and run.conversation_id != conversation_id: + run.conversation_id = conversation_id + return True + return False + + +def _format_argv_for_log(argv: Iterable[str]) -> str: + """Produce a single-line representation of *argv* safe for logs. + + The prompt is potentially large and noisy, so we replace it with a + short ```` placeholder. Every other argument is emitted + verbatim; forwarded env vars use the bare ``-e NAME`` form so the + secret value never lives on the argv in the first place. + """ + rendered: list[str] = [] + skip_next = False + for part in argv: + if skip_next: + rendered.append("") + skip_next = False + continue + if part == "--prompt": + rendered.append(part) + skip_next = True + continue + rendered.append(part) + return " ".join(rendered) + + +def _read_output_json(path: Path) -> dict[str, Any]: + """Read and JSON-decode *path*, raising ``DockerAgentError`` on problems. + + Internal helper used by :func:`run_agent_in_docker` to pull the + agent's result JSON out of the mounted output directory before the + tempdir is removed. Returns a JSON object (``dict``); raises when + the file is missing, unreadable, malformed, or not a JSON object. + """ + if not path.is_file(): + raise DockerAgentError( + f"Docker agent did not produce expected output file: {path}" + ) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except ValueError as exc: + raise DockerAgentError( + f"Docker agent output file {path} did not decode as JSON: {exc}" + ) from exc + if not isinstance(data, dict): + raise DockerAgentError( + f"Docker agent output file {path} must decode to a JSON object" + ) + return data + + +def resolve_triage_image() -> str: + """Return the image tag the triage workflows use. + + Workflows set ``TRIAGE_IMAGE`` in the job env. The fallback matches + the tag produced by the ``docker build`` step. + """ + return optional_env("TRIAGE_IMAGE") or "oz-for-oss-triage" + + +def resolve_review_image() -> str: + """Return the image tag the PR review workflow uses.""" + return optional_env("REVIEW_IMAGE") or "oz-for-oss-review" + + +__all__ = [ + "DEFAULT_TIMEOUT_SECONDS", + "DockerAgentError", + "DockerAgentRun", + "DockerAgentTimeout", + "OUTPUT_MOUNT", + "REPO_MOUNT", + "resolve_review_image", + "resolve_triage_image", + "run_agent_in_docker", +] diff --git a/.github/scripts/oz_workflows/env.py b/.github/scripts/oz_workflows/env.py new file mode 100644 index 0000000..8623cff --- /dev/null +++ b/.github/scripts/oz_workflows/env.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + + +def require_env(name: str) -> str: + """Return a required environment variable after trimming surrounding whitespace.""" + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + +def optional_env(name: str) -> str: + """Return an optional environment variable as a trimmed string.""" + return os.environ.get(name, "").strip() + + +def repo_slug() -> str: + """Return the current GitHub repository slug.""" + return require_env("GITHUB_REPOSITORY") + + +def repo_parts() -> tuple[str, str]: + """Split the current repository slug into owner and repository name.""" + owner, repo = repo_slug().split("/", 1) + return owner, repo + + +def workspace() -> Path: + """Return the workflow workspace directory.""" + return Path(os.environ.get("GITHUB_WORKSPACE") or os.getcwd()) + + +def load_event() -> dict[str, Any]: + """Load the GitHub Actions event payload JSON.""" + event_path = require_env("GITHUB_EVENT_PATH") + with open(event_path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def resolve_issue_number(event: dict[str, Any], *, env_var: str = "ISSUE_NUMBER") -> int: + """Resolve an issue number from the event payload or a workflow input env var.""" + issue_number = (event.get("issue") or {}).get("number") + if issue_number not in (None, ""): + return int(issue_number) + override = optional_env(env_var) + if override: + return int(override) + raise RuntimeError( + f"Unable to resolve issue number from event payload or ${env_var}." + ) + diff --git a/.github/scripts/oz_workflows/helpers.py b/.github/scripts/oz_workflows/helpers.py new file mode 100644 index 0000000..21aff55 --- /dev/null +++ b/.github/scripts/oz_workflows/helpers.py @@ -0,0 +1,1922 @@ +from __future__ import annotations + +import json +import logging +import re +import urllib.parse +import uuid +from datetime import datetime, timezone +from functools import lru_cache +from pathlib import Path +from typing import Any + +from github import Github +from github.GithubException import UnknownObjectException +from github.IssueComment import IssueComment +from github.PullRequest import PullRequest +from github.PullRequestComment import PullRequestComment +from github.Repository import Repository +from oz_agent_sdk.types.agent import RunItem + +from . import actions +from .artifacts import ResolvedReviewComment +from .env import optional_env, workspace +from .workflow_config import load_triage_workflow_config + +logger = logging.getLogger(__name__) + + +# Author associations that indicate organization membership. +ORG_MEMBER_ASSOCIATIONS: set[str] = {"COLLABORATOR", "MEMBER", "OWNER"} + +_CLOSING_ISSUES_QUERY = ( + "query($owner: String!, $name: String!, $number: Int!, $after: String) {" + " repository(owner: $owner, name: $name) {" + " pullRequest(number: $number) {" + " closingIssuesReferences(first: 100, after: $after) {" + " pageInfo { hasNextPage endCursor }" + " nodes {" + " number" + " repository { owner { login } name }" + " }" + " }" + " }" + " }" + " }" +) + +_MANUAL_LINKED_ISSUES_QUERY = ( + "query($owner: String!, $name: String!, $number: Int!, $after: String) {" + " repository(owner: $owner, name: $name) {" + " pullRequest(number: $number) {" + " timelineItems(first: 100, after: $after, itemTypes: [CONNECTED_EVENT, DISCONNECTED_EVENT]) {" + " pageInfo { hasNextPage endCursor }" + " nodes {" + " __typename" + " ... on ConnectedEvent {" + " subject {" + " __typename" + " ... on Issue {" + " number" + " repository { owner { login } name }" + " }" + " }" + " }" + " ... on DisconnectedEvent {" + " subject {" + " __typename" + " ... on Issue {" + " number" + " repository { owner { login } name }" + " }" + " }" + " }" + " }" + " }" + " }" + " }" + " }" +) + + +def parse_datetime(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) + + +def get_field(item: Any, name: str, default: Any = None) -> Any: + if isinstance(item, dict): + return item.get(name, default) + return getattr(item, name, default) + + +def get_login(item: Any) -> str: + if isinstance(item, dict): + return str(item.get("login") or "") + return str(getattr(item, "login", "") or "") + + +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() + user_type = str(get_field(user, "type", "") or "").strip().lower() + return ( + user_type == "bot" + or (bool(login) and login.endswith("[bot]")) + ) + + +def is_trusted_commenter( + github_client: Github, + event: dict[str, Any], + *, + org: str, +) -> bool: + """Decide whether the commenter that triggered *event* is trusted. + + Trust is evaluated deterministically in Python before the agent runs so + we can short-circuit untrusted mentions without relying on the agent to + infer trust from the presence or absence of the triggering comment in + ``fetch_github_context.py`` output (the fetch output can legitimately + miss the comment for reasons unrelated to trust: script path issues, + transient API errors, pagination edge cases, output truncation, etc.). + + Trust rules mirror the ``check_trust`` job in + ``respond-to-pr-comment-local.yml`` and the org-membership fallback in + ``fetch_github_context.py``: + + - If the comment's ``author_association`` is ``OWNER``, ``MEMBER``, or + ``COLLABORATOR`` the author is trusted immediately. + - Otherwise probe ``GET /orgs/{org}/members/{login}``. A 204 response + promotes the author to trusted so legitimate org members whose + membership is private (or whose association the event payload + reports as ``CONTRIBUTOR`` for any other reason) are not dropped. + - Any other status (404 "not a member", 302 redirect to the public + endpoint, request error, ...) leaves the author untrusted. We fail + closed on errors to avoid accidentally granting trust. + """ + # Support both comment events (event["comment"]) and review events (event["review"]). + actor = event.get("comment") if isinstance(event, dict) else None + if not isinstance(actor, dict): + actor = event.get("review") if isinstance(event, dict) else None + if not isinstance(actor, dict): + return False + association = str(actor.get("author_association") or "").upper() + if association in ORG_MEMBER_ASSOCIATIONS: + return True + login = (actor.get("user") or {}).get("login") or "" + if not login or not org: + return False + path = ( + f"/orgs/{urllib.parse.quote(org, safe='')}" + f"/members/{urllib.parse.quote(login, safe='')}" + ) + try: + status, _headers, _body = github_client.requester.requestJson( + "GET", path + ) + except Exception: + logger.exception( + "Org membership probe for @%s in %s failed; treating author as untrusted.", + login, + org, + ) + return False + return status == 204 + + +def get_timestamp_text(value: Any) -> str: + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + return str(value or "") + + +def get_label_name(label: Any) -> str: + if isinstance(label, str): + return label + return str(get_field(label, "name", "") or "") + + +def format_issue_comments_for_prompt( + comments: list[Any], + *, + metadata_prefix: str, + exclude_comment_id: int | None = None, +) -> str: + """Format human-authored issue comments for prompt context. + + ``metadata_prefix`` is kept in the signature for backwards compatibility + with older callers, but the filtering decision no longer depends on + scanning comment bodies for Oz metadata markers. Instead, we drop all + automation-authored comments so bot messages are excluded even when they + do not carry a metadata footer, and human-authored comments remain visible + even if they happen to contain the metadata prefix text. + """ + selected = [ + comment + for comment in comments + if int(get_field(comment, "id") or 0) != exclude_comment_id + and not is_automation_user(get_field(comment, "user")) + ] + if not selected: + return "- None" + formatted = [] + for comment in selected: + user = get_login(get_field(comment, "user")) or "unknown" + association = get_field(comment, "author_association") or "NONE" + body = str(get_field(comment, "body") or "").strip() or "(no body)" + formatted.append( + f"- @{user} [{association}] ({get_timestamp_text(get_field(comment, 'created_at'))}): {body}" + ) + return "\n".join(formatted) + + +def _filter_review_comments_in_thread( + all_review_comments: list[Any], + trigger_comment_id: int, +) -> list[Any]: + """Return review comments that belong to the thread containing *trigger_comment_id*. + + GitHub's REST API (and therefore PyGitHub) does not expose an endpoint for + fetching a single review thread by comment id; ``pullRequestReviewThread`` + exists only in the GraphQL API. ``PullRequest.get_review_comment(id)`` + returns just the one comment, and ``get_single_review_comments(review_id)`` + scopes to a ``PullRequestReview`` batch rather than a reply thread, so we + have to filter client-side. + + GitHub flat-threads review replies: every reply's ``in_reply_to_id`` points + directly at the thread root regardless of which comment was quoted, so the + root is either the triggering comment itself or the comment its + ``in_reply_to_id`` refers to. + """ + by_id: dict[int, Any] = {int(get_field(c, "id")): c for c in all_review_comments} + trigger = by_id.get(trigger_comment_id) + parent = get_field(trigger, "in_reply_to_id") if trigger is not None else None + root_id = int(parent) if parent is not None else trigger_comment_id + return [ + c + for c in all_review_comments + if int(get_field(c, "id")) == root_id or get_field(c, "in_reply_to_id") == root_id + ] + + +def org_member_comments_text( + comments: list[Any], + *, + exclude_comment_id: int | None = None, +) -> str: + selected = [ + comment + for comment in comments + if get_field(comment, "author_association") in ORG_MEMBER_ASSOCIATIONS + and int(get_field(comment, "id") or 0) != exclude_comment_id + ] + if not selected: + return "" + return "\n".join( + f"- {get_login(get_field(comment, 'user')) or 'unknown'} ({get_timestamp_text(get_field(comment, 'created_at'))}): {get_field(comment, 'body') or ''}" + for comment in selected + ) + + +def triggering_comment_prompt_text(event_payload: dict[str, Any]) -> str: + comment = event_payload.get("comment") + if not isinstance(comment, dict): + return "" + body = str(comment.get("body") or "").strip() + if not body: + return "" + author_login = (comment.get("user") or {}).get("login") or (event_payload.get("sender") or {}).get("login") or "unknown" + return f"@{author_login} commented:\n{body}" + + +def comment_metadata( + workflow: str, + issue_number: int, + *, + run_id: str = "", + oz_run_id: str = "", + github_run_id: str = "", +) -> str: + payload: dict[str, Any] = { + "type": "issue-status", + "workflow": workflow, + "issue": issue_number, + } + if run_id: + payload["run_id"] = run_id + if github_run_id: + payload["github_run_id"] = github_run_id + if oz_run_id: + payload["oz_run_id"] = oz_run_id + return f"" + + +def _workflow_metadata_prefix(workflow: str, issue_number: int) -> str: + """Return the stable metadata prefix shared by all runs of the same workflow on an issue.""" + return f'", start) + if end == -1: + return body + end += len("-->") + return (body[:start] + body[end:]).strip() + + +def _parse_workflow_metadata(body: str, workflow_prefix: str) -> dict[str, Any] | None: + """Parse the workflow metadata marker from *body* when it matches *workflow_prefix*.""" + if not body or not workflow_prefix: + return None + start = body.find(workflow_prefix) + if start == -1: + return None + end = body.find("-->", start) + if end == -1: + return None + marker = body[start:end].strip() + prefix = "" +ORIGINAL_REPORT_END = "" +ISSUE_TEMPLATE_CONFIG_NAMES = {"config.yml", "config.yaml"} +ISSUE_TEMPLATE_SUFFIXES = {".md", ".yml", ".yaml"} +TRIAGE_SECTION_END = "" + + +def load_triage_config(path: Path) -> dict[str, Any]: + parsed = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(parsed, dict): + raise RuntimeError("Issue triage config must be a JSON object") + labels = parsed.get("labels") + if not isinstance(labels, dict): + raise RuntimeError("Issue triage config must include a labels object") + return parsed + + +def load_stakeholders(path: Path) -> list[dict[str, Any]]: + """Parse a CODEOWNERS-style STAKEHOLDERS file into structured entries. + + Each non-comment, non-blank line is expected to have the form: + @owner1 @owner2 ... + + Returns a list of dicts with ``pattern`` and ``owners`` keys. + """ + entries: list[dict[str, Any]] = [] + if not path.exists(): + return entries + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) < 2: + continue + pattern = parts[0] + owners = [p.lstrip("@") for p in parts[1:] if p.startswith("@")] + if owners: + entries.append({"pattern": pattern, "owners": owners}) + return entries + + +def format_stakeholders_for_prompt(entries: list[dict[str, Any]]) -> str: + """Format parsed STAKEHOLDERS entries into a human-readable prompt block.""" + if not entries: + return "No stakeholders configured." + lines: list[str] = [] + for entry in entries: + owners = ", ".join(f"@{o}" for o in entry["owners"]) + lines.append(f"- {entry['pattern']} → {owners}") + return "\n".join(lines) + + +def dedupe_strings(values: list[Any]) -> list[str]: + seen: set[str] = set() + normalized: list[str] = [] + for value in values: + text = str(value or "").strip() + if not text or text in seen: + continue + seen.add(text) + normalized.append(text) + return normalized + + +def issue_has_label(issue: Any, label_name: str) -> bool: + for raw_label in get_field(issue, "labels", []): + current = raw_label if isinstance(raw_label, str) else get_field(raw_label, "name") + if current == label_name: + return True + return False + + +def select_recent_untriaged_issues( + issues: list[Any], + *, + cutoff: datetime, + triaged_label: str = "triaged", +) -> list[Any]: + selected = [ + issue + for issue in issues + if not get_field(issue, "pull_request") + and ( + get_field(issue, "created_at") >= cutoff + if isinstance(get_field(issue, "created_at"), datetime) + else parse_datetime(get_field(issue, "created_at") or "1970-01-01T00:00:00Z") >= cutoff + ) + and not issue_has_label(issue, triaged_label) + ] + selected.sort( + key=lambda issue: ( + get_field(issue, "created_at") + if isinstance(get_field(issue, "created_at"), datetime) + else parse_datetime(get_field(issue, "created_at") or "1970-01-01T00:00:00Z") + ) + ) + return selected + + +def discover_issue_templates(workspace: Path) -> dict[str, Any]: + template_dir = workspace / ".github" / "ISSUE_TEMPLATE" + config: dict[str, str] | None = None + templates: list[dict[str, str]] = [] + seen_template_paths: set[str] = set() + + def add_template(path: Path) -> None: + key = str(path.resolve()).casefold() + if key in seen_template_paths: + return + seen_template_paths.add(key) + templates.append( + { + "path": path.relative_to(workspace).as_posix(), + "content": path.read_text(encoding="utf-8").strip(), + } + ) + + if template_dir.exists(): + for path in sorted(template_dir.iterdir()): + if not path.is_file(): + continue + if path.name.lower() in ISSUE_TEMPLATE_CONFIG_NAMES: + config = { + "path": path.relative_to(workspace).as_posix(), + "content": path.read_text(encoding="utf-8").strip(), + } + continue + if path.suffix.lower() not in ISSUE_TEMPLATE_SUFFIXES: + continue + add_template(path) + + for legacy_relative_path in [".github/issue_template.md", ".github/ISSUE_TEMPLATE.md"]: + legacy_path = workspace / legacy_relative_path + if not legacy_path.exists() or not legacy_path.is_file(): + continue + add_template(legacy_path) + + return { + "config": config, + "templates": templates, + } + + +def extract_original_issue_report(body: str) -> str: + body = (body or "").strip() + if ORIGINAL_REPORT_START not in body or ORIGINAL_REPORT_END not in body: + return body + start = body.index(ORIGINAL_REPORT_START) + len(ORIGINAL_REPORT_START) + end = body.index(ORIGINAL_REPORT_END, start) + report = body[start:end].strip() + if report.startswith("
") and report.endswith("
"): + inner = report.removeprefix("
").removesuffix("
").strip() + summary = "Original issue report" + if inner.startswith(summary): + inner = inner.removeprefix(summary).strip() + report = inner.strip() + return report + + +def strip_preserved_original_report(body: str) -> str: + text = (body or "").strip() + if ORIGINAL_REPORT_START not in text or ORIGINAL_REPORT_END not in text: + return text + start = text.index(ORIGINAL_REPORT_START) + end = text.index(ORIGINAL_REPORT_END, start) + len(ORIGINAL_REPORT_END) + prefix = text[:start].rstrip() + suffix = text[end:].lstrip() + pieces = [piece for piece in [prefix, suffix] if piece] + return "\n\n".join(pieces) + + +def build_original_report_details(original_report: str) -> str: + report = original_report.strip() or "_No original issue report was provided._" + return "\n".join( + [ + ORIGINAL_REPORT_START, + "
", + "Original issue report", + "", + report, + "", + "
", + ORIGINAL_REPORT_END, + ] + ) + + +def compose_triaged_issue_body(visible_body: str, original_report: str) -> str: + content = strip_preserved_original_report(visible_body) + appendix = build_original_report_details(original_report) + if not content: + return appendix + return f"{content}\n\n{appendix}" diff --git a/.github/scripts/oz_workflows/verification.py b/.github/scripts/oz_workflows/verification.py new file mode 100644 index 0000000..bdf57d6 --- /dev/null +++ b/.github/scripts/oz_workflows/verification.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, cast + +import yaml +from oz_agent_sdk.types.agent import RunItem + +from .oz_client import build_oz_client + +_FRONTMATTER_PATTERN = re.compile( + r"\A\s*---\s*\n(?P.*?)\n---\s*(?:\n|$)", + re.DOTALL, +) +_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"} +_VIDEO_EXTENSIONS = {".mp4", ".mov", ".webm", ".m4v"} + + +@dataclass(frozen=True) +class VerificationSkill: + name: str + path: Path + description: str + + +@dataclass(frozen=True) +class VerificationArtifact: + artifact_type: str + title: str + content_type: str + download_url: str + description: str + + @property + def is_image(self) -> bool: + if self.artifact_type == "SCREENSHOT": + return True + if self.content_type.lower().startswith("image/"): + return True + return Path(self.title).suffix.lower() in _IMAGE_EXTENSIONS + + @property + def is_video(self) -> bool: + if self.content_type.lower().startswith("video/"): + return True + return Path(self.title).suffix.lower() in _VIDEO_EXTENSIONS + + +def _load_frontmatter(path: Path) -> dict[str, Any]: + try: + raw_text = path.read_text(encoding="utf-8") + except OSError: + return {} + match = _FRONTMATTER_PATTERN.match(raw_text) + if match is None: + return {} + try: + payload = yaml.safe_load(match.group("frontmatter")) or {} + except yaml.YAMLError: + return {} + if not isinstance(payload, dict): + return {} + return payload + + +def _frontmatter_metadata_flag(frontmatter: dict[str, Any], flag_name: str) -> bool: + metadata = frontmatter.get("metadata") + if not isinstance(metadata, dict): + return False + value = metadata.get(flag_name) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False + + +def discover_verification_skills(workspace_root: Path) -> list[VerificationSkill]: + skills_root = Path(workspace_root) / ".agents" / "skills" + if not skills_root.is_dir(): + return [] + discovered: list[VerificationSkill] = [] + for skill_path in sorted(skills_root.glob("*/SKILL.md")): + frontmatter = _load_frontmatter(skill_path) + if not _frontmatter_metadata_flag(frontmatter, "verification"): + continue + name = str(frontmatter.get("name") or skill_path.parent.name).strip() + if not name: + name = skill_path.parent.name + description = str(frontmatter.get("description") or "").strip() + discovered.append( + VerificationSkill( + name=name, + path=skill_path.resolve(), + description=description, + ) + ) + return discovered + + +def format_verification_skills_for_prompt( + skills: list[VerificationSkill], *, workspace_root: Path +) -> str: + if not skills: + return "- None" + lines: list[str] = [] + root = Path(workspace_root).resolve() + for skill in skills: + try: + display_path = skill.path.relative_to(root).as_posix() + except ValueError: + display_path = skill.path.as_posix() + description = f" — {skill.description}" if skill.description else "" + lines.append(f"- `{skill.name}` at `{display_path}`{description}") + return "\n".join(lines) + + +def _artifact_field(value: Any, name: str, default: str = "") -> str: + if value is None: + return default + if isinstance(value, dict): + result = value.get(name, default) + else: + result = getattr(value, name, default) + if result is None: + return default + return str(result) + + +def list_downloadable_verification_artifacts( + run: RunItem, + *, + exclude_filenames: set[str] | None = None, +) -> list[VerificationArtifact]: + client = None + excluded = {name for name in (exclude_filenames or set()) if name} + collected: list[VerificationArtifact] = [] + seen: set[tuple[str, str, str]] = set() + for artifact in cast(list[Any], run.artifacts or []): + artifact_type = _artifact_field(artifact, "artifact_type").upper() + if artifact_type in {"PLAN", "PULL_REQUEST"}: + continue + data = getattr(artifact, "data", None) + filename = _artifact_field(data, "filename").strip() + if filename and filename in excluded: + continue + artifact_uid = _artifact_field(data, "artifact_uid").strip() + response_type = artifact_type + response_data = data + download_url = _artifact_field(response_data, "download_url").strip() + if artifact_uid and not download_url: + if client is None: + client = build_oz_client() + try: + response = client.agent.get_artifact(artifact_uid) + except Exception: + continue + response_type = _artifact_field(response, "artifact_type", artifact_type).upper() + response_data = getattr(response, "data", None) + download_url = _artifact_field(response_data, "download_url").strip() + if not download_url: + continue + content_type = _artifact_field(response_data, "content_type").strip() + description = _artifact_field(response_data, "description").strip() + response_filename = _artifact_field(response_data, "filename").strip() + title = response_filename or filename or description or artifact_type.title() + key = (response_type, title, download_url) + if key in seen: + continue + seen.add(key) + collected.append( + VerificationArtifact( + artifact_type=response_type, + title=title, + content_type=content_type, + download_url=download_url, + description=description, + ) + ) + return collected + + +def render_verification_comment( + report: dict[str, Any], + *, + session_link: str = "", + artifacts: Iterable[VerificationArtifact] = (), +) -> str: + overall_status = str(report.get("overall_status") or "mixed").strip().lower() + if overall_status not in {"passed", "failed", "mixed"}: + overall_status = "mixed" + summary = str(report.get("summary") or "").strip() + raw_skills = report.get("skills") + skills: list[dict[str, str]] = [] + if isinstance(raw_skills, list): + for entry in raw_skills: + if not isinstance(entry, dict): + continue + skills.append( + { + "name": str(entry.get("name") or "").strip(), + "path": str(entry.get("path") or "").strip(), + "status": str(entry.get("status") or "").strip().lower(), + "summary": str(entry.get("summary") or "").strip(), + } + ) + + sections = [f"## /oz-verify report\nStatus: **{overall_status}**"] + if session_link.strip(): + sections.append(f"Session: [view on Warp]({session_link.strip()})") + if summary: + sections.append(f"## Summary\n{summary}") + if skills: + lines = [] + for skill in skills: + name = skill["name"] or "unnamed-skill" + path = skill["path"] + status = skill["status"] or "mixed" + detail = skill["summary"] + prefix = f"- `{name}`" + if path: + prefix += f" (`{path}`)" + prefix += f": **{status}**" + if detail: + prefix += f" — {detail}" + lines.append(prefix) + sections.append("## Skill results\n" + "\n".join(lines)) + + artifact_list = list(artifacts) + screenshots = [artifact for artifact in artifact_list if artifact.is_image] + videos = [artifact for artifact in artifact_list if artifact.is_video and not artifact.is_image] + others = [ + artifact + for artifact in artifact_list + if artifact not in screenshots and artifact not in videos + ] + + if screenshots: + screenshot_blocks = [] + for artifact in screenshots: + alt_text = artifact.description or artifact.title or "Verification screenshot" + screenshot_blocks.append(f"### {artifact.title}\n![{alt_text}]({artifact.download_url})") + sections.append("## Screenshots\n" + "\n\n".join(screenshot_blocks)) + if videos: + video_lines = [] + for artifact in videos: + label = artifact.description or artifact.title + video_lines.append(f"- [{label}]({artifact.download_url})") + sections.append("## Video artifacts\n" + "\n".join(video_lines)) + if others: + other_lines = [] + for artifact in others: + label = artifact.description or artifact.title + other_lines.append(f"- [{label}]({artifact.download_url})") + sections.append("## Additional artifacts\n" + "\n".join(other_lines)) + + return "\n\n".join(section.strip() for section in sections if section.strip()) diff --git a/.github/scripts/oz_workflows/workflow_config.py b/.github/scripts/oz_workflows/workflow_config.py new file mode 100644 index 0000000..685e351 --- /dev/null +++ b/.github/scripts/oz_workflows/workflow_config.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from .workflow_paths import preferred_repo_roots + + +CONFIG_RELATIVE_PATH = Path(".github/oz/config.yml") +_GITHUB_HANDLE_PATTERN = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})") + + +@dataclass(frozen=True) +class SelfImprovementConfig: + reviewers: list[str] | None + base_branch: str | None +@dataclass(frozen=True) +class TriageWorkflowConfig: + prior_triage_labels: frozenset[str] + + +_DEFAULT_PRIOR_TRIAGE_LABELS: frozenset[str] = frozenset({"triaged"}) + + +def resolve_repo_config_path(workspace_root: Path) -> Path | None: + """Resolve the first available workflow config path for *workspace_root*.""" + for root in preferred_repo_roots(workspace_root): + candidate = root / CONFIG_RELATIVE_PATH + if candidate.is_file(): + return candidate.resolve() + return None + + +def _fail(config_path: Path, message: str) -> RuntimeError: + return RuntimeError(f"{config_path}: {message}") + + +def _normalize_handle(raw_value: Any, *, config_path: Path, source: str) -> str: + if not isinstance(raw_value, str): + raise _fail(config_path, f"{source} entries must be strings.") + value = raw_value.strip() + if not value: + raise _fail(config_path, f"{source} entries must not be blank.") + if value.startswith("@"): + raise _fail( + config_path, + f"{source} entries must be GitHub handles without a leading '@'.", + ) + if not _GITHUB_HANDLE_PATTERN.fullmatch(value): + raise _fail(config_path, f"Invalid GitHub handle {value!r} in {source}.") + return value + + +def _parse_reviewers_list( + raw_value: Any, + *, + config_path: Path, + source: str, +) -> list[str]: + if not isinstance(raw_value, list): + raise _fail(config_path, f"{source} must be a list of GitHub handles.") + return [ + _normalize_handle(item, config_path=config_path, source=source) + for item in raw_value + ] + + +def _parse_base_branch( + raw_value: Any, + *, + config_path: Path, + source: str, +) -> str | None: + if not isinstance(raw_value, str): + raise _fail(config_path, f"{source} must be a string branch name or 'auto'.") + value = raw_value.strip() + if not value: + raise _fail(config_path, f"{source} must not be blank.") + if value == "auto": + return None + return value + + +def _parse_label_list( + raw_value: Any, + *, + config_path: Path, + source: str, +) -> frozenset[str]: + if not isinstance(raw_value, list): + raise _fail(config_path, f"{source} must be a list of label names.") + labels: set[str] = set() + for item in raw_value: + if not isinstance(item, str): + raise _fail(config_path, f"{source} entries must be strings.") + value = item.strip().lower() + if not value: + raise _fail(config_path, f"{source} entries must not be blank.") + labels.add(value) + return frozenset(labels) + + +def _load_raw_workflow_config( + workspace_root: Path, + *, + require_exists: bool, +) -> tuple[Path, dict[str, Any]]: + config_path = resolve_repo_config_path(workspace_root) + if config_path is None: + if require_exists: + raise RuntimeError( + "Unable to locate .github/oz/config.yml in either the consuming " + "repository workspace or the checked-out workflow code." + ) + return CONFIG_RELATIVE_PATH, {"version": 1} + + try: + raw_data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise _fail(config_path, "Invalid YAML in .github/oz/config.yml.") from exc + except OSError as exc: + raise _fail(config_path, "Unable to read .github/oz/config.yml.") from exc + + if raw_data is None: + raw_data = {} + if not isinstance(raw_data, dict): + raise _fail(config_path, "The config root must be a YAML mapping.") + + version = raw_data.get("version") + if version != 1: + raise _fail(config_path, "Unsupported config version; expected version: 1.") + + return config_path, raw_data + + +def _parse_env_reviewers(config_path: Path) -> list[str] | None: + if "SELF_IMPROVEMENT_REVIEWERS" not in os.environ: + return None + raw_value = os.environ["SELF_IMPROVEMENT_REVIEWERS"].strip() + if not raw_value: + raise _fail( + config_path, + "SELF_IMPROVEMENT_REVIEWERS must be a comma-separated list of handles.", + ) + return _parse_reviewers_list( + [part.strip() for part in raw_value.split(",")], + config_path=config_path, + source="SELF_IMPROVEMENT_REVIEWERS", + ) + + +def _parse_env_base_branch(config_path: Path) -> str | None: + if "SELF_IMPROVEMENT_BASE_BRANCH" not in os.environ: + return None + raw_value = os.environ["SELF_IMPROVEMENT_BASE_BRANCH"] + return _parse_base_branch( + raw_value, + config_path=config_path, + source="SELF_IMPROVEMENT_BASE_BRANCH", + ) + + +def load_self_improvement_config(workspace_root: Path) -> SelfImprovementConfig: + """Load and validate the resolved self-improvement workflow config.""" + config_path, raw_data = _load_raw_workflow_config( + workspace_root, + require_exists=True, + ) + + self_improvement = raw_data.get("self_improvement") + if self_improvement is None: + self_improvement = {} + if not isinstance(self_improvement, dict): + raise _fail(config_path, "self_improvement must be a YAML mapping.") + + unknown_keys = sorted( + key + for key in self_improvement.keys() + if key not in {"reviewers", "base_branch"} + ) + if unknown_keys: + raise _fail( + config_path, + "Unknown self_improvement keys: " + ", ".join(unknown_keys), + ) + + reviewers: list[str] | None = None + if "reviewers" in self_improvement: + reviewers = _parse_reviewers_list( + self_improvement["reviewers"], + config_path=config_path, + source="self_improvement.reviewers", + ) + + base_branch: str | None = None + if "base_branch" in self_improvement: + base_branch = _parse_base_branch( + self_improvement["base_branch"], + config_path=config_path, + source="self_improvement.base_branch", + ) + + env_reviewers = _parse_env_reviewers(config_path) + if env_reviewers is not None: + reviewers = env_reviewers + + env_base_branch = _parse_env_base_branch(config_path) + if "SELF_IMPROVEMENT_BASE_BRANCH" in os.environ: + base_branch = env_base_branch + + return SelfImprovementConfig(reviewers=reviewers, base_branch=base_branch) + + +def load_triage_workflow_config(workspace_root: Path) -> TriageWorkflowConfig: + """Load the optional triage workflow settings from `.github/oz/config.yml`.""" + config_path, raw_data = _load_raw_workflow_config( + workspace_root, + require_exists=False, + ) + + triage = raw_data.get("triage") + if triage is None: + triage = {} + if not isinstance(triage, dict): + raise _fail(config_path, "triage must be a YAML mapping.") + + unknown_keys = sorted( + key + for key in triage.keys() + if key not in {"prior_triage_labels"} + ) + if unknown_keys: + raise _fail( + config_path, + "Unknown triage keys: " + ", ".join(unknown_keys), + ) + + prior_triage_labels = _DEFAULT_PRIOR_TRIAGE_LABELS + if "prior_triage_labels" in triage: + prior_triage_labels = _parse_label_list( + triage["prior_triage_labels"], + config_path=config_path, + source="triage.prior_triage_labels", + ) + + return TriageWorkflowConfig(prior_triage_labels=prior_triage_labels) diff --git a/.github/scripts/oz_workflows/workflow_paths.py b/.github/scripts/oz_workflows/workflow_paths.py new file mode 100644 index 0000000..dcb8a4f --- /dev/null +++ b/.github/scripts/oz_workflows/workflow_paths.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + +from .env import workspace + + +def workflow_code_root(start_path: str | Path | None = None) -> Path: + """Return the workflow code root by walking up to the nearest .github directory.""" + start = Path(start_path or __file__).resolve() + for candidate in start.parents: + if (candidate / ".github").is_dir(): + return candidate + raise RuntimeError( + "Unable to locate the workflow code root: no '.github' sentinel " + f"directory found while walking up from {start}." + ) + + +def preferred_repo_roots(workspace_root: Path | None = None) -> list[Path]: + """Return the consuming repo root first, then the workflow checkout root.""" + consumer_root = (workspace_root or workspace()).resolve() + workflow_root = workflow_code_root().resolve() + roots = [consumer_root] + if workflow_root != consumer_root: + roots.append(workflow_root) + return roots diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 0000000..1863d35 --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1,4 @@ +oz-agent-sdk>=0.11.0 +httpx>=0.24 +PyGithub>=2.9.0,<3 +PyYAML>=6.0,<7 diff --git a/.github/scripts/respond_to_triaged_issue_comment.py b/.github/scripts/respond_to_triaged_issue_comment.py new file mode 100644 index 0000000..a76dafb --- /dev/null +++ b/.github/scripts/respond_to_triaged_issue_comment.py @@ -0,0 +1,221 @@ +from __future__ import annotations +from contextlib import closing +from pathlib import Path +from textwrap import dedent +from typing import Any + +from github import Auth, Github + +from oz_workflows.actions import notice +from oz_workflows.docker_agent import ( + REPO_MOUNT, + resolve_triage_image, + run_agent_in_docker, +) +from oz_workflows.env import load_event, optional_env, repo_parts, repo_slug, require_env, workspace +from oz_workflows.helpers import ( + WorkflowProgressComment, + format_issue_comments_for_prompt, + format_respond_to_triaged_start_line, + is_automation_user, + is_trusted_commenter, + record_run_session_link, + triggering_comment_prompt_text, +) +from oz_workflows.triage import extract_original_issue_report + + +WORKFLOW_NAME = "respond-to-triaged-issue-comment" +OZ_AGENT_METADATA_PREFIX = "