github-overlord 0.9.1__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: github-overlord
3
- Version: 0.9.1
3
+ Version: 0.10.0
4
4
  Summary: Automatically merge Dependabot PRs in public repositories
5
5
  Keywords: github,dependabot,automation
6
6
  Author: Michael Bianco
@@ -20,6 +20,7 @@ Requires-Dist: apscheduler>=3.10.4
20
20
  Requires-Dist: openai>=1.52.2,<4.0.0
21
21
  Requires-Dist: pydantic-ai-slim[google]>=0.0.18
22
22
  Requires-Dist: jinja2>=3.1.0
23
+ Requires-Dist: pyyaml>=6.0.3
23
24
  Requires-Python: >=3.12
24
25
  Project-URL: Homepage, https://github.com/iloveitaly/github-overlord
25
26
  Project-URL: Repository, https://github.com/iloveitaly/github-overlord
@@ -92,13 +93,15 @@ github-overlord generate-releases --topic auto-release --dry-run
92
93
  * `--topic` flag or `RELEASE_CHECKER_TOPIC` - Topic to filter repositories (required unless using `--repo`)
93
94
  * `--repo` flag or `RELEASE_CHECKER_REPO` - Single repository to process (useful for testing)
94
95
  * `RELEASE_CHECKER_MIN_GAP` - Minimum time between releases for the same repo (defaults to `2w`, e.g. `14d`, `48h`)
96
+ * `RELEASE_CHECKER_GLOBAL_MIN_GAP` - Minimum time between releases created by this tool across the repositories in that run (defaults to `1w`; set to `0` to disable). The clock is the newest GitHub release, among those repos, whose notes include this tool's `Generated-by` line, using that release's publish time. Manual releases do not reset it. While this gap is active, a run creates at most one release even if `--max-releases` is higher.
95
97
 
96
98
  **How it works:**
97
99
 
98
100
  1. Finds repositories matching the specified topic
99
- 2. For each repo, gets commits since the last release (or since repo creation if no releases)
100
- 3. Analyzes up to the last 50 commits using the configured AI model (defaulting to `gemini-3.8-flash`) to determine if a release is warranted
101
- 4. If the LLM recommends a release, automatically creates one with:
101
+ 2. Finds the newest release published by this tool across those repositories. If that publish time is newer than `RELEASE_CHECKER_GLOBAL_MIN_GAP`, the run stops
102
+ 3. For each remaining repo, skips it when its latest release is newer than `RELEASE_CHECKER_MIN_GAP`, then reads commits since that release (or since the repo was created)
103
+ 4. Analyzes up to the last 50 commits using the configured AI model (defaulting to `gemini-3.8-flash`) to determine if a release is warranted
104
+ 5. If the LLM recommends a release, automatically creates one with:
102
105
 
103
106
  * Auto-incremented semantic version (patch/minor/major based on changes)
104
107
  * AI-generated release notes highlighting key changes
@@ -65,13 +65,15 @@ github-overlord generate-releases --topic auto-release --dry-run
65
65
  * `--topic` flag or `RELEASE_CHECKER_TOPIC` - Topic to filter repositories (required unless using `--repo`)
66
66
  * `--repo` flag or `RELEASE_CHECKER_REPO` - Single repository to process (useful for testing)
67
67
  * `RELEASE_CHECKER_MIN_GAP` - Minimum time between releases for the same repo (defaults to `2w`, e.g. `14d`, `48h`)
68
+ * `RELEASE_CHECKER_GLOBAL_MIN_GAP` - Minimum time between releases created by this tool across the repositories in that run (defaults to `1w`; set to `0` to disable). The clock is the newest GitHub release, among those repos, whose notes include this tool's `Generated-by` line, using that release's publish time. Manual releases do not reset it. While this gap is active, a run creates at most one release even if `--max-releases` is higher.
68
69
 
69
70
  **How it works:**
70
71
 
71
72
  1. Finds repositories matching the specified topic
