Skip to content

Commit ac25bd8

Browse files
authored
Add dependency updates report to the dep updates command and improve dep update workflow (DataDog#22855)
* Add dependency updates report to the dep updates command and improve dep update workflow * Add changelog * Update changelog since I used a previous PR number to generate it * Fix formatting * Updated the behavior of the report to get the report printed on screen if passed without a path
1 parent 0bbf23e commit ac25bd8

4 files changed

Lines changed: 246 additions & 14 deletions

File tree

.github/workflows/update-dependencies.yml

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@ jobs:
3737
repositories: integrations-core
3838
- name: Update dependencies
3939
run: |-
40-
ddev dep updates --sync
40+
ddev dep updates --sync --report /tmp/dep-report.md --report-type markdown
41+
- name: Read dependency report
42+
id: dep-summary
43+
run: |-
44+
echo "summary<<EOF" >> "$GITHUB_OUTPUT"
45+
cat /tmp/dep-report.md >> "$GITHUB_OUTPUT"
46+
echo "EOF" >> "$GITHUB_OUTPUT"
4147
- name: Update licenses
4248
run: |-
4349
ddev validate licenses --sync
@@ -62,11 +68,11 @@ jobs:
6268
### What does this PR do?
6369
Update the dependencies
6470
71+
${{ steps.dep-summary.outputs.summary }}
72+
6573
This PR was automatically generated by the following workflow:
6674
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
6775
68-
**THE CHANGELOG FILES OFTEN HAVE THE WRONG PR NUMBER. MAKE SURE TO CHECK THEM!**
69-
7076
### Review checklist (to be filled by reviewers)
7177
7278
- [ ] Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
@@ -79,3 +85,30 @@ jobs:
7985
base: master
8086
labels: bot,qa/skip-qa
8187
draft: false
88+
- name: Fix changelog PR numbers
89+
if: steps.cpr.outputs.pull-request-number != ''
90+
env:
91+
GITHUB_TOKEN: ${{ steps.token-generator.outputs.token }}
92+
run: |-
93+
actual_pr="${{ steps.cpr.outputs.pull-request-number }}"
94+
branch="${{ steps.cpr.outputs.pull-request-branch }}"
95+
96+
# Resolve the actual base branch from the PR (avoids hardcoding 'master')
97+
base_branch=$(gh pr view "$actual_pr" --json baseRefName -q '.baseRefName')
98+
99+
git fetch origin "$branch" "$base_branch"
100+
git checkout "$branch"
101+
102+
git diff "origin/$base_branch"...HEAD --name-only --diff-filter=A | grep 'changelog\.d/' | while read -r file; do
103+
ext="${file##*.}"
104+
dir=$(dirname "$file")
105+
git mv "$file" "$dir/$actual_pr.$ext" 2>/dev/null || true
106+
done
107+
108+
git diff --cached --quiet || (
109+
git config user.email "github-actions[bot]@users.noreply.github.com"
110+
git config user.name "github-actions[bot]"
111+
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
112+
git commit -m "Fix changelog PR numbers"
113+
git push origin "$branch"
114+
)

ddev/changelog.d/22855.added

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added `--report` and `--report-type` options to `ddev dep updates`:
2+
- Pass `--report PATH` to write a dependency update report to a file (JSON or Markdown via `--report-type`).
3+
- Pass `--report` without a path to print the report as a Rich table directly in the terminal.

ddev/src/ddev/cli/dep/updates.py

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,31 @@
1616
scrape_version_data,
1717
update_agent_dependencies,
1818
)
19+
from ddev.utils.fs import Path
1920

2021

