policygate 0.1.0__tar.gz
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.
- policygate-0.1.0/LICENSE +21 -0
- policygate-0.1.0/PKG-INFO +196 -0
- policygate-0.1.0/README.md +146 -0
- policygate-0.1.0/pyproject.toml +70 -0
- policygate-0.1.0/src/policygate/__init__.py +3 -0
- policygate-0.1.0/src/policygate/config/__init__.py +9 -0
- policygate-0.1.0/src/policygate/config/settings.py +48 -0
- policygate-0.1.0/src/policygate/domains/__init__.py +10 -0
- policygate-0.1.0/src/policygate/domains/gateway/__init__.py +1 -0
- policygate-0.1.0/src/policygate/domains/gateway/exceptions.py +17 -0
- policygate-0.1.0/src/policygate/domains/gateway/models.py +40 -0
- policygate-0.1.0/src/policygate/domains/gateway/services.py +149 -0
- policygate-0.1.0/src/policygate/entry_points/__init__.py +10 -0
- policygate-0.1.0/src/policygate/entry_points/mcp_server.py +120 -0
- policygate-0.1.0/src/policygate/infrastructure/__init__.py +10 -0
- policygate-0.1.0/src/policygate/infrastructure/repository/__init__.py +1 -0
- policygate-0.1.0/src/policygate/infrastructure/repository/github_repository_gateway.py +257 -0
policygate-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Sergei Konovalov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: policygate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server gateway for task-specific AI rules and scripts stored in GitHub
|
|
5
|
+
Keywords: mcp,policy,gateway,ai,github,automation
|
|
6
|
+
Author: Sergei Konovalov
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Sergei Konovalov
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
Classifier: Development Status :: 3 - Alpha
|
|
29
|
+
Classifier: Intended Audience :: Developers
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Operating System :: OS Independent
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
36
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
37
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
38
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
39
|
+
Requires-Dist: structlog>=25.1.0
|
|
40
|
+
Requires-Dist: pydantic>=2.0.0
|
|
41
|
+
Requires-Dist: fastmcp>=2.0.0
|
|
42
|
+
Requires-Dist: httpx>=0.27.0
|
|
43
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
44
|
+
Maintainer: Sergei Konovalov
|
|
45
|
+
Requires-Python: >=3.11
|
|
46
|
+
Project-URL: Homepage, https://github.com/l0kifs/policygate
|
|
47
|
+
Project-URL: Repository, https://github.com/l0kifs/policygate
|
|
48
|
+
Project-URL: Issues, https://github.com/l0kifs/policygate/issues
|
|
49
|
+
Description-Content-Type: text/markdown
|
|
50
|
+
|
|
51
|
+
<p align="center">
|
|
52
|
+
<img src="https://capsule-render.vercel.app/api?type=waving&color=0:4F46E5,100:06B6D4&height=200§ion=header&text=policygate&fontSize=56&fontColor=ffffff&animation=fadeIn&fontAlignY=38&desc=MCP%20server%20gateway%20for%20task-specific%20AI%20rules%20and%20scripts&descAlignY=58&descSize=16" alt="policygate banner" />
|
|
53
|
+
</p>
|
|
54
|
+
|
|
55
|
+
<p align="center">
|
|
56
|
+
<a href="https://github.com/l0kifs/policygate/actions/workflows/publish-to-pypi.yml"><img src="https://img.shields.io/github/actions/workflow/status/l0kifs/policygate/publish-to-pypi.yml?branch=main&label=publish" alt="Publish workflow" /></a>
|
|
57
|
+
<a href="https://pypi.org/project/policygate/"><img src="https://img.shields.io/pypi/v/policygate" alt="PyPI version" /></a>
|
|
58
|
+
<a href="https://pypi.org/project/policygate/"><img src="https://img.shields.io/pypi/pyversions/policygate" alt="Python versions" /></a>
|
|
59
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license" /></a>
|
|
60
|
+
</p>
|
|
61
|
+
|
|
62
|
+
# policygate
|
|
63
|
+
|
|
64
|
+
Policygate is an MCP server gateway for task-specific AI rules and scripts stored in a GitHub repository.
|
|
65
|
+
|
|
66
|
+
## Features
|
|
67
|
+
|
|
68
|
+
- Syncs repository content into a local cache at `~/.policygate/repo_data`
|
|
69
|
+
- Parses and validates `router.yaml`
|
|
70
|
+
- Exposes MCP tools:
|
|
71
|
+
- `sync_repository`
|
|
72
|
+
- `outline_router`
|
|
73
|
+
- `read_rules`
|
|
74
|
+
- `copy_scripts`
|
|
75
|
+
|
|
76
|
+
Detailed usage reference: [docs/REFERENCE.md](docs/REFERENCE.md)
|
|
77
|
+
|
|
78
|
+
## Required repository structure
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
rules/
|
|
82
|
+
scripts/
|
|
83
|
+
router.yaml
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`router.yaml` structure:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
tasks:
|
|
90
|
+
task1:
|
|
91
|
+
description: "Short description of task 1"
|
|
92
|
+
rules:
|
|
93
|
+
- rule1
|
|
94
|
+
scripts:
|
|
95
|
+
- script1
|
|
96
|
+
|
|
97
|
+
rules:
|
|
98
|
+
rule1:
|
|
99
|
+
path: rules/rule1.md
|
|
100
|
+
description: "Short description of rule 1"
|
|
101
|
+
|
|
102
|
+
scripts:
|
|
103
|
+
script1:
|
|
104
|
+
path: scripts/script1.py
|
|
105
|
+
description: "Short description of script 1"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Configuration
|
|
109
|
+
|
|
110
|
+
Set environment variables:
|
|
111
|
+
|
|
112
|
+
- `POLICYGATE__GITHUB_REPOSITORY_URL`
|
|
113
|
+
- `POLICYGATE__GITHUB_ACCESS_TOKEN`
|
|
114
|
+
- `POLICYGATE__LOCAL_REPO_DATA_DIR` (optional, default `~/.policygate/repo_data`)
|
|
115
|
+
- `POLICYGATE__REPOSITORY_REFRESH_INTERVAL_SECONDS` (optional, default `60`)
|
|
116
|
+
|
|
117
|
+
## Run MCP server
|
|
118
|
+
|
|
119
|
+
Run with:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
uv run policygate-mcp
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
VS Code workspace MCP config example (`.vscode/mcp.json`):
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"inputs": [
|
|
130
|
+
{
|
|
131
|
+
"id": "POLICYGATE__GITHUB_REPOSITORY_URL",
|
|
132
|
+
"type": "promptString",
|
|
133
|
+
"description": "GitHub repository URL",
|
|
134
|
+
"password": false
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"id": "POLICYGATE__GITHUB_ACCESS_TOKEN",
|
|
138
|
+
"type": "promptString",
|
|
139
|
+
"description": "GitHub access token",
|
|
140
|
+
"password": true
|
|
141
|
+
}
|
|
142
|
+
],
|
|
143
|
+
"servers": {
|
|
144
|
+
"policygate": {
|
|
145
|
+
"type": "stdio",
|
|
146
|
+
"command": "uvx",
|
|
147
|
+
"args": ["--from", "policygate:latest", "policygate-mcp"],
|
|
148
|
+
"env": {
|
|
149
|
+
"POLICYGATE__GITHUB_REPOSITORY_URL": "${input:POLICYGATE__GITHUB_REPOSITORY_URL}",
|
|
150
|
+
"POLICYGATE__GITHUB_ACCESS_TOKEN": "${input:POLICYGATE__GITHUB_ACCESS_TOKEN}"
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
For local testing from the current workspace (after `uv sync --all-groups`):
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"inputs": [
|
|
162
|
+
{
|
|
163
|
+
"id": "POLICYGATE__GITHUB_REPOSITORY_URL",
|
|
164
|
+
"type": "promptString",
|
|
165
|
+
"description": "GitHub repository URL",
|
|
166
|
+
"password": false
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"id": "POLICYGATE__GITHUB_ACCESS_TOKEN",
|
|
170
|
+
"type": "promptString",
|
|
171
|
+
"description": "GitHub access token",
|
|
172
|
+
"password": true
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
"servers": {
|
|
176
|
+
"policygate-local": {
|
|
177
|
+
"type": "stdio",
|
|
178
|
+
"command": "uv",
|
|
179
|
+
"args": ["run", "policygate-mcp"],
|
|
180
|
+
"env": {
|
|
181
|
+
"POLICYGATE__GITHUB_REPOSITORY_URL": "${input:POLICYGATE__GITHUB_REPOSITORY_URL}",
|
|
182
|
+
"POLICYGATE__GITHUB_ACCESS_TOKEN": "${input:POLICYGATE__GITHUB_ACCESS_TOKEN}"
|
|
183
|
+
},
|
|
184
|
+
"cwd": "${workspaceFolder}"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Testing
|
|
191
|
+
|
|
192
|
+
Run feature-organized end-to-end suites:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
uv run pytest --maxfail=1 --tb=short
|
|
196
|
+
```
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://capsule-render.vercel.app/api?type=waving&color=0:4F46E5,100:06B6D4&height=200§ion=header&text=policygate&fontSize=56&fontColor=ffffff&animation=fadeIn&fontAlignY=38&desc=MCP%20server%20gateway%20for%20task-specific%20AI%20rules%20and%20scripts&descAlignY=58&descSize=16" alt="policygate banner" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/l0kifs/policygate/actions/workflows/publish-to-pypi.yml"><img src="https://img.shields.io/github/actions/workflow/status/l0kifs/policygate/publish-to-pypi.yml?branch=main&label=publish" alt="Publish workflow" /></a>
|
|
7
|
+
<a href="https://pypi.org/project/policygate/"><img src="https://img.shields.io/pypi/v/policygate" alt="PyPI version" /></a>
|
|
8
|
+
<a href="https://pypi.org/project/policygate/"><img src="https://img.shields.io/pypi/pyversions/policygate" alt="Python versions" /></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license" /></a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
# policygate
|
|
13
|
+
|
|
14
|
+
Policygate is an MCP server gateway for task-specific AI rules and scripts stored in a GitHub repository.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- Syncs repository content into a local cache at `~/.policygate/repo_data`
|
|
19
|
+
- Parses and validates `router.yaml`
|
|
20
|
+
- Exposes MCP tools:
|
|
21
|
+
- `sync_repository`
|
|
22
|
+
- `outline_router`
|
|
23
|
+
- `read_rules`
|
|
24
|
+
- `copy_scripts`
|
|
25
|
+
|
|
26
|
+
Detailed usage reference: [docs/REFERENCE.md](docs/REFERENCE.md)
|
|
27
|
+
|
|
28
|
+
## Required repository structure
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
rules/
|
|
32
|
+
scripts/
|
|
33
|
+
router.yaml
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`router.yaml` structure:
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
tasks:
|
|
40
|
+
task1:
|
|
41
|
+
description: "Short description of task 1"
|
|
42
|
+
rules:
|
|
43
|
+
- rule1
|
|
44
|
+
scripts:
|
|
45
|
+
- script1
|
|
46
|
+
|
|
47
|
+
rules:
|
|
48
|
+
rule1:
|
|
49
|
+
path: rules/rule1.md
|
|
50
|
+
description: "Short description of rule 1"
|
|
51
|
+
|
|
52
|
+
scripts:
|
|
53
|
+
script1:
|
|
54
|
+
path: scripts/script1.py
|
|
55
|
+
description: "Short description of script 1"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Set environment variables:
|
|
61
|
+
|
|
62
|
+
- `POLICYGATE__GITHUB_REPOSITORY_URL`
|
|
63
|
+
- `POLICYGATE__GITHUB_ACCESS_TOKEN`
|
|
64
|
+
- `POLICYGATE__LOCAL_REPO_DATA_DIR` (optional, default `~/.policygate/repo_data`)
|
|
65
|
+
- `POLICYGATE__REPOSITORY_REFRESH_INTERVAL_SECONDS` (optional, default `60`)
|
|
66
|
+
|
|
67
|
+
## Run MCP server
|
|
68
|
+
|
|
69
|
+
Run with:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv run policygate-mcp
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
VS Code workspace MCP config example (`.vscode/mcp.json`):
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"inputs": [
|
|
80
|
+
{
|
|
81
|
+
"id": "POLICYGATE__GITHUB_REPOSITORY_URL",
|
|
82
|
+
"type": "promptString",
|
|
83
|
+
"description": "GitHub repository URL",
|
|
84
|
+
"password": false
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "POLICYGATE__GITHUB_ACCESS_TOKEN",
|
|
88
|
+
"type": "promptString",
|
|
89
|
+
"description": "GitHub access token",
|
|
90
|
+
"password": true
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"servers": {
|
|
94
|
+
"policygate": {
|
|
95
|
+
"type": "stdio",
|
|
96
|
+
"command": "uvx",
|
|
97
|
+
"args": ["--from", "policygate:latest", "policygate-mcp"],
|
|
98
|
+
"env": {
|
|
99
|
+
"POLICYGATE__GITHUB_REPOSITORY_URL": "${input:POLICYGATE__GITHUB_REPOSITORY_URL}",
|
|
100
|
+
"POLICYGATE__GITHUB_ACCESS_TOKEN": "${input:POLICYGATE__GITHUB_ACCESS_TOKEN}"
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
For local testing from the current workspace (after `uv sync --all-groups`):
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"inputs": [
|
|
112
|
+
{
|
|
113
|
+
"id": "POLICYGATE__GITHUB_REPOSITORY_URL",
|
|
114
|
+
"type": "promptString",
|
|
115
|
+
"description": "GitHub repository URL",
|
|
116
|
+
"password": false
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"id": "POLICYGATE__GITHUB_ACCESS_TOKEN",
|
|
120
|
+
"type": "promptString",
|
|
121
|
+
"description": "GitHub access token",
|
|
122
|
+
"password": true
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
"servers": {
|
|
126
|
+
"policygate-local": {
|
|
127
|
+
"type": "stdio",
|
|
128
|
+
"command": "uv",
|
|
129
|
+
"args": ["run", "policygate-mcp"],
|
|
130
|
+
"env": {
|
|
131
|
+
"POLICYGATE__GITHUB_REPOSITORY_URL": "${input:POLICYGATE__GITHUB_REPOSITORY_URL}",
|
|
132
|
+
"POLICYGATE__GITHUB_ACCESS_TOKEN": "${input:POLICYGATE__GITHUB_ACCESS_TOKEN}"
|
|
133
|
+
},
|
|
134
|
+
"cwd": "${workspaceFolder}"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Testing
|
|
141
|
+
|
|
142
|
+
Run feature-organized end-to-end suites:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
uv run pytest --maxfail=1 --tb=short
|
|
146
|
+
```
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.9.30"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "policygate"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server gateway for task-specific AI rules and scripts stored in GitHub"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Sergei Konovalov" },
|
|
14
|
+
]
|
|
15
|
+
maintainers = [
|
|
16
|
+
{ name = "Sergei Konovalov" },
|
|
17
|
+
]
|
|
18
|
+
keywords = [
|
|
19
|
+
"mcp",
|
|
20
|
+
"policy",
|
|
21
|
+
"gateway",
|
|
22
|
+
"ai",
|
|
23
|
+
"github",
|
|
24
|
+
"automation",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 3 - Alpha",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Topic :: Software Development :: Libraries",
|
|
36
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
37
|
+
]
|
|
38
|
+
dependencies = [
|
|
39
|
+
# Configuration and logging
|
|
40
|
+
"pydantic-settings>=2.0.0",
|
|
41
|
+
"structlog>=25.1.0",
|
|
42
|
+
|
|
43
|
+
# Data validation
|
|
44
|
+
"pydantic>=2.0.0",
|
|
45
|
+
|
|
46
|
+
# MCP and integration
|
|
47
|
+
"fastmcp>=2.0.0",
|
|
48
|
+
"httpx>=0.27.0",
|
|
49
|
+
"pyyaml>=6.0.0",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Homepage = "https://github.com/l0kifs/policygate"
|
|
54
|
+
Repository = "https://github.com/l0kifs/policygate"
|
|
55
|
+
Issues = "https://github.com/l0kifs/policygate/issues"
|
|
56
|
+
|
|
57
|
+
[project.scripts]
|
|
58
|
+
policygate-mcp = "policygate.entry_points.mcp_server:run"
|
|
59
|
+
|
|
60
|
+
[dependency-groups]
|
|
61
|
+
dev = [
|
|
62
|
+
"pytest>=9.0.0",
|
|
63
|
+
"pytest-xdist>=3.0.0",
|
|
64
|
+
"pytest-cov>=7.0.0",
|
|
65
|
+
"ruff>=0.14.5",
|
|
66
|
+
"ty>=0.0.11",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.uv]
|
|
70
|
+
package = true
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project configuration package.
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Contains application settings, constants, and logging configuration.
|
|
6
|
+
- No technical implementation details (database engines, clients, etc.).
|
|
7
|
+
- No business logic.
|
|
8
|
+
- Should not import from domains, infrastructure or entry_points.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Settings(BaseSettings):
|
|
6
|
+
"""
|
|
7
|
+
Configuration settings.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
model_config = SettingsConfigDict(
|
|
11
|
+
env_prefix="POLICYGATE__",
|
|
12
|
+
env_nested_delimiter="__",
|
|
13
|
+
env_file=".env",
|
|
14
|
+
env_file_encoding="utf-8",
|
|
15
|
+
case_sensitive=False,
|
|
16
|
+
extra="ignore",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Application settings
|
|
20
|
+
app_name: str = Field(default="policygate", description="Application name")
|
|
21
|
+
app_version: str = Field(default="0.1.0", description="Application version")
|
|
22
|
+
|
|
23
|
+
# GitHub repository integration
|
|
24
|
+
github_repository_url: str = Field(
|
|
25
|
+
default="",
|
|
26
|
+
description="GitHub repository URL containing router.yaml, rules/, and scripts/",
|
|
27
|
+
)
|
|
28
|
+
github_access_token: str = Field(
|
|
29
|
+
default="",
|
|
30
|
+
description="GitHub access token for repository access",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Local repository cache
|
|
34
|
+
local_repo_data_dir: str = Field(
|
|
35
|
+
default="~/.policygate/repo_data",
|
|
36
|
+
description="Local repository cache path",
|
|
37
|
+
)
|
|
38
|
+
repository_refresh_interval_seconds: int = Field(
|
|
39
|
+
default=1800,
|
|
40
|
+
description="Minimal interval between remote refresh checks",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_settings() -> Settings:
|
|
45
|
+
"""
|
|
46
|
+
Get configuration settings.
|
|
47
|
+
"""
|
|
48
|
+
return Settings()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Domain layer containing business logic.
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Pure Python code (no framework dependencies).
|
|
6
|
+
- Contains entities, value objects, and domain services.
|
|
7
|
+
- Models may use Pydantic for validation.
|
|
8
|
+
- Logging via available logging libraries is allowed.
|
|
9
|
+
- No imports from infrastructure or entry_points.
|
|
10
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Gateway domain package."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Domain exceptions for policy gateway flows."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PolicyGateError(Exception):
|
|
5
|
+
"""Base exception for gateway operations."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RouterValidationError(PolicyGateError):
|
|
9
|
+
"""Raised when router.yaml content is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RouterReferenceError(PolicyGateError):
|
|
13
|
+
"""Raised when requested aliases are missing in router.yaml."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RepositorySyncError(PolicyGateError):
|
|
17
|
+
"""Raised when repository sync cannot complete."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Domain models for repository routing configuration."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TaskConfig(BaseModel):
|
|
7
|
+
"""Task mapping in router configuration."""
|
|
8
|
+
|
|
9
|
+
description: str = Field(description="Task description")
|
|
10
|
+
rules: list[str] = Field(default_factory=list, description="Rule aliases")
|
|
11
|
+
scripts: list[str] = Field(default_factory=list, description="Script aliases")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RuleConfig(BaseModel):
|
|
15
|
+
"""Rule descriptor from router configuration."""
|
|
16
|
+
|
|
17
|
+
path: str = Field(description="Relative path to markdown rule file")
|
|
18
|
+
description: str = Field(description="Rule description")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ScriptConfig(BaseModel):
|
|
22
|
+
"""Script descriptor from router configuration."""
|
|
23
|
+
|
|
24
|
+
path: str = Field(description="Relative path to script file")
|
|
25
|
+
description: str = Field(description="Script description")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RouterConfig(BaseModel):
|
|
29
|
+
"""Top-level router.yaml document."""
|
|
30
|
+
|
|
31
|
+
tasks: dict[str, TaskConfig] = Field(default_factory=dict)
|
|
32
|
+
rules: dict[str, RuleConfig] = Field(default_factory=dict)
|
|
33
|
+
scripts: dict[str, ScriptConfig] = Field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CopiedScriptsResult(BaseModel):
|
|
37
|
+
"""Result payload for copied scripts."""
|
|
38
|
+
|
|
39
|
+
destination_directory: str
|
|
40
|
+
copied_files: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Application service for policy gateway use-cases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from policygate.domains.gateway.exceptions import (
|
|
12
|
+
RepositorySyncError,
|
|
13
|
+
RouterReferenceError,
|
|
14
|
+
RouterValidationError,
|
|
15
|
+
)
|
|
16
|
+
from policygate.domains.gateway.models import CopiedScriptsResult, RouterConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RepositoryGateway(Protocol):
|
|
20
|
+
"""Port for repository synchronization and file access."""
|
|
21
|
+
|
|
22
|
+
def refresh_if_needed(self) -> None: ...
|
|
23
|
+
|
|
24
|
+
def force_refresh(self) -> None: ...
|
|
25
|
+
|
|
26
|
+
def read_text(self, relative_path: str) -> str: ...
|
|
27
|
+
|
|
28
|
+
def read_many_texts(self, relative_paths: list[str]) -> dict[str, str]: ...
|
|
29
|
+
|
|
30
|
+
def copy_many_files(
|
|
31
|
+
self,
|
|
32
|
+
relative_paths: list[str],
|
|
33
|
+
destination_directory: str,
|
|
34
|
+
) -> list[str]: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PolicyGatewayService:
|
|
38
|
+
"""Use-case service for router outline, rules reading, and scripts copying."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, repository_gateway: RepositoryGateway) -> None:
|
|
41
|
+
self._repository_gateway = repository_gateway
|
|
42
|
+
|
|
43
|
+
def outline_router(self) -> str:
|
|
44
|
+
"""Return parsed and validated router.yaml content as markdown text."""
|
|
45
|
+
router = self._load_router()
|
|
46
|
+
return self._router_to_markdown(router)
|
|
47
|
+
|
|
48
|
+
def sync_repository(self) -> dict[str, str]:
|
|
49
|
+
"""Force synchronization of remote repository to local cache."""
|
|
50
|
+
self._repository_gateway.force_refresh()
|
|
51
|
+
return {"status": "synced"}
|
|
52
|
+
|
|
53
|
+
def read_rules(self, rule_names: list[str]) -> str:
|
|
54
|
+
"""Return rule markdown content by aliases from router.yaml as markdown text."""
|
|
55
|
+
if not rule_names:
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
router = self._load_router()
|
|
59
|
+
missing = [name for name in rule_names if name not in router.rules]
|
|
60
|
+
if missing:
|
|
61
|
+
joined = ", ".join(missing)
|
|
62
|
+
raise RouterReferenceError(f"unknown rule aliases: {joined}")
|
|
63
|
+
|
|
64
|
+
names_to_paths = {name: router.rules[name].path for name in rule_names}
|
|
65
|
+
contents_by_path = self._repository_gateway.read_many_texts(
|
|
66
|
+
list(names_to_paths.values())
|
|
67
|
+
)
|
|
68
|
+
sections: list[str] = []
|
|
69
|
+
for name, path in names_to_paths.items():
|
|
70
|
+
if path not in contents_by_path:
|
|
71
|
+
continue
|
|
72
|
+
sections.append(f"<{name}>\n{contents_by_path[path].rstrip()}\n</{name}>")
|
|
73
|
+
|
|
74
|
+
return "\n\n".join(sections)
|
|
75
|
+
|
|
76
|
+
def copy_scripts(self, script_names: list[str]) -> CopiedScriptsResult:
|
|
77
|
+
"""Copy script files by aliases to a temporary directory."""
|
|
78
|
+
if not script_names:
|
|
79
|
+
destination = tempfile.mkdtemp(prefix="policygate-scripts-")
|
|
80
|
+
return CopiedScriptsResult(
|
|
81
|
+
destination_directory=destination,
|
|
82
|
+
copied_files=[],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
router = self._load_router()
|
|
86
|
+
missing = [name for name in script_names if name not in router.scripts]
|
|
87
|
+
if missing:
|
|
88
|
+
joined = ", ".join(missing)
|
|
89
|
+
raise RouterReferenceError(f"unknown script aliases: {joined}")
|
|
90
|
+
|
|
91
|
+
destination = tempfile.mkdtemp(prefix="policygate-scripts-")
|
|
92
|
+
paths = [router.scripts[name].path for name in script_names]
|
|
93
|
+
copied_files = self._repository_gateway.copy_many_files(
|
|
94
|
+
relative_paths=paths,
|
|
95
|
+
destination_directory=destination,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return CopiedScriptsResult(
|
|
99
|
+
destination_directory=destination,
|
|
100
|
+
copied_files=copied_files,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _load_router(self) -> RouterConfig:
|
|
104
|
+
try:
|
|
105
|
+
self._repository_gateway.refresh_if_needed()
|
|
106
|
+
router_raw = self._repository_gateway.read_text("router.yaml")
|
|
107
|
+
parsed = yaml.safe_load(router_raw)
|
|
108
|
+
if not isinstance(parsed, dict):
|
|
109
|
+
raise RouterValidationError(
|
|
110
|
+
"router.yaml must contain a top-level object"
|
|
111
|
+
)
|
|
112
|
+
return RouterConfig.model_validate(parsed)
|
|
113
|
+
except ValidationError as error:
|
|
114
|
+
raise RouterValidationError(str(error)) from error
|
|
115
|
+
except OSError as error:
|
|
116
|
+
raise RepositorySyncError(str(error)) from error
|
|
117
|
+
|
|
118
|
+
def _router_to_markdown(self, router: RouterConfig) -> str:
|
|
119
|
+
sections: list[str] = ["# Router"]
|
|
120
|
+
|
|
121
|
+
sections.append("## Tasks")
|
|
122
|
+
if not router.tasks:
|
|
123
|
+
sections.append("- _none_")
|
|
124
|
+
else:
|
|
125
|
+
for name, task in router.tasks.items():
|
|
126
|
+
sections.append(f"### {name}")
|
|
127
|
+
sections.append(f"- Description: {task.description}")
|
|
128
|
+
sections.append(
|
|
129
|
+
f"- Rules: {', '.join(task.rules) if task.rules else '_none_'}"
|
|
130
|
+
)
|
|
131
|
+
sections.append(
|
|
132
|
+
f"- Scripts: {', '.join(task.scripts) if task.scripts else '_none_'}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
sections.append("## Rules")
|
|
136
|
+
if not router.rules:
|
|
137
|
+
sections.append("- _none_")
|
|
138
|
+
else:
|
|
139
|
+
for name, rule in router.rules.items():
|
|
140
|
+
sections.append(f"- **{name}**: `{rule.path}` — {rule.description}")
|
|
141
|
+
|
|
142
|
+
sections.append("## Scripts")
|
|
143
|
+
if not router.scripts:
|
|
144
|
+
sections.append("- _none_")
|
|
145
|
+
else:
|
|
146
|
+
for name, script in router.scripts.items():
|
|
147
|
+
sections.append(f"- **{name}**: `{script.path}` — {script.description}")
|
|
148
|
+
|
|
149
|
+
return "\n".join(sections)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application entry points (CLI, API, workers).
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Handles application bootstrapping and dependency injection.
|
|
6
|
+
- Contains controllers/handlers for external interfaces.
|
|
7
|
+
- Orchestrates interaction between outer world and domain layer.
|
|
8
|
+
- Can import from domains and infrastructure.
|
|
9
|
+
- No business logic.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""MCP server entry point for policy routing gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, is_dataclass
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from policygate.config.settings import get_settings
|
|
12
|
+
from policygate.domains.gateway.services import PolicyGatewayService
|
|
13
|
+
from policygate.infrastructure.repository.github_repository_gateway import (
|
|
14
|
+
GitHubRepositoryGateway,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _to_serializable(value: Any) -> Any:
|
|
19
|
+
if isinstance(value, list):
|
|
20
|
+
return [_to_serializable(item) for item in value]
|
|
21
|
+
if isinstance(value, dict):
|
|
22
|
+
return {key: _to_serializable(item) for key, item in value.items()}
|
|
23
|
+
if is_dataclass(value):
|
|
24
|
+
return _to_serializable(asdict(value))
|
|
25
|
+
if hasattr(value, "model_dump"):
|
|
26
|
+
return value.model_dump(mode="json")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
mcp = FastMCP(
|
|
31
|
+
name="policygate",
|
|
32
|
+
instructions=(
|
|
33
|
+
"Policy gateway for task routing. Use router outline first, then read rules, "
|
|
34
|
+
"and copy scripts only for scripts explicitly mapped in router.yaml."
|
|
35
|
+
),
|
|
36
|
+
version=get_settings().app_version,
|
|
37
|
+
on_duplicate="error",
|
|
38
|
+
mask_error_details=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_service() -> PolicyGatewayService:
|
|
43
|
+
"""Build service graph with GitHub-backed repository gateway."""
|
|
44
|
+
settings = get_settings()
|
|
45
|
+
return PolicyGatewayService(
|
|
46
|
+
repository_gateway=GitHubRepositoryGateway(
|
|
47
|
+
repository_url=settings.github_repository_url,
|
|
48
|
+
access_token=settings.github_access_token,
|
|
49
|
+
local_repo_data_dir=settings.local_repo_data_dir,
|
|
50
|
+
refresh_interval_seconds=settings.repository_refresh_interval_seconds,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@mcp.tool(
|
|
56
|
+
annotations={
|
|
57
|
+
"readOnlyHint": True,
|
|
58
|
+
"idempotentHint": True,
|
|
59
|
+
"openWorldHint": False,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
def outline_router() -> str:
|
|
63
|
+
"""Parse and return router.yaml contents as markdown text."""
|
|
64
|
+
return build_service().outline_router()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@mcp.tool(
|
|
68
|
+
annotations={
|
|
69
|
+
"readOnlyHint": False,
|
|
70
|
+
"idempotentHint": True,
|
|
71
|
+
"openWorldHint": False,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
def sync_repository() -> dict[str, str]:
|
|
75
|
+
"""Force repository synchronization to refresh local cache now."""
|
|
76
|
+
return build_service().sync_repository()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@mcp.tool(
|
|
80
|
+
annotations={
|
|
81
|
+
"readOnlyHint": True,
|
|
82
|
+
"idempotentHint": True,
|
|
83
|
+
"openWorldHint": False,
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
def read_rules(
|
|
87
|
+
rule_names: Annotated[
|
|
88
|
+
list[str],
|
|
89
|
+
Field(
|
|
90
|
+
description=(
|
|
91
|
+
"Rule aliases from router.yaml rules section. "
|
|
92
|
+
"Example: [\"rule1\", \"rule_security\"]"
|
|
93
|
+
)
|
|
94
|
+
),
|
|
95
|
+
],
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Read selected rules and return a combined markdown document."""
|
|
98
|
+
return build_service().read_rules(rule_names=rule_names)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@mcp.tool(
|
|
102
|
+
annotations={
|
|
103
|
+
"readOnlyHint": False,
|
|
104
|
+
"destructiveHint": False,
|
|
105
|
+
"openWorldHint": False,
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
def copy_scripts(
|
|
109
|
+
script_names: Annotated[
|
|
110
|
+
list[str],
|
|
111
|
+
Field(description="Script aliases from router.yaml scripts section."),
|
|
112
|
+
],
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Copy selected scripts to a temporary directory for execution."""
|
|
115
|
+
return _to_serializable(build_service().copy_scripts(script_names=script_names))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run() -> None:
|
|
119
|
+
"""Run MCP server."""
|
|
120
|
+
mcp.run()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Infrastructure layer containing technical implementation details.
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Implements interfaces defined in the domain or supports domain logic.
|
|
6
|
+
- Contains database repositories, API clients, file storage, etc.
|
|
7
|
+
- Handles interactions with external systems (SQLAlchemy, httpx, etc.).
|
|
8
|
+
- Can import from domains.
|
|
9
|
+
- No business logic.
|
|
10
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Repository infrastructure adapters."""
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""GitHub repository gateway with local caching for policy assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from io import BytesIO
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from policygate.domains.gateway.exceptions import RepositorySyncError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitHubRepositoryGateway:
|
|
21
|
+
"""Synchronize a GitHub repository and expose files from local cache."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
repository_url: str,
|
|
26
|
+
access_token: str,
|
|
27
|
+
local_repo_data_dir: str,
|
|
28
|
+
refresh_interval_seconds: int = 60,
|
|
29
|
+
) -> None:
|
|
30
|
+
if not repository_url:
|
|
31
|
+
raise RepositorySyncError("github_repository_url is not configured")
|
|
32
|
+
if not access_token:
|
|
33
|
+
raise RepositorySyncError("github_access_token is not configured")
|
|
34
|
+
|
|
35
|
+
self._repository_url = repository_url
|
|
36
|
+
self._access_token = access_token
|
|
37
|
+
self._local_repo_data_dir = Path(local_repo_data_dir).expanduser().resolve()
|
|
38
|
+
self._refresh_interval_seconds = max(refresh_interval_seconds, 1)
|
|
39
|
+
self._last_refresh_check_at = 0.0
|
|
40
|
+
self._refresh_lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
self._owner, self._repo = self._parse_owner_repo(repository_url)
|
|
43
|
+
self._metadata_file = self._local_repo_data_dir / ".policygate_sync.json"
|
|
44
|
+
|
|
45
|
+
def refresh_if_needed(self) -> None:
|
|
46
|
+
"""Refresh local cache if check interval elapsed and commit changed."""
|
|
47
|
+
with self._refresh_lock:
|
|
48
|
+
now = time.time()
|
|
49
|
+
if (
|
|
50
|
+
now - self._last_refresh_check_at < self._refresh_interval_seconds
|
|
51
|
+
and self._local_repo_data_dir.exists()
|
|
52
|
+
):
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
self._last_refresh_check_at = now
|
|
56
|
+
self._refresh(force=not self._local_repo_data_dir.exists())
|
|
57
|
+
|
|
58
|
+
def force_refresh(self) -> None:
|
|
59
|
+
"""Force synchronization regardless of refresh interval and cached SHA."""
|
|
60
|
+
with self._refresh_lock:
|
|
61
|
+
self._refresh(force=True)
|
|
62
|
+
self._last_refresh_check_at = time.time()
|
|
63
|
+
|
|
64
|
+
def read_text(self, relative_path: str) -> str:
|
|
65
|
+
"""Read text file from synchronized local repository cache."""
|
|
66
|
+
target = self._resolve_relative_path(relative_path)
|
|
67
|
+
return target.read_text(encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
def read_many_texts(self, relative_paths: list[str]) -> dict[str, str]:
|
|
70
|
+
"""Read multiple files from local repository cache."""
|
|
71
|
+
content_by_path: dict[str, str] = {}
|
|
72
|
+
for relative_path in relative_paths:
|
|
73
|
+
target = self._resolve_relative_path(relative_path)
|
|
74
|
+
content_by_path[relative_path] = target.read_text(encoding="utf-8")
|
|
75
|
+
return content_by_path
|
|
76
|
+
|
|
77
|
+
def copy_many_files(
|
|
78
|
+
self,
|
|
79
|
+
relative_paths: list[str],
|
|
80
|
+
destination_directory: str,
|
|
81
|
+
) -> list[str]:
|
|
82
|
+
"""Copy files from local cache to destination directory."""
|
|
83
|
+
destination = Path(destination_directory).resolve()
|
|
84
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
copied: list[str] = []
|
|
87
|
+
for relative_path in relative_paths:
|
|
88
|
+
source = self._resolve_relative_path(relative_path)
|
|
89
|
+
target = destination / Path(relative_path).name
|
|
90
|
+
shutil.copy2(source, target)
|
|
91
|
+
copied.append(str(target))
|
|
92
|
+
return copied
|
|
93
|
+
|
|
94
|
+
def _refresh(self, force: bool = False) -> None:
|
|
95
|
+
default_branch, latest_sha, tarball_url = self._get_repository_state()
|
|
96
|
+
cached_sha = self._read_cached_sha()
|
|
97
|
+
|
|
98
|
+
if not force and cached_sha == latest_sha:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
self._download_and_extract(tarball_url=tarball_url)
|
|
102
|
+
self._write_metadata(
|
|
103
|
+
{
|
|
104
|
+
"repository": f"{self._owner}/{self._repo}",
|
|
105
|
+
"default_branch": default_branch,
|
|
106
|
+
"sha": latest_sha,
|
|
107
|
+
"synced_at": int(time.time()),
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _get_repository_state(self) -> tuple[str, str, str]:
|
|
112
|
+
headers = self._build_headers()
|
|
113
|
+
|
|
114
|
+
with httpx.Client(timeout=30.0, headers=headers) as client:
|
|
115
|
+
repository_response = client.get(
|
|
116
|
+
f"https://api.github.com/repos/{self._owner}/{self._repo}"
|
|
117
|
+
)
|
|
118
|
+
repository_response.raise_for_status()
|
|
119
|
+
repository_payload = repository_response.json()
|
|
120
|
+
|
|
121
|
+
default_branch = repository_payload["default_branch"]
|
|
122
|
+
tarball_url = self._resolve_tarball_url(
|
|
123
|
+
repository_payload=repository_payload,
|
|
124
|
+
default_branch=default_branch,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
commit_response = client.get(
|
|
128
|
+
f"https://api.github.com/repos/{self._owner}/{self._repo}/commits/{default_branch}"
|
|
129
|
+
)
|
|
130
|
+
commit_response.raise_for_status()
|
|
131
|
+
latest_sha = commit_response.json()["sha"]
|
|
132
|
+
|
|
133
|
+
return default_branch, latest_sha, tarball_url
|
|
134
|
+
|
|
135
|
+
def _resolve_tarball_url(
|
|
136
|
+
self,
|
|
137
|
+
repository_payload: dict,
|
|
138
|
+
default_branch: str,
|
|
139
|
+
) -> str:
|
|
140
|
+
tarball_url = repository_payload.get("tarball_url")
|
|
141
|
+
if isinstance(tarball_url, str) and tarball_url:
|
|
142
|
+
if "{/ref}" in tarball_url:
|
|
143
|
+
return tarball_url.replace("{/ref}", f"/{default_branch}")
|
|
144
|
+
return tarball_url
|
|
145
|
+
|
|
146
|
+
archive_url = repository_payload.get("archive_url")
|
|
147
|
+
if isinstance(archive_url, str) and archive_url:
|
|
148
|
+
url = archive_url.replace("{/archive_format}", "/tarball")
|
|
149
|
+
url = url.replace("{archive_format}", "tarball")
|
|
150
|
+
if "{/ref}" in url:
|
|
151
|
+
return url.replace("{/ref}", f"/{default_branch}")
|
|
152
|
+
return url.rstrip("/") + f"/{default_branch}"
|
|
153
|
+
|
|
154
|
+
return f"https://api.github.com/repos/{self._owner}/{self._repo}/tarball/{default_branch}"
|
|
155
|
+
|
|
156
|
+
def _download_and_extract(self, tarball_url: str) -> None:
|
|
157
|
+
headers = self._build_headers()
|
|
158
|
+
with httpx.Client(
|
|
159
|
+
timeout=60.0, headers=headers, follow_redirects=True
|
|
160
|
+
) as client:
|
|
161
|
+
archive_response = client.get(tarball_url)
|
|
162
|
+
archive_response.raise_for_status()
|
|
163
|
+
archive_bytes = archive_response.content
|
|
164
|
+
|
|
165
|
+
with tempfile.TemporaryDirectory(prefix="policygate-sync-") as temp_dir:
|
|
166
|
+
temp_path = Path(temp_dir)
|
|
167
|
+
with tarfile.open(fileobj=BytesIO(archive_bytes), mode="r:gz") as archive:
|
|
168
|
+
archive.extractall(path=temp_path)
|
|
169
|
+
|
|
170
|
+
extracted_roots = [child for child in temp_path.iterdir() if child.is_dir()]
|
|
171
|
+
if not extracted_roots:
|
|
172
|
+
raise RepositorySyncError("unable to extract repository archive")
|
|
173
|
+
|
|
174
|
+
source_root = extracted_roots[0]
|
|
175
|
+
self._copy_repository_entries(source_root)
|
|
176
|
+
|
|
177
|
+
def _copy_repository_entries(self, source_root: Path) -> None:
|
|
178
|
+
required_entries = ["router.yaml", "rules"]
|
|
179
|
+
optional_entries = ["scripts"]
|
|
180
|
+
|
|
181
|
+
self._local_repo_data_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
for child in list(self._local_repo_data_dir.iterdir()):
|
|
183
|
+
if child.name == self._metadata_file.name:
|
|
184
|
+
continue
|
|
185
|
+
if child.is_dir():
|
|
186
|
+
shutil.rmtree(child)
|
|
187
|
+
else:
|
|
188
|
+
child.unlink()
|
|
189
|
+
|
|
190
|
+
for entry in required_entries:
|
|
191
|
+
source_path = source_root / entry
|
|
192
|
+
if not source_path.exists():
|
|
193
|
+
raise RepositorySyncError(
|
|
194
|
+
f"repository is missing required entry: {entry}"
|
|
195
|
+
)
|
|
196
|
+
self._copy_entry(source_path=source_path, target_name=entry)
|
|
197
|
+
|
|
198
|
+
for entry in optional_entries:
|
|
199
|
+
source_path = source_root / entry
|
|
200
|
+
if not source_path.exists():
|
|
201
|
+
continue
|
|
202
|
+
self._copy_entry(source_path=source_path, target_name=entry)
|
|
203
|
+
|
|
204
|
+
def _copy_entry(self, source_path: Path, target_name: str) -> None:
|
|
205
|
+
target_path = self._local_repo_data_dir / target_name
|
|
206
|
+
if source_path.is_dir():
|
|
207
|
+
shutil.copytree(source_path, target_path, dirs_exist_ok=True)
|
|
208
|
+
else:
|
|
209
|
+
shutil.copy2(source_path, target_path)
|
|
210
|
+
|
|
211
|
+
def _resolve_relative_path(self, relative_path: str) -> Path:
|
|
212
|
+
if not relative_path:
|
|
213
|
+
raise RepositorySyncError("relative path cannot be empty")
|
|
214
|
+
|
|
215
|
+
candidate = (self._local_repo_data_dir / relative_path).resolve()
|
|
216
|
+
base = self._local_repo_data_dir.resolve()
|
|
217
|
+
if base not in candidate.parents and candidate != base:
|
|
218
|
+
raise RepositorySyncError("path traversal is not allowed")
|
|
219
|
+
if not candidate.exists() or not candidate.is_file():
|
|
220
|
+
raise RepositorySyncError(f"file not found: {relative_path}")
|
|
221
|
+
return candidate
|
|
222
|
+
|
|
223
|
+
def _read_cached_sha(self) -> str | None:
|
|
224
|
+
if not self._metadata_file.exists():
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
payload = json.loads(self._metadata_file.read_text(encoding="utf-8"))
|
|
228
|
+
return payload.get("sha")
|
|
229
|
+
except (json.JSONDecodeError, OSError):
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def _write_metadata(self, payload: dict[str, str | int]) -> None:
|
|
233
|
+
self._metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
self._metadata_file.write_text(
|
|
235
|
+
json.dumps(payload, ensure_ascii=False, indent=2),
|
|
236
|
+
encoding="utf-8",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _parse_owner_repo(self, repository_url: str) -> tuple[str, str]:
|
|
240
|
+
parsed = urlparse(repository_url)
|
|
241
|
+
path = parsed.path.strip("/")
|
|
242
|
+
if path.endswith(".git"):
|
|
243
|
+
path = path[:-4]
|
|
244
|
+
|
|
245
|
+
segments = path.split("/")
|
|
246
|
+
if len(segments) < 2:
|
|
247
|
+
raise RepositorySyncError(
|
|
248
|
+
"github_repository_url must include owner and repository name"
|
|
249
|
+
)
|
|
250
|
+
return segments[0], segments[1]
|
|
251
|
+
|
|
252
|
+
def _build_headers(self) -> dict[str, str]:
|
|
253
|
+
return {
|
|
254
|
+
"Authorization": f"Bearer {self._access_token}",
|
|
255
|
+
"Accept": "application/vnd.github+json",
|
|
256
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
257
|
+
}
|