glab-release-notes 0.1.0__py3-none-any.whl
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.
- glab_release_notes/__init__.py +32 -0
- glab_release_notes/__main__.py +6 -0
- glab_release_notes/api.py +40 -0
- glab_release_notes/append_cmd.py +81 -0
- glab_release_notes/cli.py +104 -0
- glab_release_notes/config.py +178 -0
- glab_release_notes/constants.py +15 -0
- glab_release_notes/edit.py +87 -0
- glab_release_notes/gitops.py +56 -0
- glab_release_notes/http.py +39 -0
- glab_release_notes/load.py +30 -0
- glab_release_notes/models.py +48 -0
- glab_release_notes/parse.py +55 -0
- glab_release_notes/payload.py +49 -0
- glab_release_notes/render.py +73 -0
- glab_release_notes/split.py +58 -0
- glab_release_notes-0.1.0.dist-info/METADATA +277 -0
- glab_release_notes-0.1.0.dist-info/RECORD +21 -0
- glab_release_notes-0.1.0.dist-info/WHEEL +4 -0
- glab_release_notes-0.1.0.dist-info/entry_points.txt +3 -0
- glab_release_notes-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Release notes for GitLab projects, generated from merged merge requests."""
|
|
2
|
+
|
|
3
|
+
from glab_release_notes.config import NotesConfig, default_config, load_config
|
|
4
|
+
from glab_release_notes.edit import append_note, promote_notes
|
|
5
|
+
from glab_release_notes.models import LabelDecision, MergeRequest, ReleaseNotesError
|
|
6
|
+
from glab_release_notes.parse import (
|
|
7
|
+
already_recorded,
|
|
8
|
+
classify,
|
|
9
|
+
parse_quote,
|
|
10
|
+
parse_ticket,
|
|
11
|
+
validate_record,
|
|
12
|
+
)
|
|
13
|
+
from glab_release_notes.payload import merge_request_from_payload
|
|
14
|
+
from glab_release_notes.render import format_entry
|
|
15
|
+
|
|
16
|
+
__all__ = (
|
|
17
|
+
"LabelDecision",
|
|
18
|
+
"MergeRequest",
|
|
19
|
+
"NotesConfig",
|
|
20
|
+
"ReleaseNotesError",
|
|
21
|
+
"already_recorded",
|
|
22
|
+
"append_note",
|
|
23
|
+
"classify",
|
|
24
|
+
"default_config",
|
|
25
|
+
"format_entry",
|
|
26
|
+
"load_config",
|
|
27
|
+
"merge_request_from_payload",
|
|
28
|
+
"parse_quote",
|
|
29
|
+
"parse_ticket",
|
|
30
|
+
"promote_notes",
|
|
31
|
+
"validate_record",
|
|
32
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Fetch merge requests from the GitLab API."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from glab_release_notes.http import Payload, gitlab_json, payload_list
|
|
6
|
+
from glab_release_notes.models import MergeRequest, ReleaseNotesError
|
|
7
|
+
from glab_release_notes.payload import merge_request_from_payload
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fetch_mr(iid: int) -> MergeRequest:
|
|
11
|
+
"""Load a merge request by iid."""
|
|
12
|
+
raw = gitlab_json(f"merge_requests/{iid}")
|
|
13
|
+
if not isinstance(raw, dict):
|
|
14
|
+
msg = f"Invalid GitLab response for MR !{iid}"
|
|
15
|
+
raise ReleaseNotesError(msg)
|
|
16
|
+
return merge_request_from_payload(raw)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _first_or_none(payloads: list[Payload]) -> Payload | None:
|
|
20
|
+
if payloads:
|
|
21
|
+
return payloads[0]
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _pick_payload(payloads: list[Payload]) -> Payload | None:
|
|
26
|
+
merged = [payload for payload in payloads if payload.get("state") == "merged"]
|
|
27
|
+
chosen = _first_or_none(merged)
|
|
28
|
+
if chosen is not None:
|
|
29
|
+
return chosen
|
|
30
|
+
return _first_or_none(payloads)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fetch_mr_for_commit(sha: str) -> MergeRequest | None:
|
|
34
|
+
"""Find the merged merge request for a commit SHA."""
|
|
35
|
+
encoded = quote(sha, safe="")
|
|
36
|
+
path = f"repository/commits/{encoded}/merge_requests"
|
|
37
|
+
chosen = _pick_payload(payload_list(gitlab_json(path)))
|
|
38
|
+
if chosen is None:
|
|
39
|
+
return None
|
|
40
|
+
return merge_request_from_payload(chosen)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The append command: record a merged merge request."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from glab_release_notes import constants
|
|
7
|
+
from glab_release_notes.config import NotesConfig
|
|
8
|
+
from glab_release_notes.edit import AppendMutator
|
|
9
|
+
from glab_release_notes.gitops import push_notes
|
|
10
|
+
from glab_release_notes.load import load_mr_for_append
|
|
11
|
+
from glab_release_notes.models import Classification, LabelDecision, MergeRequest, ReleaseNotesError
|
|
12
|
+
from glab_release_notes.parse import (
|
|
13
|
+
already_recorded,
|
|
14
|
+
classify,
|
|
15
|
+
parse_quote,
|
|
16
|
+
parse_ticket,
|
|
17
|
+
validate_record,
|
|
18
|
+
)
|
|
19
|
+
from glab_release_notes.render import format_entry
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_entry(
|
|
25
|
+
merge_request: MergeRequest,
|
|
26
|
+
classification: Classification,
|
|
27
|
+
*,
|
|
28
|
+
cfg: NotesConfig,
|
|
29
|
+
jira_base: str | None,
|
|
30
|
+
) -> tuple[str, str]:
|
|
31
|
+
validate_record(labels=merge_request.labels, description=merge_request.description, cfg=cfg)
|
|
32
|
+
if classification.spec is None:
|
|
33
|
+
msg = "No section after a successful validation"
|
|
34
|
+
raise ReleaseNotesError(msg)
|
|
35
|
+
quote = parse_quote(merge_request.description)
|
|
36
|
+
if quote is None:
|
|
37
|
+
msg = "No entry text"
|
|
38
|
+
raise ReleaseNotesError(msg)
|
|
39
|
+
entry = format_entry(
|
|
40
|
+
text=quote,
|
|
41
|
+
spec=classification.spec,
|
|
42
|
+
mr=merge_request,
|
|
43
|
+
ticket=parse_ticket(merge_request.source_branch),
|
|
44
|
+
jira_base=jira_base,
|
|
45
|
+
)
|
|
46
|
+
return entry, classification.spec.header
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_skipped(
|
|
50
|
+
merge_request: MergeRequest,
|
|
51
|
+
notes_path: Path,
|
|
52
|
+
classification: Classification,
|
|
53
|
+
) -> bool:
|
|
54
|
+
if merge_request.state and merge_request.state != "merged":
|
|
55
|
+
logger.info("MR !%s is in state %s, skipping", merge_request.iid, merge_request.state)
|
|
56
|
+
return True
|
|
57
|
+
if classification.decision is LabelDecision.SKIP:
|
|
58
|
+
logger.info("Skip label on MR !%s, skipping", merge_request.iid)
|
|
59
|
+
return True
|
|
60
|
+
notes_markdown = notes_path.read_text(encoding=constants.encoding)
|
|
61
|
+
if already_recorded(notes_markdown, merge_request.iid):
|
|
62
|
+
logger.info("MR !%s is already recorded, skipping", merge_request.iid)
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_append(*, notes_path: Path, cfg: NotesConfig, jira_base: str | None = None) -> None:
|
|
68
|
+
"""Record a merged merge request and push the commit."""
|
|
69
|
+
merge_request = load_mr_for_append()
|
|
70
|
+
if merge_request is None:
|
|
71
|
+
logger.info("No merge request found, skipping")
|
|
72
|
+
return
|
|
73
|
+
classification = classify(merge_request.labels, cfg)
|
|
74
|
+
if _is_skipped(merge_request, notes_path, classification):
|
|
75
|
+
return
|
|
76
|
+
entry, header = _build_entry(merge_request, classification, cfg=cfg, jira_base=jira_base)
|
|
77
|
+
push_notes(
|
|
78
|
+
notes_path,
|
|
79
|
+
f"chore(notes): add entry for MR !{merge_request.iid}",
|
|
80
|
+
AppendMutator(entry, header, merge_request.iid, cfg=cfg),
|
|
81
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""CLI commands: validate, append, promote."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from glab_release_notes import constants
|
|
9
|
+
from glab_release_notes.append_cmd import cmd_append
|
|
10
|
+
from glab_release_notes.config import NotesConfig, load_config
|
|
11
|
+
from glab_release_notes.edit import PromoteMutator, promote_notes
|
|
12
|
+
from glab_release_notes.gitops import push_notes
|
|
13
|
+
from glab_release_notes.load import load_mr_for_validate
|
|
14
|
+
from glab_release_notes.models import ReleaseNotesError
|
|
15
|
+
from glab_release_notes.parse import validate_record
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cmd_validate(cfg: NotesConfig) -> None:
|
|
21
|
+
"""Validate the labels and quote of the current merge request."""
|
|
22
|
+
merge_request = load_mr_for_validate()
|
|
23
|
+
validate_record(labels=merge_request.labels, description=merge_request.description, cfg=cfg)
|
|
24
|
+
logger.info("Release notes validation passed for MR !%s", merge_request.iid)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_promoted(notes_path: Path, version: str, cfg: NotesConfig) -> None:
|
|
28
|
+
current = notes_path.read_text(encoding=constants.encoding)
|
|
29
|
+
updated = promote_notes(current, version, cfg)
|
|
30
|
+
if updated == current:
|
|
31
|
+
logger.info("Nothing to change, skipping")
|
|
32
|
+
return
|
|
33
|
+
notes_path.write_text(updated, encoding=constants.encoding)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_promote(*, notes_path: Path, cfg: NotesConfig, version: str, push: bool = True) -> None:
|
|
37
|
+
"""Promote the unreleased block under a version heading.
|
|
38
|
+
|
|
39
|
+
With ``push=False`` the file is rewritten in place only; the release
|
|
40
|
+
process owns the commit.
|
|
41
|
+
"""
|
|
42
|
+
if not push:
|
|
43
|
+
_write_promoted(notes_path, version, cfg)
|
|
44
|
+
return
|
|
45
|
+
message = f"chore(notes): promote {version}"
|
|
46
|
+
push_notes(notes_path, message, PromoteMutator(version, cfg))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
50
|
+
parser = argparse.ArgumentParser(description="Maintain release notes from merged GitLab MRs")
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--config",
|
|
53
|
+
default=None,
|
|
54
|
+
metavar="PATH",
|
|
55
|
+
help="TOML config file; defaults to release-notes.toml in the current directory",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--file",
|
|
59
|
+
default=None,
|
|
60
|
+
metavar="PATH",
|
|
61
|
+
help="Path to the notes file; overrides 'file' from the config",
|
|
62
|
+
)
|
|
63
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
64
|
+
subparsers.add_parser("validate", help="Validate the current merge request")
|
|
65
|
+
append = subparsers.add_parser("append", help="Append an entry after a merge")
|
|
66
|
+
append.add_argument(
|
|
67
|
+
"--jira-base",
|
|
68
|
+
default=None,
|
|
69
|
+
help="Base URL of Jira, e.g. https://jira.mwnts.net/browse; no link without it",
|
|
70
|
+
)
|
|
71
|
+
promote = subparsers.add_parser("promote", help="Move unreleased entries under a version")
|
|
72
|
+
promote.add_argument("--version", required=True, help="Version, e.g. 0.8.2")
|
|
73
|
+
promote.add_argument(
|
|
74
|
+
"--no-push",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="Rewrite the file only, without a commit and push",
|
|
77
|
+
)
|
|
78
|
+
return parser
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _dispatch(
|
|
82
|
+
command: str, notes_path: Path, args: argparse.Namespace, *, cfg: NotesConfig
|
|
83
|
+
) -> None:
|
|
84
|
+
if command == "validate":
|
|
85
|
+
cmd_validate(cfg)
|
|
86
|
+
return
|
|
87
|
+
if command == "append":
|
|
88
|
+
cmd_append(notes_path=notes_path, cfg=cfg, jira_base=args.jira_base)
|
|
89
|
+
return
|
|
90
|
+
if command == "promote":
|
|
91
|
+
cmd_promote(notes_path=notes_path, cfg=cfg, version=args.version, push=not args.no_push)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv: list[str] | None = None) -> None:
|
|
95
|
+
"""Entry point for the CLI."""
|
|
96
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
97
|
+
args = _build_parser().parse_args(argv)
|
|
98
|
+
cfg = load_config(args.config)
|
|
99
|
+
notes_path = Path(args.file) if args.file else cfg.notes_file
|
|
100
|
+
try:
|
|
101
|
+
_dispatch(args.command, notes_path, args, cfg=cfg)
|
|
102
|
+
except ReleaseNotesError:
|
|
103
|
+
logger.exception("Failed to update release notes")
|
|
104
|
+
sys.exit(1)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Configuration for release note generation."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
|
|
9
|
+
from glab_release_notes.models import ReleaseNotesError, SectionSpec
|
|
10
|
+
|
|
11
|
+
#: The config file looked up in the current directory by default.
|
|
12
|
+
DEFAULT_CONFIG_FILE = "release-notes.toml"
|
|
13
|
+
|
|
14
|
+
_DEFAULT_NOTES_FILE = "docs/release-notes.md"
|
|
15
|
+
_DEFAULT_UNRELEASED_HEADER = "## Latest Changes"
|
|
16
|
+
_DEFAULT_SECTION_PREFIX = "### "
|
|
17
|
+
_DEFAULT_SKIP_LABELS = ("release", "refactor", "test", "chore", "style")
|
|
18
|
+
_DEFAULT_ALWAYS_KEEP = ("Features", "Fixes")
|
|
19
|
+
_DEFAULT_SECTIONS = (
|
|
20
|
+
SectionSpec("feature", "Features", "✨", "feat"),
|
|
21
|
+
SectionSpec("bug", "Fixes", "🐛", "fix"),
|
|
22
|
+
SectionSpec("docs", "Documentation", "📝", "docs"),
|
|
23
|
+
SectionSpec("ci", "CI", "👷", "ci"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_TOP_LEVEL_KEYS = (
|
|
27
|
+
"file",
|
|
28
|
+
"unreleased_header",
|
|
29
|
+
"section_prefix",
|
|
30
|
+
"skip_labels",
|
|
31
|
+
"always_keep",
|
|
32
|
+
"section",
|
|
33
|
+
)
|
|
34
|
+
_SECTION_KEYS = ("label", "header", "emoji", "anchor_prefix")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class NotesConfig:
|
|
39
|
+
"""The configurable text of the release notes."""
|
|
40
|
+
|
|
41
|
+
notes_file: Path
|
|
42
|
+
unreleased_header: str
|
|
43
|
+
section_prefix: str
|
|
44
|
+
skip_labels: frozenset[str]
|
|
45
|
+
always_keep: tuple[str, ...]
|
|
46
|
+
sections: tuple[SectionSpec, ...]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def section_order(self) -> tuple[str, ...]:
|
|
50
|
+
"""Section headers in display order."""
|
|
51
|
+
return tuple(spec.header for spec in self.sections)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def by_header(self) -> Mapping[str, SectionSpec]:
|
|
55
|
+
"""Sections indexed by header."""
|
|
56
|
+
return MappingProxyType({spec.header: spec for spec in self.sections})
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def by_label(self) -> Mapping[str, SectionSpec]:
|
|
60
|
+
"""Sections indexed by label."""
|
|
61
|
+
return MappingProxyType({spec.label: spec for spec in self.sections})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_config() -> NotesConfig:
|
|
65
|
+
"""The built-in defaults (the team convention)."""
|
|
66
|
+
return NotesConfig(
|
|
67
|
+
notes_file=Path(_DEFAULT_NOTES_FILE),
|
|
68
|
+
unreleased_header=_DEFAULT_UNRELEASED_HEADER,
|
|
69
|
+
section_prefix=_DEFAULT_SECTION_PREFIX,
|
|
70
|
+
skip_labels=frozenset(_DEFAULT_SKIP_LABELS),
|
|
71
|
+
always_keep=_DEFAULT_ALWAYS_KEEP,
|
|
72
|
+
sections=_DEFAULT_SECTIONS,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_config(path: str | Path | None = None) -> NotesConfig:
|
|
77
|
+
"""Load the config from a TOML file, or from the built-in defaults.
|
|
78
|
+
|
|
79
|
+
An explicit ``path`` must exist. Without one, ``release-notes.toml`` from the
|
|
80
|
+
current directory is used if present.
|
|
81
|
+
"""
|
|
82
|
+
if path is not None:
|
|
83
|
+
config_path = Path(path)
|
|
84
|
+
if not config_path.is_file():
|
|
85
|
+
msg = f"Config file not found: {config_path}"
|
|
86
|
+
raise ReleaseNotesError(msg)
|
|
87
|
+
return _config_from_data(_read_toml(config_path))
|
|
88
|
+
config_path = Path(DEFAULT_CONFIG_FILE)
|
|
89
|
+
if config_path.is_file():
|
|
90
|
+
return _config_from_data(_read_toml(config_path))
|
|
91
|
+
return default_config()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _read_toml(config_path: Path) -> dict[str, object]:
|
|
95
|
+
try:
|
|
96
|
+
with config_path.open("rb") as handle:
|
|
97
|
+
data = tomllib.load(handle)
|
|
98
|
+
except tomllib.TOMLDecodeError as exc:
|
|
99
|
+
msg = f"Invalid TOML in {config_path}: {exc}"
|
|
100
|
+
raise ReleaseNotesError(msg) from exc
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _config_from_data(data: dict[str, object]) -> NotesConfig:
|
|
105
|
+
unknown = sorted(set(data) - set(_TOP_LEVEL_KEYS))
|
|
106
|
+
if unknown:
|
|
107
|
+
msg = f"Unknown config keys: {', '.join(unknown)}"
|
|
108
|
+
raise ReleaseNotesError(msg)
|
|
109
|
+
|
|
110
|
+
sections = _parse_sections(data.get("section"))
|
|
111
|
+
always_keep = _str_list(data.get("always_keep", list(_DEFAULT_ALWAYS_KEEP)), "always_keep")
|
|
112
|
+
skip_labels = _str_list(data.get("skip_labels", list(_DEFAULT_SKIP_LABELS)), "skip_labels")
|
|
113
|
+
known_headers = {spec.header for spec in sections}
|
|
114
|
+
unknown_kept = [header for header in always_keep if header not in known_headers]
|
|
115
|
+
if unknown_kept:
|
|
116
|
+
msg = f"always_keep references unknown sections: {', '.join(unknown_kept)}"
|
|
117
|
+
raise ReleaseNotesError(msg)
|
|
118
|
+
|
|
119
|
+
return NotesConfig(
|
|
120
|
+
notes_file=Path(str(data.get("file", _DEFAULT_NOTES_FILE))),
|
|
121
|
+
unreleased_header=str(data.get("unreleased_header", _DEFAULT_UNRELEASED_HEADER)),
|
|
122
|
+
section_prefix=str(data.get("section_prefix", _DEFAULT_SECTION_PREFIX)),
|
|
123
|
+
skip_labels=frozenset(skip_labels),
|
|
124
|
+
always_keep=tuple(always_keep),
|
|
125
|
+
sections=sections,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_sections(raw: object) -> tuple[SectionSpec, ...]:
|
|
130
|
+
if not isinstance(raw, list) or not raw:
|
|
131
|
+
msg = "At least one [[section]] is required"
|
|
132
|
+
raise ReleaseNotesError(msg)
|
|
133
|
+
sections: list[SectionSpec] = []
|
|
134
|
+
seen: set[str] = set()
|
|
135
|
+
for index, entry in enumerate(raw):
|
|
136
|
+
spec = _parse_section(entry, index)
|
|
137
|
+
if spec.label in seen:
|
|
138
|
+
msg = f"Duplicate section label: {spec.label}"
|
|
139
|
+
raise ReleaseNotesError(msg)
|
|
140
|
+
seen.add(spec.label)
|
|
141
|
+
sections.append(spec)
|
|
142
|
+
return tuple(sections)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_section(entry: object, index: int) -> SectionSpec:
|
|
146
|
+
if not isinstance(entry, dict):
|
|
147
|
+
msg = f"[[section]] #{index + 1} must be a table"
|
|
148
|
+
raise ReleaseNotesError(msg)
|
|
149
|
+
unknown = sorted(set(entry) - set(_SECTION_KEYS))
|
|
150
|
+
if unknown:
|
|
151
|
+
msg = f"[[section]] #{index + 1}: unknown keys {', '.join(unknown)}"
|
|
152
|
+
raise ReleaseNotesError(msg)
|
|
153
|
+
label = _required_str(entry, "label", index)
|
|
154
|
+
header = _required_str(entry, "header", index)
|
|
155
|
+
emoji = entry.get("emoji", "")
|
|
156
|
+
if not isinstance(emoji, str):
|
|
157
|
+
msg = f"[[section]] #{index + 1}: 'emoji' must be a string"
|
|
158
|
+
raise ReleaseNotesError(msg)
|
|
159
|
+
anchor = _required_str(entry, "anchor_prefix", index, default=label)
|
|
160
|
+
return SectionSpec(label=label, header=header, emoji=emoji, anchor_prefix=anchor)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _required_str(
|
|
164
|
+
entry: dict[str, object], key: str, index: int, *, default: str | None = None
|
|
165
|
+
) -> str:
|
|
166
|
+
value = entry.get(key, default)
|
|
167
|
+
if not isinstance(value, str) or not value:
|
|
168
|
+
required = "' is required" if default is None else "' must be a non-empty string"
|
|
169
|
+
msg = f"[[section]] #{index + 1}: '{key}{required}"
|
|
170
|
+
raise ReleaseNotesError(msg)
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _str_list(raw: object, name: str) -> list[str]:
|
|
175
|
+
if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
|
|
176
|
+
msg = f"'{name}' must be a list of strings"
|
|
177
|
+
raise ReleaseNotesError(msg)
|
|
178
|
+
return raw
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Format constants for the release notes (regexes and tool settings)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
push_retries = 10
|
|
6
|
+
http_timeout = 30
|
|
7
|
+
double_newline = "\n\n"
|
|
8
|
+
encoding = "utf-8"
|
|
9
|
+
git_bin = "git"
|
|
10
|
+
|
|
11
|
+
quote_re = re.compile(r"^> ?(.*\S.*)$", re.MULTILINE)
|
|
12
|
+
ticket_re = re.compile(r"(?i)\b([a-z]{2,})[-_](\d+)\b")
|
|
13
|
+
version_line_re = re.compile(r"^## ", re.MULTILINE)
|
|
14
|
+
anchor_re = re.compile(r"\s*\{#.*\}\s*$")
|
|
15
|
+
item_re = re.compile(r"^\* ", re.MULTILINE)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Insert an entry and promote unreleased entries under a version."""
|
|
2
|
+
|
|
3
|
+
from glab_release_notes import constants
|
|
4
|
+
from glab_release_notes.config import NotesConfig
|
|
5
|
+
from glab_release_notes.parse import already_recorded
|
|
6
|
+
from glab_release_notes.render import assemble, prepend_entry, render_unreleased
|
|
7
|
+
from glab_release_notes.split import parse_sections, split_unreleased
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def has_items(body: str) -> bool:
|
|
11
|
+
"""Whether a section has any list items."""
|
|
12
|
+
return constants.item_re.search(body) is not None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def append_note(notes_markdown: str, entry: str, header: str, *, cfg: NotesConfig) -> str:
|
|
16
|
+
"""Insert an entry into the right section of the unreleased block."""
|
|
17
|
+
prefix, unreleased, postfix = split_unreleased(notes_markdown, cfg)
|
|
18
|
+
sections = prepend_entry(parse_sections(unreleased, cfg), header, entry)
|
|
19
|
+
return assemble(prefix, render_unreleased(sections, cfg), postfix)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _version_blocks(sections: dict[str, str], version: str, cfg: NotesConfig) -> list[str]:
|
|
23
|
+
blocks: list[str] = []
|
|
24
|
+
for header in cfg.section_order:
|
|
25
|
+
body = sections.get(header, "")
|
|
26
|
+
if not has_items(body):
|
|
27
|
+
continue
|
|
28
|
+
spec = cfg.by_header[header]
|
|
29
|
+
title = f"{cfg.section_prefix}{header} {{#{spec.anchor_prefix}_{version}}}"
|
|
30
|
+
blocks.append(f"{title}{constants.double_newline}{body}")
|
|
31
|
+
return blocks
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _assemble_promoted(
|
|
35
|
+
*,
|
|
36
|
+
prefix: str,
|
|
37
|
+
postfix: str,
|
|
38
|
+
version: str,
|
|
39
|
+
blocks: list[str],
|
|
40
|
+
cfg: NotesConfig,
|
|
41
|
+
) -> str:
|
|
42
|
+
emptied: dict[str, str] = dict.fromkeys(cfg.always_keep, "")
|
|
43
|
+
joined = constants.double_newline.join(blocks)
|
|
44
|
+
heading = f"## {version}{constants.double_newline}{joined}"
|
|
45
|
+
new_post = f"{heading}{constants.double_newline}{postfix.strip()}"
|
|
46
|
+
return assemble(prefix, render_unreleased(emptied, cfg), new_post.strip())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def promote_notes(notes_markdown: str, version: str, cfg: NotesConfig) -> str:
|
|
50
|
+
"""Move non-empty unreleased sections under a version heading."""
|
|
51
|
+
prefix, unreleased, postfix = split_unreleased(notes_markdown, cfg)
|
|
52
|
+
blocks = _version_blocks(parse_sections(unreleased, cfg), version, cfg)
|
|
53
|
+
if not blocks:
|
|
54
|
+
return notes_markdown
|
|
55
|
+
return _assemble_promoted(
|
|
56
|
+
prefix=prefix, postfix=postfix, version=version, blocks=blocks, cfg=cfg
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AppendMutator:
|
|
61
|
+
"""Re-inserts the entry after a git pull."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, entry: str, header: str, iid: int, *, cfg: NotesConfig) -> None:
|
|
64
|
+
"""Keep the entry text, the section, the MR iid, and the config."""
|
|
65
|
+
self._entry = entry
|
|
66
|
+
self._header = header
|
|
67
|
+
self._iid = iid
|
|
68
|
+
self._cfg = cfg
|
|
69
|
+
|
|
70
|
+
def __call__(self, notes_markdown: str) -> str:
|
|
71
|
+
"""Return the markdown with the entry, if not already present."""
|
|
72
|
+
if already_recorded(notes_markdown, self._iid):
|
|
73
|
+
return notes_markdown
|
|
74
|
+
return append_note(notes_markdown, self._entry, self._header, cfg=self._cfg)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PromoteMutator:
|
|
78
|
+
"""Re-promotes the unreleased block after a git pull."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, version: str, cfg: NotesConfig) -> None:
|
|
81
|
+
"""Keep the version number and the config."""
|
|
82
|
+
self._version = version
|
|
83
|
+
self._cfg = cfg
|
|
84
|
+
|
|
85
|
+
def __call__(self, notes_markdown: str) -> str:
|
|
86
|
+
"""Return the markdown with the promoted entries."""
|
|
87
|
+
return promote_notes(notes_markdown, self._version, self._cfg)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Commit and push the updated release notes."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from glab_release_notes import constants
|
|
9
|
+
from glab_release_notes.models import ReleaseNotesError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run_git(args: list[str], *, check: bool) -> subprocess.CompletedProcess[str]:
|
|
15
|
+
return subprocess.run(args, check=check, text=True) # noqa: S603
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _commit_notes(path: Path, message: str, updated: str) -> subprocess.CompletedProcess[str]:
|
|
19
|
+
encoding = constants.encoding
|
|
20
|
+
git = constants.git_bin
|
|
21
|
+
path.write_text(updated, encoding=encoding)
|
|
22
|
+
_run_git([git, "add", str(path)], check=True)
|
|
23
|
+
_run_git([git, "commit", "-m", message], check=True)
|
|
24
|
+
return _run_git([git, "push"], check=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _reset_notes(path: Path) -> None:
|
|
28
|
+
git = constants.git_bin
|
|
29
|
+
_run_git([git, "reset", "HEAD^"], check=True)
|
|
30
|
+
_run_git([git, "checkout", "--", str(path)], check=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _try_push(path: Path, message: str, mutate: Callable[[str], str]) -> bool:
|
|
34
|
+
git = constants.git_bin
|
|
35
|
+
encoding = constants.encoding
|
|
36
|
+
_run_git([git, "pull", "--rebase"], check=False)
|
|
37
|
+
current = path.read_text(encoding=encoding)
|
|
38
|
+
updated = mutate(current)
|
|
39
|
+
if updated == current:
|
|
40
|
+
logger.info("Nothing to change, skipping")
|
|
41
|
+
return True
|
|
42
|
+
pushed = _commit_notes(path, message, updated)
|
|
43
|
+
if pushed.returncode == 0:
|
|
44
|
+
return True
|
|
45
|
+
_reset_notes(path)
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def push_notes(path: Path, message: str, mutate: Callable[[str], str]) -> None:
|
|
50
|
+
"""Commit the notes, retrying on a push race."""
|
|
51
|
+
for trial in range(constants.push_retries):
|
|
52
|
+
logger.info("Push attempt %s/%s", trial + 1, constants.push_retries)
|
|
53
|
+
if _try_push(path, message, mutate):
|
|
54
|
+
return
|
|
55
|
+
msg = f"Could not push the notes after {constants.push_retries} attempts"
|
|
56
|
+
raise ReleaseNotesError(msg)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Low-level HTTP client for the GitLab API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from urllib.error import HTTPError
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
from urllib.request import Request, urlopen
|
|
8
|
+
|
|
9
|
+
from glab_release_notes import constants
|
|
10
|
+
from glab_release_notes.models import ReleaseNotesError
|
|
11
|
+
|
|
12
|
+
type Payload = dict[str, object]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def api_url(path: str) -> str:
|
|
16
|
+
"""Build a GitLab API URL for the current project."""
|
|
17
|
+
base = os.environ["CI_API_V4_URL"].rstrip("/")
|
|
18
|
+
project = quote(str(os.environ["CI_PROJECT_ID"]), safe="")
|
|
19
|
+
return f"{base}/projects/{project}/{path}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def gitlab_json(path: str) -> object:
|
|
23
|
+
"""GET JSON with the CI job token."""
|
|
24
|
+
url = api_url(path)
|
|
25
|
+
headers = {"PRIVATE-TOKEN": os.environ["GITLAB_API_TOKEN"]}
|
|
26
|
+
http_request = Request(url, headers=headers) # noqa: S310
|
|
27
|
+
try:
|
|
28
|
+
with urlopen(http_request, timeout=constants.http_timeout) as response: # noqa: S310
|
|
29
|
+
return json.loads(response.read().decode())
|
|
30
|
+
except HTTPError as exc:
|
|
31
|
+
msg = f"GitLab API {url} -> HTTP {exc.code}"
|
|
32
|
+
raise ReleaseNotesError(msg) from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def payload_list(raw: object) -> list[Payload]:
|
|
36
|
+
"""Keep only dict objects from an API response."""
|
|
37
|
+
if not isinstance(raw, list):
|
|
38
|
+
return []
|
|
39
|
+
return [payload for payload in raw if isinstance(payload, dict)]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Pick the merge request from CI variables."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from glab_release_notes.api import fetch_mr, fetch_mr_for_commit
|
|
6
|
+
from glab_release_notes.models import MergeRequest, ReleaseNotesError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _env_value(name: str) -> str:
|
|
10
|
+
return os.environ.get(name, "").strip()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_mr_for_validate() -> MergeRequest:
|
|
14
|
+
"""MR for validation: iid from the merge request pipeline."""
|
|
15
|
+
iid = _env_value("CI_MERGE_REQUEST_IID")
|
|
16
|
+
if not iid:
|
|
17
|
+
msg = "CI_MERGE_REQUEST_IID is required"
|
|
18
|
+
raise ReleaseNotesError(msg)
|
|
19
|
+
return fetch_mr(int(iid))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_mr_for_append() -> MergeRequest | None:
|
|
23
|
+
"""MR for appending: manual MR_IID or the MR of the current commit."""
|
|
24
|
+
iid = _env_value("MR_IID")
|
|
25
|
+
if iid:
|
|
26
|
+
return fetch_mr(int(iid))
|
|
27
|
+
sha = _env_value("CI_COMMIT_SHA")
|
|
28
|
+
if not sha:
|
|
29
|
+
return None
|
|
30
|
+
return fetch_mr_for_commit(sha)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Data models for release notes."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LabelDecision(Enum):
|
|
8
|
+
"""Result of analyzing a merge request's labels."""
|
|
9
|
+
|
|
10
|
+
SKIP = "skip"
|
|
11
|
+
MISSING = "missing"
|
|
12
|
+
SECTION = "section"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReleaseNotesError(Exception):
|
|
16
|
+
"""Error while validating or updating release notes."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class SectionSpec:
|
|
21
|
+
"""A GitLab label and its section header, emoji, and anchor prefix."""
|
|
22
|
+
|
|
23
|
+
label: str
|
|
24
|
+
header: str
|
|
25
|
+
emoji: str
|
|
26
|
+
anchor_prefix: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Classification:
|
|
31
|
+
"""Classification of a merge request by its labels."""
|
|
32
|
+
|
|
33
|
+
decision: LabelDecision
|
|
34
|
+
spec: SectionSpec | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class MergeRequest:
|
|
39
|
+
"""Merge request data needed to record an entry."""
|
|
40
|
+
|
|
41
|
+
iid: int
|
|
42
|
+
description: str
|
|
43
|
+
labels: tuple[str, ...]
|
|
44
|
+
web_url: str
|
|
45
|
+
author_username: str
|
|
46
|
+
author_url: str
|
|
47
|
+
source_branch: str
|
|
48
|
+
state: str
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Parse the quote, ticket key, and labels of a merge request."""
|
|
2
|
+
|
|
3
|
+
from glab_release_notes import constants
|
|
4
|
+
from glab_release_notes.config import NotesConfig
|
|
5
|
+
from glab_release_notes.models import Classification, LabelDecision, ReleaseNotesError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_quote(description: str) -> str | None:
|
|
9
|
+
"""Return the last non-empty quote from the MR description."""
|
|
10
|
+
matches: list[str] = [line.strip() for line in constants.quote_re.findall(description)]
|
|
11
|
+
if not matches:
|
|
12
|
+
return None
|
|
13
|
+
return matches[-1]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_ticket(branch: str) -> str | None:
|
|
17
|
+
"""Extract a Jira ticket key from a branch name."""
|
|
18
|
+
match = constants.ticket_re.search(branch)
|
|
19
|
+
if match is None:
|
|
20
|
+
return None
|
|
21
|
+
prefix = match.group(1).upper()
|
|
22
|
+
number = match.group(2)
|
|
23
|
+
return f"{prefix}-{number}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classify(labels: tuple[str, ...], cfg: NotesConfig) -> Classification:
|
|
27
|
+
"""Determine skip / missing / section from labels."""
|
|
28
|
+
label_set = set(labels)
|
|
29
|
+
if label_set & cfg.skip_labels:
|
|
30
|
+
return Classification(LabelDecision.SKIP)
|
|
31
|
+
for spec in cfg.sections:
|
|
32
|
+
if spec.label in label_set:
|
|
33
|
+
return Classification(LabelDecision.SECTION, spec)
|
|
34
|
+
return Classification(LabelDecision.MISSING)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_record(*, labels: tuple[str, ...], description: str, cfg: NotesConfig) -> None:
|
|
38
|
+
"""Check that the MR is ready to be recorded in the release notes."""
|
|
39
|
+
classification = classify(labels, cfg)
|
|
40
|
+
if classification.decision is LabelDecision.SKIP:
|
|
41
|
+
return
|
|
42
|
+
if classification.decision is LabelDecision.MISSING:
|
|
43
|
+
section_labels = ", ".join(spec.label for spec in cfg.sections)
|
|
44
|
+
skip_labels = ", ".join(sorted(cfg.skip_labels))
|
|
45
|
+
msg = f"A section label ({section_labels}) or a skip label ({skip_labels}) is required"
|
|
46
|
+
raise ReleaseNotesError(msg)
|
|
47
|
+
if parse_quote(description) is None:
|
|
48
|
+
msg = "The MR description has no entry text (a `> …` quote)"
|
|
49
|
+
raise ReleaseNotesError(msg)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def already_recorded(notes_markdown: str, iid: int) -> bool:
|
|
53
|
+
"""Check whether a link to this MR is already present."""
|
|
54
|
+
marker = f"/merge_requests/{iid})"
|
|
55
|
+
return marker in notes_markdown
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Parse GitLab API JSON into a MergeRequest."""
|
|
2
|
+
|
|
3
|
+
from glab_release_notes.models import MergeRequest, ReleaseNotesError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _labels_from_string(raw: str) -> tuple[str, ...]:
|
|
7
|
+
parts = raw.split(",")
|
|
8
|
+
return tuple(part.strip() for part in parts if part.strip())
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _labels_from_list(raw: list[object]) -> tuple[str, ...]:
|
|
12
|
+
return tuple(
|
|
13
|
+
raw_label.strip() for raw_label in raw if isinstance(raw_label, str) and raw_label.strip()
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def split_labels(raw: object) -> tuple[str, ...]:
|
|
18
|
+
"""Normalize labels from a string or a list from the API."""
|
|
19
|
+
if isinstance(raw, str):
|
|
20
|
+
return _labels_from_string(raw)
|
|
21
|
+
if isinstance(raw, list):
|
|
22
|
+
return _labels_from_list(raw)
|
|
23
|
+
return ()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _author_map(payload: dict[str, object]) -> dict[str, object]:
|
|
27
|
+
author_obj = payload.get("author")
|
|
28
|
+
if isinstance(author_obj, dict):
|
|
29
|
+
return author_obj
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def merge_request_from_payload(payload: dict[str, object]) -> MergeRequest:
|
|
34
|
+
"""Build a MergeRequest from GitLab API JSON."""
|
|
35
|
+
author = _author_map(payload)
|
|
36
|
+
iid_raw = payload.get("iid", payload.get("id"))
|
|
37
|
+
if not isinstance(iid_raw, int):
|
|
38
|
+
msg = "No MR iid in the GitLab response"
|
|
39
|
+
raise ReleaseNotesError(msg)
|
|
40
|
+
return MergeRequest(
|
|
41
|
+
iid=iid_raw,
|
|
42
|
+
description=str(payload.get("description") or ""),
|
|
43
|
+
labels=split_labels(payload.get("labels")),
|
|
44
|
+
web_url=str(payload.get("web_url") or ""),
|
|
45
|
+
author_username=str(author.get("username") or ""),
|
|
46
|
+
author_url=str(author.get("web_url") or ""),
|
|
47
|
+
source_branch=str(payload.get("source_branch") or ""),
|
|
48
|
+
state=str(payload.get("state") or ""),
|
|
49
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Build the markdown sections of the release notes."""
|
|
2
|
+
|
|
3
|
+
from glab_release_notes import constants
|
|
4
|
+
from glab_release_notes.config import NotesConfig
|
|
5
|
+
from glab_release_notes.models import MergeRequest, SectionSpec
|
|
6
|
+
|
|
7
|
+
type Sections = dict[str, str]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_entry(
|
|
11
|
+
*,
|
|
12
|
+
text: str,
|
|
13
|
+
spec: SectionSpec,
|
|
14
|
+
mr: MergeRequest,
|
|
15
|
+
ticket: str | None,
|
|
16
|
+
jira_base: str | None = None,
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Build a single list item for the release notes."""
|
|
19
|
+
links = [f"[#{mr.iid}]({mr.web_url})"]
|
|
20
|
+
if ticket is not None and jira_base is not None:
|
|
21
|
+
links.append(f"[{ticket}]({jira_base}/{ticket})")
|
|
22
|
+
emoji = f"{spec.emoji} " if spec.emoji else ""
|
|
23
|
+
return f"* {emoji}{text} ({', '.join(links)}) by [@{mr.author_username}]({mr.author_url})"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_unreleased(sections: Sections, cfg: NotesConfig) -> str:
|
|
27
|
+
"""Build the unreleased block.
|
|
28
|
+
|
|
29
|
+
The ``always_keep`` sections are always present; other sections only when non-empty.
|
|
30
|
+
"""
|
|
31
|
+
blocks: list[str] = []
|
|
32
|
+
for header in cfg.section_order:
|
|
33
|
+
_append_section_block(blocks, sections, header, cfg=cfg)
|
|
34
|
+
return constants.double_newline.join(blocks)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _append_section_block(
|
|
38
|
+
blocks: list[str],
|
|
39
|
+
sections: Sections,
|
|
40
|
+
header: str,
|
|
41
|
+
*,
|
|
42
|
+
cfg: NotesConfig,
|
|
43
|
+
) -> None:
|
|
44
|
+
body = sections.get(header, "")
|
|
45
|
+
keep_empty = header in cfg.always_keep
|
|
46
|
+
if not body and not keep_empty:
|
|
47
|
+
return
|
|
48
|
+
prefix = f"{cfg.section_prefix}{header}"
|
|
49
|
+
if body:
|
|
50
|
+
blocks.append(f"{prefix}{constants.double_newline}{body}")
|
|
51
|
+
return
|
|
52
|
+
blocks.append(prefix)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def assemble(prefix: str, unreleased: str, postfix: str) -> str:
|
|
56
|
+
"""Join the file parts with a single trailing newline."""
|
|
57
|
+
chunks = [prefix.rstrip(), unreleased.strip()]
|
|
58
|
+
post_stripped = postfix.strip()
|
|
59
|
+
if post_stripped:
|
|
60
|
+
chunks.append(post_stripped)
|
|
61
|
+
joined = constants.double_newline.join(chunks)
|
|
62
|
+
return f"{joined}\n"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def prepend_entry(sections: Sections, header: str, entry: str) -> Sections:
|
|
66
|
+
"""Add an entry at the top of a section."""
|
|
67
|
+
current = sections.get(header, "")
|
|
68
|
+
if not current:
|
|
69
|
+
sections[header] = entry
|
|
70
|
+
return sections
|
|
71
|
+
combined = f"{entry}\n{current}"
|
|
72
|
+
sections[header] = combined.strip()
|
|
73
|
+
return sections
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Split the release notes markdown into sections."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from glab_release_notes import constants
|
|
6
|
+
from glab_release_notes.config import NotesConfig
|
|
7
|
+
from glab_release_notes.models import ReleaseNotesError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _find_unreleased(notes_markdown: str, cfg: NotesConfig) -> re.Match[str]:
|
|
11
|
+
header = cfg.unreleased_header
|
|
12
|
+
match = re.search(rf"^{re.escape(header)}\s*$", notes_markdown, re.MULTILINE)
|
|
13
|
+
if match is None:
|
|
14
|
+
msg = f"Header {header!r} not found"
|
|
15
|
+
raise ReleaseNotesError(msg)
|
|
16
|
+
return match
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cut_after_header(prefix: str, rest: str) -> tuple[str, str, str]:
|
|
20
|
+
next_version = constants.version_line_re.search(rest)
|
|
21
|
+
if next_version is None:
|
|
22
|
+
return prefix, rest, ""
|
|
23
|
+
start = next_version.start()
|
|
24
|
+
return prefix, rest[:start], rest[start:]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def split_unreleased(notes_markdown: str, cfg: NotesConfig) -> tuple[str, str, str]:
|
|
28
|
+
"""Cut the file into a header, the unreleased block, and the rest."""
|
|
29
|
+
match = _find_unreleased(notes_markdown, cfg)
|
|
30
|
+
prefix = notes_markdown[: match.end()]
|
|
31
|
+
rest = notes_markdown[match.end() :]
|
|
32
|
+
return _cut_after_header(prefix, rest)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _header_name(raw: str) -> str:
|
|
36
|
+
return constants.anchor_re.sub("", raw).strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _section_end(matches: list[re.Match[str]], index: int, unreleased: str) -> int:
|
|
40
|
+
if index + 1 < len(matches):
|
|
41
|
+
return matches[index + 1].start()
|
|
42
|
+
return len(unreleased)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fill_sections(matches: list[re.Match[str]], unreleased: str) -> dict[str, str]:
|
|
46
|
+
sections: dict[str, str] = {}
|
|
47
|
+
for index, match in enumerate(matches):
|
|
48
|
+
title = _header_name(match.group(1))
|
|
49
|
+
end = _section_end(matches, index, unreleased)
|
|
50
|
+
sections[title] = unreleased[match.end() : end].strip()
|
|
51
|
+
return sections
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_sections(unreleased: str, cfg: NotesConfig) -> dict[str, str]:
|
|
55
|
+
"""Parse the section lines of the unreleased block."""
|
|
56
|
+
section_re = re.compile(rf"^{re.escape(cfg.section_prefix)}(.+)$", re.MULTILINE)
|
|
57
|
+
matches = list(section_re.finditer(unreleased))
|
|
58
|
+
return _fill_sections(matches, unreleased)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: glab-release-notes
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GitLab release notes from merged MRs: validate labels and quote, append entries, and promote them under a version in CI.
|
|
5
|
+
Keywords: gitlab,release-notes,changelog,ci,merge-request
|
|
6
|
+
Author: Vladimir Voronov
|
|
7
|
+
Author-email: Vladimir Voronov <vvoronov@mwnts.ru>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development
|
|
18
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Homepage, https://github.com/redb0/glab-release-notes
|
|
22
|
+
Project-URL: Repository, https://github.com/redb0/glab-release-notes
|
|
23
|
+
Project-URL: Issues, https://github.com/redb0/glab-release-notes/issues
|
|
24
|
+
Project-URL: Changelog, https://github.com/redb0/glab-release-notes/releases
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Glab Release Notes
|
|
28
|
+
|
|
29
|
+
Automatically add the changes from each merged merge request to human-readable release notes in a file.
|
|
30
|
+
|
|
31
|
+
This is a GitLab-oriented take on [latest-changes](https://github.com/tiangolo/latest-changes): a small, dependency-free CLI that runs in your GitLab CI and keeps a `release-notes.md` up to date as merge requests get merged.
|
|
32
|
+
|
|
33
|
+
Unlike tools that scrape `git log` or conventional-commit messages, it reads the data **from the merge request itself**:
|
|
34
|
+
|
|
35
|
+
* a **label** decides which section the entry lands in,
|
|
36
|
+
* a **one-line quote** (`> …`) in the MR description is the entry text,
|
|
37
|
+
* a **ticket key** in the source-branch name (e.g. `FMT-1110`) becomes an optional Jira link.
|
|
38
|
+
|
|
39
|
+
So the person writing the MR writes the one sentence that matters, and CI takes care of the formatting, links, author, and ordering.
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
A release notes file is plain Markdown with an `## Latest Changes` block at the top and versioned blocks below it:
|
|
44
|
+
|
|
45
|
+
```Markdown
|
|
46
|
+
# Release notes
|
|
47
|
+
|
|
48
|
+
## Latest Changes
|
|
49
|
+
|
|
50
|
+
### Features
|
|
51
|
+
|
|
52
|
+
### Fixes
|
|
53
|
+
|
|
54
|
+
## 0.8.1
|
|
55
|
+
|
|
56
|
+
### Fixes {#fix_0.8.1}
|
|
57
|
+
|
|
58
|
+
* 🐛 Fixed a crash on empty input ([#35](https://gitlab.example/your/project/-/merge_requests/35)) by [@alice](https://gitlab.example/alice)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
After a merge request is merged, the `append` command finds the `## Latest Changes` block and adds a new list item to the section chosen by the MR's label:
|
|
62
|
+
|
|
63
|
+
> ## Latest Changes
|
|
64
|
+
>
|
|
65
|
+
> ### Features
|
|
66
|
+
>
|
|
67
|
+
> * ✨ Add support for detailed gRPC errors ([#123](https://gitlab.example/your/project/-/merge_requests/123), [FMT-1110](https://jira.example/browse/FMT-1110)) by [@bob](https://gitlab.example/bob)
|
|
68
|
+
|
|
69
|
+
The entry is built from:
|
|
70
|
+
|
|
71
|
+
* the **label** → section and emoji (see [Using labels](#using-labels)),
|
|
72
|
+
* the **last non-empty `> …` quote** in the MR description → the entry text,
|
|
73
|
+
* the **ticket key** parsed from the source branch name → a Jira link (only when a `--jira-base` is given),
|
|
74
|
+
* the MR **author** and **URL** → trailing `by [@author](…)`.
|
|
75
|
+
|
|
76
|
+
The entry is prepended to the top of the section so the newest change reads first. Because the notes are just a file in your repo, you can later tweak, reorder, or extend any entry by hand.
|
|
77
|
+
|
|
78
|
+
## Using labels
|
|
79
|
+
|
|
80
|
+
A merge request is routed to a section by its labels. The built-in default mapping is:
|
|
81
|
+
|
|
82
|
+
| Label | Section | Emoji |
|
|
83
|
+
| ----------- | --------------- | ----- |
|
|
84
|
+
| `feature` | `Features` | ✨ |
|
|
85
|
+
| `bug` | `Fixes` | 🐛 |
|
|
86
|
+
| `docs` | `Documentation` | 📝 |
|
|
87
|
+
| `ci` | `CI` | 👷 |
|
|
88
|
+
|
|
89
|
+
Labels that are never recorded into the notes:
|
|
90
|
+
|
|
91
|
+
* `release`, `refactor`, `test`, `chore`, `style`
|
|
92
|
+
|
|
93
|
+
Rules:
|
|
94
|
+
|
|
95
|
+
* A merge request must carry **either** a section label **or** a skip label.
|
|
96
|
+
* A **skip label wins** if both a section label and a skip label are present.
|
|
97
|
+
* With several section labels, the first one in the configured order wins.
|
|
98
|
+
* A section label **requires** a `> …` quote in the description; a skip label does not.
|
|
99
|
+
|
|
100
|
+
`Features` and `Fixes` always exist (even when empty). The other sections appear only once they have at least one entry.
|
|
101
|
+
|
|
102
|
+
## Getting started
|
|
103
|
+
|
|
104
|
+
1. **Add the notes file** (if you don't have one), e.g. `docs/release-notes.md`:
|
|
105
|
+
|
|
106
|
+
```Markdown
|
|
107
|
+
# Release notes
|
|
108
|
+
|
|
109
|
+
## Latest Changes
|
|
110
|
+
|
|
111
|
+
### Features
|
|
112
|
+
|
|
113
|
+
### Fixes
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
2. **Add a merge-request template** that asks authors to set a label and write a one-line quote. A minimal `Default.md`:
|
|
117
|
+
|
|
118
|
+
```Markdown
|
|
119
|
+
/label ~feature
|
|
120
|
+
|
|
121
|
+
## Release notes
|
|
122
|
+
|
|
123
|
+
One line in a quote. No emojis and no links — CI adds those after the merge.
|
|
124
|
+
|
|
125
|
+
>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
3. **Add the CI jobs** (see [CI wiring](#ci-wiring)).
|
|
129
|
+
|
|
130
|
+
That's enough for `validate` and `append` to work. `promote` is what your release job calls.
|
|
131
|
+
|
|
132
|
+
## Commands
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
glab-release-notes [--config PATH] [--file PATH] <command>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
* `validate` — a gate for the MR pipeline. Fails the pipeline when the MR has neither a section label nor a skip label, or has a section label but no `> …` quote.
|
|
139
|
+
* `append` — after a merge, appends the entry to the `## Latest Changes` block and pushes a `chore(notes): add entry for MR !<iid>` commit. It retries on a push race and is idempotent: an already-recorded MR is skipped.
|
|
140
|
+
* `promote` — moves the non-empty sections of `## Latest Changes` under a new `## <version>` heading with anchors, then empties the block back to the always-kept sections. `--version` is required; `--no-push` rewrites the file only so your release process can own the commit.
|
|
141
|
+
|
|
142
|
+
## CI wiring
|
|
143
|
+
|
|
144
|
+
The tool talks to the GitLab API using CI variables, so it is bound to whichever project's pipeline runs it:
|
|
145
|
+
|
|
146
|
+
* `CI_API_V4_URL`, `CI_PROJECT_ID`, `GITLAB_API_TOKEN` — API endpoint and auth;
|
|
147
|
+
* `CI_MERGE_REQUEST_IID` — used by `validate`;
|
|
148
|
+
* `CI_COMMIT_SHA` — used by `append` to find the MR of the current commit.
|
|
149
|
+
|
|
150
|
+
A typical `.gitlab-ci.yml`:
|
|
151
|
+
|
|
152
|
+
```yaml
|
|
153
|
+
validate-release-notes:
|
|
154
|
+
stage: linter
|
|
155
|
+
script:
|
|
156
|
+
- uv run glab-release-notes validate
|
|
157
|
+
rules:
|
|
158
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
159
|
+
|
|
160
|
+
append-release-notes:
|
|
161
|
+
stage: linter
|
|
162
|
+
variables:
|
|
163
|
+
GIT_DEPTH: 0
|
|
164
|
+
before_script:
|
|
165
|
+
- !reference [.base_git_pre_settings, script] # git credentials for the push
|
|
166
|
+
script:
|
|
167
|
+
- uv run glab-release-notes append --jira-base https://jira.example/browse
|
|
168
|
+
rules:
|
|
169
|
+
- if: $CI_COMMIT_TAG || $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
170
|
+
when: never
|
|
171
|
+
- if: '$CI_COMMIT_MESSAGE =~ /^chore\(notes\)/'
|
|
172
|
+
when: never
|
|
173
|
+
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The `chore(notes)` rule prevents the job from re-running on its own commits, and `GIT_DEPTH: 0` gives `append` a full history. The `before_script` must configure git with credentials able to push to the target branch (see [Pushing to a protected branch](#pushing-to-a-protected-branch)).
|
|
177
|
+
|
|
178
|
+
### Promoting on release
|
|
179
|
+
|
|
180
|
+
Call `promote` in your release job, right before (and, with `--no-push`, folded into) the release commit:
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
- uv run glab-release-notes promote --version "${version}" --no-push
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Existing MRs — running manually
|
|
187
|
+
|
|
188
|
+
`append` resolves the MR from `CI_COMMIT_SHA` by default. To record a specific merge request by hand — for example one that was merged before you added this — run `append` on the target branch with the `MR_IID` variable set:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
MR_IID=123 uv run glab-release-notes append --jira-base https://jira.example/browse
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This is also the escape hatch for any MR the automatic run skipped.
|
|
195
|
+
|
|
196
|
+
## Configuration
|
|
197
|
+
|
|
198
|
+
The wording of the release notes — the file, the `Latest Changes` header, the sections, the labels, and the skip labels — is configurable, in the same spirit as [latest-changes](https://github.com/tiangolo/latest-changes). Put a `release-notes.toml` in the repo root and it is picked up automatically; pass `--config PATH` to point at a different file. Without a config file, the built-in defaults from [Using labels](#using-labels) apply.
|
|
199
|
+
|
|
200
|
+
Precedence: CLI flags (`--file`) > config file > built-in defaults.
|
|
201
|
+
|
|
202
|
+
```toml
|
|
203
|
+
# release-notes.toml
|
|
204
|
+
file = "docs/release-notes.md" # the notes file (CLI --file wins)
|
|
205
|
+
unreleased_header = "## Latest Changes"
|
|
206
|
+
section_prefix = "### "
|
|
207
|
+
skip_labels = ["release", "refactor", "test", "chore", "style"]
|
|
208
|
+
always_keep = ["Features", "Fixes"]
|
|
209
|
+
|
|
210
|
+
[[section]]
|
|
211
|
+
label = "feature"
|
|
212
|
+
header = "Features"
|
|
213
|
+
emoji = "✨"
|
|
214
|
+
anchor_prefix = "feat"
|
|
215
|
+
|
|
216
|
+
[[section]]
|
|
217
|
+
label = "bug"
|
|
218
|
+
header = "Fixes"
|
|
219
|
+
emoji = "🐛"
|
|
220
|
+
anchor_prefix = "fix"
|
|
221
|
+
|
|
222
|
+
[[section]]
|
|
223
|
+
label = "docs"
|
|
224
|
+
header = "Documentation"
|
|
225
|
+
emoji = "📝"
|
|
226
|
+
anchor_prefix = "docs"
|
|
227
|
+
|
|
228
|
+
[[section]]
|
|
229
|
+
label = "ci"
|
|
230
|
+
header = "CI"
|
|
231
|
+
emoji = "👷"
|
|
232
|
+
anchor_prefix = "ci"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Keys:
|
|
236
|
+
|
|
237
|
+
* `file` — the notes file to update. Defaults to `docs/release-notes.md`.
|
|
238
|
+
* `unreleased_header` — the header of the accumulating block. Defaults to `## Latest Changes`.
|
|
239
|
+
* `section_prefix` — the prefix of the section headers inside the block. Defaults to `### `.
|
|
240
|
+
* `skip_labels` — labels that are never recorded. A skip label also wins over a section label.
|
|
241
|
+
* `always_keep` — section headers that are always rendered, even when empty. Defaults to `["Features", "Fixes"]`; use `[]` to render a section only once it has an entry (the latest-changes behavior).
|
|
242
|
+
* `[[section]]` — one table per section:
|
|
243
|
+
* `label` (required) — the GitLab label that routes an MR to this section.
|
|
244
|
+
* `header` (required) — the section header text.
|
|
245
|
+
* `emoji` (optional) — the emoji prefixed to entries in this section. Omit it for a plain entry.
|
|
246
|
+
* `anchor_prefix` (optional) — the anchor prefix used when promoting, e.g. `{#feat_1.0.0}`. Defaults to the label.
|
|
247
|
+
|
|
248
|
+
The order of `[[section]]` tables matters in both directions: it is the display order of the sections, and the first matching label wins when an MR carries several section labels.
|
|
249
|
+
|
|
250
|
+
The entry line format itself is fixed: `* <emoji> <text> (links) by <author>`. It is not a template.
|
|
251
|
+
|
|
252
|
+
## Pushing to a protected branch
|
|
253
|
+
|
|
254
|
+
`append` and `promote` (without `--no-push`) `git commit` and `git push` the updated notes. If your target branch is protected against direct pushes, the CI job must be running with credentials that can push to it.
|
|
255
|
+
|
|
256
|
+
In GitLab that usually means one of:
|
|
257
|
+
|
|
258
|
+
* the job runs as a user/role allowed to push to the branch, or
|
|
259
|
+
* you pass a token to the git setup (the `!reference [.base_git_pre_settings, script]` pattern above) that can push.
|
|
260
|
+
|
|
261
|
+
The commits are attributed to the CI identity, not to any individual author.
|
|
262
|
+
|
|
263
|
+
## Development
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
uv sync
|
|
267
|
+
uv run ruff check .
|
|
268
|
+
uv run ruff format --check .
|
|
269
|
+
uv run mypy glab_release_notes tests
|
|
270
|
+
uv run pytest
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Releases are managed with [commitizen](https://github.com/commitizen/commitizen): `uv run cz bump` updates the version and `CHANGELOG.md` and tags the commit. A GitHub Release on that tag publishes the package to PyPI via trusted publishing (`.github/workflows/publish.yaml`).
|
|
274
|
+
|
|
275
|
+
## License
|
|
276
|
+
|
|
277
|
+
This project is licensed under the terms of the MIT license.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
glab_release_notes/__init__.py,sha256=sDDTvPs01f4frME4JCUf0dXNyqneG_Bcagyp1ZMu7ZQ,894
|
|
2
|
+
glab_release_notes/__main__.py,sha256=nxz-bFr3jXCYad399e1xnwgjVPSA6RjJ9JhcNt6gZ58,133
|
|
3
|
+
glab_release_notes/api.py,sha256=vNQXuNDc7mJJ0q6p_Az3AOEfYIaC_1xatuiHs5XprfM,1337
|
|
4
|
+
glab_release_notes/append_cmd.py,sha256=VSHHSogGZEGxwpFsRdUlag_fcdPOj97c9f2_3_fKETA,2838
|
|
5
|
+
glab_release_notes/cli.py,sha256=xjNpje_gDD93dmgGZ81E0ndx0g7ONuXpe2dnOCQdKvc,3908
|
|
6
|
+
glab_release_notes/config.py,sha256=vB7LJkq11qQnWGSqcEWYkkoiZZCVBPi6gSF1C65fFlg,6442
|
|
7
|
+
glab_release_notes/constants.py,sha256=ZaM1ru__ncVrciWfA90eCTHJP89bQDm4_4P5nxJGrHM,430
|
|
8
|
+
glab_release_notes/edit.py,sha256=jjTh1FNSKQxCSIg2GsZ6kKwUzlP5vraK2MhQH6CtLIM,3374
|
|
9
|
+
glab_release_notes/gitops.py,sha256=08o9oK10PsMDuiIKA0qyzzEcvJmgRVe8_ALetxJuqvc,1926
|
|
10
|
+
glab_release_notes/http.py,sha256=TUZ2IAQZcrGOgxKaq069gpCjX3eesFhZTs2-JZan8fM,1311
|
|
11
|
+
glab_release_notes/load.py,sha256=7T3IbKh90c1xfL8Mcy8EthZdPQr_iyDK0gnWBgSzszw,877
|
|
12
|
+
glab_release_notes/models.py,sha256=3b0nkIjJcTIsZowNlr7vc6o0SSisnEejsjpwkDWQ-dE,1007
|
|
13
|
+
glab_release_notes/parse.py,sha256=-3b84m6yeVkVGhZoCN0q6on-nFsr-dyPqvnrSWez89A,2182
|
|
14
|
+
glab_release_notes/payload.py,sha256=kv0yVNcfI3PjtN1W4RhFEzdQbymwnNt7d9TyP1Cr7zc,1682
|
|
15
|
+
glab_release_notes/render.py,sha256=mYnxbgJwjlx0xzSHEB8FvCMxlSdwApQSYVKxEVnDanA,2257
|
|
16
|
+
glab_release_notes/split.py,sha256=-Vk_jw92jGD0dpGeSBUEqx0pBQcHm2ZTUjX3AKWyQV8,2084
|
|
17
|
+
glab_release_notes-0.1.0.dist-info/licenses/LICENSE,sha256=2ONWB-4UEk0c1mnrV_w-wAKgqs66p70nrQCXksEnx04,1073
|
|
18
|
+
glab_release_notes-0.1.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
|
|
19
|
+
glab_release_notes-0.1.0.dist-info/entry_points.txt,sha256=9c4ahZ3Z3LXEnU_YUSHWdI3oCTdE3KUgOaLqB7_Ph34,68
|
|
20
|
+
glab_release_notes-0.1.0.dist-info/METADATA,sha256=TGlRRfqwzRxGDdqdeOJB5vfnPrCPB1ntGkDGwDYsKAA,11035
|
|
21
|
+
glab_release_notes-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladimir Voronov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|