Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/hackbot/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ triage run but swaps it for `phabricator.update_patch` on a follow-up.
| `phabricator.submit_patch` | Deliver a fix as a **new** revision | `bug_id`, `title`, `summary`, `test_plan`, `parent_revision_id` |
| `phabricator.update_patch` | Add a new diff to an **existing** revision | `revision_id`, `title`, `summary`, `test_plan` |
| `phabricator.add_comment` | Reply on a revision without changing code | `revision_id`, `text` |
| `github.submit_patch` | Deliver a fix as a **new** pull request | `title`, `body` |
| `testrail.submit_test_plan` | Submit a generated test plan to TestRail | the validated feature + test cases |
| `slack.post_message` | Post a message to Slack | `channel`, `text` |
| `email.send` | Email a report about the run | `to`, `subject`, `body_markdown` |
Expand Down Expand Up @@ -79,6 +80,15 @@ Neither takes a patch file. The agent's final working-tree state _is_ the diff
why the runtime builds the Phabricator payload during `publish_changes`, while the checkout
still exists.

`github.submit_patch` is the GitHub counterpart of `phabricator.submit_patch`, for an agent
whose `[source].repo_url` is on GitHub. hackbot-api has no checkout to apply a patch in, so
`publish_changes` also captures each of the run's commits (message, author and changed
files) as `changes/github_commits.json`. The handler recreates those commits through
GitHub's Git Data API on a `hackbot/<run_id>` branch of the source repository and opens a
draft pull request against its default branch, with the GitHub App's credentials (so the
App must be installed on that repository). Retrying reuses the branch and any pull request
already open from it.

### Hooks

