Coverage for mcp/resources/ci.py: 85%
140 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
1"""CI/CD resources (ci:// scheme) for the GCO MCP server."""
3from pathlib import Path
5from server import mcp
7PROJECT_ROOT = Path(__file__).parent.parent.parent
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", ".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: 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true
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(): 33 ↛ 36line 33 didn't jump to line 36 because the condition on line 33 was always true
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(): 46 ↛ 45line 46 didn't jump to line 45 because the condition on line 46 was always true
47 lines.append(f"- `ci://gco/config/{name}`")
48 lines.append("")
50 if GITHUB_WORKFLOWS_DIR.is_dir(): 50 ↛ 63line 50 didn't jump to line 63 because the condition on line 50 was always true
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: 56 ↛ 63line 56 didn't jump to line 63 because the condition on line 56 was always true
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(): 63 ↛ 72line 63 didn't jump to line 72 because the condition on line 63 was always true
64 action_names = sorted(d.name for d in GITHUB_ACTIONS_DIR.iterdir() if d.is_dir())
65 if action_names: 65 ↛ 72line 65 didn't jump to line 72 because the condition on line 65 was always true
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(): 72 ↛ 83line 72 didn't jump to line 83 because the condition on line 72 was always true
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: 76 ↛ 83line 76 didn't jump to line 83 because the condition on line 76 was always true
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(): 84 ↛ 88line 84 didn't jump to line 88 because the condition on line 84 was always true
85 for f in sorted(GITHUB_ISSUE_TEMPLATE_DIR.iterdir()):
86 if f.is_file() and f.suffix in {".md", ".yml", ".yaml"}: 86 ↛ 85line 86 didn't jump to line 85 because the condition on line 86 was always true
87 template_entries.append(f"- `ci://gco/templates/{f.name}` — {f.stem}")
88 if (GITHUB_DIR / "pull_request_template.md").is_file(): 88 ↛ 92line 88 didn't jump to line 92 because the condition on line 88 was always true
89 template_entries.append(
90 "- `ci://gco/templates/pull_request_template.md` — Pull request template"
91 )
92 if template_entries: 92 ↛ 97line 92 didn't jump to line 97 because the condition on line 92 was always true
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(): 97 ↛ 105line 97 didn't jump to line 105 because the condition on line 97 was always true
98 codeql_files = sorted(f for f in GITHUB_CODEQL_DIR.iterdir() if f.is_file())
99 if codeql_files: 99 ↛ 105line 99 didn't jump to line 105 because the condition on line 99 was always true
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(): 105 ↛ 113line 105 didn't jump to line 113 because the condition on line 105 was always true
106 kind_files = sorted(f for f in GITHUB_KIND_DIR.iterdir() if f.is_file())
107 if kind_files: 107 ↛ 113line 107 didn't jump to line 113 because the condition on line 107 was always true
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(): 114 ↛ 116line 114 didn't jump to line 116 because the condition on line 114 was always true
115 automation_entries.append("- `ci://gco/config/dependabot.yml` — Dependabot config")
116 if (GITHUB_DIR / "release.yml").is_file(): 116 ↛ 120line 116 didn't jump to line 120 because the condition on line 116 was always true
117 automation_entries.append(
118 "- `ci://gco/config/release.yml` — Release notes auto-categorisation"
119 )
120 if automation_entries: 120 ↛ 125line 120 didn't jump to line 125 because the condition on line 120 was always true
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(): 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 return action_path_alt.read_text()
148 if GITHUB_ACTIONS_DIR.is_dir(): 148 ↛ 151line 148 didn't jump to line 151 because the condition on line 148 was always true
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(): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
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(): 170 ↛ 172line 170 didn't jump to line 172 because the condition on line 170 was always true
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(): 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true
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(): 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 return f"Config file '{filename}' not found."
201 return path.read_text()