Skip to content

Commit 8bd456d

Browse files
renemadsenclaude
andcommitted
chore: add cronjob.py for dependency automation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b1b67a8 commit 8bd456d

1 file changed

Lines changed: 261 additions & 0 deletions

File tree

cronjob.py

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"""Automated NuGet dependency updater.
2+
3+
Discovers every `*.csproj` under the repo root (excluding `bin/`, `obj/`,
4+
`node_modules/` and any `EXCLUDED_PATH_PREFIXES`), reads each one's inline
5+
`<PackageReference ... Version="..."/>` entries, and atomically bumps every
6+
reference to the latest stable (non-pre-release) NuGet version.
7+
8+
Pre-release versions (any version containing `-`, per SemVer) are never
9+
installed. References currently pinned to a pre-release are left alone with a
10+
printed warning — fixing those is a manual decision.
11+
12+
On success: one GitHub issue summarizing every bump, one commit staging every
13+
modified csproj (never `git add .`), one patch-version tag, one push.
14+
15+
On `dotnet restore` failure: every modified csproj is rolled back via
16+
`git checkout` and the script exits non-zero without creating an issue,
17+
commit, or tag.
18+
"""
19+
20+
import os
21+
import re
22+
import subprocess
23+
import sys
24+
from pathlib import Path
25+
from xml.etree import ElementTree as ET
26+
27+
import requests
28+
29+
GITHUB_REPO_OWNER = "microting"
30+
GITHUB_REPO_NAME = "eform-angular-frontend"
31+
32+
EXCLUDED_PATH_PREFIXES = ["eFormAPI/Plugins"]
33+
EXCLUDED_DIR_NAMES = {"bin", "obj", "node_modules"}
34+
35+
REPO_ROOT = Path(__file__).resolve().parent
36+
GITHUB_ACCESS_TOKEN = os.getenv("CHANGELOG_GITHUB_TOKEN")
37+
38+
39+
def discover_csprojs():
40+
excluded_prefixes = tuple(p.rstrip("/") + "/" for p in EXCLUDED_PATH_PREFIXES)
41+
results = []
42+
for path in REPO_ROOT.rglob("*.csproj"):
43+
rel = path.relative_to(REPO_ROOT)
44+
if any(part in EXCLUDED_DIR_NAMES for part in rel.parts):
45+
continue
46+
rel_str = rel.as_posix()
47+
if rel_str.startswith(excluded_prefixes):
48+
continue
49+
results.append(path)
50+
return sorted(results)
51+
52+
53+
def read_package_references(csproj_path):
54+
tree = ET.parse(csproj_path)
55+
refs = []
56+
for pr in tree.getroot().iter("PackageReference"):
57+
name = pr.attrib.get("Include")
58+
version = pr.attrib.get("Version")
59+
if name and version:
60+
refs.append((name, version))
61+
return refs
62+
63+
64+
def get_latest_stable_version(package_name):
65+
url = f"https://api.nuget.org/v3-flatcontainer/{package_name.lower()}/index.json"
66+
response = requests.get(url, timeout=30)
67+
if response.status_code != 200:
68+
return None
69+
stable = [v for v in response.json().get("versions", []) if "-" not in v]
70+
return stable[-1] if stable else None
71+
72+
73+
def update_csproj_versions(csproj_path, bumps):
74+
content = csproj_path.read_text(encoding="utf-8")
75+
for name, _old, new in bumps:
76+
pattern = re.compile(
77+
r'(<PackageReference\s+Include="'
78+
+ re.escape(name)
79+
+ r'"\s+Version=")[^"]+(")'
80+
)
81+
content, n = pattern.subn(r"\g<1>" + new + r"\g<2>", content)
82+
if n == 0:
83+
raise RuntimeError(
84+
f"No PackageReference with inline Version attribute found for "
85+
f"{name} in {csproj_path}"
86+
)
87+
csproj_path.write_text(content, encoding="utf-8")
88+
89+
90+
def rollback(csproj_paths):
91+
if not csproj_paths:
92+
return
93+
rel_paths = [str(p.relative_to(REPO_ROOT)) for p in csproj_paths]
94+
subprocess.run(["git", "checkout", "--", *rel_paths], check=True)
95+
96+
97+
def run_restore():
98+
slns = sorted(REPO_ROOT.glob("*.sln"))
99+
if slns:
100+
return subprocess.run(
101+
["dotnet", "restore", str(slns[0])],
102+
capture_output=True,
103+
text=True,
104+
)
105+
last_failure = None
106+
for csproj in discover_csprojs():
107+
result = subprocess.run(
108+
["dotnet", "restore", str(csproj)],
109+
capture_output=True,
110+
text=True,
111+
)
112+
if result.returncode != 0:
113+
last_failure = result
114+
if last_failure is not None:
115+
return last_failure
116+
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
117+
118+
119+
def create_github_issue(bumps_by_csproj):
120+
total = sum(len(bs) for bs in bumps_by_csproj.values())
121+
plural = "s" if total != 1 else ""
122+
title = f"Bump {total} NuGet package{plural}"
123+
lines = ["The following packages were updated:", ""]
124+
for csproj_rel, bumps in bumps_by_csproj.items():
125+
lines.append(f"### `{csproj_rel}`")
126+
for name, old, new in bumps:
127+
lines.append(f"- `{name}`: {old} -> {new}")
128+
lines.append("")
129+
body = "\n".join(lines)
130+
131+
headers = {
132+
"Authorization": f"Bearer {GITHUB_ACCESS_TOKEN}",
133+
"Accept": "application/vnd.github.v3+json",
134+
}
135+
response = requests.post(
136+
f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/issues",
137+
headers=headers,
138+
json={"title": title, "body": body},
139+
)
140+
if response.status_code != 201:
141+
raise RuntimeError(f"Failed to create GitHub issue: {response.text}")
142+
issue_number = response.json()["number"]
143+
print(f"GitHub issue '{title}' created. Issue Number: {issue_number}")
144+
145+
for label in (".Net", "backend", "enhancement"):
146+
label_response = requests.post(
147+
f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/issues/{issue_number}/labels",
148+
headers=headers,
149+
json={"labels": [label]},
150+
)
151+
if label_response.status_code == 200:
152+
print(f"Label '{label}' added to the issue.")
153+
else:
154+
print(f"Failed to add label '{label}' to the issue.")
155+
return issue_number
156+
157+
158+
def commit_modified_csprojs(csproj_paths, issue_number):
159+
rel_paths = [str(p.relative_to(REPO_ROOT)) for p in csproj_paths]
160+
subprocess.run(["git", "add", *rel_paths], check=True)
161+
subprocess.run(["git", "commit", "-m", f"closes #{issue_number}"], check=True)
162+
163+
164+
def push_new_version_tag():
165+
tags_output = (
166+
subprocess.check_output(["git", "tag", "--sort=-creatordate"])
167+
.decode("utf-8")
168+
.strip()
169+
)
170+
if not tags_output:
171+
print("No tags found in the repository.")
172+
return
173+
latest = tags_output.splitlines()[0].lstrip("v")
174+
major, minor, build = map(int, latest.split("."))
175+
new_tag = f"v{major}.{minor}.{build + 1}"
176+
print(f"Current Git Version: {latest}. Creating new tag {new_tag}.")
177+
subprocess.run(["git", "tag", new_tag], check=True)
178+
subprocess.run(["git", "push", "--tags"], check=True)
179+
subprocess.run(["git", "push"], check=True)
180+
181+
182+
def main():
183+
commits_before = len(
184+
subprocess.check_output(["git", "log", "--oneline"])
185+
.decode("utf-8")
186+
.splitlines()
187+
)
188+
print("Current number of commits:", commits_before)
189+
190+
csprojs = discover_csprojs()
191+
if not csprojs:
192+
print("No csproj files found in scope.")
193+
return
194+
print(f"Discovered {len(csprojs)} csproj(s) in scope:")
195+
for p in csprojs:
196+
print(f" {p.relative_to(REPO_ROOT)}")
197+
198+
latest_cache = {}
199+
pre_release_pins = []
200+
planned = {}
201+
202+
for csproj in csprojs:
203+
for name, current in read_package_references(csproj):
204+
if "-" in current:
205+
pre_release_pins.append((csproj, name, current))
206+
continue
207+
if name not in latest_cache:
208+
print(f"Checking {name}")
209+
latest_cache[name] = get_latest_stable_version(name)
210+
latest = latest_cache[name]
211+
if latest is None:
212+
print(f"Failed to retrieve package information for {name}.")
213+
continue
214+
if latest == current:
215+
continue
216+
planned.setdefault(csproj, []).append((name, current, latest))
217+
218+
for csproj, name, version in pre_release_pins:
219+
rel = csproj.relative_to(REPO_ROOT)
220+
print(f"Skipping {name} in {rel}: pinned to pre-release ({version}).")
221+
222+
if not planned:
223+
print("Nothing to do, everything is up to date.")
224+
return
225+
226+
print()
227+
print("Planned bumps:")
228+
for csproj, bumps in planned.items():
229+
rel = csproj.relative_to(REPO_ROOT)
230+
print(f" {rel}")
231+
for name, old, new in bumps:
232+
print(f" {name}: {old} -> {new}")
233+
234+
modified = []
235+
try:
236+
for csproj, bumps in planned.items():
237+
update_csproj_versions(csproj, bumps)
238+
modified.append(csproj)
239+
except Exception:
240+
rollback(modified)
241+
raise
242+
243+
restore = run_restore()
244+
if restore.returncode != 0:
245+
print("dotnet restore failed after applying bumps. Rolling back.")
246+
print(restore.stdout)
247+
print(restore.stderr, file=sys.stderr)
248+
rollback(modified)
249+
sys.exit(1)
250+
251+
bumps_by_csproj_rel = {
252+
str(csproj.relative_to(REPO_ROOT)): bumps
253+
for csproj, bumps in planned.items()
254+
}
255+
issue_number = create_github_issue(bumps_by_csproj_rel)
256+
commit_modified_csprojs(modified, issue_number)
257+
push_new_version_tag()
258+
259+
260+
if __name__ == "__main__":
261+
main()

0 commit comments

Comments
 (0)