`ActionsRecorder` supports per-type hooks that run before an action is appended. A hook may
Expand Down
2 changes: 2 additions & 0 deletions libs/hackbot-runtime/hackbot_runtime/actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
bugzilla,
email,
feedback,
github,
phabricator,
slack,
testrail,
Expand All @@ -31,6 +32,7 @@
"bugzilla",
"email",
"feedback",
"github",
"phabricator",
"slack",
"testrail",
Expand Down
2 changes: 2 additions & 0 deletions libs/hackbot-runtime/hackbot_runtime/actions/claude_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from hackbot_runtime.actions import agents as _agents
from hackbot_runtime.actions import bugzilla as _bugzilla
from hackbot_runtime.actions import feedback as _feedback
from hackbot_runtime.actions import github as _github
from hackbot_runtime.actions import phabricator as _phabricator
from hackbot_runtime.actions import slack as _slack
from hackbot_runtime.actions import testrail as _testrail
Expand Down Expand Up @@ -50,6 +51,7 @@ def actions_server_for(
+ _agents.TOOLS
+ _bugzilla.TOOLS
+ _phabricator.TOOLS
+ _github.TOOLS
+ _testrail.TOOLS
+ _slack.TOOLS
+ _try_server.TOOLS
Expand Down
70 changes: 70 additions & 0 deletions libs/hackbot-runtime/hackbot_runtime/actions/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""GitHub-domain recordable actions.

Mirrors ``actions/phabricator.py``: the handler records an intended change
(nothing is pushed to GitHub here) and returns a short confirmation string. See
hackbot-api's ``app/action_handlers/github_handler.py`` for the apply side,
which pushes a branch carrying the run's commits and opens a pull request.
"""

from typing import Annotated

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

from hackbot_runtime.actions.recorder import ActionsRecorder, confirmation

SUBMIT_PATCH_ACTION_TYPE = "github.submit_patch"

# Actions that deliver the working directory's changes to GitHub, gating the
# commits artifact built in ``context.publish_changes``. The GitHub counterpart
# of ``phabricator.PATCH_ACTION_TYPES``: kept separate so a GitHub submission
# does not also build a Phabricator diff, and vice versa.
PATCH_ACTION_TYPES = frozenset({SUBMIT_PATCH_ACTION_TYPE})


@tool
async def submit_patch(
recorder: ActionsRecorder,
title: Annotated[
str,
Field(
description=(
"Title for the new pull request: a single line describing the "
"fix, as you would write a commit message subject."
)
),
],
reasoning: Annotated[
str, Field(description="Why you are submitting this patch (for audit log).")
],
body: Annotated[
str | None,
Field(
description=(
"Pull request description (GitHub Markdown). Explain what the "
"change does and how you verified it. Keep it concise and do not "
"narrate the investigation."
),
),
] = None,
) -> str:
"""Submit your fix for review as a new GitHub pull request.

This is how you deliver a code fix to a repository hosted on GitHub. The
pull request is opened from a new branch against the repository's default
branch, as a draft.

You do not supply a patch file. Your final code changes in the working
directory (and any commits you made) are pushed as the pull request's
commits, so make and verify all your edits first, then call this once you
are done. Calling it records the submission as a proposed action for review;
nothing is sent to GitHub during the run.

Later actions can reference its `url`.
"""
params: dict = {"title": title, "body": body}
action = recorder.record(SUBMIT_PATCH_ACTION_TYPE, params, reasoning=reasoning)
return confirmation(action)


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

import base64
import contextlib
import itertools
import logging
import os
import re
Expand Down Expand Up @@ -335,6 +336,93 @@ def build_try_push(repo: Path, base: str) -> dict | None:
}


# A submodule entry: its tree entry points at a commit, not a blob.
_GITLINK_MODE = "160000"


def _github_file_changes(repo: Path, parent: str, commit: str) -> list[dict]:
"""The tree entries ``commit`` changes relative to ``parent``.

Each entry is ``{"path", "mode"}`` plus one of ``"content"`` (the new blob,
base64-encoded so binary files survive JSON), ``"sha"`` (a submodule's new
commit) or ``"deleted": True``. Renames are reported as a deletion plus an
addition, which is how a git tree records them anyway.
"""
out = _git_bytes(
repo, "diff-tree", "-r", "-z", "--no-renames", "--no-commit-id", parent, commit
)
# -z output is ":<srcmode> <dstmode> <srcsha> <dstsha> <status>\0<path>\0" per
# entry, with paths unquoted. --no-renames keeps it to one path per entry (a
# rename or copy would carry two), and strict pairing raises if that ever
# stops holding. A commit that changes no files prints nothing at all. GitHub
# only takes UTF-8 paths, so any other path raises here rather than being
# pushed mangled.
fields = out.removesuffix(b"\0").split(b"\0") if out else []
entries = []
for header, raw_path in itertools.batched(fields, 2, strict=True):
src_mode, dst_mode, _src_sha, dst_sha, status = header.lstrip(b":").split()
path = raw_path.decode("utf-8")
if status == b"D":
entries.append({"path": path, "mode": src_mode.decode(), "deleted": True})
elif dst_mode.decode() == _GITLINK_MODE:
entries.append(
{"path": path, "mode": _GITLINK_MODE, "sha": dst_sha.decode()}
)
else:
blob = _git_bytes(repo, "cat-file", "blob", dst_sha.decode())
entries.append(
{
"path": path,
"mode": dst_mode.decode(),
"content": base64.b64encode(blob).decode("ascii"),
}
)
return entries


def build_github_commits(repo: Path, base: str, repo_url: str) -> dict | None:
"""Build the artifact for pushing the agent's commits to GitHub.

hackbot-api has no git checkout (or git binary) to ``git am`` the run's
patch into, so this captures each commit in ``base..HEAD`` in the shape
GitHub's Git Data API takes: the full message, the author, and the files it
changes with their new contents. The apply-side handler recreates the same
commits on top of ``base`` from that alone.

``base`` must be a commit the GitHub repository already has (the published
base, as for a try push), since the recreated commits are parented on it.
Returns ``None`` when there are no commits to push.
"""
# Normally already done by `collect`; repeated here (a no-op on a clean
# tree) so this does not drop uncommitted work if called on its own.
commit_all(repo, WIP_MESSAGE)

# First parent only: each commit is replayed as the diff against the one
# before it, so a merge the agent made is flattened rather than lost.
revisions = _git(
repo, "rev-list", "--reverse", "--first-parent", f"{base}..HEAD"
).split()
if not revisions:
return None

fmt = _FIELD_SEP.join(["%an", "%ae", "%aI", "%B"])
commits = []
parent = base
for revision in revisions:
out = _git(repo, "show", "-s", f"--format={fmt}", revision)
name, email, date, message = out.split(_FIELD_SEP, 3)
commits.append(
{
"message": message.rstrip("\n"),
"author": {"name": name, "email": email, "date": date},
"changes": _github_file_changes(repo, parent, revision),
}
)
parent = revision

return {"repo_url": repo_url, "base_commit": base, "commits": commits}


def collect(
repo: Path, base: str, repo_url: str, message: str = WIP_MESSAGE
) -> ChangeSet | None:
Expand Down
27 changes: 26 additions & 1 deletion libs/hackbot-runtime/hackbot_runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from pydantic_settings import BaseSettings, SettingsConfigDict

from hackbot_runtime import artifacts, changes
from hackbot_runtime.actions import github
from hackbot_runtime.actions.phabricator import (
PATCH_ACTION_TYPES,
SUBMIT_PATCH_ACTION_TYPE,
Expand Down Expand Up @@ -53,7 +54,11 @@ def _default_run_id() -> str:

def _wip_commit_message(actions: list[dict]) -> str:
"""The patch title, else the try push title, for the agent's uncommitted work."""
for action_type in (SUBMIT_PATCH_ACTION_TYPE, TRY_PUSH_ACTION_TYPE):
for action_type in (
SUBMIT_PATCH_ACTION_TYPE,
github.SUBMIT_PATCH_ACTION_TYPE,
TRY_PUSH_ACTION_TYPE,
):
for action in actions:
if action["type"] == action_type and action["params"]["title"]:
return action["params"]["title"]
Expand Down Expand Up @@ -304,6 +309,7 @@ def publish_changes(
meta_key: str = "changes/changes.json",
phabricator_diff_key: str = "changes/phabricator_diff.json",
try_push_key: str = "changes/try_push.json",
github_commits_key: str = "changes/github_commits.json",
) -> str | None:
"""Collect the agent's source-tree changes and publish them as artifacts."""
if self._source_base is None:
Expand Down Expand Up @@ -334,6 +340,25 @@ def publish_changes(
if diff_payload is not None:
self.publish_json(phabricator_diff_key, diff_payload)

if recorded_types & github.PATCH_ACTION_TYPES:
# From the published base, like the try push: the commits are
# recreated on GitHub on top of it, so it has to be a commit the
# repository already has.
try:
github_payload = changes.build_github_commits(
self.repo_path,
self._published_base,
self._config.source.repo_url,
)
except Exception:
# Best-effort, as for the Phabricator diff: without the
# artifact the action fails when applied, saying so, rather
# than failing an otherwise successful run here.
log.warning("Could not build the GitHub commits", exc_info=True)
github_payload = None
if github_payload is not None:
self.publish_json(github_commits_key, github_payload)

if recorded_types & TRY_ACTION_TYPES:
# From the *published* base, not `_source_base`: Lando has to
# resolve the base commit in its own clone, and it pushes the patch
Expand Down
Loading