se-admin 0.2.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.
- se_admin/__init__.py +1 -0
- se_admin/__main__.py +6 -0
- se_admin/_version.py +24 -0
- se_admin/actions/__init__.py +35 -0
- se_admin/actions/copy_file.py +57 -0
- se_admin/actions/dependabot.py +58 -0
- se_admin/actions/git_pull.py +18 -0
- se_admin/actions/patch_markdown.py +108 -0
- se_admin/actions/patch_toml.py +149 -0
- se_admin/actions/replace_file.py +93 -0
- se_admin/actions/run_command.py +38 -0
- se_admin/app.py +374 -0
- se_admin/checks/__init__.py +83 -0
- se_admin/checks/exact_files.py +72 -0
- se_admin/checks/python_version.py +94 -0
- se_admin/checks/reference_files.py +63 -0
- se_admin/checks/required_paths.py +29 -0
- se_admin/checks/tags.py +54 -0
- se_admin/checks/workflows.py +29 -0
- se_admin/cli.py +89 -0
- se_admin/domain/__init__.py +1 -0
- se_admin/domain/capabilities.py +20 -0
- se_admin/domain/findings.py +34 -0
- se_admin/domain/operations.py +194 -0
- se_admin/domain/profiles.py +92 -0
- se_admin/domain/repos.py +77 -0
- se_admin/domain/selectors.py +38 -0
- se_admin/domain/tasks.py +72 -0
- se_admin/migrations/__init__.py +17 -0
- se_admin/migrations/python_package_profile.py +62 -0
- se_admin/migrations/python_tooling_profile.py +54 -0
- se_admin/migrations/python_version.py +65 -0
- se_admin/migrations/replace_mkdocs_with_zensical.py +108 -0
- se_admin/migrations/workflow_names.py +82 -0
- se_admin/observe/__init__.py +91 -0
- se_admin/observe/filesystem.py +41 -0
- se_admin/observe/git.py +74 -0
- se_admin/observe/github.py +80 -0
- se_admin/observe/pyproject.py +52 -0
- se_admin/observe/toml_files.py +54 -0
- se_admin/observe/workflows.py +43 -0
- se_admin/py.typed +0 -0
- se_admin/reports/__init__.py +13 -0
- se_admin/reports/json_report.py +62 -0
- se_admin/reports/markdown.py +78 -0
- se_admin/reports/summary.py +56 -0
- se_admin/utils/__init__.py +1 -0
- se_admin/utils/paths.py +41 -0
- se_admin/utils/subprocesses.py +23 -0
- se_admin/utils/text.py +55 -0
- se_admin-0.2.0.dist-info/METADATA +258 -0
- se_admin-0.2.0.dist-info/RECORD +55 -0
- se_admin-0.2.0.dist-info/WHEEL +4 -0
- se_admin-0.2.0.dist-info/entry_points.txt +2 -0
- se_admin-0.2.0.dist-info/licenses/LICENSE +21 -0
se_admin/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Structural Explainability admin package."""
|
se_admin/__main__.py
ADDED
se_admin/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""se_admin actions layer - primitive mutations, single responsibility each.
|
|
2
|
+
|
|
3
|
+
Every action:
|
|
4
|
+
- is deterministic
|
|
5
|
+
- is idempotent (no-op if already in target state)
|
|
6
|
+
- returns ActionResult
|
|
7
|
+
- scopes side effects to the target repo_path
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Self
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ActionResult:
|
|
16
|
+
"""Result of performing an action."""
|
|
17
|
+
|
|
18
|
+
ok: bool
|
|
19
|
+
changed: bool
|
|
20
|
+
message: str | None = None
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def noop(cls, message: str | None = None) -> Self:
|
|
24
|
+
"""Return an ActionResult representing a no-op (no changes made)."""
|
|
25
|
+
return cls(ok=True, changed=False, message=message)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def done(cls, message: str | None = None) -> Self:
|
|
29
|
+
"""Return an ActionResult representing a successful action with changes."""
|
|
30
|
+
return cls(ok=True, changed=True, message=message)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def error(cls, message: str) -> Self:
|
|
34
|
+
"""Return an ActionResult representing a failed action."""
|
|
35
|
+
return cls(ok=False, changed=False, message=message)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Filesystem mutation actions.
|
|
2
|
+
|
|
3
|
+
CopyFile - copy a file from source repo into target repo
|
|
4
|
+
DeleteFile - delete a file (no-op if missing)
|
|
5
|
+
EnsureDirectory - create a directory if absent
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
|
|
11
|
+
from se_admin.actions import ActionResult
|
|
12
|
+
from se_admin.domain.operations import CopyFile, DeleteFile, EnsureDirectory
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_copy_file(
|
|
16
|
+
op: CopyFile,
|
|
17
|
+
*,
|
|
18
|
+
target_path: Path,
|
|
19
|
+
source_path: Path,
|
|
20
|
+
) -> ActionResult:
|
|
21
|
+
"""Copy src (relative to source_path) to dest (relative to target_path).
|
|
22
|
+
|
|
23
|
+
Idempotent: overwrites if already present (content may differ).
|
|
24
|
+
Creates intermediate directories as needed.
|
|
25
|
+
"""
|
|
26
|
+
src = source_path / op.src
|
|
27
|
+
dest = target_path / op.dest
|
|
28
|
+
|
|
29
|
+
if not src.exists():
|
|
30
|
+
return ActionResult.error(f"Source not found: {src}")
|
|
31
|
+
|
|
32
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
already_identical = dest.exists() and dest.read_bytes() == src.read_bytes()
|
|
35
|
+
if already_identical:
|
|
36
|
+
return ActionResult.noop(f"Already identical: {op.dest}")
|
|
37
|
+
|
|
38
|
+
shutil.copy2(src, dest)
|
|
39
|
+
return ActionResult.done(f"Copied {op.src} to {op.dest}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_delete_file(op: DeleteFile, *, target_path: Path) -> ActionResult:
|
|
43
|
+
"""Delete a file. No-op if already missing."""
|
|
44
|
+
p = target_path / op.path
|
|
45
|
+
if not p.exists():
|
|
46
|
+
return ActionResult.noop(f"Already absent: {op.path}")
|
|
47
|
+
p.unlink()
|
|
48
|
+
return ActionResult.done(f"Deleted {op.path}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_ensure_directory(op: EnsureDirectory, *, target_path: Path) -> ActionResult:
|
|
52
|
+
"""Create a directory (and parents) if not already present."""
|
|
53
|
+
d = target_path / op.path
|
|
54
|
+
if d.is_dir():
|
|
55
|
+
return ActionResult.noop(f"Already exists: {op.path}")
|
|
56
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
return ActionResult.done(f"Created directory {op.path}")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""src/se_admin/actions/dependabot.py.
|
|
2
|
+
|
|
3
|
+
Actions related to Dependabot PRs, using GitHub CLI.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from se_admin.utils.subprocesses import run_command
|
|
10
|
+
|
|
11
|
+
# === List Dependabot PRs ===
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def list_dependabot_prs(repo_path: Path) -> list[dict]:
|
|
15
|
+
"""List open Dependabot PRs in the given repo, with metadata."""
|
|
16
|
+
result = run_command(
|
|
17
|
+
[
|
|
18
|
+
"gh",
|
|
19
|
+
"pr",
|
|
20
|
+
"list",
|
|
21
|
+
"--author",
|
|
22
|
+
"dependabot[bot]",
|
|
23
|
+
"--state",
|
|
24
|
+
"open",
|
|
25
|
+
"--json",
|
|
26
|
+
"number,title,mergeable,reviewDecision,statusCheckRollup",
|
|
27
|
+
],
|
|
28
|
+
cwd=repo_path,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if result.returncode != 0:
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
return json.loads(result.stdout)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# === Merge Dependabot PR ===
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def merge_dependabot_pr(repo_path: Path, pr_number: int) -> tuple[bool, str]:
|
|
41
|
+
"""Merge the given Dependabot PR number in the given repo, using squash merge."""
|
|
42
|
+
result = run_command(
|
|
43
|
+
[
|
|
44
|
+
"gh",
|
|
45
|
+
"pr",
|
|
46
|
+
"merge",
|
|
47
|
+
str(pr_number),
|
|
48
|
+
"--squash",
|
|
49
|
+
"--delete-branch",
|
|
50
|
+
],
|
|
51
|
+
cwd=repo_path,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if result.returncode == 0:
|
|
55
|
+
return True, result.stdout.strip()
|
|
56
|
+
|
|
57
|
+
message = result.stderr.strip() or result.stdout.strip()
|
|
58
|
+
return False, message
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""src/se_admin/actions/git_pull.py."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from se_admin.utils.subprocesses import run_command
|
|
6
|
+
|
|
7
|
+
# === Git Pull ===
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def git_pull(repo_path: Path) -> tuple[bool, str]:
|
|
11
|
+
"""Perform a git pull --ff-only in the given repo."""
|
|
12
|
+
result = run_command(["git", "pull", "--ff-only"], cwd=repo_path)
|
|
13
|
+
|
|
14
|
+
if result.returncode == 0:
|
|
15
|
+
return True, result.stdout.strip()
|
|
16
|
+
|
|
17
|
+
message = result.stderr.strip() or result.stdout.strip()
|
|
18
|
+
return False, message
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Markdown section mutation action.
|
|
2
|
+
|
|
3
|
+
PatchMarkdownSection - find a heading block and replace, insert, or remove it.
|
|
4
|
+
|
|
5
|
+
Section boundaries are defined by ATX headings (# through ######).
|
|
6
|
+
A section runs from its heading line to the line before the next
|
|
7
|
+
heading of equal or lesser depth (or end of file).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from se_admin.actions import ActionResult
|
|
14
|
+
from se_admin.domain.operations import PatchMarkdownSection
|
|
15
|
+
|
|
16
|
+
_HEADING = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _heading_depth(line: str) -> int:
|
|
20
|
+
m = re.match(r"^(#{1,6})\s", line)
|
|
21
|
+
return len(m.group(1)) if m else 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_section(lines: list[str], heading_text: str) -> tuple[int, int] | None:
|
|
25
|
+
"""Return (start, end) line indices for the section, or None if not found.
|
|
26
|
+
|
|
27
|
+
*start* is the heading line; *end* is exclusive (first line of next
|
|
28
|
+
same-or-lesser-depth heading, or len(lines)).
|
|
29
|
+
"""
|
|
30
|
+
for i, line in enumerate(lines):
|
|
31
|
+
m = re.match(r"^(#{1,6})\s+(.+)$", line.rstrip())
|
|
32
|
+
if m and m.group(2).strip() == heading_text.strip():
|
|
33
|
+
depth = len(m.group(1))
|
|
34
|
+
end = len(lines)
|
|
35
|
+
for j in range(i + 1, len(lines)):
|
|
36
|
+
d = _heading_depth(lines[j])
|
|
37
|
+
if d > 0 and d <= depth:
|
|
38
|
+
end = j
|
|
39
|
+
break
|
|
40
|
+
return i, end
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_patch_markdown_section(
|
|
45
|
+
op: PatchMarkdownSection, *, target_path: Path
|
|
46
|
+
) -> ActionResult:
|
|
47
|
+
"""Run the given PatchMarkdownSection operation on the target repo."""
|
|
48
|
+
dispatch = {
|
|
49
|
+
"replace": _replace_section,
|
|
50
|
+
"insert": _insert_section,
|
|
51
|
+
"remove": _remove_section,
|
|
52
|
+
}
|
|
53
|
+
fn = dispatch.get(op.operation)
|
|
54
|
+
if fn is None:
|
|
55
|
+
return ActionResult.error(
|
|
56
|
+
f"Unknown PatchMarkdownSection operation: {op.operation!r}"
|
|
57
|
+
)
|
|
58
|
+
return fn(op, target_path=target_path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _replace_section(op: PatchMarkdownSection, *, target_path: Path) -> ActionResult:
|
|
62
|
+
p = target_path / op.file
|
|
63
|
+
if not p.exists():
|
|
64
|
+
return ActionResult.error(f"File not found: {op.file}")
|
|
65
|
+
|
|
66
|
+
lines = p.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
67
|
+
span = _find_section(lines, op.section)
|
|
68
|
+
if span is None:
|
|
69
|
+
return ActionResult.error(f"Section not found: {op.section!r} in {op.file}")
|
|
70
|
+
|
|
71
|
+
start, end = span
|
|
72
|
+
replacement = (op.content or "").rstrip("\n") + "\n"
|
|
73
|
+
new_lines = lines[:start] + [replacement] + lines[end:]
|
|
74
|
+
p.write_text("".join(new_lines), encoding="utf-8")
|
|
75
|
+
return ActionResult.done(f"Replaced section {op.section!r} in {op.file}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _insert_section(op: PatchMarkdownSection, *, target_path: Path) -> ActionResult:
|
|
79
|
+
"""Append the section at end of file if not already present."""
|
|
80
|
+
p = target_path / op.file
|
|
81
|
+
text = p.read_text(encoding="utf-8") if p.exists() else ""
|
|
82
|
+
lines = text.splitlines(keepends=True)
|
|
83
|
+
|
|
84
|
+
if _find_section(lines, op.section) is not None:
|
|
85
|
+
return ActionResult.noop(
|
|
86
|
+
f"Section already present: {op.section!r} in {op.file}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
content = (op.content or "").rstrip("\n") + "\n"
|
|
90
|
+
separator = "\n" if text and not text.endswith("\n\n") else ""
|
|
91
|
+
p.write_text(text + separator + content, encoding="utf-8")
|
|
92
|
+
return ActionResult.done(f"Inserted section {op.section!r} in {op.file}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _remove_section(op: PatchMarkdownSection, *, target_path: Path) -> ActionResult:
|
|
96
|
+
p = target_path / op.file
|
|
97
|
+
if not p.exists():
|
|
98
|
+
return ActionResult.noop(f"File absent: {op.file}")
|
|
99
|
+
|
|
100
|
+
lines = p.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
101
|
+
span = _find_section(lines, op.section)
|
|
102
|
+
if span is None:
|
|
103
|
+
return ActionResult.noop(f"Section already absent: {op.section!r} in {op.file}")
|
|
104
|
+
|
|
105
|
+
start, end = span
|
|
106
|
+
new_lines = lines[:start] + lines[end:]
|
|
107
|
+
p.write_text("".join(new_lines), encoding="utf-8")
|
|
108
|
+
return ActionResult.done(f"Removed section {op.section!r} from {op.file}")
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""TOML mutation action.
|
|
2
|
+
|
|
3
|
+
PatchToml dispatches to one of five operations:
|
|
4
|
+
set_key - write a value at a dotted key path
|
|
5
|
+
remove_key - delete a key (no-op if absent)
|
|
6
|
+
add_dependency - append to optional-dependency group (idempotent)
|
|
7
|
+
remove_dependency - remove from optional-dependency group (idempotent)
|
|
8
|
+
ensure_table - create a table header if absent
|
|
9
|
+
|
|
10
|
+
Uses tomlkit for style-preserving round-trip editing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import tomlkit
|
|
16
|
+
|
|
17
|
+
from se_admin.actions import ActionResult
|
|
18
|
+
from se_admin.domain.operations import PatchToml
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load(p: Path) -> tomlkit.TOMLDocument:
|
|
22
|
+
if p.exists():
|
|
23
|
+
return tomlkit.parse(p.read_text(encoding="utf-8"))
|
|
24
|
+
return tomlkit.document()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _save(p: Path, doc: tomlkit.TOMLDocument) -> None:
|
|
28
|
+
p.write_text(tomlkit.dumps(doc), encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve(doc: tomlkit.TOMLDocument, dotted: str) -> tuple[dict, str]:
|
|
32
|
+
"""Walk dotted key path; return (parent_table, final_key)."""
|
|
33
|
+
parts = dotted.split(".")
|
|
34
|
+
current: dict = doc # type: ignore[assignment]
|
|
35
|
+
for part in parts[:-1]:
|
|
36
|
+
if part not in current:
|
|
37
|
+
current[part] = tomlkit.table()
|
|
38
|
+
current = current[part] # type: ignore[assignment]
|
|
39
|
+
return current, parts[-1]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Dispatch
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_patch_toml(op: PatchToml, *, target_path: Path) -> ActionResult:
|
|
48
|
+
"""Run a PatchToml operation."""
|
|
49
|
+
dispatch = {
|
|
50
|
+
"set_key": _set_key,
|
|
51
|
+
"remove_key": _remove_key,
|
|
52
|
+
"add_dependency": _add_dependency,
|
|
53
|
+
"remove_dependency": _remove_dependency,
|
|
54
|
+
"ensure_table": _ensure_table,
|
|
55
|
+
}
|
|
56
|
+
fn = dispatch.get(op.operation)
|
|
57
|
+
if fn is None:
|
|
58
|
+
return ActionResult.error(f"Unknown PatchToml operation: {op.operation!r}")
|
|
59
|
+
return fn(op, target_path=target_path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Operations
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _set_key(op: PatchToml, *, target_path: Path) -> ActionResult:
|
|
68
|
+
"""Set a key in a TOML file."""
|
|
69
|
+
p = target_path / op.file
|
|
70
|
+
doc = _load(p)
|
|
71
|
+
parent, key = _resolve(doc, op.key) # type: ignore[arg-type]
|
|
72
|
+
if parent.get(key) == op.value:
|
|
73
|
+
return ActionResult.noop(f"Key already set: {op.key}")
|
|
74
|
+
parent[key] = op.value
|
|
75
|
+
_save(p, doc)
|
|
76
|
+
return ActionResult.done(f"Set {op.key} = {op.value!r} in {op.file}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _remove_key(op: PatchToml, *, target_path: Path) -> ActionResult:
|
|
80
|
+
p = target_path / op.file
|
|
81
|
+
if not p.exists():
|
|
82
|
+
return ActionResult.noop(f"File absent: {op.file}")
|
|
83
|
+
doc = _load(p)
|
|
84
|
+
parts = op.key.split(".") # type: ignore[union-attr]
|
|
85
|
+
current: dict = doc # type: ignore[assignment]
|
|
86
|
+
for part in parts[:-1]:
|
|
87
|
+
if part not in current:
|
|
88
|
+
return ActionResult.noop(f"Key already absent: {op.key}")
|
|
89
|
+
current = current[part] # type: ignore[assignment]
|
|
90
|
+
final = parts[-1]
|
|
91
|
+
if final not in current:
|
|
92
|
+
return ActionResult.noop(f"Key already absent: {op.key}")
|
|
93
|
+
del current[final]
|
|
94
|
+
_save(p, doc)
|
|
95
|
+
return ActionResult.done(f"Removed {op.key} from {op.file}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _add_dependency(op: PatchToml, *, target_path: Path) -> ActionResult:
|
|
99
|
+
"""Add op.name to [project.optional-dependencies.<group>]."""
|
|
100
|
+
p = target_path / op.file
|
|
101
|
+
doc = _load(p)
|
|
102
|
+
|
|
103
|
+
project = doc.setdefault("project", tomlkit.table())
|
|
104
|
+
opt = project.setdefault("optional-dependencies", tomlkit.table())
|
|
105
|
+
group_list: list = opt.setdefault(op.group, tomlkit.array())
|
|
106
|
+
|
|
107
|
+
name_lower = op.name.lower() # type: ignore[union-attr]
|
|
108
|
+
if any(str(d).lower().startswith(name_lower) for d in group_list):
|
|
109
|
+
return ActionResult.noop(f"{op.name!r} already in [{op.group}] in {op.file}")
|
|
110
|
+
|
|
111
|
+
group_list.append(op.name)
|
|
112
|
+
_save(p, doc)
|
|
113
|
+
return ActionResult.done(f"Added {op.name!r} to [{op.group}] in {op.file}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _remove_dependency(op: PatchToml, *, target_path: Path) -> ActionResult:
|
|
117
|
+
"""Remove op.name from [project.optional-dependencies.<group>]."""
|
|
118
|
+
p = target_path / op.file
|
|
119
|
+
if not p.exists():
|
|
120
|
+
return ActionResult.noop(f"File absent: {op.file}")
|
|
121
|
+
doc = _load(p)
|
|
122
|
+
|
|
123
|
+
group_list: list = (
|
|
124
|
+
doc.get("project", {}).get("optional-dependencies", {}).get(op.group, [])
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
name_lower = op.name.lower() # type: ignore[union-attr]
|
|
128
|
+
matches = [d for d in group_list if str(d).lower().startswith(name_lower)]
|
|
129
|
+
if not matches:
|
|
130
|
+
return ActionResult.noop(
|
|
131
|
+
f"{op.name!r} already absent from [{op.group}] in {op.file}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
for m in matches:
|
|
135
|
+
group_list.remove(m)
|
|
136
|
+
_save(p, doc)
|
|
137
|
+
return ActionResult.done(f"Removed {op.name!r} from [{op.group}] in {op.file}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _ensure_table(op: PatchToml, *, target_path: Path) -> ActionResult:
|
|
141
|
+
"""Create a table at the dotted key path if not already present."""
|
|
142
|
+
p = target_path / op.file
|
|
143
|
+
doc = _load(p)
|
|
144
|
+
parent, key = _resolve(doc, op.key) # type: ignore[arg-type]
|
|
145
|
+
if key in parent:
|
|
146
|
+
return ActionResult.noop(f"Table already exists: {op.key}")
|
|
147
|
+
parent[key] = tomlkit.table()
|
|
148
|
+
_save(p, doc)
|
|
149
|
+
return ActionResult.done(f"Created table {op.key} in {op.file}")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""File replacement and workflow mutation actions.
|
|
2
|
+
|
|
3
|
+
ReplaceFile - overwrite a file unconditionally from canonical source
|
|
4
|
+
EnsureWorkflow - copy workflow into .github/workflows/ if not present
|
|
5
|
+
ReplaceWorkflow - overwrite a workflow file unconditionally
|
|
6
|
+
RemoveWorkflow - delete a workflow file (no-op if missing)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
from se_admin.actions import ActionResult
|
|
13
|
+
from se_admin.domain.operations import (
|
|
14
|
+
EnsureWorkflow,
|
|
15
|
+
RemoveWorkflow,
|
|
16
|
+
ReplaceFile,
|
|
17
|
+
ReplaceWorkflow,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_WORKFLOWS = ".github/workflows"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_replace_file(
|
|
24
|
+
op: ReplaceFile,
|
|
25
|
+
*,
|
|
26
|
+
target_path: Path,
|
|
27
|
+
source_path: Path,
|
|
28
|
+
) -> ActionResult:
|
|
29
|
+
"""Overwrite dest in target_path from src in source_path.
|
|
30
|
+
|
|
31
|
+
Always writes (unconditional replacement).
|
|
32
|
+
Creates intermediate directories as needed.
|
|
33
|
+
"""
|
|
34
|
+
src = source_path / op.src
|
|
35
|
+
dest = target_path / op.dest
|
|
36
|
+
|
|
37
|
+
if not src.exists():
|
|
38
|
+
return ActionResult.error(f"Source not found: {src}")
|
|
39
|
+
|
|
40
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
shutil.copy2(src, dest)
|
|
42
|
+
return ActionResult.done(f"Replaced {op.dest}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_ensure_workflow(
|
|
46
|
+
op: EnsureWorkflow,
|
|
47
|
+
*,
|
|
48
|
+
target_path: Path,
|
|
49
|
+
source_path: Path,
|
|
50
|
+
) -> ActionResult:
|
|
51
|
+
"""Copy workflow into .github/workflows/ only if not already present."""
|
|
52
|
+
dest = target_path / _WORKFLOWS / op.name
|
|
53
|
+
if dest.exists():
|
|
54
|
+
return ActionResult.noop(f"Workflow already present: {op.name}")
|
|
55
|
+
|
|
56
|
+
src = source_path / op.src
|
|
57
|
+
if not src.exists():
|
|
58
|
+
return ActionResult.error(f"Source workflow not found: {src}")
|
|
59
|
+
|
|
60
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
shutil.copy2(src, dest)
|
|
62
|
+
return ActionResult.done(f"Added workflow {op.name}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_replace_workflow(
|
|
66
|
+
op: ReplaceWorkflow,
|
|
67
|
+
*,
|
|
68
|
+
target_path: Path,
|
|
69
|
+
source_path: Path,
|
|
70
|
+
) -> ActionResult:
|
|
71
|
+
"""Overwrite a workflow file unconditionally."""
|
|
72
|
+
src = source_path / op.src
|
|
73
|
+
dest = target_path / _WORKFLOWS / op.name
|
|
74
|
+
|
|
75
|
+
if not src.exists():
|
|
76
|
+
return ActionResult.error(f"Source workflow not found: {src}")
|
|
77
|
+
|
|
78
|
+
already_identical = dest.exists() and dest.read_bytes() == src.read_bytes()
|
|
79
|
+
if already_identical:
|
|
80
|
+
return ActionResult.noop(f"Workflow already up to date: {op.name}")
|
|
81
|
+
|
|
82
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
shutil.copy2(src, dest)
|
|
84
|
+
return ActionResult.done(f"Replaced workflow {op.name}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_remove_workflow(op: RemoveWorkflow, *, target_path: Path) -> ActionResult:
|
|
88
|
+
"""Delete a workflow file. No-op if already missing."""
|
|
89
|
+
p = target_path / _WORKFLOWS / op.name
|
|
90
|
+
if not p.exists():
|
|
91
|
+
return ActionResult.noop(f"Workflow already absent: {op.name}")
|
|
92
|
+
p.unlink()
|
|
93
|
+
return ActionResult.done(f"Removed workflow {op.name}")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Process mutation action.
|
|
2
|
+
|
|
3
|
+
RunCommand - run a shell command inside the target repo directory.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from se_admin.actions import ActionResult
|
|
10
|
+
from se_admin.domain.operations import RunCommand
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_run_command(op: RunCommand, *, target_path: Path) -> ActionResult:
|
|
14
|
+
"""Run op.command with op.args in target_path.
|
|
15
|
+
|
|
16
|
+
Returns ActionResult.done on exit code 0, ActionResult.error otherwise.
|
|
17
|
+
stdout/stderr are captured and surfaced in the message on failure.
|
|
18
|
+
"""
|
|
19
|
+
cmd = [op.command, *op.args]
|
|
20
|
+
|
|
21
|
+
result = subprocess.run( # noqa: S603
|
|
22
|
+
cmd,
|
|
23
|
+
cwd=target_path,
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if result.returncode == 0:
|
|
29
|
+
return ActionResult.done(
|
|
30
|
+
f"Ran {' '.join(cmd)!r}"
|
|
31
|
+
+ (f": {result.stdout.strip()}" if result.stdout.strip() else "")
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
35
|
+
return ActionResult.error(
|
|
36
|
+
f"Command failed (exit {result.returncode}): {' '.join(cmd)!r}"
|
|
37
|
+
+ (f"\n{detail}" if detail else "")
|
|
38
|
+
)
|