polycoding 0.1.0__py3-none-any.whl
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.
- cli/__init__.py +53 -0
- cli/db.py +67 -0
- cli/flow.py +187 -0
- cli/main.py +44 -0
- cli/project.py +166 -0
- cli/server.py +127 -0
- cli/utils.py +70 -0
- cli/worker.py +124 -0
- github_app/__init__.py +13 -0
- github_app/app.py +224 -0
- github_app/auth.py +137 -0
- github_app/config.py +38 -0
- github_app/installation_manager.py +194 -0
- github_app/label_mapper.py +112 -0
- github_app/models.py +112 -0
- github_app/webhook_handler.py +217 -0
- persistence/__init__.py +5 -0
- persistence/config.py +12 -0
- persistence/postgres.py +346 -0
- persistence/registry.py +111 -0
- persistence/tasks.py +178 -0
- polycoding-0.1.0.dist-info/METADATA +225 -0
- polycoding-0.1.0.dist-info/RECORD +41 -0
- polycoding-0.1.0.dist-info/WHEEL +4 -0
- polycoding-0.1.0.dist-info/entry_points.txt +3 -0
- polycoding-0.1.0.dist-info/licenses/LICENSE +20 -0
- project_manager/README.md +668 -0
- project_manager/__init__.py +29 -0
- project_manager/base.py +202 -0
- project_manager/config.py +36 -0
- project_manager/conversation/__init__.py +19 -0
- project_manager/conversation/flow.py +233 -0
- project_manager/conversation/types.py +64 -0
- project_manager/flow_runner.py +160 -0
- project_manager/git_utils.py +30 -0
- project_manager/github.py +367 -0
- project_manager/github_conversation.py +144 -0
- project_manager/github_projects_client.py +329 -0
- project_manager/hooks.py +377 -0
- project_manager/module.py +66 -0
- project_manager/types.py +79 -0
cli/utils.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Shared utilities for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.logging import RichHandler
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
NOISY_LOGGERS = [
|
|
12
|
+
"urllib3.connectionpool",
|
|
13
|
+
"httpcore.connection",
|
|
14
|
+
"httpcore",
|
|
15
|
+
"openai._base_client",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup_logging(level: int | str = logging.INFO) -> None:
|
|
20
|
+
"""Configure Rich logging.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) as int or str
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(level, str):
|
|
26
|
+
level = getattr(logging, level.upper(), logging.INFO)
|
|
27
|
+
|
|
28
|
+
logging.basicConfig(
|
|
29
|
+
level=level,
|
|
30
|
+
format="%(name)s - %(message)s",
|
|
31
|
+
handlers=[
|
|
32
|
+
RichHandler(
|
|
33
|
+
console=console,
|
|
34
|
+
rich_tracebacks=True,
|
|
35
|
+
)
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
for logger_name in NOISY_LOGGERS:
|
|
40
|
+
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_logger(name: str) -> logging.Logger:
|
|
44
|
+
"""Get logger instance.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
name: Logger name (typically __name__)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Logger instance
|
|
51
|
+
"""
|
|
52
|
+
return logging.getLogger(f"polycode.{name}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_bootstrap() -> Any:
|
|
56
|
+
"""Load bootstrap system for full runtime initialization.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
ModuleContext with engine, hook manager, and config
|
|
60
|
+
"""
|
|
61
|
+
from bootstrap import bootstrap
|
|
62
|
+
|
|
63
|
+
log = get_logger("cli.utils")
|
|
64
|
+
log.debug("🔧 Loading bootstrap system...")
|
|
65
|
+
|
|
66
|
+
context = bootstrap()
|
|
67
|
+
|
|
68
|
+
log.debug(f"✅ Bootstrap loaded: {len(context.config)} modules configured")
|
|
69
|
+
|
|
70
|
+
return context
|
cli/worker.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Worker commands for Polycode CLI."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from cli import print_error, print_info, print_success
|
|
9
|
+
from cli.utils import get_logger
|
|
10
|
+
|
|
11
|
+
log = get_logger(__name__)
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
worker_app = typer.Typer(help="Celery worker management commands")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@worker_app.command("start")
|
|
18
|
+
def worker_start(
|
|
19
|
+
queues: str = typer.Option("celery,default", "--queues", "-q", help="Comma-separated queue names"),
|
|
20
|
+
concurrency: int = typer.Option(0, "--concurrency", "-c", help="Number of worker processes (0 = auto)"),
|
|
21
|
+
loglevel: str = typer.Option("info", "--loglevel", "-l", help="Log level"),
|
|
22
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose logging"),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Start Celery worker for processing async tasks.
|
|
25
|
+
|
|
26
|
+
Worker handles flow execution, webhook processing, and periodic tasks.
|
|
27
|
+
"""
|
|
28
|
+
if verbose:
|
|
29
|
+
import cli.utils
|
|
30
|
+
|
|
31
|
+
cli.utils.setup_logging("DEBUG")
|
|
32
|
+
|
|
33
|
+
queues_list = [q.strip() for q in queues.split(",") if q.strip()]
|
|
34
|
+
concurrency_str = str(concurrency) if concurrency > 0 else 10
|
|
35
|
+
|
|
36
|
+
print_info("⚙️ Starting Celery worker")
|
|
37
|
+
print_info(f" Queues: {', '.join(queues_list)}")
|
|
38
|
+
print_info(f" Concurrency: {concurrency_str}")
|
|
39
|
+
print_info(f" Log level: {loglevel}")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from bootstrap import bootstrap
|
|
43
|
+
from tasks import app as celery_app
|
|
44
|
+
from tasks import worker
|
|
45
|
+
|
|
46
|
+
assert worker
|
|
47
|
+
|
|
48
|
+
print_info("📦 Initializing modules...")
|
|
49
|
+
bootstrap()
|
|
50
|
+
|
|
51
|
+
registered = worker.register_module_tasks()
|
|
52
|
+
if registered:
|
|
53
|
+
print_info(f" Module tasks: {len(registered)} registered")
|
|
54
|
+
|
|
55
|
+
celery_app.worker_main(
|
|
56
|
+
[
|
|
57
|
+
"worker",
|
|
58
|
+
f"--queues={','.join(queues_list)}",
|
|
59
|
+
f"--concurrency={concurrency_str}",
|
|
60
|
+
f"--loglevel={loglevel}",
|
|
61
|
+
"-Ofair",
|
|
62
|
+
"--task-events",
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
print_info("⏹ Worker stopped by user")
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
print_error(f"Failed to start worker: {e}")
|
|
70
|
+
log.exception(f"Worker startup failed: {e}")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@worker_app.command("status")
|
|
75
|
+
def worker_status(
|
|
76
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose logging"),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Check Celery worker status using inspect.
|
|
79
|
+
|
|
80
|
+
Shows active workers, registered tasks, and queue statistics.
|
|
81
|
+
"""
|
|
82
|
+
if verbose:
|
|
83
|
+
import cli.utils
|
|
84
|
+
|
|
85
|
+
cli.utils.setup_logging("DEBUG")
|
|
86
|
+
|
|
87
|
+
print_info("🔍 Checking worker status...")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
from tasks import app as celery_app
|
|
91
|
+
|
|
92
|
+
inspect = celery_app.control.inspect()
|
|
93
|
+
|
|
94
|
+
active = inspect.active()
|
|
95
|
+
registered = inspect.registered()
|
|
96
|
+
stats = inspect.stats()
|
|
97
|
+
|
|
98
|
+
if active:
|
|
99
|
+
for worker_name, tasks in active.items():
|
|
100
|
+
console.print(f"\n[bold cyan]Worker: {worker_name}[/]")
|
|
101
|
+
console.print(f" Active tasks: {len(tasks)}")
|
|
102
|
+
else:
|
|
103
|
+
console.print("[yellow]⚠️ No active workers found[/]")
|
|
104
|
+
|
|
105
|
+
if registered:
|
|
106
|
+
console.print("\n[bold cyan]Registered tasks:[/]")
|
|
107
|
+
for worker_name, tasks in registered.items():
|
|
108
|
+
for task in tasks:
|
|
109
|
+
console.print(f" • {task}")
|
|
110
|
+
|
|
111
|
+
if stats:
|
|
112
|
+
console.print("\n[bold cyan]Statistics:[/]")
|
|
113
|
+
for worker_name, stat in stats.items():
|
|
114
|
+
console.print(f" {worker_name}:")
|
|
115
|
+
for key, value in stat.items():
|
|
116
|
+
if key == "pool":
|
|
117
|
+
console.print(f" Pool: {value.get('max-concurrency', 'N/A')} processes")
|
|
118
|
+
|
|
119
|
+
print_success("Worker status check complete")
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print_error(f"Failed to check worker status: {e}")
|
|
123
|
+
log.exception(f"Worker status check failed: {e}")
|
|
124
|
+
sys.exit(1)
|
github_app/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""GitHub App module for multi-repo CrewAI flow automation."""
|
|
2
|
+
|
|
3
|
+
from github_app.auth import GitHubAppAuth
|
|
4
|
+
from github_app.installation_manager import InstallationManager
|
|
5
|
+
from github_app.label_mapper import LabelFlowMapper
|
|
6
|
+
from github_app.webhook_handler import GitHubAppWebhookHandler
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"GitHubAppAuth",
|
|
10
|
+
"InstallationManager",
|
|
11
|
+
"LabelFlowMapper",
|
|
12
|
+
"GitHubAppWebhookHandler",
|
|
13
|
+
]
|
github_app/app.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""GitHub App FastAPI webhook server - unified webhook endpoint.
|
|
2
|
+
|
|
3
|
+
Merges functionality from:
|
|
4
|
+
- src/github_app/webhook_handler.py (GitHub App webhooks)
|
|
5
|
+
- src/project_manager/webhook.py (signature validation, ping handling)
|
|
6
|
+
|
|
7
|
+
This replaces the separate FastAPI instance in project_manager.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
14
|
+
from fastapi.responses import JSONResponse
|
|
15
|
+
|
|
16
|
+
from github_app.auth import GitHubAppAuth
|
|
17
|
+
from github_app.config import settings
|
|
18
|
+
from github_app.installation_manager import InstallationManager
|
|
19
|
+
from github_app.label_mapper import LabelFlowMapper
|
|
20
|
+
from github_app.webhook_handler import GitHubAppWebhookHandler
|
|
21
|
+
from persistence.postgres import SessionLocal
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
app = FastAPI(
|
|
26
|
+
title=settings.GITHUB_APP_NAME,
|
|
27
|
+
description="GitHub App for multi-repo CrewAI flow automation",
|
|
28
|
+
version="2.0.0",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
github_auth = GitHubAppAuth(
|
|
32
|
+
app_id=settings.GITHUB_APP_ID,
|
|
33
|
+
private_key=settings.GITHUB_APP_PRIVATE_KEY.replace("\\n", "\n"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_db_session():
|
|
38
|
+
"""Get database session from postgres.py."""
|
|
39
|
+
return SessionLocal()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_webhook_handler(db_session) -> GitHubAppWebhookHandler:
|
|
43
|
+
"""Create webhook handler with dependencies."""
|
|
44
|
+
installation_manager = InstallationManager(db_session, github_auth)
|
|
45
|
+
label_mapper = LabelFlowMapper(db_session)
|
|
46
|
+
|
|
47
|
+
return GitHubAppWebhookHandler(
|
|
48
|
+
github_auth=github_auth,
|
|
49
|
+
installation_manager=installation_manager,
|
|
50
|
+
label_mapper=label_mapper,
|
|
51
|
+
webhook_secret=settings.GITHUB_APP_WEBHOOK_SECRET,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ============================================================================
|
|
56
|
+
# Health & Status Endpoints (from project_manager/webhook.py)
|
|
57
|
+
# ============================================================================
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.get("/")
|
|
61
|
+
async def root():
|
|
62
|
+
return {
|
|
63
|
+
"name": settings.GITHUB_APP_NAME,
|
|
64
|
+
"version": "2.0.0",
|
|
65
|
+
"status": "running",
|
|
66
|
+
"mode": "github_app",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.get("/health")
|
|
71
|
+
async def health():
|
|
72
|
+
"""Health check endpoint (from project_manager/webhook.py)."""
|
|
73
|
+
return {
|
|
74
|
+
"status": "healthy",
|
|
75
|
+
"redis": "connected", # TODO: Check actual Redis connection
|
|
76
|
+
"database": "connected", # TODO: Check actual DB connection
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ============================================================================
|
|
81
|
+
# Main Webhook Endpoint (unified)
|
|
82
|
+
# ============================================================================
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.post("/webhook/github")
|
|
86
|
+
async def github_webhook(request: Request):
|
|
87
|
+
"""Handle GitHub webhook events.
|
|
88
|
+
|
|
89
|
+
Unified endpoint for:
|
|
90
|
+
- GitHub App webhooks (multi-repo, installation-based)
|
|
91
|
+
- Legacy webhooks (single repo, from project_manager/webhook.py)
|
|
92
|
+
|
|
93
|
+
Supported events:
|
|
94
|
+
- ping: Webhook verification
|
|
95
|
+
- installation: GitHub App installation lifecycle
|
|
96
|
+
- issues: Issue events
|
|
97
|
+
"""
|
|
98
|
+
db_session = get_db_session()
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
handler = get_webhook_handler(db_session)
|
|
102
|
+
result = await handler.handle_webhook(request)
|
|
103
|
+
return result
|
|
104
|
+
except HTTPException:
|
|
105
|
+
raise
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"Webhook processing error: {e}", exc_info=True)
|
|
108
|
+
raise HTTPException(status_code=500, detail="Internal server error")
|
|
109
|
+
finally:
|
|
110
|
+
db_session.close()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ============================================================================
|
|
114
|
+
# Installation Management API
|
|
115
|
+
# ============================================================================
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.get("/installations")
|
|
119
|
+
async def list_installations():
|
|
120
|
+
"""List all active installations."""
|
|
121
|
+
db_session = get_db_session()
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
installation_manager = InstallationManager(db_session, github_auth)
|
|
125
|
+
installations = installation_manager.list_installations()
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"installations": [
|
|
129
|
+
{
|
|
130
|
+
"id": inst.installation_id,
|
|
131
|
+
"account": inst.account_login,
|
|
132
|
+
"active": inst.is_active,
|
|
133
|
+
"repos_count": len(inst.repositories.get("repos", []) if inst.repositories else []),
|
|
134
|
+
}
|
|
135
|
+
for inst in installations
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
finally:
|
|
139
|
+
db_session.close()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ============================================================================
|
|
143
|
+
# Label Mapping API
|
|
144
|
+
# ============================================================================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.post("/mappings")
|
|
148
|
+
async def create_label_mapping(
|
|
149
|
+
installation_id: int,
|
|
150
|
+
label_name: str,
|
|
151
|
+
flow_name: str,
|
|
152
|
+
repo_pattern: Optional[str] = None,
|
|
153
|
+
priority: int = 0,
|
|
154
|
+
):
|
|
155
|
+
"""Create a label-to-flow mapping."""
|
|
156
|
+
db_session = get_db_session()
|
|
157
|
+
label_mapper = LabelFlowMapper(db_session)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
mapping = label_mapper.create_mapping(
|
|
161
|
+
installation_id=installation_id,
|
|
162
|
+
label_name=label_name,
|
|
163
|
+
flow_name=flow_name,
|
|
164
|
+
repo_pattern=repo_pattern,
|
|
165
|
+
priority=priority,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"id": mapping.id,
|
|
170
|
+
"label": mapping.label_name,
|
|
171
|
+
"flow": mapping.flow_name,
|
|
172
|
+
"pattern": mapping.repo_pattern,
|
|
173
|
+
"priority": mapping.priority,
|
|
174
|
+
}
|
|
175
|
+
finally:
|
|
176
|
+
db_session.close()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.get("/mappings")
|
|
180
|
+
async def list_label_mappings(installation_id: Optional[int] = None):
|
|
181
|
+
"""List label-to-flow mappings."""
|
|
182
|
+
db_session = get_db_session()
|
|
183
|
+
label_mapper = LabelFlowMapper(db_session)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
mappings = label_mapper.list_mappings(installation_id=installation_id)
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
"mappings": [
|
|
190
|
+
{
|
|
191
|
+
"id": m.id,
|
|
192
|
+
"label": m.label_name,
|
|
193
|
+
"flow": m.flow_name,
|
|
194
|
+
"pattern": m.repo_pattern,
|
|
195
|
+
"priority": m.priority,
|
|
196
|
+
"active": m.is_active,
|
|
197
|
+
}
|
|
198
|
+
for m in mappings
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
finally:
|
|
202
|
+
db_session.close()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ============================================================================
|
|
206
|
+
# Error Handlers
|
|
207
|
+
# ============================================================================
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.exception_handler(Exception)
|
|
211
|
+
async def global_exception_handler(request: Request, exc: Exception):
|
|
212
|
+
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
|
213
|
+
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
import uvicorn
|
|
218
|
+
|
|
219
|
+
uvicorn.run(
|
|
220
|
+
"app:app",
|
|
221
|
+
host=settings.WEBHOOK_HOST,
|
|
222
|
+
port=settings.WEBHOOK_PORT,
|
|
223
|
+
reload=True,
|
|
224
|
+
)
|
github_app/auth.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""GitHub App authentication using PyGithub's native GitHub App support."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import github
|
|
8
|
+
from github import Auth, Github, GithubIntegration
|
|
9
|
+
from github.Installation import Installation
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitHubAppAuth:
|
|
15
|
+
"""GitHub App authentication using PyGithub's GithubIntegration.
|
|
16
|
+
|
|
17
|
+
Wraps PyGithub's native GitHub App support with:
|
|
18
|
+
- Webhook signature verification
|
|
19
|
+
- Convenient access to Github client instances
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
app_id: str | int,
|
|
25
|
+
private_key: str,
|
|
26
|
+
):
|
|
27
|
+
self.app_id = str(app_id)
|
|
28
|
+
self.private_key = private_key
|
|
29
|
+
|
|
30
|
+
# Create PyGithub GithubIntegration
|
|
31
|
+
self._auth = Auth.AppAuth(app_id=self.app_id, private_key=private_key)
|
|
32
|
+
self._integration = GithubIntegration(auth=self._auth)
|
|
33
|
+
|
|
34
|
+
# Cache for Github clients per installation
|
|
35
|
+
self._clients: dict[int, Github] = {}
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def integration(self) -> GithubIntegration:
|
|
39
|
+
"""Get the GithubIntegration instance."""
|
|
40
|
+
return self._integration
|
|
41
|
+
|
|
42
|
+
def get_installation_token(self, installation_id: int) -> str | None:
|
|
43
|
+
"""Get an installation access token.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
installation_id: GitHub App installation ID
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Installation token or None if failed
|
|
50
|
+
"""
|
|
51
|
+
# Get fresh token
|
|
52
|
+
try:
|
|
53
|
+
# Fallback: create token directly via API
|
|
54
|
+
return self._create_installation_token(installation_id)
|
|
55
|
+
except github.GithubException as e:
|
|
56
|
+
logger.error(f"Failed to get installation token: {e}")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def _create_installation_token(self, installation_id: int) -> str | None:
|
|
60
|
+
"""Create installation token via GitHub API (fallback method)."""
|
|
61
|
+
return self._integration.get_access_token(installation_id=installation_id).token
|
|
62
|
+
|
|
63
|
+
def get_installation(self, installation_id: int) -> Installation | None:
|
|
64
|
+
"""Get installation by ID using PyGithub.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
installation_id: GitHub App installation ID
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Installation object or None
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
return self._integration.get_app_installation(installation_id)
|
|
74
|
+
except github.GithubException as e:
|
|
75
|
+
logger.error(f"Failed to get installation {installation_id}: {e}")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def get_repo_installation(self, owner: str, repo: str) -> Installation | None:
|
|
79
|
+
"""Get installation for a specific repository.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
owner: Repository owner
|
|
83
|
+
repo: Repository name
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Installation object or None
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
return self._integration.get_repo_installation(owner, repo)
|
|
90
|
+
except github.GithubException as e:
|
|
91
|
+
logger.error(f"Failed to get installation for {owner}/{repo}: {e}")
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def get_org_installation(self, org: str) -> Installation | None:
|
|
95
|
+
"""Get installation for an organization.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
org: Organization name
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Installation object or None
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
return self._integration.get_org_installation(org)
|
|
105
|
+
except github.GithubException as e:
|
|
106
|
+
logger.error(f"Failed to get installation for org {org}: {e}")
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def list_installations(self) -> list[Installation]:
|
|
110
|
+
"""List all installations for this GitHub App.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of Installation objects
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
return list(self._integration.get_installations())
|
|
117
|
+
except github.GithubException as e:
|
|
118
|
+
logger.error(f"Failed to list installations: {e}")
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
def verify_webhook_payload(self, payload: str | bytes, signature: str, secret: str) -> bool:
|
|
122
|
+
"""Verify GitHub webhook signature.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
payload: Raw webhook payload (string or bytes)
|
|
126
|
+
signature: X-Hub-Signature-256 header value
|
|
127
|
+
secret: Webhook secret
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True if signature is valid
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(payload, str):
|
|
133
|
+
payload = payload.encode()
|
|
134
|
+
|
|
135
|
+
expected_signature = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
136
|
+
|
|
137
|
+
return hmac.compare_digest(f"sha256={expected_signature}", signature)
|
github_app/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GitHubAppSettings(BaseSettings):
|
|
7
|
+
# GitHub App Configuration
|
|
8
|
+
GITHUB_APP_ID: str = ""
|
|
9
|
+
GITHUB_APP_PRIVATE_KEY: str = ""
|
|
10
|
+
GITHUB_APP_WEBHOOK_SECRET: str = ""
|
|
11
|
+
GITHUB_APP_NAME: str = "Polycode GitHub App"
|
|
12
|
+
|
|
13
|
+
# Redis Configuration
|
|
14
|
+
REDIS_URL: str = "redis://localhost:6379/0"
|
|
15
|
+
REDIS_HOST: str = "localhost"
|
|
16
|
+
REDIS_PORT: int = 6379
|
|
17
|
+
REDIS_DB: int = 0
|
|
18
|
+
|
|
19
|
+
# Database Configuration
|
|
20
|
+
DATABASE_URL: str = "sqlite:///polycode.db"
|
|
21
|
+
|
|
22
|
+
# Webhook Server Configuration
|
|
23
|
+
WEBHOOK_HOST: str = "0.0.0.0"
|
|
24
|
+
WEBHOOK_PORT: int = 8000
|
|
25
|
+
WEBHOOK_PATH: str = "/webhook/github"
|
|
26
|
+
WEBHOOK_URL: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
# GitHub API Configuration
|
|
29
|
+
GITHUB_API_URL: str = "https://api.github.com"
|
|
30
|
+
|
|
31
|
+
# Celery Configuration
|
|
32
|
+
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
|
33
|
+
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/0"
|
|
34
|
+
|
|
35
|
+
model_config = SettingsConfigDict(extra="ignore", env_file=".env", case_sensitive=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
settings = GitHubAppSettings() # pyright:ignore # ty:ignore
|