From f571841e521fdd290b5a9e2f416a5bc1fd4f44ea Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 11 Mar 2024 17:35:27 +0100 Subject: [PATCH 01/18] job-runner: tidy job header printing Make this a bit easier to read and tweak how we handle JSON formatting. Instead of special-casing the top-level, add a `default` handler which encodes all encountered objects via `__dict__`. We're going to introduce a new object soon. --- lib/aio/job.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/aio/job.py b/lib/aio/job.py index a48931ae79..00eb846e65 100644 --- a/lib/aio/job.py +++ b/lib/aio/job.py @@ -162,7 +162,11 @@ async def run_job(job: Job, ctx: JobContext) -> None: logger.info('Log: %s', log.url) try: - log.start(f'{title}\nRunning on: {platform.node()}\n\nJob(' + json.dumps(job.__dict__, indent=4) + ')\n') + log.start( + f'{title}\n\n' + f'Running on: {platform.node()}\n\n' + f'Job({json.dumps(job, default=lambda obj: obj.__dict__, indent=4)})\n' + ) await status.post('pending', 'In progress') tasks = {run_container(job, subject, ctx, log)} From d843cddca601d5616296bf772f887b6d65a709b5 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 11 Mar 2024 17:34:36 +0100 Subject: [PATCH 02/18] job-runner: split "subject" into its own object We may also (some day) split this into a sub-object in the "job" object, as it appears on the wire, but for now let's not break protocol. --- lib/aio/abc.py | 15 +++++++++++---- lib/aio/github.py | 26 +++++++++++--------------- lib/aio/job.py | 22 +++++++++------------- test/test_aio.py | 3 ++- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/lib/aio/abc.py b/lib/aio/abc.py index 8cc838b05d..e1a64c7613 100644 --- a/lib/aio/abc.py +++ b/lib/aio/abc.py @@ -18,7 +18,16 @@ from yarl import URL -from .jsonutil import JsonObject +from .jsonutil import JsonObject, get_int, get_str + + +class SubjectSpecification: + def __init__(self, obj: JsonObject) -> None: + self.repo = get_str(obj, 'repo') + self.sha = get_str(obj, 'sha', None) + self.pull = get_int(obj, 'pull', None) + self.branch = get_str(obj, 'branch', None) + self.target = get_str(obj, 'target', None) class Subject(NamedTuple): @@ -35,9 +44,7 @@ async def post(self, state: str, description: str) -> None: class Forge: - async def resolve_subject( - self, repo: str, sha: str | None, pull_nr: int | None, branch: str | None, target: str | None - ) -> Subject: + async def resolve_subject(self, spec: SubjectSpecification) -> Subject: raise NotImplementedError async def check_pr_changed(self, repo: str, pull_nr: int, expected_sha: str) -> str | None: diff --git a/lib/aio/github.py b/lib/aio/github.py index fa477a2438..041956d2c2 100644 --- a/lib/aio/github.py +++ b/lib/aio/github.py @@ -24,7 +24,7 @@ import aiohttp from yarl import URL -from .abc import Forge, Status, Subject +from .abc import Forge, Status, Subject, SubjectSpecification from .jsonutil import JsonError, JsonObject, JsonValue, get_bool, get_dict, get_nested, get_str, typechecked from .util import LRUCache, create_http_session @@ -143,28 +143,24 @@ async def open_issue(self, repo: str, issue: JsonObject) -> None: def get_status(self, repo: str, sha: str, context: str | None, location: URL) -> Status: return GitHubStatus(self, repo, sha, context, location) - async def resolve_subject( - self, repo: str, sha: str | None, pull_nr: int | None, branch: str | None, target: str | None - ) -> Subject: - clone_url = self.clone / repo + async def resolve_subject(self, spec: SubjectSpecification) -> Subject: + clone_url = self.clone / spec.repo - if sha is not None: + if spec.sha is not None: # if pull_nr is set and our sha doesn't match the PR, we will # detect it soon - return Subject(clone_url, sha, target) + return Subject(clone_url, spec.sha, spec.target) - elif pull_nr is not None: - pull = await self.get_obj(f'repos/{repo}/pulls/{pull_nr}') - if target is None: - target = get_str(get_dict(pull, 'base'), 'ref') + elif spec.pull is not None: + pull = await self.get_obj(f'repos/{spec.repo}/pulls/{spec.pull}') + target = spec.target or get_str(get_dict(pull, 'base'), 'ref') return Subject(clone_url, get_str(get_dict(pull, 'head'), 'sha'), target) else: - if not branch: - branch = get_str(await self.get_obj(f'repos/{repo}'), 'default_branch') + branch = spec.branch or get_str(await self.get_obj(f'repos/{spec.repo}'), 'default_branch') - with get_nested(await self.get_obj(f'repos/{repo}/git/refs/heads/{branch}'), 'object') as object: - return Subject(clone_url, get_str(object, 'sha'), target) + with get_nested(await self.get_obj(f'repos/{spec.repo}/git/refs/heads/{branch}'), 'object') as object: + return Subject(clone_url, get_str(object, 'sha'), spec.target) class GitHubStatus(Status): diff --git a/lib/aio/job.py b/lib/aio/job.py index 00eb846e65..f601ed04b9 100644 --- a/lib/aio/job.py +++ b/lib/aio/job.py @@ -28,7 +28,7 @@ from typing import Never from ..constants import BOTS_DIR -from .abc import Forge, Subject +from .abc import Forge, Subject, SubjectSpecification from .jobcontext import JobContext from .jsonutil import JsonObject, get_dict, get_int, get_str, get_str_map, get_strv from .s3streamer import Index, LogStreamer @@ -45,11 +45,7 @@ class Failure(Exception): class Job: def __init__(self, obj: JsonObject) -> None: # test subject specification - self.repo = get_str(obj, 'repo') - self.sha = get_str(obj, 'sha', None) - self.pull = get_int(obj, 'pull', None) - self.branch = get_str(obj, 'branch', None) - self.target = get_str(obj, 'target', None) + self.subject = SubjectSpecification(obj) # test specification self.container = get_str(obj, 'container', None) @@ -150,15 +146,15 @@ async def run_container(job: Job, subject: Subject, ctx: JobContext, log: LogStr async def run_job(job: Job, ctx: JobContext) -> None: - subject = await ctx.forge.resolve_subject(job.repo, job.sha, job.pull, job.branch, job.target) - title = job.title or f'{job.context}@{job.repo}#{subject.sha[:12]}' - slug = job.slug or f'{job.repo}/{job.context or "-"}/{subject.sha[:12]}' + subject = await ctx.forge.resolve_subject(job.subject) + title = job.title or f'{job.context}@{job.subject.repo}#{subject.sha[:12]}' + slug = job.slug or f'{job.subject.repo}/{job.context or "-"}/{subject.sha[:12]}' async with ctx.logs.get_destination(slug) as destination: index = Index(destination) log = LogStreamer(index) - status = ctx.forge.get_status(job.repo, subject.sha, job.context, log.url) + status = ctx.forge.get_status(job.subject.repo, subject.sha, job.context, log.url) logger.info('Log: %s', log.url) try: @@ -174,8 +170,8 @@ async def run_job(job: Job, ctx: JobContext) -> None: if job.timeout: tasks.add(timeout_minutes(job.timeout)) - if job.pull: - tasks.add(poll_pr(ctx.forge, job.repo, job.pull, subject.sha)) + if job.subject.pull is not None: + tasks.add(poll_pr(ctx.forge, job.subject.repo, job.subject.pull, subject.sha)) await gather_and_cancel(tasks) @@ -193,7 +189,7 @@ async def run_job(job: Job, ctx: JobContext) -> None: """).lstrip(), **job.report } - await ctx.forge.open_issue(job.repo, issue) + await ctx.forge.open_issue(job.subject.repo, issue) except asyncio.CancelledError: await status.post('error', 'Cancelled') diff --git a/test/test_aio.py b/test/test_aio.py index db9091629b..9ee0ae8845 100644 --- a/test/test_aio.py +++ b/test/test_aio.py @@ -10,6 +10,7 @@ from aioresponses import CallbackResult, aioresponses from yarl import URL +from lib.aio.abc import SubjectSpecification from lib.aio.github import GitHub from lib.aio.jobcontext import JobContext from lib.aio.jsonutil import JsonObject, JsonValue, json_merge_patch @@ -219,7 +220,7 @@ async def test_github_pr_lookup(service: GitHubService, api: GitHub) -> None: }) # Look up the sha in the PR via the REST API - subject = await api.resolve_subject('owner/repo', None, pull_nr, None, None) + subject = await api.resolve_subject(SubjectSpecification({'repo': 'owner/repo', 'pull': pull_nr})) assert subject == (service.CLONE_URL / repo, sha, 'main') service.assert_hits(1, 1) From 109e145c31dbaf62ff641f08c94279415620783d Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 11 Mar 2024 17:36:34 +0100 Subject: [PATCH 03/18] job-runner: add a "command subject" field to Job This is the same as the fields which allow us to specify what we've been calling "subject" up to this point, but refers exclusively to the thing that we checkout and run. The statuses (and any issues or anything else) will continue to be reported against the main Subject, as before. We copy our existing get_object() code from Cockpit to help us with this. This enables us to run cross-project tests, albeit still in an ad-hoc manner (but no worse than we had it before). --- lib/aio/job.py | 9 +++++++-- lib/aio/jsonutil.py | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/aio/job.py b/lib/aio/job.py index f601ed04b9..e261a914ee 100644 --- a/lib/aio/job.py +++ b/lib/aio/job.py @@ -30,7 +30,7 @@ from ..constants import BOTS_DIR from .abc import Forge, Subject, SubjectSpecification from .jobcontext import JobContext -from .jsonutil import JsonObject, get_dict, get_int, get_str, get_str_map, get_strv +from .jsonutil import JsonObject, get_dict, get_int, get_object, get_str, get_str_map, get_strv from .s3streamer import Index, LogStreamer from .spawn import run, spawn from .util import gather_and_cancel, read_utf8 @@ -49,6 +49,7 @@ def __init__(self, obj: JsonObject) -> None: # test specification self.container = get_str(obj, 'container', None) + self.command_subject = get_object(obj, 'command-subject', SubjectSpecification, None) self.secrets = get_strv(obj, 'secrets', ()) self.command = get_strv(obj, 'command', None) self.env = get_str_map(obj, 'env', {}) @@ -165,7 +166,11 @@ async def run_job(job: Job, ctx: JobContext) -> None: ) await status.post('pending', 'In progress') - tasks = {run_container(job, subject, ctx, log)} + if job.command_subject is not None: + command_subject = await ctx.forge.resolve_subject(job.command_subject) + else: + command_subject = subject + tasks = {run_container(job, command_subject, ctx, log)} if job.timeout: tasks.add(timeout_minutes(job.timeout)) diff --git a/lib/aio/jsonutil.py b/lib/aio/jsonutil.py index 100490acc1..07698bf924 100644 --- a/lib/aio/jsonutil.py +++ b/lib/aio/jsonutil.py @@ -83,6 +83,15 @@ def get_dict(obj: JsonObject, key: str, default: DT | _Empty = _empty) -> DT | J return _get(obj, lambda v: typechecked(v, dict), key, default) +def get_object( + obj: JsonObject, + key: str, + constructor: Callable[[JsonObject], T], + default: Union[DT, _Empty] = _empty +) -> Union[DT, T]: + return _get(obj, lambda v: constructor(typechecked(v, dict)), key, default) + + def get_str_map(obj: JsonObject, key: str, default: DT | _Empty = _empty) -> DT | Mapping[str, str]: def as_str_map(value: JsonValue) -> Mapping[str, str]: return {key: typechecked(value, str) for key, value in typechecked(value, dict).items()} From b615ff3ee400921f672e256306b9251d23aac17e Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Tue, 12 Mar 2024 12:07:06 +0100 Subject: [PATCH 04/18] job-runner: fix a small bug on missing configs pyright noticed that we were making use of an uninitialized variable in this case. Make sure we return if the file is missing. --- lib/aio/jobcontext.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/aio/jobcontext.py b/lib/aio/jobcontext.py index 3b5434ae33..580081c051 100644 --- a/lib/aio/jobcontext.py +++ b/lib/aio/jobcontext.py @@ -68,6 +68,7 @@ def load_config(self, path: Path, name: str, *, missing_ok: bool = False) -> Non except FileNotFoundError as exc: if missing_ok: logger.debug('No %s configuration found at %s', name, str(path)) + return else: sys.exit(f'{path}: {exc}') except OSError as exc: From a28aeddc226ee7b0a76d31111a376bb3950343d8 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Tue, 12 Mar 2024 12:41:03 +0100 Subject: [PATCH 05/18] job-runner: always lookup target on PRs Before, if we got a sha given as part of the Job, we'd assume that we also got the target given to us and skip looking up anything on the PR (when it was set). Change that logic a bit: now, if the pull number is given, always do the lookup and use it as default values for both the sha and the target. It should almost never be necessary to specify the target from outside, anymore. --- lib/aio/github.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/aio/github.py b/lib/aio/github.py index 041956d2c2..60d48dd73c 100644 --- a/lib/aio/github.py +++ b/lib/aio/github.py @@ -144,18 +144,16 @@ def get_status(self, repo: str, sha: str, context: str | None, location: URL) -> return GitHubStatus(self, repo, sha, context, location) async def resolve_subject(self, spec: SubjectSpecification) -> Subject: - clone_url = self.clone / spec.repo + if spec.pull is not None: + pull = await self.get_obj(f'repos/{spec.repo}/pulls/{spec.pull}') + return Subject(clone_url, + # mypy needs some help here. See https://github.com/python/mypy/issues/16659 + spec.sha if spec.sha else get_str(get_dict(pull, 'head'), 'sha'), + spec.target or get_str(get_dict(pull, 'base'), 'ref')) - if spec.sha is not None: - # if pull_nr is set and our sha doesn't match the PR, we will - # detect it soon + elif spec.sha is not None: return Subject(clone_url, spec.sha, spec.target) - elif spec.pull is not None: - pull = await self.get_obj(f'repos/{spec.repo}/pulls/{spec.pull}') - target = spec.target or get_str(get_dict(pull, 'base'), 'ref') - return Subject(clone_url, get_str(get_dict(pull, 'head'), 'sha'), target) - else: branch = spec.branch or get_str(await self.get_obj(f'repos/{spec.repo}'), 'default_branch') From 3cc88308ca21f38a7ce1f38d0a45ecbc1fcff236 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Tue, 12 Mar 2024 12:42:17 +0100 Subject: [PATCH 06/18] job-runner: store forge and repo in Subject clone_url is a really a derived property of the repo and the forge. Store those in there, instead, and turn clone_url into a property. --- lib/aio/abc.py | 9 ++++++++- lib/aio/github.py | 6 +++--- test/test_aio.py | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/aio/abc.py b/lib/aio/abc.py index e1a64c7613..0376fa271b 100644 --- a/lib/aio/abc.py +++ b/lib/aio/abc.py @@ -31,10 +31,15 @@ def __init__(self, obj: JsonObject) -> None: class Subject(NamedTuple): - clone_url: URL + forge: 'Forge' + repo: str sha: str rebase: str | None = None + @property + def clone_url(self) -> URL: + return self.forge.clone / self.repo + class Status: link: str @@ -44,6 +49,8 @@ async def post(self, state: str, description: str) -> None: class Forge: + clone: URL + async def resolve_subject(self, spec: SubjectSpecification) -> Subject: raise NotImplementedError diff --git a/lib/aio/github.py b/lib/aio/github.py index 60d48dd73c..a87e9e5135 100644 --- a/lib/aio/github.py +++ b/lib/aio/github.py @@ -146,19 +146,19 @@ def get_status(self, repo: str, sha: str, context: str | None, location: URL) -> async def resolve_subject(self, spec: SubjectSpecification) -> Subject: if spec.pull is not None: pull = await self.get_obj(f'repos/{spec.repo}/pulls/{spec.pull}') - return Subject(clone_url, + return Subject(self, spec.repo, # mypy needs some help here. See https://github.com/python/mypy/issues/16659 spec.sha if spec.sha else get_str(get_dict(pull, 'head'), 'sha'), spec.target or get_str(get_dict(pull, 'base'), 'ref')) elif spec.sha is not None: - return Subject(clone_url, spec.sha, spec.target) + return Subject(self, spec.repo, spec.sha, spec.target) else: branch = spec.branch or get_str(await self.get_obj(f'repos/{spec.repo}'), 'default_branch') with get_nested(await self.get_obj(f'repos/{spec.repo}/git/refs/heads/{branch}'), 'object') as object: - return Subject(clone_url, get_str(object, 'sha'), spec.target) + return Subject(self, spec.repo, get_str(object, 'sha'), spec.target) class GitHubStatus(Status): diff --git a/test/test_aio.py b/test/test_aio.py index 9ee0ae8845..8315cf1a5c 100644 --- a/test/test_aio.py +++ b/test/test_aio.py @@ -221,7 +221,7 @@ async def test_github_pr_lookup(service: GitHubService, api: GitHub) -> None: # Look up the sha in the PR via the REST API subject = await api.resolve_subject(SubjectSpecification({'repo': 'owner/repo', 'pull': pull_nr})) - assert subject == (service.CLONE_URL / repo, sha, 'main') + assert subject == (api, repo, sha, 'main') service.assert_hits(1, 1) # The next thing that happens is that we poll this API a lot From 9c67898cf34b568a56681fb331a5aedda1f5714a Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Tue, 12 Mar 2024 12:13:06 +0100 Subject: [PATCH 07/18] job-runner: factor our GitHub retry logic We do the same thing for GET and POST, so re-use that code. We're about to reuse it some more, and a third copy is really just too many. --- lib/aio/github.py | 51 +++++++++++++++++++---------------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/lib/aio/github.py b/lib/aio/github.py index a87e9e5135..3c57093b1c 100644 --- a/lib/aio/github.py +++ b/lib/aio/github.py @@ -19,18 +19,35 @@ import logging import platform from collections.abc import Mapping -from typing import NamedTuple, Self +from typing import Awaitable, Callable, NamedTuple, Self import aiohttp from yarl import URL from .abc import Forge, Status, Subject, SubjectSpecification from .jsonutil import JsonError, JsonObject, JsonValue, get_bool, get_dict, get_nested, get_str, typechecked -from .util import LRUCache, create_http_session +from .util import LRUCache, T, create_http_session logger = logging.getLogger(__name__) +async def retry(func: Callable[[], Awaitable[T]]) -> T: + for attempt in range(4): + try: + return await func() + except aiohttp.ClientResponseError as exc: + if exc.status < 500: + raise + except aiohttp.ClientError: + pass + + # 1 → 2 → 4 → 8s delay + await asyncio.sleep(2 ** attempt) + + # ...last attempt. + return await func() + + class CacheEntry(NamedTuple): conditions: Mapping[str, str] value: JsonValue @@ -65,20 +82,7 @@ async def post_once() -> JsonValue: logger.debug('response %r', response) return await response.json() # type: ignore[no-any-return] - for attempt in range(4): - try: - return await post_once() - except aiohttp.ClientResponseError as exc: - if exc.status < 500: - raise - except aiohttp.ClientError: - pass - - # 1 → 2 → 4 → 8s delay - await asyncio.sleep(2 ** attempt) - - # ...last attempt. - return await post_once() + return await retry(post_once) async def get(self, resource: str) -> JsonValue: async def get_once() -> JsonValue: @@ -103,20 +107,7 @@ async def get_once() -> JsonValue: self.cache.add(resource, CacheEntry(conditions, value)) return value # type: ignore[no-any-return] - for attempt in range(4): - try: - return await get_once() - except aiohttp.ClientResponseError as exc: - if exc.status < 500: - raise - except aiohttp.ClientError: - pass - - # 1 → 2 → 4 → 8s delay - await asyncio.sleep(2 ** attempt) - - # ...last attempt. - return await get_once() + return await retry(get_once) async def get_obj(self, resource: str) -> JsonObject: return typechecked(await self.get(resource), dict) From 349b891e1ce8ad5dd48046691d64dd831c2d89c4 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Tue, 12 Mar 2024 12:13:57 +0100 Subject: [PATCH 08/18] job-runner: add support for getting GitHub content Add support for fetching file content from GitHub via the raw.githubusercontent.com interface. --- job-runner.toml | 1 + lib/aio/abc.py | 8 ++++++++ lib/aio/github.py | 14 ++++++++++++++ test/test_aio.py | 2 ++ 4 files changed, 25 insertions(+) diff --git a/job-runner.toml b/job-runner.toml index 7f5d877f63..7ecb8dbabf 100644 --- a/job-runner.toml +++ b/job-runner.toml @@ -50,6 +50,7 @@ driver='github' [forge.github] clone-url = 'https://github.com/' api-url = 'https://api.github.com/' +content-url = 'https://raw.githubusercontent.com/' post = true # whether to post statuses, open issues, etc. user-agent = 'job-runner (cockpit-project/bots)' # (at least) one of `token` or `post = false` must be set diff --git a/lib/aio/abc.py b/lib/aio/abc.py index 0376fa271b..3ecf05708d 100644 --- a/lib/aio/abc.py +++ b/lib/aio/abc.py @@ -40,6 +40,10 @@ class Subject(NamedTuple): def clone_url(self) -> URL: return self.forge.clone / self.repo + @property + def content_url(self) -> URL: + return self.forge.content / self.repo + class Status: link: str @@ -50,6 +54,7 @@ async def post(self, state: str, description: str) -> None: class Forge: clone: URL + content: URL async def resolve_subject(self, spec: SubjectSpecification) -> Subject: raise NotImplementedError @@ -63,6 +68,9 @@ def get_status(self, repo: str, sha: str, context: str | None, location: URL) -> async def open_issue(self, repo: str, issue: JsonObject) -> None: raise NotImplementedError + async def read_file(self, subject: Subject, filename: str) -> str | None: + raise NotImplementedError + @classmethod def new(cls, config: JsonObject) -> Self: raise NotImplementedError diff --git a/lib/aio/github.py b/lib/aio/github.py index 3c57093b1c..a25b2ea4fc 100644 --- a/lib/aio/github.py +++ b/lib/aio/github.py @@ -60,6 +60,7 @@ def __init__(self, config: JsonObject) -> None: self.config = config self.clone = URL(get_str(config, 'clone-url')) self.api = URL(get_str(config, 'api-url')) + self.content = URL(get_str(config, 'content-url')) self.dry_run = not get_bool(config, 'post') async def __aenter__(self) -> Self: @@ -131,6 +132,19 @@ async def check_pr_changed(self, repo: str, pull_nr: int, expected_sha: str) -> async def open_issue(self, repo: str, issue: JsonObject) -> None: await self.post(f'repos/{repo}/issues', issue) + async def read_file(self, subject: Subject, filename: str) -> str | None: + async def read_once() -> str | None: + try: + async with self.session.get(self.content / subject.repo / subject.sha / filename) as response: + logger.debug('response %r', response) + return await response.text() + except aiohttp.ClientResponseError as exc: + if exc.status == 404: + return None + raise + + return await retry(read_once) + def get_status(self, repo: str, sha: str, context: str | None, location: URL) -> Status: return GitHubStatus(self, repo, sha, context, location) diff --git a/test/test_aio.py b/test/test_aio.py index 8315cf1a5c..eada7babb0 100644 --- a/test/test_aio.py +++ b/test/test_aio.py @@ -21,6 +21,7 @@ class GitHubService: CLONE_URL = URL('http://github.test/') API_URL = URL('http://api.github.test/') + CONTENT_URL = URL('http://content.github.test/') TOKEN = 'token_ABCDEFG' USER_AGENT = __file__ # or any magic unique string @@ -34,6 +35,7 @@ def __init__(self) -> None: self.config: JsonObject = { 'api-url': str(self.API_URL), 'clone-url': str(self.CLONE_URL), + 'content-url': str(self.CONTENT_URL), 'post': True, 'token': self.TOKEN, 'user-agent': self.USER_AGENT, From 47605fabbdd4fa52819e483d2d93764e0eecda05 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Tue, 12 Mar 2024 12:15:12 +0100 Subject: [PATCH 09/18] job-runner: query .cockpit-ci/container In case the job didn't explicitly specify a container image to use, query the target repository for the `.cockpit-ci/container` file. Only fall back to the default container image if we get a 404. Also: add a message to the log about which container image we decided to use in the end. --- lib/aio/job.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/aio/job.py b/lib/aio/job.py index e261a914ee..53ac180f96 100644 --- a/lib/aio/job.py +++ b/lib/aio/job.py @@ -80,6 +80,14 @@ async def run_container(job: Job, subject: Subject, ctx: JobContext, log: LogStr cidfile = tmpdir / 'cidfile' attachments = tmpdir / 'attachments' + container_image = ( + job.container or + await ctx.forge.read_file(subject, '.cockpit-ci/container') or + ctx.default_image + ).strip() + + log.write(f'Using container image: {container_image}\n') + args = [ *ctx.container_cmd, 'run', # we run arbitrary commands in that container, which aren't prepared for being pid 1; reap zombies @@ -91,7 +99,7 @@ async def run_container(job: Job, subject: Subject, ctx: JobContext, log: LogStr f'--env=COCKPIT_CI_LOG_URL={log.url}', *itertools.chain.from_iterable(args for name, args in ctx.secrets_args.items() if name in job.secrets), - job.container or ctx.default_image, + container_image, # we might be using podman-remote, so we can't --volume this: 'python3', '-c', Path(f'{BOTS_DIR}/checkout-and-run').read_text(), # lulz @@ -162,7 +170,7 @@ async def run_job(job: Job, ctx: JobContext) -> None: log.start( f'{title}\n\n' f'Running on: {platform.node()}\n\n' - f'Job({json.dumps(job, default=lambda obj: obj.__dict__, indent=4)})\n' + f'Job({json.dumps(job, default=lambda obj: obj.__dict__, indent=4)})\n\n' ) await status.post('pending', 'In progress') From 44652f1aee89a9d8f228000c033badf00d9081c4 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 11 Mar 2024 17:42:42 +0100 Subject: [PATCH 10/18] tests-scan: use github context for job context job-runner is interested in the thing we call `github_context` here, so make sure we use it instead. Fixes #6065 --- tests-scan | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests-scan b/tests-scan index 52850a6cc6..c86ea88c41 100755 --- a/tests-scan +++ b/tests-scan @@ -230,7 +230,7 @@ def queue_test(job: Job, channel, options: argparse.Namespace) -> None: "job": None if job.repo != options.repo else { "repo": job.repo, "sha": job.revision, - "context": job.context, + "context": job.github_context, "target": job.base, "slug": slug, "env": env, From d8f976d1a388056e67b939e61796218a6f3b3b33 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 11 Mar 2024 17:53:27 +0100 Subject: [PATCH 11/18] tests-scan: simplify PR# handling for job object We can do this inline, avoiding some messy conditionals. Adjust tests and use normal asserts in a couple places. --- test/test_tests_scan.py | 15 +++++++++------ tests-scan | 16 +++++----------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/test/test_tests_scan.py b/test/test_tests_scan.py index c0c96f93d9..b8c6f5ad20 100755 --- a/test/test_tests_scan.py +++ b/test/test_tests_scan.py @@ -268,7 +268,7 @@ def test_amqp_pr(self, mock_queue, _mock_strftime): " COCKPIT_BOTS_REF=main TEST_SCENARIO=nightly ../tests-invoke --pull-number" f" {self.pull_number} --revision {self.revision} --repo {self.repo}\"") - self.assertEqual(request, { + assert request == { "command": expected_command, "type": "test", "sha": "abcdef", @@ -278,6 +278,7 @@ def test_amqp_pr(self, mock_queue, _mock_strftime): "context": "fedora/nightly", "repo": "project/repo", "pull": int(self.pull_number), + "report": None, "sha": "abcdef", "slug": f"pull-{self.pull_number}-abcdef-20240102-030405-fedora-nightly", "target": "stable-1.0", @@ -290,7 +291,7 @@ def test_amqp_pr(self, mock_queue, _mock_strftime): "TEST_SCENARIO": "nightly", } } - }) + } # mock time for predictable test name @unittest.mock.patch("time.strftime", return_value="20240102-030405") @@ -320,7 +321,7 @@ def test_amqp_sha_nightly(self, mock_queue, _mock_strftime): 'cd make-checkout-workdir && TEST_OS=fedora COCKPIT_BOTS_REF=main ' 'TEST_SCENARIO=nightly ../tests-invoke --revision 9988aa --repo project/repo"') self.maxDiff = None - self.assertEqual(request, { + assert request == { "command": expected_command, "type": "test", "sha": "9988aa", @@ -332,6 +333,7 @@ def test_amqp_sha_nightly(self, mock_queue, _mock_strftime): "sha": "9988aa", "slug": "pull-0-9988aa-20240102-030405-fedora-nightly", "target": None, + "pull": None, "report": { "title": "Tests failed on 9988aa", "labels": ["nightly"], @@ -345,7 +347,7 @@ def test_amqp_sha_nightly(self, mock_queue, _mock_strftime): "TEST_SCENARIO": "nightly", } } - }) + } # mock time for predictable test name @unittest.mock.patch("time.strftime", return_value="20240102-030405") @@ -375,7 +377,7 @@ def test_amqp_sha_pr(self, mock_queue, _mock_strftime): './make-checkout --verbose --repo=project/repo --rebase=stable-1.0 abcdef && ' 'cd make-checkout-workdir && TEST_OS=fedora BASE_BRANCH=stable-1.0 COCKPIT_BOTS_REF=main ' 'TEST_SCENARIO=nightly ../tests-invoke --pull-number 1 --revision abcdef --repo project/repo"') - self.assertEqual(request, { + assert request == { "command": expected_command, "type": "test", "sha": "abcdef", @@ -385,6 +387,7 @@ def test_amqp_sha_pr(self, mock_queue, _mock_strftime): "context": "fedora/nightly", "repo": "project/repo", "pull": int(self.pull_number), + "report": None, "sha": "abcdef", "slug": f"pull-{self.pull_number}-abcdef-20240102-030405-fedora-nightly", "target": "stable-1.0", @@ -397,7 +400,7 @@ def test_amqp_sha_pr(self, mock_queue, _mock_strftime): "TEST_SCENARIO": "nightly", } } - }) + } def do_test_tests_invoke(self, attachments_url, expected_logs_url): repo = "cockpit-project/cockpit" diff --git a/tests-scan b/tests-scan index c86ea88c41..261cf05e20 100755 --- a/tests-scan +++ b/tests-scan @@ -232,6 +232,11 @@ def queue_test(job: Job, channel, options: argparse.Namespace) -> None: "sha": job.revision, "context": job.github_context, "target": job.base, + "pull": job.number or None, # job.number uses 0 to mean 'None' + "report": None if job.number else { + "title": f"Tests failed on {job.revision}", + "labels": ["nightly"], + }, "slug": slug, "env": env, "container": job.container, @@ -240,17 +245,6 @@ def queue_test(job: Job, channel, options: argparse.Namespace) -> None: } } - # report issue for nightly runs - if isinstance(body['job'], dict): - if body["job"] is not None: - if job.number == 0: - body["job"]["report"] = { - "title": f"Tests failed on {job.revision}", - "labels": ["nightly"], - } - else: - body["job"]["pull"] = job.number - queue = 'rhel' if is_internal_context(job.context) else 'public' channel.basic_publish('', queue, json.dumps(body), properties=pika.BasicProperties(priority=priority)) logging.info("Published '%s' on '%s' with command: '%s'", job.name, job.revision, command) From c6b7c1f5291fc8a8022489f574f6f3f249114908 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Tue, 12 Mar 2024 08:49:51 +0100 Subject: [PATCH 12/18] test: Add tests-scan unit test for cross-project test This validates the mechanics for make-checkout/tests-invoke and confirms commit c377eb892. Cover both "implicit default branch" and "explicitly specified branch" scenarios. --- test/test_tests_scan.py | 54 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/test_tests_scan.py b/test/test_tests_scan.py index b8c6f5ad20..e8526e17f1 100755 --- a/test/test_tests_scan.py +++ b/test/test_tests_scan.py @@ -402,6 +402,60 @@ def test_amqp_sha_pr(self, mock_queue, _mock_strftime): } } + # mock time for predictable test name + @unittest.mock.patch("time.strftime", return_value="20240102-030405") + @unittest.mock.patch("task.distributed_queue.DistributedQueue") + def do_test_amqp_pr_cross_project(self, status_branch, mock_queue, _mock_strftime): + repo_branch = f"cockpit-project/cockpituous{f'/{status_branch}' if status_branch else ''}" + # SHA is attached to PR #1 + args = ["tests-scan", "--dry", "--repo", self.repo, + # need to pick a project with a REPO_BRANCH_CONTEXT entry for default branch + "--context", f"{self.context}@{repo_branch}", + "--sha", "abcdef", "--amqp", "amqp.example.com:1234"] + + with unittest.mock.patch("sys.argv", args): + # needs to be in-process for mocking + self.get_tests_scan_module().main() + + mock_queue.assert_called_once_with("amqp.example.com:1234", ["rhel", "public"]) + channel = mock_queue.return_value.__enter__.return_value.channel + + channel.basic_publish.assert_called_once() + self.assertEqual(channel.basic_publish.call_args[0][0], "") + self.assertEqual(channel.basic_publish.call_args[0][1], "public") + request = json.loads(channel.basic_publish.call_args[0][2]) + + branch = status_branch or "main" + + # make-checkout tests cockpituous, but tests-invoke *reports* for project/repo + expected_command = ( + './s3-streamer --repo project/repo --test-name pull-1-20240102-030405 ' + f'--github-context fedora/nightly@{repo_branch} --revision abcdef -- ' + '/bin/sh -c "PRIORITY=0005 ' + f'./make-checkout --verbose --repo=cockpit-project/cockpituous --rebase={branch} {branch} && ' + f'cd make-checkout-workdir && TEST_OS=fedora BASE_BRANCH={branch} COCKPIT_BOTS_REF=main ' + 'TEST_SCENARIO=nightly ../tests-invoke --pull-number 1 --revision abcdef --repo project/repo"') + + assert request == { + "command": expected_command, + "type": "test", + "sha": "abcdef", + "ref": branch, + "name": f"pull-{self.pull_number}", + # job-runner currently disabled for cross-project tests (commit c377eb892) + "job": None, + } + + def test_amqp_sha_pr_cross_project_default_branch(self): + """Default branch cross-project status event on PR""" + + self.do_test_amqp_pr_cross_project(None) + + def test_amqp_sha_pr_cross_project_explicit_branch(self): + """Explicit branch cross-project status event on PR""" + + self.do_test_amqp_pr_cross_project("otherbranch") + def do_test_tests_invoke(self, attachments_url, expected_logs_url): repo = "cockpit-project/cockpit" args = ["--revision", self.revision, "--repo", repo] From 3459b0c3e8b6bf0491337be9f43db3d36f2c705b Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 11 Mar 2024 17:54:20 +0100 Subject: [PATCH 13/18] tests-scan: minor tweak to job creation We're actually interested in options.repo, not job.repo. Job.repo is what gets called "context_project" elsewhere, and refers more to the command that we want to run than it does to the thing we're interested in testing (and definitely doesn't refer to the thing we report status on). This change doesn't actually do anything (since we currently ignore the case where job.repo != options.repo) but it's important that we get the semantics correct here, because it's about to start mattering. job.repo will be handled differently in the next commit. --- tests-scan | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests-scan b/tests-scan index 261cf05e20..94bbb303b1 100755 --- a/tests-scan +++ b/tests-scan @@ -228,7 +228,7 @@ def queue_test(job: Job, channel, options: argparse.Namespace) -> None: # job-runner doesn't support this yet... "job": None if job.repo != options.repo else { - "repo": job.repo, + "repo": options.repo, "sha": job.revision, "context": job.github_context, "target": job.base, From 6548a3bf53304d295dff688d840503648e067f5e Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Tue, 12 Mar 2024 09:32:33 +0100 Subject: [PATCH 14/18] tests-scan: Fix container detection for cross-project tests If the test project isn't the same as the status repo (i.e. a cross-project test), then we need to read the container file from the actual test repo. This isn't yet covered by unit tests due to commit c377eb892, but the next commit will enable that again and check it. --- tests-scan | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests-scan b/tests-scan index 94bbb303b1..fdd608ac64 100755 --- a/tests-scan +++ b/tests-scan @@ -391,8 +391,6 @@ def cockpit_tasks(api: github.GitHub, contexts, opts: argparse.Namespace, repo: for context in build_policy(repo, contexts).get(base, []): todos[context] = {} - container = api.fetch_file(merge_sha, '.cockpit-ci/container') - # there are 3 different HEADs # ref: the PR or SHA that we are testing # base: the target branch of that PR (None for direct SHA trigger) @@ -428,6 +426,11 @@ def cockpit_tasks(api: github.GitHub, contexts, opts: argparse.Namespace, repo: checkout_ref = ref if project != repo: checkout_ref = testmap.get_default_branch(project) + project_api = github.GitHub(repo=project) + container = project_api.fetch_file(checkout_ref, '.cockpit-ci/container') + else: + container = api.fetch_file(merge_sha, '.cockpit-ci/container') + if base != branch: checkout_ref = branch From 1e54b5793e3fe27c702aea56aeb55d54c4edf997 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Tue, 12 Mar 2024 09:46:59 +0100 Subject: [PATCH 15/18] tests-scan: Fix slug for cross-project tests We want to include the test repository for cross-project tests, which is `job.github_context`. --- tests-scan | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests-scan b/tests-scan index fdd608ac64..d7a3e0966b 100755 --- a/tests-scan +++ b/tests-scan @@ -205,7 +205,8 @@ def queue_test(job: Job, channel, options: argparse.Namespace) -> None: if command: priority = min(job.priority, distributed_queue.MAX_PRIORITY) - slug = f"{job.name}-{job.revision[:8]}-{time.strftime('%Y%m%d-%H%M%S')}-{job.context.replace('/', '-')}" + slug_suffix = job.github_context.replace('/', '-').replace('@', '-') + slug = f"{job.name}-{job.revision[:8]}-{time.strftime('%Y%m%d-%H%M%S')}-{slug_suffix}" (image, _, scenario) = job.context.partition("/") env = { From ddd3c30add4585a63ce6fd18127ec6cc7afc3332 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Tue, 12 Mar 2024 12:41:37 +0100 Subject: [PATCH 16/18] tests-scan: don't send target to job-runner It's going to look this up on its own, now. --- test/test_tests_scan.py | 3 --- tests-scan | 1 - 2 files changed, 4 deletions(-) diff --git a/test/test_tests_scan.py b/test/test_tests_scan.py index e8526e17f1..61a38c7cb4 100755 --- a/test/test_tests_scan.py +++ b/test/test_tests_scan.py @@ -281,7 +281,6 @@ def test_amqp_pr(self, mock_queue, _mock_strftime): "report": None, "sha": "abcdef", "slug": f"pull-{self.pull_number}-abcdef-20240102-030405-fedora-nightly", - "target": "stable-1.0", "container": "supertasks", "secrets": ["github-token", "image-download"], "env": { @@ -332,7 +331,6 @@ def test_amqp_sha_nightly(self, mock_queue, _mock_strftime): "repo": "project/repo", "sha": "9988aa", "slug": "pull-0-9988aa-20240102-030405-fedora-nightly", - "target": None, "pull": None, "report": { "title": "Tests failed on 9988aa", @@ -390,7 +388,6 @@ def test_amqp_sha_pr(self, mock_queue, _mock_strftime): "report": None, "sha": "abcdef", "slug": f"pull-{self.pull_number}-abcdef-20240102-030405-fedora-nightly", - "target": "stable-1.0", "container": "supertasks", "secrets": ["github-token", "image-download"], "env": { diff --git a/tests-scan b/tests-scan index d7a3e0966b..5016b45ff8 100755 --- a/tests-scan +++ b/tests-scan @@ -232,7 +232,6 @@ def queue_test(job: Job, channel, options: argparse.Namespace) -> None: "repo": options.repo, "sha": job.revision, "context": job.github_context, - "target": job.base, "pull": job.number or None, # job.number uses 0 to mean 'None' "report": None if job.number else { "title": f"Tests failed on {job.revision}", From 49b5f758112e30482e459e8f56e6f0fb80404800 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Tue, 12 Mar 2024 12:44:23 +0100 Subject: [PATCH 17/18] tests-scan: remove container logic job-runner can do this on its own now. --- test/test_tests_scan.py | 6 ------ tests-scan | 8 -------- 2 files changed, 14 deletions(-) diff --git a/test/test_tests_scan.py b/test/test_tests_scan.py index 61a38c7cb4..eb8a167a87 100755 --- a/test/test_tests_scan.py +++ b/test/test_tests_scan.py @@ -82,8 +82,6 @@ def do_GET(self): elif self.path.endswith("/issues"): issues = self.server.data.get('issues', []) self.replyJson(issues) - elif self.path == "/project/repo/abcdef/.cockpit-ci/container": - self.replyData("supertasks") else: self.send_error(404, 'Mock Not Found: ' + self.path) @@ -281,7 +279,6 @@ def test_amqp_pr(self, mock_queue, _mock_strftime): "report": None, "sha": "abcdef", "slug": f"pull-{self.pull_number}-abcdef-20240102-030405-fedora-nightly", - "container": "supertasks", "secrets": ["github-token", "image-download"], "env": { "BASE_BRANCH": "stable-1.0", @@ -336,8 +333,6 @@ def test_amqp_sha_nightly(self, mock_queue, _mock_strftime): "title": "Tests failed on 9988aa", "labels": ["nightly"], }, - # project/repo doesn't have a custom container name file - "container": None, "secrets": ["github-token", "image-download"], "env": { "COCKPIT_BOTS_REF": "main", @@ -388,7 +383,6 @@ def test_amqp_sha_pr(self, mock_queue, _mock_strftime): "report": None, "sha": "abcdef", "slug": f"pull-{self.pull_number}-abcdef-20240102-030405-fedora-nightly", - "container": "supertasks", "secrets": ["github-token", "image-download"], "env": { "BASE_BRANCH": "stable-1.0", diff --git a/tests-scan b/tests-scan index 5016b45ff8..ff46e477cf 100755 --- a/tests-scan +++ b/tests-scan @@ -119,7 +119,6 @@ class Job(NamedTuple): repo: str bots_ref: 'str | None' github_context: str - container: 'str | None' # Prepare a human readable output @@ -239,7 +238,6 @@ def queue_test(job: Job, channel, options: argparse.Namespace) -> None: }, "slug": slug, "env": env, - "container": job.container, # for updating naughty trackers and downloading private images "secrets": ["github-token", "image-download"], } @@ -365,7 +363,6 @@ def cockpit_tasks(api: github.GitHub, contexts, opts: argparse.Namespace, repo: statuses = api.statuses(revision) login = pull["head"]["user"]["login"] base = pull.get("base", {}).get("ref") # The branch this pull request targets (None for direct SHA triggers) - merge_sha = pull.get("merge_commit_sha", revision) allowed = login in ALLOWLIST @@ -426,10 +423,6 @@ def cockpit_tasks(api: github.GitHub, contexts, opts: argparse.Namespace, repo: checkout_ref = ref if project != repo: checkout_ref = testmap.get_default_branch(project) - project_api = github.GitHub(repo=project) - container = project_api.fetch_file(checkout_ref, '.cockpit-ci/container') - else: - container = api.fetch_file(merge_sha, '.cockpit-ci/container') if base != branch: checkout_ref = branch @@ -457,7 +450,6 @@ def cockpit_tasks(api: github.GitHub, contexts, opts: argparse.Namespace, repo: project, bots_ref, context, - container, ) From fc789bf9524a2bb40f87a9fd6315a1e9cf9257c1 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 11 Mar 2024 18:02:11 +0100 Subject: [PATCH 18/18] tests-scan: re-enable cross-project 'job' creation Use the new 'command-subject' support in job-runner to bring back cross-project support for creating 'Job' objects. Co-authored-by: Martin Pitt --- test/test_tests_scan.py | 23 +++++++++++++++++++++-- tests-scan | 8 +++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/test/test_tests_scan.py b/test/test_tests_scan.py index eb8a167a87..1197d388b6 100755 --- a/test/test_tests_scan.py +++ b/test/test_tests_scan.py @@ -279,6 +279,7 @@ def test_amqp_pr(self, mock_queue, _mock_strftime): "report": None, "sha": "abcdef", "slug": f"pull-{self.pull_number}-abcdef-20240102-030405-fedora-nightly", + "command-subject": None, "secrets": ["github-token", "image-download"], "env": { "BASE_BRANCH": "stable-1.0", @@ -333,6 +334,7 @@ def test_amqp_sha_nightly(self, mock_queue, _mock_strftime): "title": "Tests failed on 9988aa", "labels": ["nightly"], }, + "command-subject": None, "secrets": ["github-token", "image-download"], "env": { "COCKPIT_BOTS_REF": "main", @@ -383,6 +385,7 @@ def test_amqp_sha_pr(self, mock_queue, _mock_strftime): "report": None, "sha": "abcdef", "slug": f"pull-{self.pull_number}-abcdef-20240102-030405-fedora-nightly", + "command-subject": None, "secrets": ["github-token", "image-download"], "env": { "BASE_BRANCH": "stable-1.0", @@ -427,14 +430,30 @@ def do_test_amqp_pr_cross_project(self, status_branch, mock_queue, _mock_strftim f'cd make-checkout-workdir && TEST_OS=fedora BASE_BRANCH={branch} COCKPIT_BOTS_REF=main ' 'TEST_SCENARIO=nightly ../tests-invoke --pull-number 1 --revision abcdef --repo project/repo"') + slug_repo_branch = repo_branch.replace('@', '-').replace('/', '-') + assert request == { "command": expected_command, "type": "test", "sha": "abcdef", "ref": branch, "name": f"pull-{self.pull_number}", - # job-runner currently disabled for cross-project tests (commit c377eb892) - "job": None, + "job": { + "context": f"fedora/nightly@{repo_branch}", + "repo": "project/repo", + "pull": int(self.pull_number), + "report": None, + "sha": "abcdef", + "slug": f"pull-{self.pull_number}-abcdef-20240102-030405-fedora-nightly-{slug_repo_branch}", + "command-subject": {"repo": "cockpit-project/cockpituous", "branch": branch}, + "secrets": ["github-token", "image-download"], + "env": { + "BASE_BRANCH": branch, + "COCKPIT_BOTS_REF": "main", + "TEST_OS": "fedora", + "TEST_SCENARIO": "nightly", + } + } } def test_amqp_sha_pr_cross_project_default_branch(self): diff --git a/tests-scan b/tests-scan index ff46e477cf..2205e0b9fe 100755 --- a/tests-scan +++ b/tests-scan @@ -225,9 +225,7 @@ def queue_test(job: Job, channel, options: argparse.Namespace) -> None: "sha": job.revision, "ref": job.ref, "name": job.name, - - # job-runner doesn't support this yet... - "job": None if job.repo != options.repo else { + "job": { "repo": options.repo, "sha": job.revision, "context": job.github_context, @@ -236,6 +234,10 @@ def queue_test(job: Job, channel, options: argparse.Namespace) -> None: "title": f"Tests failed on {job.revision}", "labels": ["nightly"], }, + "command-subject": None if job.repo == options.repo else { + "repo": job.repo, + "branch": job.ref, + }, "slug": slug, "env": env, # for updating naughty trackers and downloading private images