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
5 changes: 4 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@ Before running:
"phab_api_key": "xxxxxxxxxxxxxx",
"iam_client_secret": "xxxxxxxxxxxxxx",
"iam_client_id": "xxxxxxxxxxxxxx",
"socorro_token": "xxxxxxxxxxxxxx"
"socorro_token": "xxxxxxxxxxxxxx",
"hackbot_api_key": "xxxxxxxxxxxxxx"
}

The ``hackbot_api_key`` is only needed by rules that start a `hackbot <https://github.com/mozilla/bugbug/tree/master/services/hackbot-api>`_ agent run (currently ``frontend_triage``). Those rules talk to ``https://hackbot-api.moz.tools`` by default; set ``HACKBOT_API_URL`` to point at a different deployment.

Do a dryrun::

uv run -m bugbot.rules.stalled
Expand Down
57 changes: 57 additions & 0 deletions bugbot/hackbot_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.

import os

import requests

from bugbot import utils

DEFAULT_API_URL = "https://hackbot-api.moz.tools"

TIMEOUT = 60


def api_url() -> str:
"""The hackbot API base URL.

hackbot runs the LLM agents (see mozilla/bugbug's services/hackbot-api). We
only ever start a run from here: the agent applies its own findings to
Bugzilla and reports the result to Slack itself, so nothing comes back to us.

Defaults to the production deployment so the cron host needs no extra
environment, the way ``BUGBUG_HTTP_SERVER`` does. Override
``HACKBOT_API_URL`` to point at another deployment — that is the name
hackbot's other clients already use (the pulse listener's setting and the
console's deploy env), so there is one name to remember across all three.

Read per call rather than at import so the override is settable by anything
that configures the environment after this module loads.
"""
return os.environ.get("HACKBOT_API_URL", DEFAULT_API_URL)


def trigger_agent_run(agent: str, inputs: dict) -> str:
"""Start a hackbot agent run.

Args:
agent: The agent to run, e.g. `frontend-triage`.
inputs: The agent's per-run inputs, e.g. `{"bug_id": 1234567}`.

Returns:
The id of the created run.
"""
base_url = api_url()
if not base_url:
raise ValueError("HACKBOT_API_URL is not set")

response = requests.post(
f"{base_url.rstrip('/')}/agents/{agent}/runs",
headers={"X-API-Key": utils.get_login_info()["hackbot_api_key"]},
json=inputs,
timeout=TIMEOUT,
)
response.raise_for_status()

return response.json()["run_id"]
130 changes: 130 additions & 0 deletions bugbot/rules/frontend_triage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.

from bugbot import logger
from bugbot.bzcleaner import BzCleaner
from bugbot.hackbot_utils import api_url, trigger_agent_run
from bugbot.people import People

AGENT = "frontend-triage"


class FrontendTriage(BzCleaner):
"""Ask hackbot's frontend-triage agent to triage newly filed frontend bugs.

Scoped to bugs filed by Mozilla staff (which includes QA) in a small set of
components, because the agent's analysis is posted to the bug unattended when
it is confident. This rule only starts runs: the agent investigates the
source, comments on the bug, and reports to Slack itself, so nothing is
written to Bugzilla from here.
"""

def __init__(self, people: People | None = None) -> None:
super().__init__()
# Injectable because configs/people.json is gitignored and absent in CI.
self.people = people or People.get_instance()
self.max_triggers = self.get_config("max_triggers", 10)
self.left_for_next_run = 0

def description(self):
return "[Using AI] Bugs sent for automatic frontend triage"

def columns(self):
return ["id", "summary", "creator", "run_id"]

def has_default_products(self):
# The query names its own product; the 19-product default list would put
# the whole tree in scope.
return False

def get_bz_params(self, date):
start_date, _ = self.get_dates(date)
components = self.get_config("components", {})

return {
"include_fields": ["id", "summary", "creator", "component"],
"product": list(components.keys()),
"component": [c for cs in components.values() for c in cs],
# Defects only: the agent triages broken behaviour, not feature work.
"bug_type": "defect",
"resolution": "---",
"f1": "creation_ts",
"o1": "greaterthan",
"v1": start_date,
}

def handle_bug(self, bug, data):
# The IAM roster covers QA too, now that they file from @mozilla.com
# addresses, so staff membership is the whole filter. It beats a check on
# the address itself, which would miss the employees who file from a
# personal Bugzilla account.
if not self.people.is_mozilla(bug["creator"]):
return None
Comment on lines +58 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Instead of @mozilla only, we could extend to everyone with "editbugs", which we also expect to file well.
You can simply change the query to check group membership (see for example the spambug case).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

We intentionally want to just have this run on employee-filed bugs first, and then open it to editbugs next as part of our rollout plan once we are happy with the results.


