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

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

2 

3from pathlib import Path 

4 

5from cli_runner import PROJECT_ROOT # runtime-resolved checkout root (uvx-safe) 

6from server import mcp 

7 

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

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: 

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

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

47 lines.append(f"- `ci://gco/config/{name}`") 

48 lines.append("") 

49 

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

62 

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

71 

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

82 

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

96 

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

104 

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

112 

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

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

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

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

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) 

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

200 return f"Config file '{filename}' not found." 

201 return path.read_text()