git-acta 1.0.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.
acta/git/branch.py ADDED
@@ -0,0 +1,77 @@
1
+ import re
2
+
3
+ from acta.git import git
4
+
5
+ TYPES = frozenset(
6
+ [
7
+ "build",
8
+ "chore",
9
+ "ci",
10
+ "docs",
11
+ "feat",
12
+ "fix",
13
+ "perf",
14
+ "refactor",
15
+ "revert",
16
+ "style",
17
+ "test",
18
+ ]
19
+ )
20
+
21
+ # type/scope, with an optional third descriptive segment (e.g. an issue topic);
22
+ # the conventional-commit type and scope are derived from the first two.
23
+ _BRANCH_RE = re.compile(r"([^/]+)/([^/]+)(?:/.+)?")
24
+ _SCOPE_RE = re.compile(r"[a-z0-9][a-z0-9_-]*", re.IGNORECASE)
25
+
26
+
27
+ def parse(branch: str) -> tuple[str, str]:
28
+ branch_match = _BRANCH_RE.fullmatch(branch)
29
+ if not branch_match:
30
+ raise ValueError(f"Branch '{branch}' does not follow type/scope convention")
31
+ type_ = branch_match.group(1)
32
+ if type_ not in TYPES:
33
+ raise ValueError(
34
+ f"'{type_}' is not a conventional commit type. Use one of: {', '.join(sorted(TYPES))}"
35
+ )
36
+ scope = branch_match.group(2)
37
+ if not _SCOPE_RE.fullmatch(scope):
38
+ raise ValueError(
39
+ f"'{scope}' is not a valid scope. Use letters, digits, hyphens, and underscores."
40
+ )
41
+ return type_, scope
42
+
43
+
44
+ def get_current_branch() -> str:
45
+ return git("branch", "--show-current", capture=True)
46
+
47
+
48
+ def fetch_origin() -> None:
49
+ git("fetch", "origin", quiet=True)
50
+
51
+
52
+ def switch_new_branch(name: str) -> None:
53
+ git("switch", "-c", name, "origin/main", quiet=True)
54
+
55
+
56
+ def switch_main() -> None:
57
+ git("switch", "main", quiet=True)
58
+
59
+
60
+ def pull_origin_main() -> None:
61
+ git("pull", "origin", "main", quiet=True)
62
+
63
+
64
+ def branch_exists(name: str) -> bool:
65
+ return bool(git("branch", "--list", name, capture=True))
66
+
67
+
68
+ def delete_branch(name: str) -> None:
69
+ git("branch", "-D", name, quiet=True)
70
+
71
+
72
+ def switch_branch(name: str) -> None:
73
+ git("switch", name, quiet=True)
74
+
75
+
76
+ def merge_origin_main() -> None:
77
+ git("merge", "origin/main", quiet=True)
acta/git/commit.py ADDED
@@ -0,0 +1,20 @@
1
+ from acta.git import git
2
+
3
+
4
+ def add_all() -> None:
5
+ git("add", "-A")
6
+
7
+
8
+ def commit(header: str, body: str | None = None) -> None:
9
+ commit_args = ["commit", "-m", header]
10
+ if body:
11
+ commit_args += ["-m", body]
12
+ git(*commit_args)
13
+
14
+
15
+ def push_head() -> None:
16
+ git("push", "--set-upstream", "origin", "HEAD", quiet=True)
17
+
18
+
19
+ def commit_subjects(rev_range: str) -> list[str]:
20
+ return git("log", rev_range, "--format=%s", capture=True).splitlines()
acta/git/config.py ADDED
@@ -0,0 +1,22 @@
1
+ import subprocess
2
+
3
+ from acta.git import git
4
+
5
+
6
+ def get_active_issue() -> int | None:
7
+ try:
8
+ config_value = git("config", "--get", "clerk.active-issue", capture=True)
9
+ return int(config_value) if config_value else None
10
+ except subprocess.CalledProcessError:
11
+ return None
12
+
13
+
14
+ def set_active_issue(number: int) -> None:
15
+ git("config", "clerk.active-issue", str(number))
16
+
17
+
18
+ def clear_active_issue() -> None:
19
+ try:
20
+ git("config", "--unset", "clerk.active-issue")
21
+ except subprocess.CalledProcessError:
22
+ pass
acta/git/tag.py ADDED
@@ -0,0 +1,117 @@
1
+ import re
2
+ from datetime import date
3
+ from typing import Final, Literal, TypeAlias
4
+
5
+ from acta.git import git
6
+ from acta.git.commit import commit_subjects
7
+
8
+ CALVER: Final = "CalVer"
9
+ SEMVER: Final = "SemVer"
10
+ Scheme: TypeAlias = Literal["CalVer", "SemVer"]
11
+
12
+ _CALVER_RE = re.compile(r"v\d{4}\.\d{2}\.\d+")
13
+ _SEMVER_RE = re.compile(r"v\d+\.\d+\.\d+")
14
+ _CONVENTIONAL_HEADER_RE = re.compile(r"(?P<type>\w+)(\([^)]*\))?(?P<breaking>!)?:")
15
+
16
+
17
+ def detect_scheme(existing_tags: list[str]) -> Scheme | None:
18
+ found_schemes: set[Scheme] = set()
19
+ for tag in existing_tags:
20
+ if _CALVER_RE.fullmatch(tag):
21
+ found_schemes.add(CALVER)
22
+ elif _SEMVER_RE.fullmatch(tag):
23
+ found_schemes.add(SEMVER)
24
+ if not found_schemes:
25
+ return None
26
+ if len(found_schemes) > 1:
27
+ raise ValueError(f"mixed {CALVER} and {SEMVER} tags found — pass --scheme to proceed")
28
+ return next(iter(found_schemes))
29
+
30
+
31
+ def compute_next_calver(existing_tags: list[str], today: date) -> str:
32
+ prefix = f"v{today.year}.{today.month:02d}."
33
+ month_tags = [tag for tag in existing_tags if re.fullmatch(rf"{re.escape(prefix)}\d+", tag)]
34
+ last_counter = max((int(tag[len(prefix) :]) for tag in month_tags), default=0)
35
+ return f"{prefix}{last_counter + 1}"
36
+
37
+
38
+ def latest_semver_tag(existing_tags: list[str]) -> str | None:
39
+ semver_tags = sorted(
40
+ (
41
+ tag
42
+ for tag in existing_tags
43
+ if _SEMVER_RE.fullmatch(tag) and not _CALVER_RE.fullmatch(tag)
44
+ ),
45
+ key=lambda tag: tuple(int(part) for part in tag[1:].split(".")),
46
+ )
47
+ return semver_tags[-1] if semver_tags else None
48
+
49
+
50
+ def semver_major(tag: str) -> int:
51
+ return int(tag[1:].split(".")[0])
52
+
53
+
54
+ def derive_bump(commit_subjects: list[str], current_major: int) -> str:
55
+ """Derive the SemVer bump from the conventional-commit subjects since the last release.
56
+
57
+ A `!` breaking marker bumps major once stable (1.0+); while still 0.x it is capped at
58
+ minor, since a pre-1.0 breaking change must not force 1.0.0 — that is the deliberate
59
+ `--stable` decision. A `feat` bumps minor; anything else, patch.
60
+ """
61
+ has_breaking = False
62
+ has_feat = False
63
+ for subject in commit_subjects:
64
+ header = _CONVENTIONAL_HEADER_RE.match(subject)
65
+ if header is None:
66
+ continue
67
+ has_breaking = has_breaking or header.group("breaking") is not None
68
+ has_feat = has_feat or header.group("type") == "feat"
69
+ if has_breaking and current_major >= 1:
70
+ return "major"
71
+ if has_breaking or has_feat:
72
+ return "minor"
73
+ return "patch"
74
+
75
+
76
+ def compute_next_semver(existing_tags: list[str], bump: str) -> str:
77
+ latest = latest_semver_tag(existing_tags)
78
+ if latest is None:
79
+ return "v0.1.0"
80
+ major, minor, patch = (int(part) for part in latest[1:].split("."))
81
+ if bump == "major":
82
+ return f"v{major + 1}.0.0"
83
+ if bump == "minor":
84
+ return f"v{major}.{minor + 1}.0"
85
+ return f"v{major}.{minor}.{patch + 1}"
86
+
87
+
88
+ def next_release_tag(existing_tags: list[str], stable: bool) -> str:
89
+ """The next SemVer tag: a deliberate v1.0.0 with `stable`, else derived from commits.
90
+
91
+ Raises ValueError if `stable` is requested on an already-stable (1.0+) project.
92
+ """
93
+ latest = latest_semver_tag(existing_tags)
94
+ if stable:
95
+ if latest is not None and semver_major(latest) >= 1:
96
+ raise ValueError("--stable only promotes 0.x to v1.0.0; this project is already stable")
97
+ return "v1.0.0"
98
+ current_major = semver_major(latest) if latest is not None else 0
99
+ subjects = commit_subjects(f"{latest}..origin/main") if latest is not None else []
100
+ return compute_next_semver(existing_tags, derive_bump(subjects, current_major))
101
+
102
+
103
+ def fetch_tags() -> None:
104
+ git("fetch", "--tags", "origin", quiet=True)
105
+
106
+
107
+ def list_tags(pattern: str = "v*") -> list[str]:
108
+ return [tag for tag in git("tag", "--list", pattern, capture=True).splitlines() if tag]
109
+
110
+
111
+ def create_tag(tag: str, ref: str = "origin/main") -> None:
112
+ if tag in list_tags():
113
+ raise RuntimeError(
114
+ f"tag '{tag}' already exists — re-run 'acta release' to get the next version"
115
+ )
116
+ git("tag", tag, ref, quiet=True)
117
+ git("push", "origin", tag, quiet=True)
@@ -0,0 +1,43 @@
1
+ import functools
2
+ import subprocess
3
+ from urllib.parse import urlparse
4
+
5
+ from acta.git import get_remote_url
6
+
7
+
8
+ def parse_repo_from_url(url: str) -> str:
9
+ if "://" in url:
10
+ path = urlparse(url).path.lstrip("/")
11
+ else:
12
+ _, _, path = url.partition(":")
13
+ repo_slug = path.removesuffix(".git")
14
+ repo_parts = repo_slug.split("/")
15
+ if len(repo_parts) != 2 or not all(repo_parts):
16
+ raise ValueError(f"cannot parse GitHub repo from remote URL: {url}")
17
+ return repo_slug
18
+
19
+
20
+ def gh(*args: str, capture: bool = False) -> str:
21
+ try:
22
+ completed_process = subprocess.run(
23
+ ["gh", *args],
24
+ check=True,
25
+ text=True,
26
+ stdout=subprocess.PIPE if capture else None,
27
+ )
28
+ return completed_process.stdout.strip() if completed_process.stdout else ""
29
+ except FileNotFoundError:
30
+ raise RuntimeError(
31
+ "'gh' not found in PATH — install the GitHub CLI: https://cli.github.com"
32
+ )
33
+ except subprocess.CalledProcessError:
34
+ raise
35
+
36
+
37
+ @functools.cache
38
+ def get_repo() -> str:
39
+ origin_url = get_remote_url("origin")
40
+ try:
41
+ return parse_repo_from_url(origin_url)
42
+ except ValueError as error:
43
+ raise RuntimeError(str(error)) from error
acta/github/issue.py ADDED
@@ -0,0 +1,126 @@
1
+ import json
2
+ import subprocess
3
+ from dataclasses import dataclass
4
+
5
+ from acta.github import get_repo, gh
6
+ from acta.github.label import ensure_type_labels
7
+ from acta.github.milestone import milestone_view
8
+
9
+
10
+ @dataclass
11
+ class MilestoneRef:
12
+ number: int
13
+ title: str
14
+
15
+
16
+ @dataclass
17
+ class IssueInfo:
18
+ number: int
19
+ title: str
20
+ type: str
21
+ milestone: MilestoneRef | None
22
+ body: str = ""
23
+
24
+
25
+ def issue_create(
26
+ title: str,
27
+ type_label: str,
28
+ body: str = "",
29
+ milestone: int | None = None,
30
+ ) -> int:
31
+ milestone_title = milestone_view(milestone).title if milestone is not None else None
32
+ command_args = [
33
+ "issue",
34
+ "create",
35
+ "--title",
36
+ title,
37
+ "--body",
38
+ body,
39
+ "--repo",
40
+ get_repo(),
41
+ "--label",
42
+ f"type: {type_label}",
43
+ ]
44
+ if milestone_title is not None:
45
+ command_args += ["--milestone", milestone_title]
46
+ try:
47
+ issue_url = gh(*command_args, capture=True)
48
+ except subprocess.CalledProcessError:
49
+ ensure_type_labels()
50
+ issue_url = gh(*command_args, capture=True)
51
+ return int(issue_url.rstrip("/").split("/")[-1])
52
+
53
+
54
+ def issue_list(milestone: int | None = None) -> list[IssueInfo]:
55
+ command_args = [
56
+ "issue",
57
+ "list",
58
+ "--repo",
59
+ get_repo(),
60
+ "--json",
61
+ "number,title,labels,milestone",
62
+ "--state",
63
+ "open",
64
+ ]
65
+ if milestone is not None:
66
+ command_args += ["--milestone", str(milestone)]
67
+ response_json = gh(*command_args, capture=True)
68
+ issues: list[IssueInfo] = []
69
+ for issue_data in json.loads(response_json):
70
+ milestone_data = issue_data["milestone"]
71
+ issues.append(
72
+ IssueInfo(
73
+ number=int(issue_data["number"]),
74
+ title=str(issue_data["title"]),
75
+ type=_extract_type(issue_data["labels"]) or "",
76
+ milestone=MilestoneRef(
77
+ number=int(milestone_data["number"]), title=str(milestone_data["title"])
78
+ )
79
+ if milestone_data
80
+ else None,
81
+ )
82
+ )
83
+ return issues
84
+
85
+
86
+ def issue_view(number: int) -> IssueInfo:
87
+ response_json = gh(
88
+ "issue",
89
+ "view",
90
+ str(number),
91
+ "--repo",
92
+ get_repo(),
93
+ "--json",
94
+ "number,title,labels,milestone,body",
95
+ capture=True,
96
+ )
97
+ issue_data = json.loads(response_json)
98
+ milestone_data = issue_data["milestone"]
99
+ issue_type = _extract_type(issue_data["labels"])
100
+ if not issue_type:
101
+ raise RuntimeError(
102
+ f"Issue #{number} has no type label — assign a 'type: TYPE' label on GitHub"
103
+ )
104
+ return IssueInfo(
105
+ number=int(issue_data["number"]),
106
+ title=str(issue_data["title"]),
107
+ type=issue_type,
108
+ milestone=MilestoneRef(
109
+ number=int(milestone_data["number"]), title=str(milestone_data["title"])
110
+ )
111
+ if milestone_data
112
+ else None,
113
+ body=str(issue_data.get("body") or ""),
114
+ )
115
+
116
+
117
+ def issue_close_not_planned(number: int) -> None:
118
+ gh("issue", "close", str(number), "--reason", "not planned", "--repo", get_repo())
119
+
120
+
121
+ def _extract_type(labels: list[dict[str, str]]) -> str | None:
122
+ for label in labels:
123
+ name = label.get("name", "")
124
+ if name.startswith("type: "):
125
+ return name[6:]
126
+ return None
acta/github/label.py ADDED
@@ -0,0 +1,22 @@
1
+ from acta.git.branch import TYPES
2
+ from acta.github import get_repo, gh
3
+
4
+ TYPE_COLORS: dict[str, str] = {
5
+ "build": "0075ca",
6
+ "chore": "cfd3d7",
7
+ "ci": "e4e669",
8
+ "docs": "0052cc",
9
+ "feat": "a2eeef",
10
+ "fix": "d73a4a",
11
+ "perf": "5319e7",
12
+ "refactor": "e99695",
13
+ "revert": "f9d0c4",
14
+ "style": "fef2c0",
15
+ "test": "bfd4f2",
16
+ }
17
+
18
+
19
+ def ensure_type_labels() -> None:
20
+ for type_ in TYPES:
21
+ color = TYPE_COLORS.get(type_, "ededed")
22
+ gh("label", "create", f"type: {type_}", "--color", color, "--force", "--repo", get_repo())
@@ -0,0 +1,107 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+
4
+ from acta.github import get_repo, gh
5
+
6
+ _SCOPE_PREFIX = "scope: "
7
+
8
+
9
+ @dataclass
10
+ class MilestoneListItem:
11
+ number: int
12
+ title: str
13
+ scope: str
14
+ description: str
15
+ open_issues: int
16
+ closed_issues: int
17
+
18
+
19
+ @dataclass
20
+ class MilestoneDetail:
21
+ number: int
22
+ title: str
23
+ scope: str
24
+ description: str
25
+ open_issues: int
26
+ state: str
27
+
28
+
29
+ def milestone_create(
30
+ title: str,
31
+ scope: str,
32
+ description: str = "",
33
+ ) -> int:
34
+ gh_description = _build_description(scope, description)
35
+ response_json = gh(
36
+ "api",
37
+ f"repos/{get_repo()}/milestones",
38
+ "--method",
39
+ "POST",
40
+ "-f",
41
+ f"title={title}",
42
+ "-f",
43
+ f"description={gh_description}",
44
+ capture=True,
45
+ )
46
+ return int(json.loads(response_json)["number"])
47
+
48
+
49
+ def milestone_list() -> list[MilestoneListItem]:
50
+ response_json = gh(
51
+ "api", f"repos/{get_repo()}/milestones", "-X", "GET", "-f", "state=open", capture=True
52
+ )
53
+ milestone_items: list[MilestoneListItem] = []
54
+ for milestone_data in json.loads(response_json):
55
+ scope, description = parse_description(str(milestone_data.get("description") or ""))
56
+ milestone_items.append(
57
+ MilestoneListItem(
58
+ number=int(milestone_data["number"]),
59
+ title=str(milestone_data["title"]),
60
+ scope=scope,
61
+ description=description,
62
+ open_issues=int(milestone_data["open_issues"]),
63
+ closed_issues=int(milestone_data["closed_issues"]),
64
+ )
65
+ )
66
+ return milestone_items
67
+
68
+
69
+ def milestone_view(number: int) -> MilestoneDetail:
70
+ response_json = gh("api", f"repos/{get_repo()}/milestones/{number}", capture=True)
71
+ milestone_data = json.loads(response_json)
72
+ scope, description = parse_description(str(milestone_data.get("description") or ""))
73
+ return MilestoneDetail(
74
+ number=int(milestone_data["number"]),
75
+ title=str(milestone_data["title"]),
76
+ scope=scope,
77
+ description=description,
78
+ open_issues=int(milestone_data["open_issues"]),
79
+ state=str(milestone_data["state"]),
80
+ )
81
+
82
+
83
+ def milestone_reopen(number: int) -> None:
84
+ gh("api", f"repos/{get_repo()}/milestones/{number}", "--method", "PATCH", "-f", "state=open")
85
+
86
+
87
+ def milestone_close(number: int) -> None:
88
+ gh("api", f"repos/{get_repo()}/milestones/{number}", "--method", "PATCH", "-f", "state=closed")
89
+
90
+
91
+ def _build_description(scope: str, description: str) -> str:
92
+ description_parts = [f"scope: {scope}"]
93
+ if description:
94
+ description_parts.append(description)
95
+ return "\n\n".join(description_parts)
96
+
97
+
98
+ def parse_description(raw_description: str) -> tuple[str, str]:
99
+ """Returns (scope, description) from a GitHub milestone description."""
100
+ lines = raw_description.split("\n")
101
+ scope = ""
102
+ if lines and lines[0].startswith(_SCOPE_PREFIX):
103
+ scope = lines[0][len(_SCOPE_PREFIX) :]
104
+ remaining_description = "\n".join(lines[1:]).strip()
105
+ else:
106
+ remaining_description = raw_description.strip()
107
+ return scope, remaining_description
acta/github/pr.py ADDED
@@ -0,0 +1,111 @@
1
+ import json
2
+ import time
3
+ from enum import Enum
4
+
5
+ from acta.git.branch import get_current_branch
6
+ from acta.github import get_repo, gh
7
+
8
+ _CHECKS_POLL_INTERVAL = 5 # seconds
9
+ _CHECKS_QUEUE_TIMEOUT = 90 # seconds to wait for checks to appear
10
+
11
+ _PASSING_CONCLUSIONS = {"SUCCESS", "NEUTRAL", "SKIPPED"}
12
+ _PASSING_STATUS_STATES = {"SUCCESS"}
13
+ _PENDING_STATUS_STATES = {"PENDING", "EXPECTED"}
14
+
15
+
16
+ class ChecksState(Enum):
17
+ NONE = "none"
18
+ PENDING = "pending"
19
+ PASSED = "passed"
20
+ FAILED = "failed"
21
+
22
+
23
+ def classify_rollup(nodes: list[dict[str, object]]) -> ChecksState:
24
+ """Reduce a `statusCheckRollup` to one state.
25
+
26
+ Each node is a `CheckRun` (GitHub Actions) or a legacy `StatusContext`. A check is
27
+ pending until it reaches a terminal state, passing only on an explicitly successful
28
+ one. Fail-safe: any terminal check that is not recognised as passing counts as
29
+ FAILED, so an unexpected state never reads as green on a ship gate.
30
+ """
31
+ if not nodes:
32
+ return ChecksState.NONE
33
+ any_pending = False
34
+ for node in nodes:
35
+ if node.get("__typename") == "StatusContext":
36
+ state = node.get("state")
37
+ if state in _PENDING_STATUS_STATES:
38
+ any_pending = True
39
+ elif state not in _PASSING_STATUS_STATES:
40
+ return ChecksState.FAILED
41
+ elif node.get("status") != "COMPLETED":
42
+ any_pending = True
43
+ elif node.get("conclusion") not in _PASSING_CONCLUSIONS:
44
+ return ChecksState.FAILED
45
+ return ChecksState.PENDING if any_pending else ChecksState.PASSED
46
+
47
+
48
+ def fetch_checks_state(pr_number: int) -> ChecksState:
49
+ response_json = gh(
50
+ "pr",
51
+ "view",
52
+ str(pr_number),
53
+ "--repo",
54
+ get_repo(),
55
+ "--json",
56
+ "statusCheckRollup",
57
+ capture=True,
58
+ )
59
+ parsed: dict[str, list[dict[str, object]]] = json.loads(response_json)
60
+ return classify_rollup(parsed.get("statusCheckRollup") or [])
61
+
62
+
63
+ def pr_create(title: str, body: str, base: str = "main") -> tuple[int, str]:
64
+ command_args = [
65
+ "pr",
66
+ "create",
67
+ "--base",
68
+ base,
69
+ "--title",
70
+ title,
71
+ "--body",
72
+ body,
73
+ "--repo",
74
+ get_repo(),
75
+ ]
76
+ pr_url = gh(*command_args, capture=True)
77
+ pr_number = int(pr_url.rstrip("/").split("/")[-1])
78
+ return pr_number, pr_url
79
+
80
+
81
+ def pr_view() -> tuple[int, str]:
82
+ response_json = gh(
83
+ "pr",
84
+ "view",
85
+ get_current_branch(),
86
+ "--repo",
87
+ get_repo(),
88
+ "--json",
89
+ "number,title",
90
+ capture=True,
91
+ )
92
+ pr_data = json.loads(response_json)
93
+ return int(pr_data["number"]), str(pr_data["title"])
94
+
95
+
96
+ def pr_checks_pass(pr_number: int) -> bool:
97
+ return fetch_checks_state(pr_number) in {ChecksState.PASSED, ChecksState.NONE}
98
+
99
+
100
+ def pr_merge(pr_number: int) -> None:
101
+ gh("pr", "merge", str(pr_number), "--squash", "--delete-branch", "--repo", get_repo())
102
+
103
+
104
+ def pr_checks_watch(pr_number: int) -> None:
105
+ for _ in range(_CHECKS_QUEUE_TIMEOUT // _CHECKS_POLL_INTERVAL):
106
+ if fetch_checks_state(pr_number) is not ChecksState.NONE:
107
+ break
108
+ time.sleep(_CHECKS_POLL_INTERVAL)
109
+ else:
110
+ return # no checks ever appeared — nothing to watch
111
+ gh("pr", "checks", str(pr_number), "--repo", get_repo(), "--watch")