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/__init__.py +3 -0
- acta/cli/__init__.py +34 -0
- acta/cli/board.py +56 -0
- acta/cli/branch.py +17 -0
- acta/cli/commit.py +64 -0
- acta/cli/issue.py +161 -0
- acta/cli/milestone.py +71 -0
- acta/cli/pr.py +140 -0
- acta/cli/release.py +67 -0
- acta/cli/shared.py +45 -0
- acta/git/__init__.py +27 -0
- acta/git/branch.py +77 -0
- acta/git/commit.py +20 -0
- acta/git/config.py +22 -0
- acta/git/tag.py +117 -0
- acta/github/__init__.py +43 -0
- acta/github/issue.py +126 -0
- acta/github/label.py +22 -0
- acta/github/milestone.py +107 -0
- acta/github/pr.py +111 -0
- git_acta-1.0.0.dist-info/METADATA +541 -0
- git_acta-1.0.0.dist-info/RECORD +25 -0
- git_acta-1.0.0.dist-info/WHEEL +4 -0
- git_acta-1.0.0.dist-info/entry_points.txt +2 -0
- git_acta-1.0.0.dist-info/licenses/LICENSE +21 -0
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)
|
acta/github/__init__.py
ADDED
|
@@ -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())
|
acta/github/milestone.py
ADDED
|
@@ -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")
|