72
- 2. For each repo, gets commits since the last release (or since repo creation if no releases)
73
- 3. Analyzes up to the last 50 commits using the configured AI model (defaulting to `gemini-3.8-flash`) to determine if a release is warranted
74
- 4. If the LLM recommends a release, automatically creates one with:
73
+ 2. Finds the newest release published by this tool across those repositories. If that publish time is newer than `RELEASE_CHECKER_GLOBAL_MIN_GAP`, the run stops
74
+ 3. For each remaining repo, skips it when its latest release is newer than `RELEASE_CHECKER_MIN_GAP`, then reads commits since that release (or since the repo was created)
75
+ 4. Analyzes up to the last 50 commits using the configured AI model (defaulting to `gemini-3.8-flash`) to determine if a release is warranted
76
+ 5. If the LLM recommends a release, automatically creates one with:
75
77
 
76
78
  * Auto-incremented semantic version (patch/minor/major based on changes)
77
79
  * AI-generated release notes highlighting key changes
@@ -1,19 +1,47 @@
1
1
  import os
2
2
 
3
3
  import click
4
- from github import Github
4
+ from github import Github, GithubException
5
5
 
6
6
  import github_overlord.patch # noqa: F401
7
7
 
8
8
  from .ai import get_expected_ai_key_var, is_ai_key_configured
9
9
  from .dependabot import dependabot
10
10
  from .notifications import notifications
11
- from .release_checker import check_repo_for_release
11
+ from .release_checker import (
12
+ check_repo_for_release,
13
+ get_global_min_release_gap,
14
+ release_creation_blocked_by_global_gap,
15
+ should_stop_for_global_gap,
16
+ )
12
17
  from .stale_commenter import inspect_stale_prs
13
18
  from .utils import extract_repo_reference_from_github_url, log
14
19
  from .version import __version__
15
20
 
16
21
 
22
+ def _log_release_run_summary(
23
+ dry_run: bool, *, checked: int, created: int, skipped: int, failed: int
24
+ ) -> None:
25
+ if dry_run:
26
+ log.info(
27
+ "release check complete",
28
+ dry_run=True,
29
+ checked=checked,
30
+ would_create=created,
31
+ skipped=skipped,
32
+ errors=failed,
33
+ )
34
+ return
35
+
36
+ log.info(
37
+ "release check complete",
38
+ checked=checked,
39
+ created=created,
40
+ skipped=skipped,
41
+ failed=failed,
42
+ )
43
+
44
+
17
45
  def _ensure_ai_key_configured(command_action: str) -> None:
18
46
  if not is_ai_key_configured():
19
47
  expected_var = get_expected_ai_key_var()
@@ -120,9 +148,48 @@ def generate_releases(dry_run, topic, repo, max_releases):
120
148
 
121
149
  repo = extract_repo_reference_from_github_url(repo)
122
150
 
151
+ # Topic is required when not specifying a single repo
152
+ if not repo and not topic:
153
+ raise click.UsageError(
154
+ "Topic is required when not specifying a single repository (use --topic or set RELEASE_CHECKER_TOPIC)"
155
+ )
156
+
157
+ g = Github(token)
123
158
  if repo:
124
- g = Github(token)
125
- result = check_repo_for_release(g.get_repo(repo), dry_run)
159
+ candidates = [g.get_repo(repo)]
160
+ else:
161
+ user = g.get_user()
162
+ log.info("filtering by topic", topic=topic)
163
+ # Public repos owned by the user with the specified topic.
164
+ candidates = list(
165
+ g.search_repositories(
166
+ f"user:{user.login} topic:{topic} fork:false is:public",
167
+ sort="updated",
168
+ order="desc",
169
+ )
170
+ )
171
+ if not candidates:
172
+ log.warning("no repositories found with topic", topic=topic)
173
+ return
174
+
175
+ global_gap = get_global_min_release_gap()
176
+ try:
177
+ blocked = release_creation_blocked_by_global_gap(candidates, gap=global_gap)
178
+ except GithubException:
179
+ log.error(
180
+ "skipping release creation because global gap state could not be read"
181
+ )
182
+ return
183
+
184
+ if blocked:
185
+ if repo:
186
+ log.info("release check complete - no release needed", dry_run=dry_run)
187
+ else:
188
+ _log_release_run_summary(dry_run, checked=0, created=0, skipped=0, failed=0)
189
+ return
190
+
191
+ if repo:
192
+ result = check_repo_for_release(candidates[0], dry_run)
126
193
  if result["created"]:
127
194
  log.info("release check complete - created 1 release", dry_run=dry_run)
128
195
  elif result["failed"]:
@@ -133,32 +200,10 @@ def generate_releases(dry_run, topic, repo, max_releases):
133
200
  log.info("release check complete - no release needed", dry_run=dry_run)
134
201
  return
135
202
 
136
- # Topic is required when not specifying a single repo
137
- if not topic:
138
- raise click.UsageError(
139
- "Topic is required when not specifying a single repository (use --topic or set RELEASE_CHECKER_TOPIC)"
140
- )
141
-
142
- g = Github(token)
143
- user = g.get_user()
144
-
145
- log.info("filtering by topic", topic=topic)
146
-
147
- # Get all public repos owned by user with the specified topic
148
- repos = g.search_repositories(
149
- f"user:{user.login} topic:{topic} fork:false is:public",
150
- sort="updated",
151
- order="desc",
152
- )
153
-
154
- def _check_repo_with_log(r):
155
- log.info("checking repo for release", repo=r.full_name, dry_run=dry_run)
156
- return check_repo_for_release(r, dry_run=dry_run)
157
-
158
203
  results = []
159
204
  created_count = 0
160
205
 
161
- for r in repos:
206
+ for candidate in candidates:
162
207
  if max_releases > 0 and created_count >= max_releases:
163
208
  log.info(
164
209
  "reached max releases limit for this run",
@@ -166,40 +211,29 @@ def generate_releases(dry_run, topic, repo, max_releases):
166
211
  )
167
212
  break
168
213
 
169
- res = _check_repo_with_log(r)
170
- results.append(res)
171
- if res["created"]:
214
+ log.info("checking repo for release", repo=candidate.full_name, dry_run=dry_run)
215
+ result = check_repo_for_release(candidate, dry_run=dry_run)
216
+ results.append(result)
217
+ if result["created"]:
172
218
  created_count += 1
173
-
174
- # Check if any repos were found
175
- if not results:
176
- log.warning("no repositories found with topic", topic=topic)
177
- return
178
-
179
- # Calculate statistics
180
- total_checked = sum(1 for r in results if r["checked"])
181
- total_skipped = sum(1 for r in results if r["skipped"])
182
- total_created = sum(1 for r in results if r["created"])
183
- total_failed = sum(1 for r in results if r["failed"])
184
-
185
- # Log summary
186
- if dry_run:
187
- log.info(
188
- "release check complete",
189
- dry_run=True,
190
- checked=total_checked,
191
- would_create=total_created,
192
- skipped=total_skipped,
193
- errors=total_failed,
194
- )
195
- else:
196
- log.info(
197
- "release check complete",
198
- checked=total_checked,
199
- created=total_created,
200
- skipped=total_skipped,
201
- failed=total_failed,
202
- )
219
+ if should_stop_for_global_gap(global_gap, max_releases, created_count):
220
+ log.info(
221
+ "stopping after one auto-generated release to honor global minimum gap",
222
+ min_gap_seconds=int(global_gap.total_seconds()),
223
+ )
224
+ break
225
+
226
+ total_checked = sum(1 for result in results if result["checked"])
227
+ total_skipped = sum(1 for result in results if result["skipped"])
228
+ total_created = sum(1 for result in results if result["created"])
229
+ total_failed = sum(1 for result in results if result["failed"])
230
+ _log_release_run_summary(
231
+ dry_run,
232
+ checked=total_checked,
233
+ created=total_created,
234
+ skipped=total_skipped,
235
+ failed=total_failed,
236
+ )
203
237
 
204
238
 
205
239
  cli.add_command(dependabot)
@@ -7,6 +7,8 @@ calculates semantic version bumps, and generates formatted release notes.
7
7
 
8
8
  import os
9
9
  import re
10
+ from collections.abc import Iterable
11
+ from dataclasses import dataclass
10
12
  from datetime import UTC, datetime, timedelta
11
13
 
12
14
  from github import GithubException
