Skip to content

Commit 8e0ec39

Browse files
committed
Auto-fetch the target ref and model branch/tag as a sum type
Make the user's `--branch` or `--tag` choice a typed `Branch | Tag` produced by `validate_input`, and have every downstream helper take that `ReleaseTarget` instead of `(branch: str | None, tag: str | None)`. This puts the "exactly one is set" invariant in the type system and removes the type-narrowing `assert`s that would otherwise turn into an `AssertionError` with no context if anyone broke the invariant. While at it, drop the `git ls-remote` probe + "please run `git fetch` and try again" hint and fetch the ref ourselves. The new `fetch_target` runs `git fetch --quiet --depth=1 origin refs/heads/<branch>:refs/remotes/origin/<branch>` (or the equivalent `refs/tags/...:refs/tags/...` for `--tag`), which both confirms the ref exists on origin and populates the local refs we need to read the workflow files. If `git fetch` reports `couldn't find remote ref`, the abort message stays the same as before (`Branch X not found on origin`); other git errors surface verbatim through `Failed to fetch ... from origin: ...`. Tests now mock `GitRepository.run` (the fetch) instead of `GitRepository.capture` (the ls-remote). Two new tests pin the exact refspec the command must send so a future refactor that breaks the fetch shape can't slip through. The "please-fetch-first" test goes away; that branch is unreachable now.
1 parent ff6ed7e commit 8e0ec39

4 files changed

Lines changed: 148 additions & 81 deletions

File tree

ddev/src/ddev/cli/release/test_agent/__init__.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,21 @@ def test_agent(app: Application, branch: str | None, tag: str | None, dry_run: b
3636
from ddev.cli.release.test_agent.dispatch import dispatch_both
3737
from ddev.cli.release.test_agent.images import build_image_refs, resolve_version, validate_images_exist
3838
from ddev.cli.release.test_agent.validation import (
39+
Branch,
40+
fetch_target,
3941
validate_input,
40-
verify_ref_exists,
4142
verify_workflows_present_on_ref,
4243
)
4344

44-
branch, tag = validate_input(app, branch, tag)
45-
ref = branch or tag
46-
assert ref is not None
45+
target = validate_input(app, branch, tag)
4746

4847
if not app.config.github.token:
4948
app.abort('GitHub token required. Set `github.token` via `ddev config set github.token <token>`.')
5049

51-
verify_ref_exists(app, branch=branch, tag=tag)
52-
verify_workflows_present_on_ref(app, branch=branch, tag=tag)
50+
fetch_target(app, target)
51+
verify_workflows_present_on_ref(app, target)
5352

54-
version = resolve_version(app, branch=branch, tag=tag)
53+
version = resolve_version(app, target)
5554
validate_images_exist(app, version)
5655
linux_image, windows_image = build_image_refs(version)
5756

@@ -66,7 +65,8 @@ def test_agent(app: Application, branch: str | None, tag: str | None, dry_run: b
6665
'agent-image': linux_image,
6766
'agent-image-windows': windows_image,
6867
}
69-
_print_plan(app, ref=ref, version=version, branch=branch, inputs=inputs)
68+
is_branch = isinstance(target, Branch)
69+
_print_plan(app, ref=target.name, version=version, is_branch=is_branch, inputs=inputs)
7070

7171
if dry_run:
7272
app.display_info('Dry run — no workflows dispatched.')
@@ -76,7 +76,7 @@ def test_agent(app: Application, branch: str | None, tag: str | None, dry_run: b
7676
app.abort('Aborted by user.')
7777

7878
try:
79-
linux_url, windows_url = dispatch_both(app.config.github.token, ref=ref, inputs=inputs)
79+
linux_url, windows_url = dispatch_both(app.config.github.token, ref=target.name, inputs=inputs)
8080
except RuntimeError as e:
8181
app.abort(str(e))
8282
else:
@@ -88,7 +88,7 @@ def _print_plan(
8888
*,
8989
ref: str,
9090
version: str,
91-
branch: str | None,
91+
is_branch: bool,
9292
inputs: dict[str, str],
9393
) -> None:
9494
"""Render the resolved dispatch plan via the stderr-bound `display_info` channel.
@@ -102,7 +102,7 @@ def _print_plan(
102102
app.display_info('Dispatch plan')
103103
app.display_info(f' Workflows: {WORKFLOW_LINUX}, {WORKFLOW_WINDOWS}')
104104
app.display_info(f' Ref: {ref}')
105-
if branch is not None:
105+
if is_branch:
106106
app.display_info(f' Resolved RC: {version}')
107107
for key, value in inputs.items():
108108
app.display_info(f' {key}: {value}')

ddev/src/ddev/cli/release/test_agent/images.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import httpx
1212

1313
from ddev.cli.release.test_agent import registry
14+
from ddev.cli.release.test_agent.validation import Branch, ReleaseTarget
1415

1516
if TYPE_CHECKING:
1617
from collections.abc import Iterator
@@ -19,25 +20,24 @@
1920

2021

2122
@contextlib.contextmanager
22-
def registry_errors(app: Application, target: str) -> Iterator[None]:
23+
def registry_errors(app: Application, scope: str) -> Iterator[None]:
2324
"""Translate any `httpx.HTTPError` raised inside the block into a clean `app.abort` message.
2425
25-
`target` is interpolated into the abort text — e.g. `'tags'` for the tag listing or an
26+
`scope` is interpolated into the abort text — e.g. `'tags'` for the tag listing or an
2627
image ref like `'registry.datadoghq.com/agent:7.80.0-rc.3'` for a manifest probe.
2728
"""
2829
try:
2930
yield
3031
except httpx.HTTPError as e:
31-
app.abort(f'Failed to query registry.datadoghq.com for {target}: {e}')
32+
app.abort(f'Failed to query registry.datadoghq.com for {scope}: {e}')
3233

3334

34-
def resolve_version(app: Application, *, branch: str | None, tag: str | None) -> str:
35+
def resolve_version(app: Application, target: ReleaseTarget) -> str:
3536
"""Pick the Agent image tag to test: the explicit tag, or the highest published RC for a branch."""
36-
if tag is not None:
37-
return tag
37+
if not isinstance(target, Branch):
38+
return target.name
3839

39-
assert branch is not None
40-
major_str, minor_str, _ = branch.split('.')
40+
major_str, minor_str, _ = target.name.split('.')
4141
major, minor = int(major_str), int(minor_str)
4242

4343
app.display_waiting(f'Looking up latest {major}.{minor}.0-rc.* in registry.datadoghq.com...')
Lines changed: 83 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
# (C) Datadog, Inc. 2026-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4-
"""Input validation and git-ref checks for `ddev release test-agent`."""
4+
"""Input validation, ref fetching, and workflow-file checks for `ddev release test-agent`.
5+
6+
The user picks either a release branch or a release tag. Downstream code needs to treat
7+
those two paths differently (the fetch refspec, the local ref name for `git show`, the
8+
version-resolution logic), but the type system has to know which one is in hand. Modelling
9+
the choice as a sum type (`Branch | Tag`) — produced by `validate_input` after the
10+
user-facing checks — keeps the branch/tag distinction explicit at every call site and
11+
eliminates the type-narrowing `assert`s that would otherwise be needed.
12+
"""
513

614
from __future__ import annotations
715

816
import re
17+
from dataclasses import dataclass
918
from typing import TYPE_CHECKING
1019

1120
from ddev.cli.release.test_agent.dispatch import WORKFLOW_LINUX, WORKFLOW_WINDOWS
@@ -22,82 +31,112 @@
2231
]
2332

2433
# git error fragments that mean "ref exists but file is not in that tree" — i.e. the workflow
25-
# really isn't on this branch/tag, as opposed to the ref itself being unreachable locally.
34+
# really isn't on this branch/tag, as opposed to git failing for some other reason.
2635
GIT_FILE_MISSING_FRAGMENTS = (
2736
'exists on disk',
2837
'does not exist',
2938
'no such path',
3039
)
31-
GIT_REF_MISSING_FRAGMENTS = (
32-
'invalid object name',
33-
'unknown revision',
34-
'bad revision',
35-
'ambiguous argument',
40+
41+
# git error fragments emitted when `git fetch` cannot resolve the named ref on origin.
42+
GIT_FETCH_MISSING_FRAGMENTS = (
43+
"couldn't find remote ref",
44+
'no such ref',
3645
)
3746

3847

39-
def validate_input(app: Application, branch: str | None, tag: str | None) -> tuple[str | None, str | None]:
40-
"""Normalize and validate inputs, returning (branch, tag) with exactly one set."""
41-
if bool(branch) == bool(tag):
42-
app.abort('Exactly one of --branch or --tag must be provided.')
48+
@dataclass(frozen=True)
49+
class Branch:
50+
"""A validated release branch the user selected via `--branch`."""
51+
52+
name: str
53+
54+
55+
@dataclass(frozen=True)
56+
class Tag:
57+
"""A validated release tag the user selected via `--tag` (with any leading `v` stripped)."""
58+
59+
name: str
60+
61+
62+
ReleaseTarget = Branch | Tag
63+
64+
65+
def validate_input(app: Application, branch: str | None, tag: str | None) -> ReleaseTarget:
66+
"""Normalize and validate `--branch`/`--tag`, returning a typed `Branch` or `Tag`.
4367
44-
if branch is not None and not re.match(BRANCH_PATTERN, branch):
45-
app.abort(f'Invalid branch: {branch!r}. Must match {BRANCH_PATTERN}.')
68+
Click can't enforce the mutually-exclusive constraint by itself, so this is the single
69+
point that does. Every downstream helper takes the resulting `ReleaseTarget` instead of
70+
two optional strings, which keeps the "exactly one is set" invariant in the type system
71+
rather than restated as `assert` at every call site.
72+
"""
73+
if branch is not None and tag is not None:
74+
app.abort('Cannot use --branch and --tag together; pick one.')
75+
76+
if branch is not None:
77+
if not re.match(BRANCH_PATTERN, branch):
78+
app.abort(f'Invalid branch: {branch!r}. Must match {BRANCH_PATTERN}.')
79+
return Branch(branch)
4680

4781
if tag is not None:
4882
normalized = tag.removeprefix('v')
4983
if not re.match(TAG_PATTERN, normalized):
5084
app.abort(f'Invalid tag: {tag!r}. Must match {TAG_PATTERN}.')
51-
tag = normalized
85+
return Tag(normalized)
5286

53-
return branch, tag
87+
app.abort('Exactly one of --branch or --tag must be provided.')
5488

5589

56-
def verify_ref_exists(app: Application, *, branch: str | None, tag: str | None) -> None:
57-
"""Confirm the ref is published on origin via `git ls-remote`."""
58-
if branch is not None:
59-
kind, value, flag = 'branch', branch, '--heads'
60-
else:
61-
assert tag is not None
62-
kind, value, flag = 'tag', tag, '--tags'
63-
64-
output = app.repo.git.capture('ls-remote', flag, 'origin', value)
65-
if not output.strip():
66-
app.abort(f'{kind.capitalize()} `{value}` not found on origin.')
90+
def local_ref_for(target: ReleaseTarget) -> str:
91+
"""The local refname that `fetch_target` populates and that `git show` can read from."""
92+
if isinstance(target, Branch):
93+
return f'origin/{target.name}'
94+
return target.name
6795

6896

69-
def verify_workflows_present_on_ref(app: Application, *, branch: str | None, tag: str | None) -> None:
70-
"""Confirm both workflow files exist at the target ref.
97+
def fetch_target(app: Application, target: ReleaseTarget) -> None:
98+
"""Fetch the user's release target from origin so `local_ref_for(target)` resolves locally.
7199
72-
`git show <ref>:<path>` only resolves against local refs, so a branch the user has not yet
73-
fetched will not be found under its bare name. For branches we read `origin/<branch>` to
74-
consult the remote-tracking ref; for tags we use the tag name directly. Either way, the
75-
git error text is inspected to distinguish "file missing from the tree" from "ref not
76-
local" so the abort message points at the real problem.
100+
Combines the existence check (would otherwise be a `git ls-remote` probe) with the side
101+
effect of populating the local refs we need to read the workflow files. After this call,
102+
`git show origin/<branch>:<path>` or `git show <tag>:<path>` is guaranteed to find the
103+
target in the local clone.
77104
"""
78-
if branch is not None:
79-
local_ref = f'origin/{branch}'
80-
fetch_hint = f'Run `git fetch origin {branch}` and try again.'
105+
if isinstance(target, Branch):
106+
kind = 'branch'
107+
refspec = f'refs/heads/{target.name}:refs/remotes/origin/{target.name}'
81108
else:
82-
assert tag is not None
83-
local_ref = tag
84-
fetch_hint = f'Run `git fetch origin tag {tag}` and try again.'
109+
kind = 'tag'
110+
refspec = f'refs/tags/{target.name}:refs/tags/{target.name}'
111+
112+
app.display_waiting(f'Fetching {kind} `{target.name}` from origin...')
113+
try:
114+
app.repo.git.run('fetch', '--quiet', '--depth=1', 'origin', refspec)
115+
except OSError as e:
116+
msg = str(e).lower()
117+
if any(fragment in msg for fragment in GIT_FETCH_MISSING_FRAGMENTS):
118+
app.abort(f'{kind.capitalize()} `{target.name}` not found on origin.')
119+
app.abort(f'Failed to fetch {kind} `{target.name}` from origin: {e}')
120+
121+
122+
def verify_workflows_present_on_ref(app: Application, target: ReleaseTarget) -> None:
123+
"""Confirm both workflow files exist at the target ref (which `fetch_target` made local)."""
124+
ref = local_ref_for(target)
125+
kind = 'branch' if isinstance(target, Branch) else 'tag'
85126

86127
missing: list[str] = []
87128
for path in WORKFLOW_FILES:
88129
try:
89-
app.repo.git.show_file(path, local_ref)
130+
app.repo.git.show_file(path, ref)
90131
except OSError as e:
91132
msg = str(e).lower()
92133
if any(fragment in msg for fragment in GIT_FILE_MISSING_FRAGMENTS):
93134
missing.append(path)
94-
elif any(fragment in msg for fragment in GIT_REF_MISSING_FRAGMENTS):
95-
app.abort(f'Ref `{local_ref}` is not in your local clone. {fetch_hint} (git error: {e})')
96135
else:
97-
app.abort(f'Failed to read `{path}` from `{local_ref}`: {e}')
136+
app.abort(f'Failed to read `{path}` from `{ref}`: {e}')
98137

99138
if missing:
100139
app.abort(
101-
f'Ref `{local_ref}` is missing required workflow file(s): {", ".join(missing)}. '
140+
f'{kind.capitalize()} `{target.name}` is missing required workflow file(s): {", ".join(missing)}. '
102141
'Pick a newer ref that includes both `test-agent.yml` and `test-agent-windows.yml`.'
103142
)

ddev/tests/cli/release/test_agent/test_command.py

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020

2121
@pytest.fixture(autouse=True)
2222
def _silence_git(mocker: MockerFixture) -> None:
23-
"""Default git mocks: ref exists on origin and both workflow files are present at the ref."""
24-
mocker.patch('ddev.utils.git.GitRepository.capture', return_value='abc123\trefs/heads/7.80.x\n')
23+
"""Default git mocks: `fetch_target` succeeds and `git show` returns workflow yaml."""
24+
mocker.patch('ddev.utils.git.GitRepository.run', return_value=None)
2525
mocker.patch('ddev.utils.git.GitRepository.show_file', return_value='workflow yaml')
2626

2727

@@ -39,7 +39,7 @@ def _silence_registry(mocker: MockerFixture) -> None:
3939
'args, expected',
4040
[
4141
pytest.param([], 'Exactly one of --branch or --tag', id='neither'),
42-
pytest.param(['--branch', '7.80.x', '--tag', '7.80.0'], 'Exactly one of --branch or --tag', id='both'),
42+
pytest.param(['--branch', '7.80.x', '--tag', '7.80.0'], 'Cannot use --branch and --tag together', id='both'),
4343
pytest.param(['--branch', '7.80'], 'Invalid branch', id='bad-branch'),
4444
pytest.param(['--tag', '7.80'], 'Invalid tag', id='bad-tag'),
4545
],
@@ -130,48 +130,76 @@ def test_missing_image_aborts(ddev: CliRunner, mocker: MockerFixture, fake_async
130130

131131

132132
def test_missing_ref_aborts(ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient) -> None:
133-
mocker.patch('ddev.utils.git.GitRepository.capture', return_value='')
133+
"""When `git fetch` reports the ref does not exist on origin, surface a clean abort."""
134+
mocker.patch(
135+
'ddev.utils.git.GitRepository.run',
136+
side_effect=OSError("fatal: couldn't find remote ref refs/heads/7.80.x"),
137+
)
134138

135139
result = ddev('release', 'test-agent', '--branch', '7.80.x', '--yes')
136140

137141
assert result.exit_code != 0, result.output
138-
assert 'not found on origin' in result.output
142+
assert 'Branch `7.80.x` not found on origin' in result.output
139143
fake_async_github.assert_not_called('create_workflow_dispatch')
140144

141145

142-
def test_missing_workflow_file_aborts(
146+
def test_fetch_target_is_called_with_branch_refspec(
143147
ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient
144148
) -> None:
145-
"""A 'file exists on disk, but not in <ref>' git error reads as the workflow really being absent."""
149+
"""The command must fetch the branch into `origin/<branch>` before reading workflow files."""
150+
run_spy = mocker.patch('ddev.utils.git.GitRepository.run', return_value=None)
151+
152+
result = ddev('release', 'test-agent', '--branch', '7.80.x', '--yes')
153+
154+
assert result.exit_code == 0, result.output
155+
run_spy.assert_any_call('fetch', '--quiet', '--depth=1', 'origin', 'refs/heads/7.80.x:refs/remotes/origin/7.80.x')
156+
157+
158+
def test_fetch_target_is_called_with_tag_refspec(
159+
ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient
160+
) -> None:
161+
"""A `--tag` invocation must fetch into `refs/tags/<tag>` so `git show <tag>:path` works."""
162+
run_spy = mocker.patch('ddev.utils.git.GitRepository.run', return_value=None)
163+
164+
result = ddev('release', 'test-agent', '--tag', '7.80.0-rc.1', '--yes')
165+
166+
assert result.exit_code == 0, result.output
167+
run_spy.assert_any_call('fetch', '--quiet', '--depth=1', 'origin', 'refs/tags/7.80.0-rc.1:refs/tags/7.80.0-rc.1')
168+
169+
170+
def test_fetch_target_other_error_surfaces_original(
171+
ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient
172+
) -> None:
173+
"""Anything other than 'remote ref not found' must surface as a generic fetch-failed abort."""
146174
mocker.patch(
147-
'ddev.utils.git.GitRepository.show_file',
148-
side_effect=OSError(
149-
"fatal: path '.github/workflows/test-agent.yml' exists on disk, but not in 'origin/7.80.x'"
150-
),
175+
'ddev.utils.git.GitRepository.run',
176+
side_effect=OSError('fatal: unable to access https://github.com/...: Could not resolve host'),
151177
)
152178

153179
result = ddev('release', 'test-agent', '--branch', '7.80.x', '--yes')
154180

155181
assert result.exit_code != 0, result.output
156-
assert 'missing required workflow file' in result.output
157-
assert 'origin/7.80.x' in result.output
182+
assert 'Failed to fetch branch `7.80.x` from origin' in result.output
183+
assert 'Could not resolve host' in result.output
158184
fake_async_github.assert_not_called('create_workflow_dispatch')
159185

160186

161-
def test_unfetched_branch_surfaces_fetch_hint(
187+
def test_missing_workflow_file_aborts(
162188
ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient
163189
) -> None:
164-
"""When `origin/<branch>` isn't in the local clone, the abort should tell the user to fetch."""
190+
"""A 'file exists on disk, but not in <ref>' git error reads as the workflow really being absent."""
165191
mocker.patch(
166192
'ddev.utils.git.GitRepository.show_file',
167-
side_effect=OSError("fatal: invalid object name 'origin/7.80.x'"),
193+
side_effect=OSError(
194+
"fatal: path '.github/workflows/test-agent.yml' exists on disk, but not in 'origin/7.80.x'"
195+
),
168196
)
169197

170198
result = ddev('release', 'test-agent', '--branch', '7.80.x', '--yes')
171199

172200
assert result.exit_code != 0, result.output
173-
assert 'not in your local clone' in result.output
174-
assert 'git fetch origin 7.80.x' in result.output
201+
assert 'missing required workflow file' in result.output
202+
assert '7.80.x' in result.output
175203
fake_async_github.assert_not_called('create_workflow_dispatch')
176204

177205

0 commit comments

Comments
 (0)