2122
@click.command(short_help='Automatically check for dependency updates')
2223
@click.option('--sync', '-s', 'sync_dependencies', is_flag=True, help='Update the dependency definitions')
2324
@click.option('--include-security-deps', '-i', is_flag=True, help="Attempt to update security dependencies")
2425
@click.option('--batch-size', '-b', type=int, help='The maximum number of dependencies to upgrade if syncing')
26+
@click.option(
27+
'--report',
28+
is_flag=False,
29+
flag_value='',
30+
default=None,
31+
metavar='PATH',
32+
help='Write a dependency update report to PATH, or omit PATH to print to screen.',
33+
)
34+
@click.option(
35+
'--report-type',
36+
type=click.Choice(['json', 'markdown']),
37+
default='json',
38+
show_default=True,
39+
help='Format of the report file (only applies when a PATH is given).',
40+
)
2541
@click.pass_context
2642
@click.pass_obj
27-
def updates(app, ctx, sync_dependencies, include_security_deps, batch_size):
43+
def updates(app, ctx, sync_dependencies, include_security_deps, batch_size, report, report_type):
2844
ignore_deps = set(app.repo.config.get('/overrides/dep/updates/exclude', []))
2945

3046
if not include_security_deps:
@@ -45,6 +61,7 @@ def updates(app, ctx, sync_dependencies, include_security_deps, batch_size):
4561
new_dependencies = copy.deepcopy(dependencies)
4662
version_updates = defaultdict(lambda: defaultdict(set))
4763
updated_packages = set()
64+
report_entries: list[dict] = []
4865
for name, python_versions in sorted(new_dependencies.items()):
4966
if name in ignore_deps:
5067
continue
@@ -67,20 +84,70 @@ def updates(app, ctx, sync_dependencies, include_security_deps, batch_size):
6784
if dependency_definition != new_dependency_definition:
6885
version_updates[name][package_version].add(python_version)
6986
updated_packages.add(name)
87+
if report is not None:
88+
report_entries.append(
89+
{
90+
'package': name,
91+
'old_version': str(Requirement(dependency_definition).specifier).lstrip('='),
92+
'new_version': str(package_version),
93+
}
94+
)
95+
96+
if report is None:
97+
pass
98+
elif report == '':
99+
_print_table_report(app, report_entries)
100+
elif report_type == 'json':
101+
_write_json_report(report_entries, report)
102+
else:
103+
_write_markdown_report(report_entries, report)
70104

71105
if sync_dependencies:
72106
if updated_packages:
73107
update_agent_dependencies(app.repo, new_dependencies)
74108
ctx.invoke(sync)
75109
app.display_info(f'Updated {len(updated_packages)} dependencies')
110+
elif updated_packages:
111+
app.display_error(f"{len(updated_packages)} dependencies are out of sync:")
112+
for name, versions in version_updates.items():
113+
for package_version, python_versions in versions.items():
114+
app.display_error(
115+
f'{name} can be updated to version {package_version} on {" and ".join(sorted(python_versions))}'
116+
)
117+
app.abort()
76118
else:
77-
if updated_packages:
78-
app.display_error(f"{len(updated_packages)} dependencies are out of sync:")
79-
for name, versions in version_updates.items():
80-
for package_version, python_versions in versions.items():
81-
app.display_error(
82-
f'{name} can be updated to version {package_version} on {" and ".join(sorted(python_versions))}'
83-
)
84-
app.abort()
85-
else:
86-
app.display_info('All dependencies are up to date')
119+
app.display_info('All dependencies are up to date')
120+
121+
122+
def _print_table_report(app, entries: list[dict]) -> None:
123+
sorted_entries = sorted(entries, key=lambda e: e['package'])
124+
if not sorted_entries:
125+
app.display_info('No dependency version changes detected.')
126+
return
127+
columns = {
128+
'Package': {i: e['package'] for i, e in enumerate(sorted_entries)},
129+
'Old Version': {i: e['old_version'] for i, e in enumerate(sorted_entries)},
130+
'New Version': {i: e['new_version'] for i, e in enumerate(sorted_entries)},
131+
}
132+
app.display_table('Dependency Bumps', columns, show_lines=True)
133+
134+
135+
def _write_json_report(entries: list[dict], path: str) -> None:
136+
import json
137+
138+
Path(path).write_text(json.dumps(sorted(entries, key=lambda e: e['package']), indent=2))
139+
140+
141+
def _write_markdown_report(entries: list[dict], path: str) -> None:
142+
if sorted_entries := sorted(entries, key=lambda e: e['package']):
143+
lines = [
144+
'### Dependency Bumps',
145+
'',
146+
'| Package | Old Version | New Version |',
147+
'|---------|-------------|-------------|',
148+
]
149+
lines.extend(f"| {e['package']} | {e['old_version']} | {e['new_version']} |" for e in sorted_entries)
150+
content = '\n'.join(lines)
151+
else:
152+
content = '_No dependency version changes detected._'
153+
Path(path).write_text(content)

