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

1"""CI/CD resources (ci:// scheme) for the GCO MCP server.""" 

2 

3from pathlib import Path 

4 

5from server import mcp 

6 

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" 

15 

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} 

25 

26 

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." 

37 

38 

39@mcp.resource("ci://gco/index") 

40def ci_index() -> str: 

41 """List CI/CD artefacts under .github/.""" 

42 lines = ["# GitHub Actions & CI Configuration\n"] 

43 

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("") 

49 

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("") 

62 

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("") 

71 

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("") 

82 

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("") 

96 

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("") 

104 

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("") 

112 

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("") 

124 

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) 

130 

131 

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) 

136 

137 

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." 

152 

153 

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) 

158 

159 

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) 

176 

177 

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) 

182 

183 

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) 

188 

189 

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()