git-epic 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.
- git_epic/__init__.py +3 -0
- git_epic/__main__.py +3 -0
- git_epic/cli.py +56 -0
- git_epic/config.py +55 -0
- git_epic/git.py +74 -0
- git_epic/gitlab.py +109 -0
- git_epic/process.py +30 -0
- git_epic/workflow.py +178 -0
- git_epic-0.1.0.dist-info/METADATA +205 -0
- git_epic-0.1.0.dist-info/RECORD +13 -0
- git_epic-0.1.0.dist-info/WHEEL +5 -0
- git_epic-0.1.0.dist-info/entry_points.txt +2 -0
- git_epic-0.1.0.dist-info/top_level.txt +1 -0
git_epic/__init__.py
ADDED
git_epic/__main__.py
ADDED
git_epic/cli.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Command-line entry point, available as git-epic and git epic."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__, workflow
|
|
7
|
+
from .process import EpicError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parser() -> argparse.ArgumentParser:
|
|
11
|
+
root = argparse.ArgumentParser(
|
|
12
|
+
prog="git epic", description="Epic-based Git workflows and GitLab merge requests"
|
|
13
|
+
)
|
|
14
|
+
root.add_argument("--version", "-V", action="version", version=__version__)
|
|
15
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
16
|
+
init = commands.add_parser("init", help="Configure this repository")
|
|
17
|
+
init.add_argument("--production", default="production", metavar="BRANCH")
|
|
18
|
+
init.add_argument("--remote", default="origin")
|
|
19
|
+
start = commands.add_parser("start", help="Create an epic from latest production")
|
|
20
|
+
start.add_argument("name")
|
|
21
|
+
task = commands.add_parser("task", help="Work on tasks within an epic")
|
|
22
|
+
tasks = task.add_subparsers(dest="task_command", required=True)
|
|
23
|
+
task_start = tasks.add_parser("start", help="Create a task from the current epic")
|
|
24
|
+
task_start.add_argument("name")
|
|
25
|
+
tasks.add_parser("submit", help="Push the task and create or return its epic MR")
|
|
26
|
+
commands.add_parser("sync", help="Merge latest production into the current epic")
|
|
27
|
+
commands.add_parser("status", help="Show branch context, production drift, MRs, and CI")
|
|
28
|
+
commands.add_parser(
|
|
29
|
+
"release",
|
|
30
|
+
help="Push the epic and create or return its production MR; never merge or deploy",
|
|
31
|
+
)
|
|
32
|
+
return root
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: list[str] | None = None) -> int:
|
|
36
|
+
args = parser().parse_args(argv)
|
|
37
|
+
try:
|
|
38
|
+
if args.command == "init":
|
|
39
|
+
workflow.init(args.production, args.remote)
|
|
40
|
+
elif args.command == "start":
|
|
41
|
+
workflow.start(args.name)
|
|
42
|
+
elif args.command == "task":
|
|
43
|
+
if args.task_command == "start":
|
|
44
|
+
workflow.task_start(args.name)
|
|
45
|
+
else:
|
|
46
|
+
workflow.task_submit()
|
|
47
|
+
else:
|
|
48
|
+
{"sync": workflow.sync, "status": workflow.status, "release": workflow.release}[
|
|
49
|
+
args.command
|
|
50
|
+
]()
|
|
51
|
+
except (EpicError, OSError) as error:
|
|
52
|
+
print(f"git-epic: {error}", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
except KeyboardInterrupt:
|
|
55
|
+
return 130
|
|
56
|
+
return 0
|
git_epic/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared repository settings; credentials remain managed by Git and glab."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
|
|
6
|
+
from .git import Git
|
|
7
|
+
from .process import EpicError
|
|
8
|
+
|
|
9
|
+
CONFIG_FILE = ".git-epic.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Config:
|
|
14
|
+
production_branch: str = "production"
|
|
15
|
+
remote: str = "origin"
|
|
16
|
+
hosting: str = "gitlab"
|
|
17
|
+
|
|
18
|
+
def validate(self, git: Git) -> None:
|
|
19
|
+
git.validate_branch(self.production_branch)
|
|
20
|
+
git.validate_remote(self.remote)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_config(git: Git) -> Config:
|
|
24
|
+
path = git.root / CONFIG_FILE
|
|
25
|
+
if not path.exists():
|
|
26
|
+
raise EpicError("Run git epic init first.")
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
except json.JSONDecodeError as error:
|
|
30
|
+
raise EpicError(f"Invalid {CONFIG_FILE}: {error.msg}.") from error
|
|
31
|
+
if (
|
|
32
|
+
not isinstance(data, dict)
|
|
33
|
+
or not isinstance(data.get("productionBranch"), str)
|
|
34
|
+
or not isinstance(data.get("remote"), str)
|
|
35
|
+
or data.get("hosting") != "gitlab"
|
|
36
|
+
):
|
|
37
|
+
raise EpicError(
|
|
38
|
+
f"Invalid {CONFIG_FILE}: expected productionBranch, remote, and hosting: gitlab."
|
|
39
|
+
)
|
|
40
|
+
config = Config(data["productionBranch"], data["remote"])
|
|
41
|
+
config.validate(git)
|
|
42
|
+
return config
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_config(git: Git, config: Config) -> None:
|
|
46
|
+
path = git.root / CONFIG_FILE
|
|
47
|
+
if path.exists():
|
|
48
|
+
raise EpicError(f"{CONFIG_FILE} already exists. Edit it to change configuration.")
|
|
49
|
+
config.validate(git)
|
|
50
|
+
git.fetch(config.remote)
|
|
51
|
+
git.remote_ref(config.remote, config.production_branch)
|
|
52
|
+
data = asdict(config)
|
|
53
|
+
data["productionBranch"] = data.pop("production_branch")
|
|
54
|
+
with path.open("x", encoding="utf-8") as file:
|
|
55
|
+
file.write(json.dumps(data, indent=2) + "\n")
|
git_epic/git.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Git operations used by the workflow."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .process import EpicError, run
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Divergence:
|
|
11
|
+
behind: int
|
|
12
|
+
ahead: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Git:
|
|
16
|
+
def __init__(self, cwd: Path | None = None):
|
|
17
|
+
self.root = Path(run("git", ["rev-parse", "--show-toplevel"], cwd or Path.cwd()))
|
|
18
|
+
|
|
19
|
+
def exec(self, *args: str) -> str:
|
|
20
|
+
return run("git", list(args), self.root)
|
|
21
|
+
|
|
22
|
+
def dirty(self) -> bool:
|
|
23
|
+
return bool(
|
|
24
|
+
self.exec("status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none")
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def clean(self) -> None:
|
|
28
|
+
if self.dirty():
|
|
29
|
+
raise EpicError("Working tree has uncommitted changes. Commit or stash them first.")
|
|
30
|
+
if any(self.exists(ref) for ref in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD")):
|
|
31
|
+
raise EpicError("Finish the in-progress Git operation first.")
|
|
32
|
+
for state in ("rebase-merge", "rebase-apply", "sequencer"):
|
|
33
|
+
path = self.exec("rev-parse", "--path-format=absolute", "--git-path", state)
|
|
34
|
+
if Path(path).exists():
|
|
35
|
+
raise EpicError("Finish the in-progress Git operation first.")
|
|
36
|
+
|
|
37
|
+
def branch(self) -> str:
|
|
38
|
+
branch = self.exec("branch", "--show-current")
|
|
39
|
+
if not branch:
|
|
40
|
+
raise EpicError("Detached HEAD: check out an epic or task branch first.")
|
|
41
|
+
return branch
|
|
42
|
+
|
|
43
|
+
def exists(self, ref: str) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
self.exec("rev-parse", "--verify", "--quiet", ref)
|
|
46
|
+
except EpicError:
|
|
47
|
+
return False
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def validate_branch(self, branch: str) -> None:
|
|
51
|
+
self.exec("check-ref-format", "--branch", branch)
|
|
52
|
+
|
|
53
|
+
def validate_remote(self, remote: str) -> None:
|
|
54
|
+
if remote.startswith("-") or remote not in self.exec("remote").splitlines():
|
|
55
|
+
raise EpicError(f"Remote '{remote}' does not exist or has an unsupported name.")
|
|
56
|
+
|
|
57
|
+
def fetch(self, remote: str) -> None:
|
|
58
|
+
self.exec("fetch", "--prune", remote, f"+refs/heads/*:refs/remotes/{remote}/*")
|
|
59
|
+
|
|
60
|
+
def remote_ref(self, remote: str, branch: str) -> str:
|
|
61
|
+
ref = f"refs/remotes/{remote}/{branch}"
|
|
62
|
+
if not self.exists(ref):
|
|
63
|
+
raise EpicError(f"Branch '{branch}' does not exist on '{remote}'.")
|
|
64
|
+
return ref
|
|
65
|
+
|
|
66
|
+
def create(self, branch: str, base: str, remote: str) -> None:
|
|
67
|
+
self.validate_branch(branch)
|
|
68
|
+
if self.exists(f"refs/heads/{branch}") or self.exists(f"refs/remotes/{remote}/{branch}"):
|
|
69
|
+
raise EpicError(f"Branch '{branch}' already exists. Check it out instead.")
|
|
70
|
+
self.exec("switch", "--no-track", "-c", branch, base)
|
|
71
|
+
|
|
72
|
+
def divergence(self, base: str, head: str = "HEAD") -> Divergence:
|
|
73
|
+
behind, ahead = self.exec("rev-list", "--left-right", "--count", f"{base}...{head}").split()
|
|
74
|
+
return Divergence(int(behind), int(ahead))
|
git_epic/gitlab.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""GitLab merge requests through the authenticated glab CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .git import Git
|
|
8
|
+
from .process import EpicError, run
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MergeRequest:
|
|
13
|
+
iid: int
|
|
14
|
+
web_url: str
|
|
15
|
+
source_branch: str
|
|
16
|
+
target_branch: str
|
|
17
|
+
source_project_id: int
|
|
18
|
+
pipeline_status: str
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_json(cls, data: Any) -> "MergeRequest":
|
|
22
|
+
if not isinstance(data, dict):
|
|
23
|
+
raise EpicError("Unexpected merge-request response from glab.")
|
|
24
|
+
for field in ("iid", "source_project_id"):
|
|
25
|
+
if type(data.get(field)) is not int:
|
|
26
|
+
raise EpicError(f"Unexpected merge-request {field} from glab.")
|
|
27
|
+
for field in ("web_url", "source_branch", "target_branch"):
|
|
28
|
+
if not isinstance(data.get(field), str):
|
|
29
|
+
raise EpicError(f"Unexpected merge-request {field} from glab.")
|
|
30
|
+
pipeline = data.get("head_pipeline") or {}
|
|
31
|
+
status = pipeline.get("status") if isinstance(pipeline, dict) else None
|
|
32
|
+
return cls(
|
|
33
|
+
data["iid"],
|
|
34
|
+
data["web_url"],
|
|
35
|
+
data["source_branch"],
|
|
36
|
+
data["target_branch"],
|
|
37
|
+
data["source_project_id"],
|
|
38
|
+
status or "no pipeline reported",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GitLab:
|
|
43
|
+
def __init__(self, git: Git, remote: str):
|
|
44
|
+
self.git = git
|
|
45
|
+
self.repo = git.exec("config", "--get", f"remote.{remote}.url")
|
|
46
|
+
self._project_id: int | None = None
|
|
47
|
+
|
|
48
|
+
def exec(self, *args: str) -> str:
|
|
49
|
+
return run("glab", [*args, "--repo", self.repo], self.git.root)
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def decode(output: str) -> Any:
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(output)
|
|
55
|
+
except json.JSONDecodeError as error:
|
|
56
|
+
raise EpicError("Unexpected JSON response from glab.") from error
|
|
57
|
+
|
|
58
|
+
def project_id(self) -> int:
|
|
59
|
+
if self._project_id is None:
|
|
60
|
+
project = self.decode(
|
|
61
|
+
run("glab", ["repo", "view", self.repo, "--output", "json"], self.git.root)
|
|
62
|
+
)
|
|
63
|
+
if not isinstance(project, dict) or type(project.get("id")) is not int:
|
|
64
|
+
raise EpicError("Unexpected project response from glab.")
|
|
65
|
+
self._project_id = project["id"]
|
|
66
|
+
return self._project_id
|
|
67
|
+
|
|
68
|
+
def list(self, source: str | None = None, target: str | None = None) -> list[MergeRequest]:
|
|
69
|
+
args = ["mr", "list", "--output", "json", "--per-page", "100"]
|
|
70
|
+
if source:
|
|
71
|
+
args.extend(["--source-branch", source])
|
|
72
|
+
if target:
|
|
73
|
+
args.extend(["--target-branch", target])
|
|
74
|
+
requests = []
|
|
75
|
+
page = 1
|
|
76
|
+
while True:
|
|
77
|
+
result = self.decode(self.exec(*args, "--page", str(page)))
|
|
78
|
+
if not isinstance(result, list):
|
|
79
|
+
raise EpicError("Unexpected merge-request response from glab.")
|
|
80
|
+
for data in result:
|
|
81
|
+
mr = MergeRequest.from_json(data)
|
|
82
|
+
# Deduplication matches our source project; epic status includes fork MRs.
|
|
83
|
+
if not source or mr.source_project_id == self.project_id():
|
|
84
|
+
requests.append(mr)
|
|
85
|
+
if len(result) < 100:
|
|
86
|
+
return requests
|
|
87
|
+
page += 1
|
|
88
|
+
|
|
89
|
+
def details(self, iid: int) -> MergeRequest:
|
|
90
|
+
return MergeRequest.from_json(
|
|
91
|
+
self.decode(self.exec("mr", "view", str(iid), "--output", "json"))
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def create(self, source: str, target: str, title: str, description: str) -> str:
|
|
95
|
+
return self.exec(
|
|
96
|
+
"mr",
|
|
97
|
+
"create",
|
|
98
|
+
"--head",
|
|
99
|
+
self.repo,
|
|
100
|
+
"--source-branch",
|
|
101
|
+
source,
|
|
102
|
+
"--target-branch",
|
|
103
|
+
target,
|
|
104
|
+
"--title",
|
|
105
|
+
title,
|
|
106
|
+
"--description",
|
|
107
|
+
description,
|
|
108
|
+
"--yes",
|
|
109
|
+
)
|
git_epic/process.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shell-free execution of Git and glab."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EpicError(RuntimeError):
|
|
9
|
+
"""An actionable workflow or subprocess failure."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(command: str, args: list[str], cwd: Path) -> str:
|
|
13
|
+
try:
|
|
14
|
+
result = subprocess.run(
|
|
15
|
+
[command, *args],
|
|
16
|
+
cwd=cwd,
|
|
17
|
+
text=True,
|
|
18
|
+
encoding="utf-8",
|
|
19
|
+
errors="replace",
|
|
20
|
+
capture_output=True,
|
|
21
|
+
env={**os.environ, "GLAB_CHECK_UPDATE": "false", "GIT_TERMINAL_PROMPT": "0"},
|
|
22
|
+
)
|
|
23
|
+
except OSError as error:
|
|
24
|
+
raise EpicError(
|
|
25
|
+
f"Cannot run {command}: {error}. Ensure it is installed and on PATH."
|
|
26
|
+
) from error
|
|
27
|
+
if result.returncode:
|
|
28
|
+
detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}"
|
|
29
|
+
raise EpicError(f"{command} {args[0] if args else ''} failed:\n{detail}")
|
|
30
|
+
return result.stdout.strip()
|
git_epic/workflow.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Team branching and MR workflow. QA and deployments remain external."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .config import CONFIG_FILE, Config, load_config, save_config
|
|
6
|
+
from .git import Git
|
|
7
|
+
from .gitlab import GitLab
|
|
8
|
+
from .process import EpicError
|
|
9
|
+
|
|
10
|
+
TASK_PATTERN = re.compile(r"task/([^/]+)/([^/]+)")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def epic_name(branch: str) -> str:
|
|
14
|
+
if not branch.startswith("epic/") or not branch[5:] or "/" in branch[5:]:
|
|
15
|
+
raise EpicError("This command requires an epic/<name> branch.")
|
|
16
|
+
return branch[5:]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def name(value: str) -> str:
|
|
20
|
+
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*", value):
|
|
21
|
+
raise EpicError(
|
|
22
|
+
"Names must start with a letter or number and contain only letters, "
|
|
23
|
+
"numbers, dots, underscores, or hyphens."
|
|
24
|
+
)
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def context() -> tuple[Git, Config]:
|
|
29
|
+
git = Git()
|
|
30
|
+
return git, load_config(git)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init(production: str = "production", remote: str = "origin") -> None:
|
|
34
|
+
git = Git()
|
|
35
|
+
git.clean()
|
|
36
|
+
save_config(git, Config(production, remote))
|
|
37
|
+
print(f"Created {CONFIG_FILE}. Commit it so the team shares these settings.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def start(value: str) -> None:
|
|
41
|
+
git, config = context()
|
|
42
|
+
branch = f"epic/{name(value)}"
|
|
43
|
+
git.clean()
|
|
44
|
+
git.fetch(config.remote)
|
|
45
|
+
git.create(branch, git.remote_ref(config.remote, config.production_branch), config.remote)
|
|
46
|
+
print(f"Switched to {branch}.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def task_start(value: str) -> None:
|
|
50
|
+
git, config = context()
|
|
51
|
+
parent = f"epic/{epic_name(git.branch())}"
|
|
52
|
+
branch = f"task/{epic_name(parent)}/{name(value)}"
|
|
53
|
+
git.clean()
|
|
54
|
+
git.fetch(config.remote)
|
|
55
|
+
if git.exists(f"refs/remotes/{config.remote}/{parent}"):
|
|
56
|
+
git.exec("merge", "--ff-only", git.remote_ref(config.remote, parent))
|
|
57
|
+
git.create(branch, "HEAD", config.remote)
|
|
58
|
+
print(f"Switched to {branch}; parent: {parent}.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sync() -> None:
|
|
62
|
+
git, config = context()
|
|
63
|
+
epic_name(git.branch())
|
|
64
|
+
git.clean()
|
|
65
|
+
git.fetch(config.remote)
|
|
66
|
+
try:
|
|
67
|
+
git.exec("merge", "--no-edit", git.remote_ref(config.remote, config.production_branch))
|
|
68
|
+
except EpicError as error:
|
|
69
|
+
raise EpicError(
|
|
70
|
+
f"{error}\nResolve any conflicts and commit, or use git merge --abort. "
|
|
71
|
+
"Then rerun git epic sync."
|
|
72
|
+
) from error
|
|
73
|
+
print("Epic synchronized with production. Push it to share the changes.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def submit(source: str, target: str, git: Git, config: Config, *, is_release: bool) -> None:
|
|
77
|
+
git.clean()
|
|
78
|
+
git.fetch(config.remote)
|
|
79
|
+
target_ref = git.remote_ref(config.remote, target)
|
|
80
|
+
drift = git.divergence(target_ref)
|
|
81
|
+
if is_release and drift.behind:
|
|
82
|
+
raise EpicError(
|
|
83
|
+
"Epic is behind production. Run git epic sync and revalidate "
|
|
84
|
+
"the updated candidate with QA."
|
|
85
|
+
)
|
|
86
|
+
hosting = GitLab(git, config.remote)
|
|
87
|
+
existing = hosting.list(source, target)
|
|
88
|
+
if not existing and not drift.ahead:
|
|
89
|
+
raise EpicError(f"No new commits to submit to {target}.")
|
|
90
|
+
git.exec("push", "--set-upstream", config.remote, f"HEAD:refs/heads/{source}")
|
|
91
|
+
if existing:
|
|
92
|
+
print(f"Existing merge request: {existing[0].web_url}")
|
|
93
|
+
return
|
|
94
|
+
if is_release:
|
|
95
|
+
title = f"Release {epic_name(source)}"
|
|
96
|
+
description = (
|
|
97
|
+
f"Release {source} into {target}.\n\n"
|
|
98
|
+
f"Candidate commit: {git.exec('rev-parse', 'HEAD')}\n\n"
|
|
99
|
+
"Required CI, reviews, and QA approval must be verified before merging. "
|
|
100
|
+
"This command does not merge or deploy."
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
title = f"Task: {source.split('/')[-1]}"
|
|
104
|
+
description = f"Merge {source} into its parent epic {target}."
|
|
105
|
+
try:
|
|
106
|
+
print(hosting.create(source, target, title, description))
|
|
107
|
+
except EpicError as creation_error:
|
|
108
|
+
# Another teammate may have created the MR between lookup and creation.
|
|
109
|
+
try:
|
|
110
|
+
concurrent = hosting.list(source, target)
|
|
111
|
+
except EpicError:
|
|
112
|
+
raise creation_error from None
|
|
113
|
+
if not concurrent:
|
|
114
|
+
raise
|
|
115
|
+
print(f"Existing merge request: {concurrent[0].web_url}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def task_submit() -> None:
|
|
119
|
+
git, config = context()
|
|
120
|
+
source = git.branch()
|
|
121
|
+
match = TASK_PATTERN.fullmatch(source)
|
|
122
|
+
if not match:
|
|
123
|
+
raise EpicError("This command requires a task/<epic>/<name> branch.")
|
|
124
|
+
parent = f"epic/{match[1]}"
|
|
125
|
+
git.clean()
|
|
126
|
+
git.fetch(config.remote)
|
|
127
|
+
if not git.exists(f"refs/remotes/{config.remote}/{parent}"):
|
|
128
|
+
if not git.exists(f"refs/heads/{parent}"):
|
|
129
|
+
raise EpicError(
|
|
130
|
+
f"Parent {parent} is missing locally and remotely. Restore or publish it first."
|
|
131
|
+
)
|
|
132
|
+
git.exec("push", config.remote, f"refs/heads/{parent}:refs/heads/{parent}")
|
|
133
|
+
git.fetch(config.remote)
|
|
134
|
+
submit(source, parent, git, config, is_release=False)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def release() -> None:
|
|
138
|
+
git, config = context()
|
|
139
|
+
source = git.branch()
|
|
140
|
+
epic_name(source)
|
|
141
|
+
submit(source, config.production_branch, git, config, is_release=True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def status() -> None:
|
|
145
|
+
git, config = context()
|
|
146
|
+
branch = git.branch()
|
|
147
|
+
task = TASK_PATTERN.fullmatch(branch)
|
|
148
|
+
epic = f"epic/{task[1] if task else epic_name(branch)}"
|
|
149
|
+
git.fetch(config.remote)
|
|
150
|
+
production_ref = git.remote_ref(config.remote, config.production_branch)
|
|
151
|
+
print(f"Branch: {branch}\nEpic: {epic}\nProduction: {config.remote}/{config.production_branch}")
|
|
152
|
+
local_epic = f"refs/heads/{epic}"
|
|
153
|
+
remote_epic = f"refs/remotes/{config.remote}/{epic}"
|
|
154
|
+
for label, ref in (
|
|
155
|
+
("Local epic vs production", local_epic),
|
|
156
|
+
("Remote epic vs production", remote_epic),
|
|
157
|
+
):
|
|
158
|
+
if not git.exists(ref):
|
|
159
|
+
print(f"{label}: not present")
|
|
160
|
+
continue
|
|
161
|
+
drift = git.divergence(production_ref, ref)
|
|
162
|
+
print(f"{label}: {drift.behind} behind, {drift.ahead} ahead")
|
|
163
|
+
if git.exists(local_epic) and git.exists(remote_epic):
|
|
164
|
+
drift = git.divergence(remote_epic, local_epic)
|
|
165
|
+
print(f"Local epic vs remote epic: {drift.behind} behind, {drift.ahead} ahead")
|
|
166
|
+
if git.dirty():
|
|
167
|
+
print("Working tree: uncommitted changes")
|
|
168
|
+
hosting = GitLab(git, config.remote)
|
|
169
|
+
requests = hosting.list(branch if task else None, epic)
|
|
170
|
+
requests.extend(hosting.list(epic, config.production_branch))
|
|
171
|
+
if not requests:
|
|
172
|
+
print("No open merge requests for this task/epic.")
|
|
173
|
+
for mr in requests:
|
|
174
|
+
details = hosting.details(mr.iid)
|
|
175
|
+
print(
|
|
176
|
+
f"MR !{mr.iid}: {mr.source_branch} → {mr.target_branch}\n"
|
|
177
|
+
f"{mr.web_url}\nCI: {details.pipeline_status}"
|
|
178
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-epic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Epic-based Git workflows and GitLab merge requests
|
|
5
|
+
Author: Wonderteam
|
|
6
|
+
Project-URL: Repository, https://gitlab.wonderbeauties.com/wonderteam/git-epic
|
|
7
|
+
Project-URL: Issues, https://gitlab.wonderbeauties.com/wonderteam/git-epic/-/issues
|
|
8
|
+
Keywords: git,cli,gitlab,epic,workflow
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff<1,>=0.11; extra == "dev"
|
|
24
|
+
Requires-Dist: twine<7,>=6; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# git-epic
|
|
27
|
+
|
|
28
|
+
A Python CLI for our epic-based Git workflow, with no runtime Python dependencies.
|
|
29
|
+
Git discovers the `git-epic`
|
|
30
|
+
executable automatically, so commands use `git epic …`.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Requires Python 3.11+, Git 2.31+, and [glab](https://gitlab.com/gitlab-org/cli).
|
|
35
|
+
Authenticate once with your GitLab host:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
glab auth login --hostname gitlab.wonderbeauties.com
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
After the first PyPI release, install with [pipx](https://pipx.pypa.io/):
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pipx install git-epic
|
|
45
|
+
pipx ensurepath
|
|
46
|
+
git epic -h
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For local development before publication:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
pipx install .
|
|
53
|
+
pipx ensurepath
|
|
54
|
+
git epic -h
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If `ensurepath` changes your PATH, open a new terminal. Upgrade with
|
|
58
|
+
`pipx upgrade git-epic`. The package is not yet published; `pipx install .` works
|
|
59
|
+
from a local checkout today.
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
Use `git epic -h` for CLI help. Git reserves `git epic --help` for a system manual
|
|
64
|
+
page; this MVP does not install one. Direct invocation also supports
|
|
65
|
+
`git-epic --help`.
|
|
66
|
+
|
|
67
|
+
| Command | Behavior |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `git epic init` | Write shared `.git-epic.json`; defaults to `production`, `origin`, and GitLab. |
|
|
70
|
+
| `git epic start <name>` | Fetch production; create and switch to local `epic/<name>`. |
|
|
71
|
+
| `git epic task start <name>` | From an epic, fetch and fast-forward remote epic changes if possible; create `task/<epic>/<name>`. |
|
|
72
|
+
| `git epic task submit` | Push committed task changes; create or return its parent epic MR. Publish the local parent epic if it is not yet on the remote. |
|
|
73
|
+
| `git epic sync` | From an epic, fetch and merge production. Push the result yourself or with `release`. |
|
|
74
|
+
| `git epic status` | Fetch; show current task/epic, labelled local/remote epic drift, related open MRs (including task MRs when on an epic), and their latest reported CI status. |
|
|
75
|
+
| `git epic release` | Push committed epic changes; create or return an MR targeting production. **Never merges or deploys.** |
|
|
76
|
+
|
|
77
|
+
GitLab calls PRs **merge requests (MRs)**. Submission commands also push new commits
|
|
78
|
+
when returning an existing MR; they do not create a duplicate.
|
|
79
|
+
|
|
80
|
+
Names are single segments: letters, numbers, dots, underscores, and hyphens, starting
|
|
81
|
+
with a letter or number. Git's branch-name restrictions also apply.
|
|
82
|
+
|
|
83
|
+
For repositories using a different production branch or remote:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
git epic init --production main --remote origin
|
|
87
|
+
git add .git-epic.json
|
|
88
|
+
git commit -m "Configure git-epic"
|
|
89
|
+
git push
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Commit this file on production before creating epics so all branches and teammates
|
|
93
|
+
inherit it. It contains no credentials. Existing configuration is never overwritten;
|
|
94
|
+
edit the file to change settings. An initial production commit must already exist on
|
|
95
|
+
the remote; `init` does not bootstrap an empty application repository.
|
|
96
|
+
|
|
97
|
+
## Typical workflow
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
git epic start checkout
|
|
101
|
+
git epic task start payment
|
|
102
|
+
# Implement and commit your task.
|
|
103
|
+
git epic task submit
|
|
104
|
+
# Review, CI, and merge the task MR in GitLab.
|
|
105
|
+
git switch epic/checkout
|
|
106
|
+
# Bring merged tasks into the local epic.
|
|
107
|
+
git fetch origin
|
|
108
|
+
git merge --ff-only origin/epic/checkout
|
|
109
|
+
git epic sync
|
|
110
|
+
git push origin epic/checkout
|
|
111
|
+
# Deploy OTF and obtain QA approval through the existing team process.
|
|
112
|
+
git epic release
|
|
113
|
+
# Review, required CI, and merge in GitLab; CI/CD deploys production.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
OTF deployment and QA approval are external to this CLI. Approval belongs to an
|
|
117
|
+
exact commit/build. If the candidate or production changes, update and revalidate
|
|
118
|
+
before merging. `release` refuses an epic that is behind current production, but
|
|
119
|
+
the hosting platform/CI must also enforce release checks at merge time. The CLI
|
|
120
|
+
does not record or verify QA approval.
|
|
121
|
+
|
|
122
|
+
## Git behavior
|
|
123
|
+
|
|
124
|
+
- Branch and submission operations require a clean working tree and no unfinished
|
|
125
|
+
merge/rebase/cherry-pick/revert. `status` allows uncommitted changes.
|
|
126
|
+
- Fetch updates remote-tracking refs. No command force-pushes, resets, deletes a
|
|
127
|
+
branch, merges an MR, or deploys.
|
|
128
|
+
- `start` creates a local epic. Submission publishes branches with ordinary pushes;
|
|
129
|
+
non-fast-forward pushes are rejected rather than overwriting teammates' work.
|
|
130
|
+
- `task start` stops if local and remote epic histories have diverged. Reconcile
|
|
131
|
+
the epic using Git before retrying. `release` similarly requires your local epic
|
|
132
|
+
to include remote epic work before a push can succeed.
|
|
133
|
+
- If `sync` conflicts, resolve and commit, or use `git merge --abort`.
|
|
134
|
+
- An MR/API failure after a push leaves the branch published; retry the command.
|
|
135
|
+
- GitLab authentication is managed by `glab`, and Git authentication by Git.
|
|
136
|
+
|
|
137
|
+
## Development and release
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
python3 -m venv .venv
|
|
141
|
+
source .venv/bin/activate
|
|
142
|
+
python -m pip install -e '.[dev]'
|
|
143
|
+
ruff check .
|
|
144
|
+
ruff format --check .
|
|
145
|
+
python -m unittest discover -s tests -v
|
|
146
|
+
python -m build
|
|
147
|
+
python -m twine check --strict dist/*.whl dist/*.tar.gz
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Tests create temporary local Git repositories and a fake `glab`; they never create
|
|
151
|
+
real MRs or touch production. GitLab API behavior is delegated to the installed
|
|
152
|
+
`glab`. CI checks Python 3.11 and 3.14, builds a wheel and source distribution,
|
|
153
|
+
validates metadata with Twine, and smoke-tests the installed wheel via Git.
|
|
154
|
+
|
|
155
|
+
## Publish to PyPI
|
|
156
|
+
|
|
157
|
+
### Local publication helper
|
|
158
|
+
|
|
159
|
+
From a clean, committed checkout with a configured Git upstream, run:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
./publish.sh
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The helper uses `uv` to prompt for a PEP 440 version (press Enter to keep the
|
|
166
|
+
current version for the first release), update the version and README release-tag
|
|
167
|
+
examples, run tests, clean and build distributions, validate with Twine, upload
|
|
168
|
+
to PyPI, and commit/push the release changes. Set `PYPI_API_TOKEN` or
|
|
169
|
+
`TWINE_PASSWORD` in your shell; otherwise Twine prompts for authentication.
|
|
170
|
+
It does not create a release tag or trigger the CI publication job.
|
|
171
|
+
|
|
172
|
+
### GitLab CI publication
|
|
173
|
+
|
|
174
|
+
The distribution name is **`git-epic`**, import package is **`git_epic`**, and CLI
|
|
175
|
+
entry point is **`git-epic`**. Version is defined once in
|
|
176
|
+
`src/git_epic/__init__.py`.
|
|
177
|
+
|
|
178
|
+
1. Create a PyPI account and an API token. For the first release, a token with
|
|
179
|
+
account scope is needed; after creating the project, use a project-scoped token.
|
|
180
|
+
2. In GitLab CI/CD variables, add **`PYPI_API_TOKEN`** as a masked, protected
|
|
181
|
+
variable. Protect the release tag pattern (`v*`) so tagged jobs can access it.
|
|
182
|
+
3. Update the version, then push a matching tag, for example `v0.1.0`.
|
|
183
|
+
4. After verification and build jobs pass, manually run **`publish:pypi`**.
|
|
184
|
+
|
|
185
|
+
The job uploads only the validated wheel and source distribution from the build
|
|
186
|
+
job. A release tag must match the package version. PyPI does not allow reusing
|
|
187
|
+
an uploaded version: use a new version for subsequent releases.
|
|
188
|
+
|
|
189
|
+
An optional **`publish:testpypi`** job uses **`TESTPYPI_API_TOKEN`** and the separate
|
|
190
|
+
TestPyPI registry. TestPyPI accounts and tokens are independent of PyPI. A test
|
|
191
|
+
installation uses `pipx install --index-url https://test.pypi.org/simple/ git-epic`.
|
|
192
|
+
|
|
193
|
+
For a manual upload of locally built, validated distributions:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
export TWINE_USERNAME=__token__
|
|
197
|
+
# Set TWINE_PASSWORD to your PyPI API token in your shell.
|
|
198
|
+
python -m twine upload --non-interactive dist/*.whl dist/*.tar.gz
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Credentials are supplied by the shell or CI, never stored in project files. PyPI
|
|
202
|
+
Trusted Publishing currently supports GitLab.com, not this self-managed GitLab
|
|
203
|
+
host, so the release jobs use API tokens. Package-name availability is determined
|
|
204
|
+
by PyPI at upload time. A public PyPI release includes the source code even when
|
|
205
|
+
the GitLab repository is private.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
git_epic/__init__.py,sha256=TaPmkb9usOUgMeLVOsyHpxV_jLvApDluZt-Wkf5ZnXM,55
|
|
2
|
+
git_epic/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
git_epic/cli.py,sha256=nZ5j_ekYdc8BWiSA1UO3GVQZKqkKwt6-ytwsMJzYDNk,2271
|
|
4
|
+
git_epic/config.py,sha256=ljEHqKn94levnqKT-j8Y-CHNm0IZBlCvi1INMa5VJBQ,1774
|
|
5
|
+
git_epic/git.py,sha256=3hHhwnT6wUyAbV3Q5xDRAncfivQzDElc1UJCV1lf0N8,2905
|
|
6
|
+
git_epic/gitlab.py,sha256=rPxoVzEMKawe3q-PsC3v2-igBQko2Sk5CEsZtLeEJ_s,3843
|
|
7
|
+
git_epic/process.py,sha256=cEfNjoNHbGEG7lZk3aJskeJ7QF-9kQ2S2qqX3Lc5Glo,949
|
|
8
|
+
git_epic/workflow.py,sha256=pG1XGPZftFWjcg2xIlWwGKjTpTOSdp9D7I9-qKFV5GM,6544
|
|
9
|
+
git_epic-0.1.0.dist-info/METADATA,sha256=HKnespcLSHQ9c8oYI2Pt2WTu1DaQ8x4PXXc4y7Dse-M,8488
|
|
10
|
+
git_epic-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
git_epic-0.1.0.dist-info/entry_points.txt,sha256=xIHqx8kk4Vsqv-GDrN4d_zKEhKyT3w8bFgDFTJVojfY,47
|
|
12
|
+
git_epic-0.1.0.dist-info/top_level.txt,sha256=32PQr4miBSJjSknOJlMf5aT-3NZ4Bo7g9-kSHmrOux8,9
|
|
13
|
+
git_epic-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
git_epic
|