| |
@@ -8,11 +8,11 @@
|
| |
from functools import lru_cache, reduce
|
| |
from pathlib import Path
|
| |
from tempfile import TemporaryDirectory
|
| |
- from textwrap import TextWrapper
|
| |
from typing import Any, Dict, Optional, Sequence, Union
|
| |
|
| |
import pygit2
|
| |
|
| |
+ from .changelog import ChangelogEntry
|
| |
from .misc import AUTORELEASE_MACRO
|
| |
|
| |
|
| |
@@ -70,6 +70,21 @@
|
| |
except pygit2.GitError:
|
| |
self.repo = None
|
| |
|
| |
+ @staticmethod
|
| |
+ def _get_rpm_packager() -> str:
|
| |
+ fallback = "John Doe <packager@example.com>"
|
| |
+ try:
|
| |
+ return (
|
| |
+ subprocess.check_output(
|
| |
+ ("rpm", "--eval", f"%{{?packager}}%{{!?packager:{fallback}}}"),
|
| |
+ stderr=subprocess.DEVNULL,
|
| |
+ )
|
| |
+ .decode("UTF-8")
|
| |
+ .strip()
|
| |
+ )
|
| |
+ except Exception:
|
| |
+ return fallback
|
| |
+
|
| |
@classmethod
|
| |
def _get_rpmverflags(cls, path: str, name: Optional[str] = None) -> Optional[str]:
|
| |
"""Retrieve the epoch/version and %autorelease flags set in spec file.
|
| |
@@ -163,7 +178,7 @@
|
| |
|
| |
return self._get_rpmverflags(workdir, self.name)
|
| |
|
| |
- def release_number_visitor(self, commit: pygit2.Commit, children_must_continue: bool):
|
| |
+ def release_number_visitor(self, commit: pygit2.Commit, child_info: Dict[str, Any]):
|
| |
"""Visit a commit to determine its release number.
|
| |
|
| |
The coroutine returned first determines if the parent chain(s) must be
|
| |
@@ -189,33 +204,61 @@
|
| |
tag_string = ""
|
| |
|
| |
if not epoch_version:
|
| |
- must_continue = True
|
| |
+ child_must_continue = True
|
| |
else:
|
| |
epoch_versions_to_check = []
|
| |
for p in commit.parents:
|
| |
verflags = self._get_rpmverflags_for_commit(p)
|
| |
- if verflags:
|
| |
- epoch_versions_to_check.append(verflags["epoch-version"])
|
| |
- must_continue = epoch_version in epoch_versions_to_check
|
| |
+ if not verflags:
|
| |
+ child_must_continue = True
|
| |
+ break
|
| |
+ epoch_versions_to_check.append(verflags["epoch-version"])
|
| |
+ else:
|
| |
+ child_must_continue = (
|
| |
+ epoch_version in epoch_versions_to_check or epoch_version is None
|
| |
+ )
|
| |
+
|
| |
+ log.debug("\tepoch_version: %s", epoch_version)
|
| |
+ log.debug("\tchild must continue: %s", child_must_continue)
|
| |
|
| |
# Suspend execution, yield whether caller should continue, and get back the (partial) result
|
| |
# for this commit and parent results as dictionaries on resume.
|
| |
- commit_result, parent_results = yield must_continue
|
| |
+ commit_result, parent_results = yield {"child_must_continue": child_must_continue}
|
| |
|
| |
commit_result["epoch-version"] = epoch_version
|
| |
|
| |
- # Find the maximum applicable parent release number and increment by one.
|
| |
- commit_result["release-number"] = release_number = (
|
| |
- max(
|
| |
- (
|
| |
- res["release-number"] if res and epoch_version == res["epoch-version"] else 0
|
| |
- for res in parent_results
|
| |
- ),
|
| |
- default=0,
|
| |
- )
|
| |
- + 1
|
| |
+ log.debug("\tepoch_version: %s", epoch_version)
|
| |
+ log.debug(
|
| |
+ "\tparent rel numbers: %s",
|
| |
+ ", ".join(str(res["release-number"]) if res else "none" for res in parent_results),
|
| |
)
|
| |
|
| |
+ # Find the maximum applicable parent release number and increment by one if the
|
| |
+ # epoch-version can be parsed from the spec file.
|
| |
+ release_number = max(
|
| |
+ (
|
| |
+ res["release-number"]
|
| |
+ if res
|
| |
+ and (
|
| |
+ # Paper over gaps in epoch-versions, these could be simple syntax errors in
|
| |
+ # the spec file, or a retired, then unretired package.
|
| |
+ epoch_version is None
|
| |
+ or res["epoch-version"] is None
|
| |
+ or epoch_version == res["epoch-version"]
|
| |
+ )
|
| |
+ else 0
|
| |
+ for res in parent_results
|
| |
+ ),
|
| |
+ default=0,
|
| |
+ )
|
| |
+
|
| |
+ if epoch_version is not None:
|
| |
+ release_number += 1
|
| |
+
|
| |
+ commit_result["release-number"] = release_number
|
| |
+
|
| |
+ log.debug("\trelease_number: %s", release_number)
|
| |
+
|
| |
prerel_str = "0." if prerelease else ""
|
| |
release_number_with_base = release_number + base - 1
|
| |
commit_result["release-complete"] = f"{prerel_str}{release_number_with_base}{tag_string}"
|
| |
@@ -232,7 +275,10 @@
|
| |
files.add(delta.new_file.path)
|
| |
return files
|
| |
|
| |
- def changelog_visitor(self, commit: pygit2.Commit, children_must_continue: bool):
|
| |
+ def _changelog_for_commit(self, commit, commit_result):
|
| |
+ """Generate the changelog entry text from a commit."""
|
| |
+
|
| |
+ def changelog_visitor(self, commit: pygit2.Commit, child_info: Dict[str, Any]):
|
| |
"""Visit a commit to generate changelog entries for it and its parents.
|
| |
|
| |
It first determines if parent chain(s) must be followed, i.e. if the
|
| |
@@ -241,6 +287,7 @@
|
| |
modified) and full results of parents back (as dictionaries), which
|
| |
get processed and the results for this commit yielded again.
|
| |
"""
|
| |
+ child_must_continue = child_info["child_must_continue"]
|
| |
# Check if the spec file exists, if not, there will be no changelog.
|
| |
specfile_present = f"{self.name}.spec" in commit.tree
|
| |
|
| |
@@ -251,6 +298,8 @@
|
| |
except KeyError:
|
| |
changelog_blob = None
|
| |
|
| |
+ child_changelog_removed = child_info.get("changelog_removed")
|
| |
+ our_changelog_removed = False
|
| |
if commit.parents:
|
| |
changelog_changed = True
|
| |
for parent in commit.parents:
|
| |
@@ -258,6 +307,8 @@
|
| |
par_changelog_blob = parent.tree["changelog"]
|
| |
except KeyError:
|
| |
par_changelog_blob = None
|
| |
+ else:
|
| |
+ our_changelog_removed = our_changelog_removed or not changelog_blob
|
| |
if changelog_blob == par_changelog_blob:
|
| |
changelog_changed = False
|
| |
else:
|
| |
@@ -283,54 +334,51 @@
|
| |
# care. If it didn't change, we don't know how to continue and need to flag that.
|
| |
merge_unresolvable = not changelog_changed
|
| |
|
| |
- child_must_continue = (
|
| |
- not (changelog_changed or merge_unresolvable)
|
| |
- and specfile_present
|
| |
- and children_must_continue
|
| |
+ our_child_must_continue = (
|
| |
+ not (changelog_changed and changelog_blob or merge_unresolvable) and child_must_continue
|
| |
)
|
| |
|
| |
log.debug("\tchangelog changed: %s", changelog_changed)
|
| |
+ log.debug("\tchild changelog removed: %s", child_changelog_removed)
|
| |
+ log.debug("\tour changelog removed: %s", our_changelog_removed)
|
| |
log.debug("\tmerge unresolvable: %s", merge_unresolvable)
|
| |
log.debug("\tspec file present: %s", specfile_present)
|
| |
- log.debug("\tchildren must continue: %s", children_must_continue)
|
| |
- log.debug("\tchild must continue: %s", child_must_continue)
|
| |
-
|
| |
- commit_result, parent_results = yield child_must_continue
|
| |
+ log.debug("\tchild must continue (incoming): %s", child_must_continue)
|
| |
+ log.debug("\tchild must continue (outgoing): %s", our_child_must_continue)
|
| |
|
| |
- changelog_entry = {
|
| |
- "commit-id": commit.id,
|
| |
+ commit_result, parent_results = yield {
|
| |
+ "child_must_continue": our_child_must_continue,
|
| |
+ "changelog_removed": not (changelog_blob and changelog_changed)
|
| |
+ and (child_changelog_removed or our_changelog_removed),
|
| |
}
|
| |
|
| |
- changelog_author = f"{commit.author.name} <{commit.author.email}>"
|
| |
- changelog_date = dt.datetime.utcfromtimestamp(commit.commit_time).strftime("%a %b %d %Y")
|
| |
- changelog_evr = f"{commit_result['epoch-version']}-{commit_result['release-complete']}"
|
| |
-
|
| |
- changelog_header = f"* {changelog_date} {changelog_author} {changelog_evr}"
|
| |
+ changelog_entry = ChangelogEntry(
|
| |
+ {
|
| |
+ "commit-id": commit.id,
|
| |
+ "authorblurb": f"{commit.author.name} <{commit.author.email}>",
|
| |
+ "timestamp": dt.datetime.utcfromtimestamp(commit.commit_time),
|
| |
+ "commitlog": commit.message,
|
| |
+ "epoch-version": commit_result["epoch-version"],
|
| |
+ "release-complete": commit_result["release-complete"],
|
| |
+ }
|
| |
+ )
|
| |
|
| |
- skip_for_changelog = False
|
| |
+ skip_for_changelog = not specfile_present
|
| |
|
| |
- if not specfile_present:
|
| |
- # no spec file => start fresh
|
| |
- log.debug("\tno spec file present")
|
| |
- commit_result["changelog"] = ()
|
| |
- elif merge_unresolvable:
|
| |
+ if merge_unresolvable:
|
| |
log.debug("\tunresolvable merge")
|
| |
- changelog_entry["data"] = f"{changelog_header}\n- RPMAUTOSPEC: unresolvable merge"
|
| |
changelog_entry["error"] = "unresolvable merge"
|
| |
previous_changelog = ()
|
| |
commit_result["changelog"] = (changelog_entry,)
|
| |
- elif changelog_changed:
|
| |
+ elif changelog_changed and changelog_blob:
|
| |
log.debug("\tchangelog file changed")
|
| |
- if changelog_blob:
|
| |
+ if not child_changelog_removed:
|
| |
changelog_entry["data"] = changelog_blob.data.decode("utf-8", errors="replace")
|
| |
+ commit_result["changelog"] = (changelog_entry,)
|
| |
else:
|
| |
- # Changelog removed. Oops.
|
| |
- log.debug("\tchangelog file removed")
|
| |
- changelog_entry[
|
| |
- "data"
|
| |
- ] = f"{changelog_header}\n- RPMAUTOSPEC: changelog file removed"
|
| |
- changelog_entry["error"] = "changelog file removed"
|
| |
- commit_result["changelog"] = (changelog_entry,)
|
| |
+ # The `changelog` file was removed in a later commit, stop changelog generation.
|
| |
+ log.debug("\t skipping")
|
| |
+ commit_result["changelog"] = ()
|
| |
else:
|
| |
# Pull previous changelog entries from parent result (if any).
|
| |
if len(commit.parents) == 1:
|
| |
@@ -362,30 +410,43 @@
|
| |
for f in changed_files
|
| |
)
|
| |
|
| |
+ log.debug("\tskip_for_changelog: %s", skip_for_changelog)
|
| |
+
|
| |
+ changelog_entry["skip"] = skip_for_changelog
|
| |
+
|
| |
if not skip_for_changelog:
|
| |
- commit_subject = commit.message.split("\n", 1)[0].strip()
|
| |
- if commit_subject.startswith("-"):
|
| |
- commit_subject = commit_subject[1:].lstrip()
|
| |
- if not commit_subject:
|
| |
- commit_subject = "RPMAUTOSPEC: empty commit log subject after stripping"
|
| |
- changelog_entry["error"] = "empty commit log subject"
|
| |
- wrapper = TextWrapper(width=75, subsequent_indent=" ")
|
| |
- wrapped_msg = wrapper.fill(f"- {commit_subject}")
|
| |
- changelog_entry["data"] = f"{changelog_header}\n{wrapped_msg}"
|
| |
commit_result["changelog"] = (changelog_entry,) + previous_changelog
|
| |
else:
|
| |
commit_result["changelog"] = previous_changelog
|
| |
|
| |
yield commit_result
|
| |
|
| |
+ @staticmethod
|
| |
+ def _merge_info(f1: Dict[str, Any], f2: Dict[str, Any]) -> Dict[str, Any]:
|
| |
+ mf = f1.copy()
|
| |
+ for k, v2 in f2.items():
|
| |
+ try:
|
| |
+ v1 = mf[k]
|
| |
+ except KeyError:
|
| |
+ mf[k] = v2
|
| |
+ else:
|
| |
+ if k == "child_must_continue":
|
| |
+ mf[k] = v1 or v2
|
| |
+ elif k == "changelog_removed":
|
| |
+ mf[k] = v1 and v2
|
| |
+ else:
|
| |
+ raise KeyError(f"Unknown information key: {k}")
|
| |
+ return mf
|
| |
+
|
| |
def _run_on_history(
|
| |
- self, head: pygit2.Commit, *, visitors: Sequence = ()
|
| |
+ self, head: pygit2.Commit, *, visitors: Sequence = (), seed_info: Dict[str, Any] = None
|
| |
) -> Dict[pygit2.Commit, Dict[str, Any]]:
|
| |
"""Process historical commits with visitors and gather results."""
|
| |
+ seed_info = {"child_must_continue": True, **(seed_info or {})}
|
| |
# maps visited commits to their (in-flight) visitors and if they must
|
| |
# continue
|
| |
commit_coroutines = {}
|
| |
- commit_coroutines_must_continue = {}
|
| |
+ commit_coroutines_info = {}
|
| |
|
| |
# keep track of branches
|
| |
branch_heads = [head]
|
| |
@@ -427,7 +488,7 @@
|
| |
log.debug("commit %s: %s", commit.short_id, commit.message.split("\n", 1)[0])
|
| |
|
| |
if commit == head:
|
| |
- children_visitors_must_continue = [True for v in visitors]
|
| |
+ children_visitors_info = [seed_info for v in visitors]
|
| |
else:
|
| |
this_children = commit_children[commit]
|
| |
if not all(child in commit_coroutines for child in this_children):
|
| |
@@ -451,19 +512,22 @@
|
| |
)
|
| |
break
|
| |
|
| |
- # For all visitor coroutines, determine if any of the children must continue.
|
| |
- children_visitors_must_continue = [
|
| |
+ # For all visitor coroutines, merge their produced info, e.g. to determine if
|
| |
+ # any of the children must continue.
|
| |
+ children_visitors_info = [
|
| |
reduce(
|
| |
- lambda must_continue, child: (
|
| |
- must_continue or commit_coroutines_must_continue[child][vindex]
|
| |
+ lambda info, child: self._merge_info(
|
| |
+ info, commit_coroutines_info[child][vindex]
|
| |
),
|
| |
this_children,
|
| |
- False,
|
| |
+ {},
|
| |
)
|
| |
for vindex, v in enumerate(visitors)
|
| |
]
|
| |
|
| |
- keep_processing = keep_processing and any(children_visitors_must_continue)
|
| |
+ keep_processing = keep_processing and any(
|
| |
+ info["child_must_continue"] for info in children_visitors_info
|
| |
+ )
|
| |
|
| |
branch.append(commit)
|
| |
|
| |
@@ -472,17 +536,18 @@
|
| |
# method. Pass the ordered list of "is there a child whose coroutine of the same
|
| |
# visitor wants to continue" into it.
|
| |
commit_coroutines[commit] = coroutines = [
|
| |
- v(commit, children_visitors_must_continue[vi])
|
| |
- for vi, v in enumerate(visitors)
|
| |
+ v(commit, children_visitors_info[vi]) for vi, v in enumerate(visitors)
|
| |
]
|
| |
|
| |
# Consult all visitors for the commit on whether we should continue and store
|
| |
# the results.
|
| |
- commit_coroutines_must_continue[commit] = [next(c) for c in coroutines]
|
| |
+ commit_coroutines_info[commit] = [next(c) for c in coroutines]
|
| |
else:
|
| |
# Only traverse this commit.
|
| |
commit_coroutines[commit] = coroutines = None
|
| |
- commit_coroutines_must_continue[commit] = [False for v in visitors]
|
| |
+ commit_coroutines_info[commit] = [
|
| |
+ {"child_must_continue": False} for v in visitors
|
| |
+ ]
|
| |
|
| |
if not commit.parents:
|
| |
log.debug("\tno parents, bailing out")
|
| |
@@ -572,17 +637,30 @@
|
| |
# whether or not the worktree differs and this needs to be reflected in the result(s)
|
| |
reflect_worktree = False
|
| |
|
| |
- if not head:
|
| |
- head = self.repo[self.repo.head.target]
|
| |
- diff_to_head = self.repo.diff(head)
|
| |
- reflect_worktree = diff_to_head.stats.files_changed > 0
|
| |
- elif isinstance(head, str):
|
| |
- head = self.repo[head]
|
| |
+ if self.repo:
|
| |
+ seed_info = None
|
| |
+ if not head:
|
| |
+ head = self.repo[self.repo.head.target]
|
| |
+ diff_to_head = self.repo.diff(head)
|
| |
+ reflect_worktree = diff_to_head.stats.files_changed > 0
|
| |
+ if (
|
| |
+ reflect_worktree
|
| |
+ and not (self.specfile.parent / "changelog").exists()
|
| |
+ and "changelog" in head.tree
|
| |
+ ):
|
| |
+ seed_info = {"changelog_removed": True}
|
| |
+ elif isinstance(head, str):
|
| |
+ head = self.repo[head]
|
| |
|
| |
- visited_results = self._run_on_history(head, visitors=visitors)
|
| |
- head_result = visited_results[head]
|
| |
+ visited_results = self._run_on_history(head, visitors=visitors, seed_info=seed_info)
|
| |
+ head_result = visited_results[head]
|
| |
+ else:
|
| |
+ reflect_worktree = True
|
| |
+ visited_results = {}
|
| |
+ head_result = {}
|
| |
|
| |
if reflect_worktree:
|
| |
+ # Not a git repository, or the git worktree isn't clean.
|
| |
worktree_result = {}
|
| |
|
| |
verflags = self._get_rpmverflags(self.path, name=self.name)
|
| |
@@ -601,7 +679,7 @@
|
| |
|
| |
# Mimic the bottom half of release_visitor
|
| |
worktree_result["epoch-version"] = epoch_version = verflags["epoch-version"]
|
| |
- if epoch_version == head_result["epoch-version"]:
|
| |
+ if head_result and epoch_version == head_result["epoch-version"]:
|
| |
release_number = head_result["release-number"] + 1
|
| |
else:
|
| |
release_number = 1
|
| |
@@ -622,28 +700,36 @@
|
| |
changelog = ()
|
| |
else:
|
| |
previous_changelog = head_result.get("changelog", ())
|
| |
- changed_files = self._files_changed_in_diff(diff_to_head)
|
| |
- skip_for_changelog = all(
|
| |
- any(fnmatchcase(f, path) for path in self.changelog_ignore_patterns)
|
| |
- for f in changed_files
|
| |
- )
|
| |
+ if self.repo:
|
| |
+ changed_files = self._files_changed_in_diff(diff_to_head)
|
| |
+ skip_for_changelog = all(
|
| |
+ any(fnmatchcase(f, path) for path in self.changelog_ignore_patterns)
|
| |
+ for f in changed_files
|
| |
+ )
|
| |
+ else:
|
| |
+ skip_for_changelog = False
|
| |
|
| |
if not skip_for_changelog:
|
| |
try:
|
| |
signature = self.repo.default_signature
|
| |
- changelog_author = f"{signature.name} <{signature.email}>"
|
| |
+ authorblurb = f"{signature.name} <{signature.email}>"
|
| |
+ except AttributeError:
|
| |
+ # self.repo == None -> no git repo
|
| |
+ authorblurb = self._get_rpm_packager()
|
| |
except KeyError:
|
| |
- changelog_author = "Unknown User <please-configure-git-user@example.com>"
|
| |
- changelog_date = dt.datetime.utcnow().strftime("%a %b %d %Y")
|
| |
- changelog_evr = f"{epoch_version}-{release_complete}"
|
| |
+ authorblurb = "Unknown User <please-configure-git-user@example.com>"
|
| |
+
|
| |
+ changelog_entry = ChangelogEntry(
|
| |
+ {
|
| |
+ "commit-id": None,
|
| |
+ "authorblurb": authorblurb,
|
| |
+ "timestamp": dt.datetime.utcnow(),
|
| |
+ "commitlog": "Uncommitted changes",
|
| |
+ "epoch-version": epoch_version,
|
| |
+ "release-complete": release_complete,
|
| |
+ }
|
| |
+ )
|
| |
|
| |
- changelog_header = f"* {changelog_date} {changelog_author} {changelog_evr}"
|
| |
- changelog_item = "- Uncommitted changes"
|
| |
-
|
| |
- changelog_entry = {
|
| |
- "commit-id": None,
|
| |
- "data": f"{changelog_header}\n{changelog_item}",
|
| |
- }
|
| |
changelog = (changelog_entry,) + previous_changelog
|
| |
else:
|
| |
changelog = previous_changelog
|
| |