Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .taskcluster.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ tasks:
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 --extra bugzilla --extra firefox --extra claude-sdk pytest --cov=agent_tools --cov-append tests/ &&
cd ../hackbot-runtime &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 --extra claude-sdk --extra phabricator pytest --cov=hackbot_runtime --cov-append tests/ &&
cd ../lando-client &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=lando_client --cov-append tests/ &&
cd ../.. &&
bash <(curl -s https://codecov.io/bash)"
metadata:
Expand Down
3 changes: 2 additions & 1 deletion libs/hackbot-runtime/hackbot_runtime/actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
claude-sdk adapter is ``hackbot_runtime.actions.claude_sdk.actions_server_for``.
"""

from hackbot_runtime.actions import bugzilla, phabricator, testrail
from hackbot_runtime.actions import bugzilla, phabricator, testrail, try_server
from hackbot_runtime.actions.recorder import ActionHook, ActionsRecorder

ACTIONS_SERVER_NAME = "actions"
Expand All @@ -19,4 +19,5 @@
"bugzilla",
"phabricator",
"testrail",
"try_server",
]
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from hackbot_runtime.actions import bugzilla as _bugzilla
from hackbot_runtime.actions import phabricator as _phabricator
from hackbot_runtime.actions import testrail as _testrail
from hackbot_runtime.actions import try_server as _try_server
from hackbot_runtime.actions.recorder import ActionsRecorder


Expand All @@ -34,7 +35,7 @@ def actions_server_for(
"""
if recorder is None:
recorder = ActionsRecorder(artifacts_dir=fallback_artifacts_dir)
tools = _bugzilla.TOOLS + _phabricator.TOOLS + _testrail.TOOLS
tools = _bugzilla.TOOLS + _phabricator.TOOLS + _testrail.TOOLS + _try_server.TOOLS
if types is not None:
wanted = set(types)
tools = [t for t in tools if t.dotted in wanted]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
"""Apply-side handlers for recorded actions.

``actions/bugzilla.py`` and ``actions/phabricator.py`` (sibling package) let an
agent *record* an intent into ``summary.json``; the handlers here turn a
recorded action back into a real API call once a run has finished. Kept in the
same library so the set of action types an agent can request and the set this
package knows how to apply never drift apart.
"""
"""Apply-side handlers for recorded actions."""

from hackbot_runtime.actions.handlers.base import (
ActionHandler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
UpdatePatchHandler,
)
from hackbot_runtime.actions.handlers.testrail_handler import SubmitTestPlanHandler
from hackbot_runtime.actions.handlers.try_server_handler import PushHandler

# Maps a recorded action's dotted `type` to the handler that applies it.
# Adding a new action type later is a one-line addition here — the dispatch
Expand All @@ -26,6 +27,7 @@
"phabricator.update_patch": UpdatePatchHandler(),
"phabricator.add_comment": PhabricatorAddCommentHandler(),
"testrail.submit_test_plan": SubmitTestPlanHandler(),
"try_server.push": PushHandler(),
}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Apply-side try-server action: push an already-built patch series to Lando."""

from __future__ import annotations

import json
import logging
from datetime import datetime, timezone
from email.utils import format_datetime
from functools import lru_cache
from typing import Any

from lando_client import LandoClient, encode_patch

from hackbot_runtime.actions.handlers.base import ActionResult, ApplyContext

log = logging.getLogger(__name__)

_TRY_PUSH_ARTIFACT_KEY = "changes/try_push.json"

# Which Try repository the push lands on, and the Treeherder repo it shows up
# under. Firefox's plain "try"; a Thunderbird agent would need
# "try-comm-central" here (and a matching Lando permission).
_TRY_REPO_NAME = "try"

_TRY_CONFIG_FILENAME = "try_task_config.json"

_DEFAULT_TITLE = "Hackbot try push"
_COMMIT_BODY = "Pushed via hackbot."

# Author of the generated try_task_config.json commit. Cosmetic (Lando attributes
# the push to the authenticated user), but it keeps the commit identifiable.
_AUTHOR = "Hackbot Agent <hackbot@mozilla.tld>"

# `git format-patch`'s version-info trailer. Lando's parser needs it: it finds
# the end of the diff by scanning back for the "--" barrier and raises
# "Malformed patch" without one. Built by concatenation because the barrier
# carries a trailing space that an editor or linter would strip from a literal.
_PATCH_TRAILER = "-- " + "\n2.51.0\n"

# One `git format-patch` email adding `try_task_config.json`. The diffstat block
# a real format-patch puts after "---" is omitted: Lando skips everything
# between the commit message and the first "diff " line, and `git apply` derives
# the stat from the diff itself.
_PATCH_TEMPLATE = """\
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: {author}
Date: {date}
Subject: [PATCH] {title}

