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.
Files changed (55) hide show
  1. se_admin/__init__.py +1 -0
  2. se_admin/__main__.py +6 -0
  3. se_admin/_version.py +24 -0
  4. se_admin/actions/__init__.py +35 -0
  5. se_admin/actions/copy_file.py +57 -0
  6. se_admin/actions/dependabot.py +58 -0
  7. se_admin/actions/git_pull.py +18 -0
  8. se_admin/actions/patch_markdown.py +108 -0
  9. se_admin/actions/patch_toml.py +149 -0
  10. se_admin/actions/replace_file.py +93 -0
  11. se_admin/actions/run_command.py +38 -0
  12. se_admin/app.py +374 -0
  13. se_admin/checks/__init__.py +83 -0
  14. se_admin/checks/exact_files.py +72 -0
  15. se_admin/checks/python_version.py +94 -0
  16. se_admin/checks/reference_files.py +63 -0
  17. se_admin/checks/required_paths.py +29 -0
  18. se_admin/checks/tags.py +54 -0
  19. se_admin/checks/workflows.py +29 -0
  20. se_admin/cli.py +89 -0
  21. se_admin/domain/__init__.py +1 -0
  22. se_admin/domain/capabilities.py +20 -0
  23. se_admin/domain/findings.py +34 -0
  24. se_admin/domain/operations.py +194 -0
  25. se_admin/domain/profiles.py +92 -0
  26. se_admin/domain/repos.py +77 -0
  27. se_admin/domain/selectors.py +38 -0
  28. se_admin/domain/tasks.py +72 -0
  29. se_admin/migrations/__init__.py +17 -0
  30. se_admin/migrations/python_package_profile.py +62 -0
  31. se_admin/migrations/python_tooling_profile.py +54 -0
  32. se_admin/migrations/python_version.py +65 -0
  33. se_admin/migrations/replace_mkdocs_with_zensical.py +108 -0
  34. se_admin/migrations/workflow_names.py +82 -0
  35. se_admin/observe/__init__.py +91 -0
  36. se_admin/observe/filesystem.py +41 -0
  37. se_admin/observe/git.py +74 -0
  38. se_admin/observe/github.py +80 -0
  39. se_admin/observe/pyproject.py +52 -0
  40. se_admin/observe/toml_files.py +54 -0
  41. se_admin/observe/workflows.py +43 -0
  42. se_admin/py.typed +0 -0
  43. se_admin/reports/__init__.py +13 -0
  44. se_admin/reports/json_report.py +62 -0
  45. se_admin/reports/markdown.py +78 -0
  46. se_admin/reports/summary.py +56 -0
  47. se_admin/utils/__init__.py +1 -0
  48. se_admin/utils/paths.py +41 -0
  49. se_admin/utils/subprocesses.py +23 -0
  50. se_admin/utils/text.py +55 -0
  51. se_admin-0.2.0.dist-info/METADATA +258 -0
  52. se_admin-0.2.0.dist-info/RECORD +55 -0
  53. se_admin-0.2.0.dist-info/WHEEL +4 -0
  54. se_admin-0.2.0.dist-info/entry_points.txt +2 -0
  55. 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
@@ -0,0 +1,6 @@
1
+ """Module entry point."""
2
+
3
+ from se_admin.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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
+ )