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/__init__.py
ADDED
acta/cli/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from acta.cli.board import board
|
|
4
|
+
from acta.cli.branch import branch
|
|
5
|
+
from acta.cli.commit import commit
|
|
6
|
+
from acta.cli.issue import issue
|
|
7
|
+
from acta.cli.milestone import milestone
|
|
8
|
+
from acta.cli.pr import pr, ship, watch
|
|
9
|
+
from acta.cli.release import release
|
|
10
|
+
from acta.cli.shared import CLIGroup
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group(cls=CLIGroup, invoke_without_command=True)
|
|
14
|
+
@click.version_option(package_name="git-acta")
|
|
15
|
+
@click.pass_context
|
|
16
|
+
def main(ctx: click.Context) -> None:
|
|
17
|
+
"""Structured git workflow: conventional commits, trunk-based branches, GitHub PR lifecycle.
|
|
18
|
+
|
|
19
|
+
Branch names follow the type/scope convention from the conventional commits specification.
|
|
20
|
+
See https://www.conventionalcommits.org for the full spec.
|
|
21
|
+
"""
|
|
22
|
+
if ctx.invoked_subcommand is None:
|
|
23
|
+
click.echo(ctx.get_help())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
main.add_command(board)
|
|
27
|
+
main.add_command(branch)
|
|
28
|
+
main.add_command(commit)
|
|
29
|
+
main.add_command(pr)
|
|
30
|
+
main.add_command(ship)
|
|
31
|
+
main.add_command(watch)
|
|
32
|
+
main.add_command(release)
|
|
33
|
+
main.add_command(milestone)
|
|
34
|
+
main.add_command(issue)
|
acta/cli/board.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from acta.cli.issue import format_issue_lines
|
|
4
|
+
from acta.cli.milestone import format_milestone_line
|
|
5
|
+
from acta.git.branch import get_current_branch
|
|
6
|
+
from acta.git.config import get_active_issue
|
|
7
|
+
from acta.github.issue import issue_list, issue_view
|
|
8
|
+
from acta.github.milestone import milestone_list
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
def board() -> None:
|
|
13
|
+
"""Session snapshot: active work, current milestone, and backlog."""
|
|
14
|
+
branch_name = get_current_branch()
|
|
15
|
+
click.echo(f"Current branch: {branch_name}")
|
|
16
|
+
active_issue_number = get_active_issue()
|
|
17
|
+
focus_milestone_number: int | None = None
|
|
18
|
+
if active_issue_number is not None:
|
|
19
|
+
active_issue = issue_view(active_issue_number)
|
|
20
|
+
click.echo(f"Active issue: #{active_issue_number} {active_issue.title}")
|
|
21
|
+
focus_milestone_number = (
|
|
22
|
+
active_issue.milestone.number if active_issue.milestone is not None else None
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
milestones = milestone_list()
|
|
26
|
+
if not milestones:
|
|
27
|
+
click.echo()
|
|
28
|
+
click.echo("No open milestones.")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
if focus_milestone_number is None:
|
|
32
|
+
focus_milestone_number = milestones[0].number
|
|
33
|
+
focus_milestone = next(
|
|
34
|
+
(
|
|
35
|
+
milestone_list_item
|
|
36
|
+
for milestone_list_item in milestones
|
|
37
|
+
if milestone_list_item.number == focus_milestone_number
|
|
38
|
+
),
|
|
39
|
+
None,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if focus_milestone is not None:
|
|
43
|
+
click.echo()
|
|
44
|
+
click.echo(format_milestone_line(focus_milestone))
|
|
45
|
+
for line in format_issue_lines(issue_list(focus_milestone.number), indent=" "):
|
|
46
|
+
click.echo(line)
|
|
47
|
+
|
|
48
|
+
remaining_milestones = [
|
|
49
|
+
milestone_list_item
|
|
50
|
+
for milestone_list_item in milestones
|
|
51
|
+
if milestone_list_item.number != focus_milestone_number
|
|
52
|
+
]
|
|
53
|
+
if remaining_milestones:
|
|
54
|
+
click.echo()
|
|
55
|
+
for milestone_list_item in remaining_milestones:
|
|
56
|
+
click.echo(format_milestone_line(milestone_list_item))
|
acta/cli/branch.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from acta.git.branch import fetch_origin, switch_new_branch
|
|
4
|
+
from acta.git.branch import parse as parse_branch
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.command()
|
|
8
|
+
@click.argument("name", metavar="TYPE/scope")
|
|
9
|
+
def branch(name: str) -> None:
|
|
10
|
+
"""Create a branch from origin/main."""
|
|
11
|
+
try:
|
|
12
|
+
parse_branch(name)
|
|
13
|
+
except ValueError as error:
|
|
14
|
+
raise click.ClickException(str(error))
|
|
15
|
+
fetch_origin()
|
|
16
|
+
switch_new_branch(name)
|
|
17
|
+
click.echo(f"Branched {name} from origin/main.")
|
acta/cli/commit.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from acta.cli.shared import TYPE_CHOICE, open_editor
|
|
4
|
+
from acta.git.branch import get_current_branch
|
|
5
|
+
from acta.git.branch import parse as parse_branch
|
|
6
|
+
from acta.git.commit import add_all, push_head
|
|
7
|
+
from acta.git.commit import commit as git_commit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.option(
|
|
12
|
+
"-A", "--stage-all", "stage_all", is_flag=True, help="Stage all changes before committing."
|
|
13
|
+
)
|
|
14
|
+
@click.option("-P", "--push", "push", is_flag=True, help="Push to origin after committing.")
|
|
15
|
+
@click.option(
|
|
16
|
+
"-t",
|
|
17
|
+
"--type",
|
|
18
|
+
"type_override",
|
|
19
|
+
default=None,
|
|
20
|
+
metavar="TYPE",
|
|
21
|
+
type=TYPE_CHOICE,
|
|
22
|
+
help="Override the commit type inferred from the branch name.",
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"-s",
|
|
26
|
+
"--scope",
|
|
27
|
+
"scope_override",
|
|
28
|
+
default=None,
|
|
29
|
+
help="Override the commit scope inferred from the branch name.",
|
|
30
|
+
)
|
|
31
|
+
@click.option("-b", "--body", "body", default=None, help="Commit body. Mutually exclusive with -e.")
|
|
32
|
+
@click.option("-e", "--edit", "edit_body", is_flag=True, help="Open $EDITOR for commit body.")
|
|
33
|
+
@click.argument("description")
|
|
34
|
+
def commit(
|
|
35
|
+
stage_all: bool,
|
|
36
|
+
push: bool,
|
|
37
|
+
type_override: str | None,
|
|
38
|
+
scope_override: str | None,
|
|
39
|
+
body: str | None,
|
|
40
|
+
edit_body: bool,
|
|
41
|
+
description: str,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Create a conventional commit from the branch name.
|
|
44
|
+
|
|
45
|
+
Type and scope are derived from the branch. By default no body is added —
|
|
46
|
+
pass -b for an inline body, or -e to open $EDITOR. -b and -e are mutually
|
|
47
|
+
exclusive.
|
|
48
|
+
"""
|
|
49
|
+
if body and edit_body:
|
|
50
|
+
raise click.UsageError("--body and --edit are mutually exclusive")
|
|
51
|
+
branch_name = get_current_branch()
|
|
52
|
+
try:
|
|
53
|
+
type_, scope = parse_branch(branch_name)
|
|
54
|
+
except ValueError as error:
|
|
55
|
+
raise click.ClickException(str(error))
|
|
56
|
+
header = f"{type_override or type_}({scope_override or scope}): {description}"
|
|
57
|
+
if edit_body:
|
|
58
|
+
body = open_editor(header)
|
|
59
|
+
if stage_all:
|
|
60
|
+
add_all()
|
|
61
|
+
git_commit(header, body)
|
|
62
|
+
if push:
|
|
63
|
+
push_head()
|
|
64
|
+
click.echo("Pushed. If a PR is open, refresh its description: gh pr edit")
|
acta/cli/issue.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from acta.cli.shared import TYPE_CHOICE, CLIGroup, open_editor
|
|
6
|
+
from acta.git.branch import fetch_origin, switch_new_branch
|
|
7
|
+
from acta.git.config import set_active_issue
|
|
8
|
+
from acta.github.issue import (
|
|
9
|
+
IssueInfo,
|
|
10
|
+
issue_close_not_planned,
|
|
11
|
+
issue_create,
|
|
12
|
+
issue_list,
|
|
13
|
+
issue_view,
|
|
14
|
+
)
|
|
15
|
+
from acta.github.milestone import milestone_view
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def slugify_title(title: str) -> str:
|
|
19
|
+
"""Turn an issue title into a short, branch-safe topic slug."""
|
|
20
|
+
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:40].rstrip("-")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_issue_column_widths(issues: list[IssueInfo]) -> tuple[int, int]:
|
|
24
|
+
"""Compute column widths (number, type) wide enough to align every issue in the set."""
|
|
25
|
+
number_width = max((len(f"#{issue_info.number}") for issue_info in issues), default=1)
|
|
26
|
+
type_width = max((len(issue_info.type or "—") for issue_info in issues), default=1)
|
|
27
|
+
return number_width, type_width
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_issue_lines(
|
|
31
|
+
issues: list[IssueInfo],
|
|
32
|
+
indent: str = "",
|
|
33
|
+
widths: tuple[int, int] | None = None,
|
|
34
|
+
) -> list[str]:
|
|
35
|
+
"""Render issues as aligned lines, sorted by number.
|
|
36
|
+
|
|
37
|
+
Pass `widths` to align against a wider set than `issues` (e.g. a whole board
|
|
38
|
+
when rendering one milestone group); otherwise widths fit `issues` alone.
|
|
39
|
+
"""
|
|
40
|
+
number_width, type_width = widths if widths is not None else compute_issue_column_widths(issues)
|
|
41
|
+
return [
|
|
42
|
+
f"{indent}{f'#{issue_info.number}':<{number_width}} "
|
|
43
|
+
f"{(issue_info.type or '—'):<{type_width}} {issue_info.title}"
|
|
44
|
+
for issue_info in sorted(issues, key=lambda issue_info: issue_info.number)
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@click.group(cls=CLIGroup)
|
|
49
|
+
def issue() -> None:
|
|
50
|
+
"""Manage issues."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@issue.command(name="new")
|
|
54
|
+
@click.argument("title")
|
|
55
|
+
@click.option("--type", "type_", required=True, type=TYPE_CHOICE, help="Issue type label.")
|
|
56
|
+
@click.option(
|
|
57
|
+
"--milestone",
|
|
58
|
+
"milestone_number",
|
|
59
|
+
default=None,
|
|
60
|
+
type=int,
|
|
61
|
+
metavar="NUMBER",
|
|
62
|
+
help="Milestone number.",
|
|
63
|
+
)
|
|
64
|
+
@click.option("-b", "--body", "body", default=None, help="Issue body. Mutually exclusive with -e.")
|
|
65
|
+
@click.option("-e", "--edit", "edit_body", is_flag=True, help="Open $EDITOR for the issue body.")
|
|
66
|
+
def new_issue(
|
|
67
|
+
title: str,
|
|
68
|
+
type_: str,
|
|
69
|
+
milestone_number: int | None,
|
|
70
|
+
body: str | None,
|
|
71
|
+
edit_body: bool,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Create a new issue."""
|
|
74
|
+
if body and edit_body:
|
|
75
|
+
raise click.UsageError("--body and --edit are mutually exclusive")
|
|
76
|
+
if edit_body:
|
|
77
|
+
body = open_editor(title)
|
|
78
|
+
number = issue_create(title, type_, body or "", milestone_number)
|
|
79
|
+
click.echo(f"Issue #{number} created.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@issue.command(name="list")
|
|
83
|
+
@click.option(
|
|
84
|
+
"--milestone",
|
|
85
|
+
"milestone_number",
|
|
86
|
+
default=None,
|
|
87
|
+
type=int,
|
|
88
|
+
metavar="NUMBER",
|
|
89
|
+
help="Filter by milestone number.",
|
|
90
|
+
)
|
|
91
|
+
def list_issues(milestone_number: int | None) -> None:
|
|
92
|
+
"""List open issues."""
|
|
93
|
+
issues = issue_list(milestone_number)
|
|
94
|
+
if not issues:
|
|
95
|
+
click.echo("No open issues.")
|
|
96
|
+
return
|
|
97
|
+
if milestone_number is not None:
|
|
98
|
+
for line in format_issue_lines(issues):
|
|
99
|
+
click.echo(line)
|
|
100
|
+
return
|
|
101
|
+
issues_by_milestone: dict[int | None, list[IssueInfo]] = {}
|
|
102
|
+
headers_by_milestone: dict[int | None, str] = {}
|
|
103
|
+
for issue_info in issues:
|
|
104
|
+
milestone_ref = issue_info.milestone
|
|
105
|
+
milestone_key = milestone_ref.number if milestone_ref is not None else None
|
|
106
|
+
issues_by_milestone.setdefault(milestone_key, []).append(issue_info)
|
|
107
|
+
headers_by_milestone[milestone_key] = (
|
|
108
|
+
f"#{milestone_ref.number} {milestone_ref.title}"
|
|
109
|
+
if milestone_ref is not None
|
|
110
|
+
else "No milestone"
|
|
111
|
+
)
|
|
112
|
+
widths = compute_issue_column_widths(issues)
|
|
113
|
+
is_first_group = True
|
|
114
|
+
for milestone_key in sorted(issues_by_milestone, key=lambda key: (key is None, key or 0)):
|
|
115
|
+
if not is_first_group:
|
|
116
|
+
click.echo()
|
|
117
|
+
is_first_group = False
|
|
118
|
+
click.echo(headers_by_milestone[milestone_key])
|
|
119
|
+
for line in format_issue_lines(
|
|
120
|
+
issues_by_milestone[milestone_key], indent=" ", widths=widths
|
|
121
|
+
):
|
|
122
|
+
click.echo(line)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@issue.command(name="start")
|
|
126
|
+
@click.argument("number", type=int)
|
|
127
|
+
def start_issue(number: int) -> None:
|
|
128
|
+
"""Start work on an issue: create branch and record the active issue."""
|
|
129
|
+
issue_info = issue_view(number)
|
|
130
|
+
issue_type = issue_info.type
|
|
131
|
+
milestone_ref = issue_info.milestone
|
|
132
|
+
if milestone_ref is None:
|
|
133
|
+
raise click.ClickException(
|
|
134
|
+
f"Issue #{number} has no milestone — assign it to a milestone first"
|
|
135
|
+
)
|
|
136
|
+
milestone_detail = milestone_view(milestone_ref.number)
|
|
137
|
+
scope = milestone_detail.scope
|
|
138
|
+
if not scope:
|
|
139
|
+
raise click.ClickException(
|
|
140
|
+
f"Milestone #{milestone_ref.number} has no scope — "
|
|
141
|
+
"its description must start with 'scope: SCOPE'"
|
|
142
|
+
)
|
|
143
|
+
slug = slugify_title(issue_info.title)
|
|
144
|
+
topic = f"{number}-{slug}" if slug else str(number)
|
|
145
|
+
branch_name = f"{issue_type}/{scope}/{topic}"
|
|
146
|
+
fetch_origin()
|
|
147
|
+
switch_new_branch(branch_name)
|
|
148
|
+
set_active_issue(number)
|
|
149
|
+
click.echo(f"On branch {branch_name}, active issue is #{number}.")
|
|
150
|
+
issue_body = issue_info.body.strip()
|
|
151
|
+
if issue_body:
|
|
152
|
+
click.echo()
|
|
153
|
+
click.echo(issue_body)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@issue.command(name="discard")
|
|
157
|
+
@click.argument("number", type=int)
|
|
158
|
+
def discard_issue(number: int) -> None:
|
|
159
|
+
"""Close an issue as discarded (not planned)."""
|
|
160
|
+
issue_close_not_planned(number)
|
|
161
|
+
click.echo(f"Issue #{number} discarded.")
|
acta/cli/milestone.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from acta.cli.shared import CLIGroup, open_editor
|
|
4
|
+
from acta.github import get_repo
|
|
5
|
+
from acta.github.milestone import (
|
|
6
|
+
MilestoneListItem,
|
|
7
|
+
milestone_create,
|
|
8
|
+
milestone_list,
|
|
9
|
+
milestone_reopen,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_repo_name() -> str:
|
|
14
|
+
return get_repo().split("/")[-1]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_milestone_line(milestone_list_item: MilestoneListItem) -> str:
|
|
18
|
+
noun = "issue" if milestone_list_item.open_issues == 1 else "issues"
|
|
19
|
+
closed = (
|
|
20
|
+
f", {milestone_list_item.closed_issues} closed" if milestone_list_item.closed_issues else ""
|
|
21
|
+
)
|
|
22
|
+
return (
|
|
23
|
+
f"#{milestone_list_item.number} {milestone_list_item.title} — "
|
|
24
|
+
f"{milestone_list_item.open_issues} {noun} open{closed}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group(cls=CLIGroup)
|
|
29
|
+
def milestone() -> None:
|
|
30
|
+
"""Manage milestones."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@milestone.command(name="new")
|
|
34
|
+
@click.argument("title")
|
|
35
|
+
@click.option("--scope", required=True, help="Scope used for branch names in this milestone.")
|
|
36
|
+
@click.option(
|
|
37
|
+
"-d",
|
|
38
|
+
"--description",
|
|
39
|
+
"description",
|
|
40
|
+
default=None,
|
|
41
|
+
help="Milestone description. Mutually exclusive with -e.",
|
|
42
|
+
)
|
|
43
|
+
@click.option("-e", "--edit", "edit_body", is_flag=True, help="Open $EDITOR for the description.")
|
|
44
|
+
def new_milestone(title: str, scope: str, description: str | None, edit_body: bool) -> None:
|
|
45
|
+
"""Create a new milestone."""
|
|
46
|
+
if description and edit_body:
|
|
47
|
+
raise click.UsageError("--description and --edit are mutually exclusive")
|
|
48
|
+
if edit_body:
|
|
49
|
+
description = open_editor(title)
|
|
50
|
+
number = milestone_create(title, scope, description or "")
|
|
51
|
+
click.echo(f"Milestone #{number} created.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@milestone.command(name="list")
|
|
55
|
+
def list_milestones() -> None:
|
|
56
|
+
"""List open milestones."""
|
|
57
|
+
milestones = milestone_list()
|
|
58
|
+
if not milestones:
|
|
59
|
+
click.echo("No open milestones.")
|
|
60
|
+
return
|
|
61
|
+
click.echo(f"{get_repo_name()} milestones:")
|
|
62
|
+
for milestone_list_item in milestones:
|
|
63
|
+
click.echo(format_milestone_line(milestone_list_item))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@milestone.command(name="reopen")
|
|
67
|
+
@click.argument("number", type=int)
|
|
68
|
+
def reopen_milestone(number: int) -> None:
|
|
69
|
+
"""Reopen a closed milestone."""
|
|
70
|
+
milestone_reopen(number)
|
|
71
|
+
click.echo(f"Milestone #{number} reopened.")
|
acta/cli/pr.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from acta.cli.shared import TYPE_CHOICE, open_editor
|
|
4
|
+
from acta.git.branch import (
|
|
5
|
+
delete_branch,
|
|
6
|
+
get_current_branch,
|
|
7
|
+
merge_origin_main,
|
|
8
|
+
pull_origin_main,
|
|
9
|
+
switch_branch,
|
|
10
|
+
switch_main,
|
|
11
|
+
)
|
|
12
|
+
from acta.git.branch import parse as parse_branch
|
|
13
|
+
from acta.git.commit import push_head
|
|
14
|
+
from acta.git.config import clear_active_issue, get_active_issue
|
|
15
|
+
from acta.github.issue import issue_view
|
|
16
|
+
from acta.github.milestone import milestone_close, milestone_view
|
|
17
|
+
from acta.github.pr import pr_checks_pass, pr_checks_watch, pr_create, pr_merge, pr_view
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command()
|
|
21
|
+
@click.option(
|
|
22
|
+
"-t",
|
|
23
|
+
"--type",
|
|
24
|
+
"type_override",
|
|
25
|
+
default=None,
|
|
26
|
+
metavar="TYPE",
|
|
27
|
+
type=TYPE_CHOICE,
|
|
28
|
+
help="Override the PR title type inferred from the branch name.",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"-s",
|
|
32
|
+
"--scope",
|
|
33
|
+
"scope_override",
|
|
34
|
+
default=None,
|
|
35
|
+
help="Override the PR title scope inferred from the branch name.",
|
|
36
|
+
)
|
|
37
|
+
@click.option("-b", "--body", "body", default=None, help="PR body. Mutually exclusive with -e.")
|
|
38
|
+
@click.option("-e", "--edit", "edit_body", is_flag=True, help="Open $EDITOR for the PR body.")
|
|
39
|
+
@click.option(
|
|
40
|
+
"--breaking",
|
|
41
|
+
is_flag=True,
|
|
42
|
+
help="Mark a breaking change: append '!' to type(scope) per conventional commits.",
|
|
43
|
+
)
|
|
44
|
+
@click.argument("title")
|
|
45
|
+
def pr(
|
|
46
|
+
type_override: str | None,
|
|
47
|
+
scope_override: str | None,
|
|
48
|
+
body: str | None,
|
|
49
|
+
edit_body: bool,
|
|
50
|
+
breaking: bool,
|
|
51
|
+
title: str,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Push branch, open a PR against main, and watch CI.
|
|
54
|
+
|
|
55
|
+
By default no body is added — pass -b for an inline body, or -e to open
|
|
56
|
+
$EDITOR. -b and -e are mutually exclusive.
|
|
57
|
+
"""
|
|
58
|
+
if body and edit_body:
|
|
59
|
+
raise click.UsageError("--body and --edit are mutually exclusive")
|
|
60
|
+
branch_name = get_current_branch()
|
|
61
|
+
try:
|
|
62
|
+
type_, scope = parse_branch(branch_name)
|
|
63
|
+
except ValueError as error:
|
|
64
|
+
raise click.ClickException(str(error))
|
|
65
|
+
breaking_marker = "!" if breaking else ""
|
|
66
|
+
pr_title = f"{type_override or type_}({scope_override or scope}){breaking_marker}: {title}"
|
|
67
|
+
if edit_body:
|
|
68
|
+
body = open_editor(f"{pr_title} ({branch_name})")
|
|
69
|
+
active_issue_number = get_active_issue()
|
|
70
|
+
if active_issue_number is not None:
|
|
71
|
+
closes_footer = f"Closes #{active_issue_number}"
|
|
72
|
+
body = f"{body}\n\n{closes_footer}" if body else closes_footer
|
|
73
|
+
push_head()
|
|
74
|
+
number, url = pr_create(pr_title, body or "")
|
|
75
|
+
click.echo(url)
|
|
76
|
+
pr_checks_watch(number)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@click.command()
|
|
80
|
+
@click.option(
|
|
81
|
+
"-u",
|
|
82
|
+
"--update",
|
|
83
|
+
"update_branch",
|
|
84
|
+
default=None,
|
|
85
|
+
metavar="BRANCH",
|
|
86
|
+
help="After shipping, switch to BRANCH and merge origin/main.",
|
|
87
|
+
)
|
|
88
|
+
@click.option("-y", "--yes", "confirmed", is_flag=True, help="Skip confirmation prompt.")
|
|
89
|
+
def ship(update_branch: str | None, confirmed: bool) -> None:
|
|
90
|
+
"""Ship the PR and return to a clean main.
|
|
91
|
+
|
|
92
|
+
Squash-merges the current branch's PR, deletes the remote branch, switches
|
|
93
|
+
to local main, pulls, and force-deletes the local branch.
|
|
94
|
+
"""
|
|
95
|
+
branch_name = get_current_branch()
|
|
96
|
+
if branch_name == "main":
|
|
97
|
+
raise click.ClickException("run 'acta ship' from the feature branch, not main")
|
|
98
|
+
pr_number, title = pr_view()
|
|
99
|
+
prompt = f'Ship "{title}" (#{pr_number})'
|
|
100
|
+
if update_branch:
|
|
101
|
+
prompt += f", then update {update_branch}"
|
|
102
|
+
if not confirmed:
|
|
103
|
+
click.confirm(prompt, abort=True)
|
|
104
|
+
if not pr_checks_pass(pr_number):
|
|
105
|
+
raise click.ClickException(
|
|
106
|
+
f"PR #{pr_number} has failing or pending checks — run 'acta watch' to monitor"
|
|
107
|
+
)
|
|
108
|
+
active_issue_number = get_active_issue()
|
|
109
|
+
milestone_number: int | None = None
|
|
110
|
+
if active_issue_number is not None:
|
|
111
|
+
active_issue = issue_view(active_issue_number)
|
|
112
|
+
milestone_ref = active_issue.milestone
|
|
113
|
+
if milestone_ref is not None:
|
|
114
|
+
milestone_number = milestone_ref.number
|
|
115
|
+
pr_merge(pr_number)
|
|
116
|
+
click.echo(f"Merged PR #{pr_number} {branch_name} → main")
|
|
117
|
+
switch_main()
|
|
118
|
+
pull_origin_main()
|
|
119
|
+
delete_branch(branch_name)
|
|
120
|
+
click.echo(f"Switched to main, pulled origin/main, deleted {branch_name}.")
|
|
121
|
+
if update_branch:
|
|
122
|
+
switch_branch(update_branch)
|
|
123
|
+
merge_origin_main()
|
|
124
|
+
click.echo(f"Switched to {update_branch}, merged origin/main.")
|
|
125
|
+
if active_issue_number is not None:
|
|
126
|
+
clear_active_issue()
|
|
127
|
+
if milestone_number is not None:
|
|
128
|
+
milestone_detail = milestone_view(milestone_number)
|
|
129
|
+
if milestone_detail.open_issues == 0:
|
|
130
|
+
milestone_close(milestone_number)
|
|
131
|
+
click.echo(
|
|
132
|
+
f'Milestone #{milestone_number} "{milestone_detail.title}" completed and closed.'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@click.command()
|
|
137
|
+
def watch() -> None:
|
|
138
|
+
"""Watch CI checks for the current PR."""
|
|
139
|
+
number, _ = pr_view()
|
|
140
|
+
pr_checks_watch(number)
|
acta/cli/release.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from acta.git.tag import (
|
|
6
|
+
CALVER,
|
|
7
|
+
SEMVER,
|
|
8
|
+
Scheme,
|
|
9
|
+
compute_next_calver,
|
|
10
|
+
create_tag,
|
|
11
|
+
detect_scheme,
|
|
12
|
+
fetch_tags,
|
|
13
|
+
list_tags,
|
|
14
|
+
next_release_tag,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command()
|
|
19
|
+
@click.option(
|
|
20
|
+
"--scheme",
|
|
21
|
+
"scheme",
|
|
22
|
+
type=click.Choice([CALVER, SEMVER], case_sensitive=False),
|
|
23
|
+
default=None,
|
|
24
|
+
help="Versioning scheme: calver (vYYYY.MM.N) or semver (vMAJOR.MINOR.PATCH).",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--stable", is_flag=True, help="Promote a 0.x project to v1.0.0 (SemVer only, one-time)."
|
|
28
|
+
)
|
|
29
|
+
@click.option("-y", "--yes", "confirmed", is_flag=True, help="Skip confirmation prompt.")
|
|
30
|
+
def release(scheme: Scheme | None, stable: bool, confirmed: bool) -> None:
|
|
31
|
+
"""Tag origin/main and push the tag.
|
|
32
|
+
|
|
33
|
+
The SemVer bump is derived from the conventional-commit subjects since the last
|
|
34
|
+
tag: a `feat` (or, once stable, a `!` breaking change) bumps minor (major when
|
|
35
|
+
stable); anything else, patch. While still 0.x a breaking change is capped at
|
|
36
|
+
minor — pass --stable for the deliberate one-time jump to v1.0.0.
|
|
37
|
+
"""
|
|
38
|
+
fetch_tags()
|
|
39
|
+
existing_tags = list_tags()
|
|
40
|
+
if not scheme:
|
|
41
|
+
try:
|
|
42
|
+
scheme = detect_scheme(existing_tags)
|
|
43
|
+
except ValueError as error:
|
|
44
|
+
raise click.ClickException(str(error))
|
|
45
|
+
if not scheme:
|
|
46
|
+
click.echo("No existing tags found. Choose a versioning scheme:")
|
|
47
|
+
click.echo(
|
|
48
|
+
f" {CALVER} vYYYY.MM.N — calendar versioning, counter resets each month"
|
|
49
|
+
)
|
|
50
|
+
click.echo(f" {SEMVER} vMAJOR.MINOR.PATCH — semantic versioning")
|
|
51
|
+
scheme_input = click.prompt(
|
|
52
|
+
"Scheme", type=click.Choice([CALVER, SEMVER], case_sensitive=False), show_choices=False
|
|
53
|
+
)
|
|
54
|
+
scheme = CALVER if scheme_input == CALVER else SEMVER
|
|
55
|
+
if stable and scheme == CALVER:
|
|
56
|
+
raise click.ClickException("--stable applies to SemVer only")
|
|
57
|
+
if scheme == CALVER:
|
|
58
|
+
tag = compute_next_calver(existing_tags, date.today())
|
|
59
|
+
else:
|
|
60
|
+
try:
|
|
61
|
+
tag = next_release_tag(existing_tags, stable)
|
|
62
|
+
except ValueError as error:
|
|
63
|
+
raise click.ClickException(str(error))
|
|
64
|
+
if not confirmed:
|
|
65
|
+
click.confirm(f"Tag and push {tag}", abort=True)
|
|
66
|
+
create_tag(tag)
|
|
67
|
+
click.echo(f"Tagged and pushed {tag}")
|
acta/cli/shared.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from acta.git.branch import TYPES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CLIGroup(click.Group):
|
|
10
|
+
def invoke(self, ctx: click.Context) -> object:
|
|
11
|
+
try:
|
|
12
|
+
return super().invoke(ctx)
|
|
13
|
+
except (click.exceptions.Exit, click.exceptions.Abort):
|
|
14
|
+
raise # Click's own control flow (--help, ctrl-C); both subclass RuntimeError
|
|
15
|
+
except subprocess.CalledProcessError as error:
|
|
16
|
+
if error.stderr:
|
|
17
|
+
click.echo(error.stderr, err=True, nl=False)
|
|
18
|
+
sys.exit(error.returncode)
|
|
19
|
+
except RuntimeError as error:
|
|
20
|
+
raise click.ClickException(str(error)) from error
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def strip_comments(text: str) -> str:
|
|
24
|
+
lines = [line for line in text.splitlines() if not line.startswith("#")]
|
|
25
|
+
collapsed: list[str] = []
|
|
26
|
+
prev_blank = False
|
|
27
|
+
for line in lines:
|
|
28
|
+
blank = not line.strip()
|
|
29
|
+
if blank and prev_blank:
|
|
30
|
+
continue
|
|
31
|
+
collapsed.append(line)
|
|
32
|
+
prev_blank = blank
|
|
33
|
+
return "\n".join(collapsed).strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def open_editor(hint: str) -> str:
|
|
37
|
+
template = f"# {hint}\n# Lines starting with '#' are ignored.\n\n"
|
|
38
|
+
edited_text = click.edit(template)
|
|
39
|
+
body_text = strip_comments(edited_text or "")
|
|
40
|
+
if not body_text:
|
|
41
|
+
raise click.Abort()
|
|
42
|
+
return body_text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
TYPE_CHOICE = click.Choice(sorted(TYPES))
|
acta/git/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def git(*args: str, capture: bool = False, quiet: bool = False) -> str:
|
|
5
|
+
"""Run a git command.
|
|
6
|
+
|
|
7
|
+
`capture` returns stdout. `quiet` suppresses stdout and stderr on success; on
|
|
8
|
+
failure the captured stderr rides along on the raised CalledProcessError so the
|
|
9
|
+
error is still surfaced (see CLIGroup).
|
|
10
|
+
"""
|
|
11
|
+
try:
|
|
12
|
+
completed_process = subprocess.run(
|
|
13
|
+
["git", *args],
|
|
14
|
+
check=True,
|
|
15
|
+
text=True,
|
|
16
|
+
stdout=subprocess.PIPE if (capture or quiet) else None,
|
|
17
|
+
stderr=subprocess.PIPE if quiet else None,
|
|
18
|
+
)
|
|
19
|
+
return completed_process.stdout.strip() if completed_process.stdout else ""
|
|
20
|
+
except FileNotFoundError:
|
|
21
|
+
raise RuntimeError("'git' not found in PATH — install git: https://git-scm.com/install/")
|
|
22
|
+
except subprocess.CalledProcessError:
|
|
23
|
+
raise
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_remote_url(remote_name: str) -> str:
|
|
27
|
+
return git("remote", "get-url", remote_name, capture=True)
|