{body}
---
diff --git a/{filename} b/{filename}
new file mode 100644
--- /dev/null
+++ b/{filename}
@@ -0,0 +1,{line_count} @@
{added_lines}
"""


@lru_cache(maxsize=1)
def _client() -> LandoClient:
return LandoClient()


def try_task_config(tasks: list[str]) -> dict:
"""The ``try_task_config.json`` contents selecting ``tasks``.

Version 2 of the format, matching what ``mach try``'s
``generate_try_task_config`` writes: the labels go in verbatim and
``optimize_target_tasks`` is off, so the tasks asked for are the tasks that
run (their dependencies can still be optimised away).

``TRY_SELECTOR`` is reported as ``fuzzy``, the selector whose pushes this one
is shaped like (an explicit list of task labels). It reaches the tasks as an
environment variable, so an invented value such as "hackbot" would be a
value no in-tree consumer has ever seen; a push should not be the thing that
finds out what happens then.
"""
return {
"version": 2,
"parameters": {
"optimize_target_tasks": False,
"try_task_config": {
"env": {"TRY_SELECTOR": "fuzzy"},
"tasks": sorted(set(tasks)),
},
},
}


def try_task_config_patch(
tasks: list[str], title: str | None = None, now: datetime | None = None
) -> bytes:
"""A ``git format-patch`` email whose one commit adds ``try_task_config.json``.

Appended to the agent's own patches as the tip commit of the try push, the
way ``mach try`` commits the same file on top of the working tree.
"""
content = (
json.dumps(
try_task_config(tasks), indent=4, separators=(",", ": "), sort_keys=True
)
+ "\n"
)
added_lines = content.splitlines()
patch = _PATCH_TEMPLATE.format(
author=_AUTHOR,
date=format_datetime(now or datetime.now(timezone.utc)),
title=_commit_title(title),
body=_COMMIT_BODY,
filename=_TRY_CONFIG_FILENAME,
line_count=len(added_lines),
added_lines="\n".join(f"+{line}" for line in added_lines),
)
return (patch + _PATCH_TRAILER).encode()


def _commit_title(title: str | None) -> str:
"""A single-line commit subject for the try commit.

The agent's title is free text, so collapse it to one line: a newline in it
would end the ``Subject`` header early and push the rest of the title into
the patch body (or, worse, be read as another header).
"""
single_line = " ".join((title or "").split())
return single_line or _DEFAULT_TITLE


class PushHandler:
"""Applies ``try_server.push``: the run's commits become a try push."""

async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult:
tasks = params.get("tasks") or []
if not tasks:
return ActionResult.failed("A try push needs at least one task label")

try:
raw = await ctx.download_artifact(_TRY_PUSH_ARTIFACT_KEY)
submission = json.loads(raw)
except Exception as exc:
log.exception("Failed to load try push artifact for run %s", ctx.run_id)
return ActionResult.failed(f"No try push artifact for this run: {exc}")

try:
client = _client()
job_id = await client.submit_try_patches(
[
*submission["patches"],
encode_patch(try_task_config_patch(tasks, params.get("title"))),
],
submission["base_commit"],
base_commit_vcs=submission["base_commit_vcs"],
patch_format=submission["patch_format"],
repo_name=_TRY_REPO_NAME,
)
except Exception as exc:
log.exception("Failed to push run %s to try", ctx.run_id)
return ActionResult.failed(str(exc))