@@ -21,6 +23,9 @@ from github_overlord.ai import (
21
23
  from github_overlord.config import JINJA_ENV
22
24
  from github_overlord.utils import log
23
25
 
26
+ # Release notes include this line. It is how a later run recognizes its own releases.
27
+ GENERATED_BY_MARKER = "**Generated-by**: https://github.com/iloveitaly/github-overlord"
28
+
24
29
 
25
30
  def _parse_duration_to_timedelta(value: str) -> timedelta:
26
31
  """Parse a simple duration string into a timedelta.
@@ -56,29 +61,168 @@ def _parse_duration_to_timedelta(value: str) -> timedelta:
56
61
  raise ValueError(f"unsupported duration unit: {unit!r}")
57
62
 
58
63
 
59
- def _get_min_release_gap() -> timedelta:
60
- raw = os.getenv("RELEASE_CHECKER_MIN_GAP", "2w")
64
+ def _get_configured_gap(
65
+ env_var: str,
66
+ default: str,
67
+ *,
68
+ invalid_event: str,
69
+ negative_event: str,
70
+ ) -> timedelta:
71
+ raw = os.getenv(env_var, default)
61
72
  try:
62
73
  gap = _parse_duration_to_timedelta(raw)
63
74
  except ValueError:
64
- log.warning(
65
- "invalid release_checker_min_gap; falling back to default",
66
- value=raw,
67
- default="2w",
68
- )
69
- gap = timedelta(weeks=2)
75
+ log.warning(invalid_event, value=raw, default=default)
76
+ gap = _parse_duration_to_timedelta(default)
70
77
 
71
78
  if gap.total_seconds() < 0:
72
- log.warning(
73
- "negative release_checker_min_gap; falling back to default",
74
- value=raw,
75
- default="2w",
76
- )
77
- return timedelta(weeks=2)
79
+ log.warning(negative_event, value=raw, default=default)
80
+ return _parse_duration_to_timedelta(default)
78
81
 
79
82
  return gap
80
83
 
81
84
 
85
+ def _get_min_release_gap() -> timedelta:
86
+ return _get_configured_gap(
87
+ "RELEASE_CHECKER_MIN_GAP",
88
+ "2w",
89
+ invalid_event="invalid release_checker_min_gap; falling back to default",
90
+ negative_event="negative release_checker_min_gap; falling back to default",
91
+ )
92
+
93
+
94
+ def get_global_min_release_gap() -> timedelta:
95
+ """Minimum time between auto-generated releases across the selected repos.
96
+
97
+ Defaults to one week. ``0`` disables the limit.
98
+ """
99
+
100
+ return _get_configured_gap(
101
+ "RELEASE_CHECKER_GLOBAL_MIN_GAP",
102
+ "1w",
103
+ invalid_event="invalid release_checker_global_min_gap; falling back to default",
104
+ negative_event="negative release_checker_global_min_gap; falling back to default",
105
+ )
106
+
107
+
108
+ def _as_utc(value: datetime) -> datetime:
109
+ if value.tzinfo is None:
110
+ return value.replace(tzinfo=UTC)
111
+ return value.astimezone(UTC)
112
+
113
+
114
+ def _is_generated_release(release: object) -> bool:
115
+ body = getattr(release, "body", None) or ""
116
+ if not isinstance(body, str):
117
+ return False
118
+ return GENERATED_BY_MARKER in body
119
+
120
+
121
+ def _published_at(release: object) -> datetime | None:
122
+ published = getattr(release, "published_at", None)
123
+ if not isinstance(published, datetime):
124
+ return None
125
+ return _as_utc(published)
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class GeneratedRelease:
130
+ """An auto-generated GitHub release, identified by its release notes."""
131
+
132
+ repo_full_name: str
133
+ tag_name: str
134
+ published_at: datetime
135
+
136
+
137
+ def latest_generated_release_since(
138
+ repos: Iterable[Repository], since: datetime
139
+ ) -> GeneratedRelease | None:
140
+ """Return the newest auto-generated release published after ``since``.
141
+
142
+ GitHub lists releases by ``created_at``, and that field is the tagged
143
+ commit's timestamp, not when the release was published. A release published
144
+ today for an older commit can sort behind a newer commit, so this compares
145
+ ``published_at`` across the full list. Manual releases are ignored.
146
+ """
147
+
148
+ since = _as_utc(since)
149
+ latest: GeneratedRelease | None = None
150
+
151
+ for repo in repos:
152
+ try:
153
+ for release in repo.get_releases():
154
+ published_at = _published_at(release)
155
+ if published_at is None or published_at <= since:
156
+ continue
157
+ if latest is not None and published_at <= latest.published_at:
158
+ continue
159
+ if not _is_generated_release(release):
160
+ continue
161
+ latest = GeneratedRelease(
162
+ repo_full_name=repo.full_name,
163
+ tag_name=release.tag_name,
164
+ published_at=published_at,
165
+ )
166
+ except GithubException as error:
167
+ log.error(
168
+ "failed to list releases while checking global gap",
169
+ repo=getattr(repo, "full_name", None),
170
+ error=str(error),
171
+ )
172
+ raise
173
+
174
+ return latest
175
+
176
+
177
+ def release_creation_blocked_by_global_gap(
178
+ repos: Iterable[Repository],
179
+ *,
180
+ now: datetime | None = None,
181
+ gap: timedelta | None = None,
182
+ ) -> bool:
183
+ """Return whether an auto-generated release is still inside the global gap.
184
+
185
+ A gap of zero disables the check. The clock is the newest published
186
+ auto-generated release among ``repos``.
187
+ """
188
+
189
+ gap = get_global_min_release_gap() if gap is None else gap
190
+ if gap.total_seconds() <= 0:
191
+ return False
192
+
193
+ now = _as_utc(now or datetime.now(UTC))
194
+ latest = latest_generated_release_since(repos, now - gap)
195
+ if latest is None:
196
+ return False
197
+
198
+ time_since = now - latest.published_at
199
+ log.info(
200
+ "skipping release check due to global minimum gap",
201
+ last_release=latest.tag_name,
202
+ last_release_repo=latest.repo_full_name,
203
+ time_since_release_seconds=int(time_since.total_seconds()),
204
+ min_gap_seconds=int(gap.total_seconds()),
205
+ )
206
+ return True
207
+
208
+
209
+ def should_stop_for_global_gap(
210
+ gap: timedelta, max_releases: int, created_count: int
211
+ ) -> bool:
212
+ """Return whether this run should stop after creating an auto-generated release.
213
+
214
+ A positive global gap allows one new auto-generated release per interval.
215
+ ``max_releases`` still caps a run on its own when the global gap is disabled.
216
+ """
217
+
218
+ if gap.total_seconds() <= 0 or created_count < 1:
219
+ return False
220
+ # max_releases <= 0 means unlimited for the run; the global gap is the cap.
221
+ if max_releases <= 0:
222
+ return True
223
+ return created_count < max_releases
224
+
225
+
82
226
  class ReleaseAnalysis(BaseModel):
83
227
  """Structured output from LLM analysis of commits."""
84
228
 
@@ -339,9 +483,7 @@ def generate_release_notes(
339
483
  changelog_url = f"https://github.com/{repo.full_name}/commits/{new_tag}"
340
484
 
341
485
  notes_parts.append(f"**Full Changelog**: {changelog_url}")
342
- notes_parts.append(
343
- "**Generated-by**: https://github.com/iloveitaly/github-overlord"
344
- )
486
+ notes_parts.append(GENERATED_BY_MARKER)
345
487
 
346
488
  return "\n".join(notes_parts)
347
489
 
@@ -381,6 +523,9 @@ def check_repo_for_release(repo: Repository, dry_run: bool) -> dict:
381
523
  """
382
524
  Check a single repository and create a release if recommended.
383
525
 
526
+ The cross-repo ``RELEASE_CHECKER_GLOBAL_MIN_GAP`` is enforced by the
527
+ generate-releases command before this function is called.
528
+
384
529
  Returns:
385
530
  dict with keys: checked, skipped, created, failed
386
531
  """
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "github-overlord"
3
- version = "0.9.1"
3
+ version = "0.10.0"
4
4
  description = "Automatically merge Dependabot PRs in public repositories"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "openai>=1.52.2,<4.0.0",
27
27
  "pydantic-ai-slim[google]>=0.0.18",
28
28
  "jinja2>=3.1.0",
29
+ "pyyaml>=6.0.3",
29
30
  ]
30
31
 
31
32
  [[project.authors]]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "github-overlord"
3
- version = "0.9.1"
3
+ version = "0.10.0"
4
4
  description = "Automatically merge Dependabot PRs in public repositories"
5
5
  authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
6
6
  readme = "README.md"
@@ -24,6 +24,7 @@ dependencies = [
24
24
  "openai>=1.52.2,<4.0.0",
25
25
  "pydantic-ai-slim[google]>=0.0.18",
26
26
  "jinja2>=3.1.0",
27
+ "pyyaml>=6.0.3",
27
28
  ]
28
29
 
29
30
  [project.urls]