ddev/tests/cli/test_dep.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,135 @@ def test_batch_size(self, ddev):
254254
assert result.exit_code != 0
255255

256256

257+
def test_report_json(ddev, fake_repo, mock_async_http_get_json, tmp_path):
258+
create_integration(fake_repo, 'foo', ['dep-a==1.0.0', 'dep-b==3.1.4'])
259+
create_integration(fake_repo, 'datadog_checks_base', ['dep-a==1.0.0', 'dep-b==3.1.4'])
260+
create_integration(fake_repo, 'datadog_checks_downloader', ['dep-a==1.0.0', 'dep-b==3.1.4'])
261+
(fake_repo / 'agent_requirements.in').write_text('dep-a==1.0.0\ndep-b==3.1.4')
262+
263+
mock_async_http_get_json(
264+
'https://pypi.org/pypi/dep-a/json',
265+
{
266+
'info': {'name': 'dep-a'},
267+
'releases': {v: [{'python_version': 'py2.py3', 'requires_python': '>=2.7'}] for v in ['1.0.0', '1.2.3']},
268+
},
269+
)
270+
mock_async_http_get_json(
271+
'https://pypi.org/pypi/dep-b/json',
272+
{
273+
'info': {'name': 'dep-b'},
274+
'releases': {v: [{'python_version': 'py2.py3', 'requires_python': '>=2.7'}] for v in ['3.1.0', '3.1.4']},
275+
},
276+
)
277+
278+
report_path = tmp_path / 'report.json'
279+
result = ddev('dep', 'updates', '--sync', '--report', str(report_path), '--report-type', 'json')
280+
281+
assert result.exit_code == 0
282+
data = json.loads(report_path.read_text())
283+
assert len(data) == 1
284+
assert data[0]['package'] == 'dep-a'
285+
assert data[0]['old_version'] == '1.0.0'
286+
assert data[0]['new_version'] == '1.2.3'
287+
288+
289+
def test_report_markdown(ddev, fake_repo, mock_async_http_get_json, tmp_path):
290+
create_integration(fake_repo, 'foo', ['dep-a==1.0.0', 'dep-b==3.1.4'])
291+
create_integration(fake_repo, 'datadog_checks_base', ['dep-a==1.0.0', 'dep-b==3.1.4'])
292+
create_integration(fake_repo, 'datadog_checks_downloader', ['dep-a==1.0.0', 'dep-b==3.1.4'])
293+
(fake_repo / 'agent_requirements.in').write_text('dep-a==1.0.0\ndep-b==3.1.4')
294+
295+
mock_async_http_get_json(
296+
'https://pypi.org/pypi/dep-a/json',
297+
{
298+
'info': {'name': 'dep-a'},
299+
'releases': {v: [{'python_version': 'py2.py3', 'requires_python': '>=2.7'}] for v in ['1.0.0', '1.2.3']},
300+
},
301+
)
302+
mock_async_http_get_json(
303+
'https://pypi.org/pypi/dep-b/json',
304+
{
305+
'info': {'name': 'dep-b'},
306+
'releases': {v: [{'python_version': 'py2.py3', 'requires_python': '>=2.7'}] for v in ['3.1.0', '3.1.4']},
307+
},
308+
)
309+
310+
report_path = tmp_path / 'report.md'
311+
result = ddev('dep', 'updates', '--sync', '--report', str(report_path), '--report-type', 'markdown')
312+
313+
assert result.exit_code == 0
314+
content = report_path.read_text()
315+
assert '### Dependency Bumps' in content
316+
assert '| Package | Old Version | New Version |' in content
317+
assert '| dep-a | 1.0.0 | 1.2.3 |' in content
318+
319+
320+
def test_report_screen(ddev, fake_repo, mock_async_http_get_json):
321+
create_integration(fake_repo, 'foo', ['dep-a==1.0.0', 'dep-b==3.1.4'])
322+
create_integration(fake_repo, 'datadog_checks_base', ['dep-a==1.0.0', 'dep-b==3.1.4'])
323+
create_integration(fake_repo, 'datadog_checks_downloader', ['dep-a==1.0.0', 'dep-b==3.1.4'])
324+
(fake_repo / 'agent_requirements.in').write_text('dep-a==1.0.0\ndep-b==3.1.4')
325+
326+
mock_async_http_get_json(
327+
'https://pypi.org/pypi/dep-a/json',
328+
{
329+
'info': {'name': 'dep-a'},
330+
'releases': {v: [{'python_version': 'py2.py3', 'requires_python': '>=2.7'}] for v in ['1.0.0', '1.2.3']},
331+
},
332+
)
333+
mock_async_http_get_json(
334+
'https://pypi.org/pypi/dep-b/json',
335+
{
336+
'info': {'name': 'dep-b'},
337+
'releases': {v: [{'python_version': 'py2.py3', 'requires_python': '>=2.7'}] for v in ['3.1.0', '3.1.4']},
338+
},
339+
)
340+
341+
result = ddev('dep', 'updates', '--sync', '--report')
342+
343+
assert result.exit_code == 0
344+
assert 'dep-a' in result.output
345+
assert '1.0.0' in result.output
346+
assert '1.2.3' in result.output
347+
348+
349+
def test_report_screen_no_updates(ddev, fake_repo, mock_async_http_get_json):
350+
create_integration(fake_repo, 'foo', ['dep-a==1.2.3'])
351+
(fake_repo / 'agent_requirements.in').write_text('dep-a==1.2.3')
352+
353+
mock_async_http_get_json(
354+
'https://pypi.org/pypi/dep-a/json',
355+
{
356+
'info': {'name': 'dep-a'},
357+
'releases': {v: [{'python_version': 'py2.py3', 'requires_python': '>=2.7'}] for v in ['1.0.0', '1.2.3']},
358+
},
359+
)
360+
361+
result = ddev('dep', 'updates', '--sync', '--report')
362+
363+
assert result.exit_code == 0
364+
assert 'No dependency version changes detected.' in result.output
365+
366+
367+
def test_report_no_updates(ddev, fake_repo, mock_async_http_get_json, tmp_path):
368+
create_integration(fake_repo, 'foo', ['dep-a==1.2.3'])
369+
(fake_repo / 'agent_requirements.in').write_text('dep-a==1.2.3')
370+
371+
mock_async_http_get_json(
372+
'https://pypi.org/pypi/dep-a/json',
373+
{
374+
'info': {'name': 'dep-a'},
375+
'releases': {v: [{'python_version': 'py2.py3', 'requires_python': '>=2.7'}] for v in ['1.0.0', '1.2.3']},
376+
},
377+
)
378+
379+
report_path = tmp_path / 'report.md'
380+
result = ddev('dep', 'updates', '--sync', '--report', str(report_path), '--report-type', 'markdown')
381+
382+
assert result.exit_code == 0
383+
assert report_path.read_text() == '_No dependency version changes detected._'
384+
385+
257386
@pytest.fixture
258387
def mock_async_http_get_json():
259388
"""Mock `get` responses assuming a JSON value is returned in the body.

0 commit comments

Comments
 (0)