Coverage for gco_mcp / resources / ci.py: 100.00%
140 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""CI/CD resources (ci:// scheme) for the GCO MCP server."""
3from pathlib import Path
5from cli_runner import PROJECT_ROOT # runtime-resolved checkout root (uvx-safe)
6from server import mcp
8GITHUB_DIR = PROJECT_ROOT / ".github"
9GITHUB_WORKFLOWS_DIR = GITHUB_DIR / "workflows"
10GITHUB_ACTIONS_DIR = GITHUB_DIR / "actions"
11GITHUB_SCRIPTS_DIR = GITHUB_DIR / "scripts"
12GITHUB_ISSUE_TEMPLATE_DIR = GITHUB_DIR / "ISSUE_TEMPLATE"
13GITHUB_KIND_DIR = GITHUB_DIR / "kind"
14GITHUB_CODEQL_DIR = GITHUB_DIR / "codeql"
16_CI_EXTENSIONS = {".yml", ".yaml", ".md", ".sh", ".py", ".json", ".toml"}
17_CI_CONFIG_FILES = {
18 "CI.md",
19 "CODEOWNERS",
20 "SECURITY.md",
21 "dependabot.yml",
22 "release.yml",
23 "pull_request_template.md",
24}
27def _ci_read(path: Path, kind: str, available_root: Path | None = None) -> str:
28 """Read a file from the .github/ tree with consistent error handling."""
29 if path.is_file():
30 if path.suffix and path.suffix not in _CI_EXTENSIONS and path.name not in _CI_CONFIG_FILES:
31 return f"File type '{path.suffix}' not served. Allowed: {', '.join(sorted(_CI_EXTENSIONS))}"
32 return path.read_text()
33 if available_root is not None and available_root.is_dir():
34 available = sorted(f.name for f in available_root.iterdir() if f.is_file())
35 return f"{kind} '{path.name}' not found. Available:\n" + "\n".join(available)
36 return f"{kind} '{path.name}' not found."
39@mcp.resource("ci://gco/index")
40def ci_index() -> str:
41 """List CI/CD artefacts under .github/."""
42 lines = ["# GitHub Actions & CI Configuration\n"]
44 lines.append("## Documentation & Policy")
45 for name in ("CI.md", "SECURITY.md", "CODEOWNERS"):
46 if (GITHUB_DIR / name).is_file():
47 lines.append(f"- `ci://gco/config/{name}`")
48 lines.append("")
50 if GITHUB_WORKFLOWS_DIR.is_dir():
51 workflow_files = sorted(
52 f
53 for f in GITHUB_WORKFLOWS_DIR.iterdir()
54 if f.is_file() and f.suffix in {".yml", ".yaml"}
55 )
56 if workflow_files:
57 lines.append(f"## Workflows ({len(workflow_files)} files)")
58 lines.append("Run on every push and PR unless otherwise noted.")
59 for f in workflow_files:
60 lines.append(f"- `ci://gco/workflows/{f.name}` — {f.stem}")
61 lines.append("")
63 if GITHUB_ACTIONS_DIR.is_dir():
64 action_names = sorted(d.name for d in GITHUB_ACTIONS_DIR.iterdir() if d.is_dir())
65 if action_names:
66 lines.append(f"## Composite Actions ({len(action_names)})")
67 lines.append("Reusable action definitions referenced by the workflows above.")
68 for name in action_names:
69 lines.append(f"- `ci://gco/actions/{name}` — {name}")
70 lines.append("")
72 if GITHUB_SCRIPTS_DIR.is_dir():
73 script_files = sorted(
74 f for f in GITHUB_SCRIPTS_DIR.iterdir() if f.is_file() and f.suffix in {".sh", ".py"}
75 )
76 if script_files:
77 lines.append(f"## Scripts ({len(script_files)})")
78 lines.append("Helper scripts invoked from the workflows.")
79 for f in script_files:
80 lines.append(f"- `ci://gco/scripts/{f.name}` — {f.stem}")
81 lines.append("")
83 template_entries: list[str] = []
84 if GITHUB_ISSUE_TEMPLATE_DIR.is_dir():
85 for f in sorted(GITHUB_ISSUE_TEMPLATE_DIR.iterdir()):
86 if f.is_file() and f.suffix in {".md", ".yml", ".yaml"}:
87 template_entries.append(f"- `ci://gco/templates/{f.name}` — {f.stem}")
88 if (GITHUB_DIR / "pull_request_template.md").is_file():
89 template_entries.append(
90 "- `ci://gco/templates/pull_request_template.md` — Pull request template"
91 )
92 if template_entries:
93 lines.append(f"## Issue & PR Templates ({len(template_entries)})")
94 lines.extend(template_entries)
95 lines.append("")
97 if GITHUB_CODEQL_DIR.is_dir():
98 codeql_files = sorted(f for f in GITHUB_CODEQL_DIR.iterdir() if f.is_file())
99 if codeql_files:
100 lines.append("## CodeQL Configuration")
101 for f in codeql_files:
102 lines.append(f"- `ci://gco/codeql/{f.name}` — {f.stem}")
103 lines.append("")
105 if GITHUB_KIND_DIR.is_dir():
106 kind_files = sorted(f for f in GITHUB_KIND_DIR.iterdir() if f.is_file())
107 if kind_files:
108 lines.append("## Kind Cluster Configuration")
109 for f in kind_files:
110 lines.append(f"- `ci://gco/kind/{f.name}` — {f.stem}")
111 lines.append("")
113 automation_entries: list[str] = []
114 if (GITHUB_DIR / "dependabot.yml").is_file():
115 automation_entries.append("- `ci://gco/config/dependabot.yml` — Dependabot config")
116 if (GITHUB_DIR / "release.yml").is_file():
117 automation_entries.append(
118 "- `ci://gco/config/release.yml` — Release notes auto-categorisation"
119 )
120 if automation_entries:
121 lines.append("## Repo Automation")
122 lines.extend(automation_entries)
123 lines.append("")
125 lines.append("## Related Resources")
126 lines.append("- `infra://gco/index` — Dockerfiles and Helm charts")
127 lines.append("- `scripts://gco/index` — Utility scripts outside CI")
128 lines.append("- `source://gco/index` — Full source code browser")
129 return "\n".join(lines)
132@mcp.resource("ci://gco/workflows/{filename}")
133def ci_workflow_resource(filename: str) -> str:
134 """Read a GitHub Actions workflow YAML file."""
135 return _ci_read(GITHUB_WORKFLOWS_DIR / filename, "Workflow", GITHUB_WORKFLOWS_DIR)
138@mcp.resource("ci://gco/actions/{action_name}")
139def ci_action_resource(action_name: str) -> str:
140 """Read the action.yml for a composite action under .github/actions/."""
141 action_name = action_name.removesuffix("/action.yml").strip("/")
142 action_path = GITHUB_ACTIONS_DIR / action_name / "action.yml"
143 if action_path.is_file():
144 return action_path.read_text()
145 action_path_alt = GITHUB_ACTIONS_DIR / action_name / "action.yaml"
146 if action_path_alt.is_file():
147 return action_path_alt.read_text()
148 if GITHUB_ACTIONS_DIR.is_dir():
149 available = sorted(d.name for d in GITHUB_ACTIONS_DIR.iterdir() if d.is_dir())
150 return f"Composite action '{action_name}' not found. Available:\n" + "\n".join(available)
151 return f"Composite action '{action_name}' not found."
154@mcp.resource("ci://gco/scripts/{filename}")
155def ci_script_resource(filename: str) -> str:
156 """Read a helper script from .github/scripts/."""
157 return _ci_read(GITHUB_SCRIPTS_DIR / filename, "Script", GITHUB_SCRIPTS_DIR)
160@mcp.resource("ci://gco/templates/{filename}")
161def ci_template_resource(filename: str) -> str:
162 """Read an issue or pull-request template."""
163 issue_path = GITHUB_ISSUE_TEMPLATE_DIR / filename
164 if issue_path.is_file():
165 return issue_path.read_text()
166 pr_path = GITHUB_DIR / filename
167 if pr_path.is_file() and filename.startswith("pull_request_template"):
168 return pr_path.read_text()
169 available: list[str] = []
170 if GITHUB_ISSUE_TEMPLATE_DIR.is_dir():
171 available.extend(f.name for f in GITHUB_ISSUE_TEMPLATE_DIR.iterdir() if f.is_file())
172 if (GITHUB_DIR / "pull_request_template.md").is_file():
173 available.append("pull_request_template.md")
174 available.sort()
175 return f"Template '{filename}' not found. Available:\n" + "\n".join(available)
178@mcp.resource("ci://gco/codeql/{filename}")
179def ci_codeql_resource(filename: str) -> str:
180 """Read a CodeQL configuration file."""
181 return _ci_read(GITHUB_CODEQL_DIR / filename, "CodeQL config", GITHUB_CODEQL_DIR)
184@mcp.resource("ci://gco/kind/{filename}")
185def ci_kind_resource(filename: str) -> str:
186 """Read a kind (Kubernetes-in-Docker) config file."""
187 return _ci_read(GITHUB_KIND_DIR / filename, "Kind config", GITHUB_KIND_DIR)
190@mcp.resource("ci://gco/config/{filename}")
191def ci_config_resource(filename: str) -> str:
192 """Read a repo-level CI config file (CI.md, CODEOWNERS, etc.)."""
193 if filename not in _CI_CONFIG_FILES:
194 return (
195 f"Config file '{filename}' is not in the served allowlist. "
196 f"Allowed: {', '.join(sorted(_CI_CONFIG_FILES))}"
197 )
198 path = GITHUB_DIR / filename
199 if not path.is_file():
200 return f"Config file '{filename}' not found."
201 return path.read_text()