# `bughandler` rebuilds each row from id + summary alone, so the reporter
# has to be stashed here to reach the report.
data[str(bug["id"])] = {"creator": bug["creator"]}

return bug

def get_bugs(self, date="today", bug_ids=[], chunk_size=None):
return self.trigger_runs(
super().get_bugs(date=date, bug_ids=bug_ids, chunk_size=chunk_size)
)

def trigger_runs(self, bugs: dict) -> dict:
"""Start a triage run per bug, and return the ones we actually started.

Bugs we did not trigger are dropped from the result so they are neither
reported as triaged nor added to the cache — a bug skipped over the cap,
or one whose trigger failed, gets another chance on the next run.
"""
if self.dryrun:
logger.info(
"Dry run: would trigger %s for bugs %s",
AGENT,
", ".join(bugs) or "(none)",
)
for bug in bugs.values():
bug["run_id"] = "(dry run)"
return bugs

if not api_url():
# A misconfiguration, not a transient failure: fail once here rather
# than letting every bug look like its own flaky trigger below. The
# rule aborts and the cron error digest picks it up.
raise ValueError(f"HACKBOT_API_URL is not set; cannot start {AGENT} runs")

triggered: dict = {}
for bugid, bug in bugs.items():
if len(triggered) >= self.max_triggers:
# Each run is real LLM spend, so a flood of filings must not turn
# into a flood of agent runs.
logger.warning(
"Reached the cap of %d %s runs; the rest wait for the next run",
self.max_triggers,
AGENT,
)
break

try:
bug["run_id"] = trigger_agent_run(AGENT, {"bug_id": int(bugid)})
except Exception:
logger.exception("Failed to trigger %s for bug %s", AGENT, bugid)
continue

triggered[bugid] = bug

# Covers both causes — over the cap, and a trigger that failed — since
# either way the bug stays out of the cache and comes back next run.
self.left_for_next_run = len(bugs) - len(triggered)

return triggered

def get_extra_for_template(self):
return {"left_for_next_run": self.left_for_next_run}


if __name__ == "__main__":
FrontendTriage().run()
8 changes: 8 additions & 0 deletions configs/rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,14 @@
"confidence_threshold": 0.95,
"cc": []
},
"frontend_triage": {
"days_lookup": 1,
"max_days_in_cache": 30,
"max_triggers": 3,
"components": {
"Firefox": ["New Tab Page"]
}
},
"performancebug": {
"max_days_in_cache": 7,
"days_lookup": 7
Expand Down
1 change: 1 addition & 0 deletions scripts/check_rules_on_wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class CheckWikiPage:
# Experimental rules:
"accessibilitybug.py",
"performancebug.py",
"frontend_triage.py", # piloting on one component before documenting it
}

def __init__(self) -> None:
Expand Down
3 changes: 3 additions & 0 deletions scripts/cron_run_hourly.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ python -m bugbot.rules.closed_dupeme --production
# Detect spam bugs using bugbug
python -m bugbot.rules.spambug --production

# Send newly filed frontend bugs from employees and QA to the frontend-triage agent
python -m bugbot.rules.frontend_triage --production

# Suggest components for untriaged bugs (hourly, list only bugs on which we acted)
python -m bugbot.rules.component --frequency hourly --production

Expand Down
32 changes: 32 additions & 0 deletions templates/frontend_triage.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<p>
The following {{ plural('bug', data, pword='bugs') }} {{ plural('was', data, pword='were') }} sent to the frontend-triage agent. The agent posts its analysis to the bug itself when it is confident; otherwise the result waits for review in the hackbot UI.
</p>
<table {{ table_attrs }}>
<thead>
<tr>
<th>Bug</th>
<th>Summary</th>
<th>Reporter</th>
<th>Run</th>
</tr>
</thead>
<tbody>
{% for i, (bugid, summary, creator, run_id) in enumerate(data) -%}
<tr {% if i % 2 == 0 %}bgcolor="#E0E0E0"
{% endif -%}
>
<td>
<a href="https://bugzilla.mozilla.org/show_bug.cgi?id={{ bugid }}">{{ bugid }}</a>
</td>
<td>{{ summary | e }}</td>
<td>{{ creator | e }}</td>
<td>{{ run_id }}</td>
</tr>
{% endfor -%}
</tbody>
</table>
{% if extra.left_for_next_run %}
<p>
<b>{{ extra.left_for_next_run }}</b> more {{ plural('bug', extra.left_for_next_run, pword='bugs') }} matched but {{ plural('was', extra.left_for_next_run, pword='were') }} not sent this time — either over the cap on how many agent runs one invocation may start, or the run failed to start. {{ plural('It', extra.left_for_next_run, pword='They') }} will be picked up on the next run.
</p>
{% endif %}
Loading