return ActionResult.ok(
{
"job_id": job_id,
# `url` is the field a `{{actions.<ref>.url}}` placeholder reads,
# so it is the one a human would want from a try push.
"url": client.treeherder_url(job_id, _TRY_REPO_NAME),
"lando_url": client.job_url(job_id),
"tasks": sorted(set(tasks)),
}
)
95 changes: 95 additions & 0 deletions libs/hackbot-runtime/hackbot_runtime/actions/try_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Try-server-domain recordable actions."""

from __future__ import annotations

from typing import Annotated

from agent_tools.registry import ToolError, tool, tools_in
from pydantic import Field

from hackbot_runtime.actions.recorder import ActionsRecorder

TRY_PUSH_ACTION_TYPE = "try_server.push"

# Anything gated on "this run pushes to try" — today the patch-series artifact
# built in ``context.publish_changes`` — keys off this set, for symmetry with
# ``phabricator.PATCH_ACTION_TYPES``.
TRY_ACTION_TYPES = frozenset({TRY_PUSH_ACTION_TYPE})


@tool
async def push(
recorder: ActionsRecorder,
tasks: Annotated[
list[str],
Field(
description=(
"Treeherder task labels to run, e.g. ['build-linux64/opt', "
"'test-linux2404-64/opt-mochitest-browser-chrome-1']. Only the "
"tasks that actually exercise your change: every extra label "
"spends build machine time. Must not be empty — there is no "
"'run everything' shorthand."
)
),
],
Comment on lines +23 to +34

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be better to have the agent select tests and not tasks, or use "mach try auto" (maybe in combination with agent's selected tests).

reasoning: Annotated[
str, Field(description="Why you are pushing to try (for audit log).")
],
title: Annotated[
str | None,
Field(
default=None,
description=(
"Single-line description of the push, used as the commit message "
"shown on Treeherder (e.g. 'Bug 123 - verify the fix on Linux')."
),
),
] = None,
ref: Annotated[
str | None,
Field(
default=None,
description=(
"Optional label for this action so a later action (e.g. a "
"bugzilla.add_comment in the same run) can reference its "
"result once applied, via {{actions.<ref>.url}} in that "
"action's text."
),
),
] = None,
) -> str:
"""Run your changes on the Firefox try server.

Use this to have CI verify a change you cannot verify locally — a platform
you cannot build, or a test suite you cannot run. It does not deliver a fix:
to submit code for review, use ``submit_patch`` (a try push and a revision
are independent, so a run may reasonably do both).

You do not supply a patch file, and you do not need to touch
``try_task_config.json``: your final code changes in the working directory
are pushed as-is and the task selection is built from ``tasks``, so make and
verify all your edits first, then call this once you are done. Calling it
records the push as a proposed action for review; nothing is pushed during
the run, so you will not see the results — a human reads them on Treeherder.
Comment on lines +71 to +73

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is until we have a way to have hackbot continue after a try push (or other action). Are you planning to do that after as a follow-up?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'm planning to do that. I will file some issues in that regard.


Set `ref` if you want to reference the push's Treeherder URL from another
action in the same run, written as `{{actions.<ref>.url}}` (for example,
inside a bug comment).
"""
cleaned = [task.strip() for task in tasks if task and task.strip()]
if not cleaned:
raise ToolError(
"A try push needs at least one Treeherder task label in `tasks`; "
"an empty selection would run nothing."
)

recorder.record(
TRY_PUSH_ACTION_TYPE,
{"tasks": cleaned, "title": title},
reasoning=reasoning,
ref=ref,
)
return f"Recorded {TRY_PUSH_ACTION_TYPE} (#{len(recorder.actions) - 1})."


TOOLS = tools_in(__name__)
36 changes: 36 additions & 0 deletions libs/hackbot-runtime/hackbot_runtime/changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@

from __future__ import annotations

import base64
import contextlib
import logging
import os
import re
import subprocess
import tempfile
from collections.abc import Iterator
Expand All @@ -24,6 +26,8 @@

log = logging.getLogger("hackbot_runtime.changes")

_FULL_SHA_RE = re.compile(r"[0-9a-f]{40}")

# Author stamped on the synthetic commit that wraps any uncommitted remainder.
_WIP_NAME = "Hackbot Agent"
_WIP_EMAIL = "hackbot@mozilla.tld"
Expand Down Expand Up @@ -279,6 +283,38 @@ def build_phabricator_diff(repo: Path, base: str, repo_url: str) -> dict | None:
}


def build_try_push(repo: Path, base: str) -> dict | None:
"""Build the artifact for pushing the agent's changes to the try server."""
if not _FULL_SHA_RE.fullmatch(base):
log.warning(
"Cannot build a try push from base commit %r: Lando needs a full "
"40-character published commit hash",
base,
)
return None

# Normally already done by `collect`; repeated here (it is a no-op on a
# clean tree) so this does not silently drop the agent's uncommitted work if
# it is ever called on its own.
_wrap_uncommitted(repo)

revisions = _git(repo, "rev-list", "--reverse", f"{base}..HEAD").split()
if not revisions:
return None

return {
"base_commit": base,
"base_commit_vcs": "git",
"patch_format": "git-format-patch",
"patches": [
base64.b64encode(
_git_bytes(repo, "format-patch", "--binary", "--stdout", "-1", revision)
).decode("ascii")
for revision in revisions
],
}


def collect(repo: Path, base: str, repo_url: str) -> ChangeSet | None:
"""Collect changes in ``repo`` since ``base`` as a patch plus metadata.

Expand Down
Loading