474 lines
15 KiB
PHP
474 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Uptelligence\Services;
|
|
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use InvalidArgumentException;
|
|
use Core\Uptelligence\Models\AnalysisLog;
|
|
use Core\Uptelligence\Models\UpstreamTodo;
|
|
use Core\Uptelligence\Models\Vendor;
|
|
|
|
/**
|
|
* Issue Generator Service - creates GitHub/Gitea issues from upstream todos.
|
|
*
|
|
* Generates individual issues and weekly digests for tracking porting work.
|
|
*/
|
|
class IssueGeneratorService
|
|
{
|
|
protected string $githubToken;
|
|
|
|
protected string $giteaUrl;
|
|
|
|
protected string $giteaToken;
|
|
|
|
protected array $defaultLabels;
|
|
|
|
protected array $assignees;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->githubToken = config('upstream.github.token', '');
|
|
$this->giteaUrl = config('upstream.gitea.url', '');
|
|
$this->giteaToken = config('upstream.gitea.token', '');
|
|
$this->defaultLabels = config('upstream.github.default_labels', ['upstream']);
|
|
$this->assignees = array_filter(config('upstream.github.assignees', []));
|
|
}
|
|
|
|
/**
|
|
* Validate target_repo format (should be 'owner/repo').
|
|
*
|
|
* @throws InvalidArgumentException if format is invalid
|
|
*/
|
|
protected function validateTargetRepo(?string $targetRepo): bool
|
|
{
|
|
if (empty($targetRepo)) {
|
|
return false;
|
|
}
|
|
|
|
// Must be in format 'owner/repo' with no extra slashes
|
|
if (! preg_match('#^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$#', $targetRepo)) {
|
|
Log::warning('Uptelligence: Invalid target_repo format', [
|
|
'target_repo' => $targetRepo,
|
|
'expected_format' => 'owner/repo',
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Create GitHub issues for all pending todos.
|
|
*/
|
|
public function createIssuesForVendor(Vendor $vendor, bool $useGitea = false): Collection
|
|
{
|
|
// Validate target_repo format before processing
|
|
if (! $this->validateTargetRepo($vendor->target_repo)) {
|
|
Log::error('Uptelligence: Cannot create issues - invalid target_repo', [
|
|
'vendor' => $vendor->slug,
|
|
'target_repo' => $vendor->target_repo,
|
|
]);
|
|
|
|
return collect();
|
|
}
|
|
|
|
$todos = $vendor->todos()
|
|
->where('status', UpstreamTodo::STATUS_PENDING)
|
|
->whereNull('github_issue_number')
|
|
->orderByDesc('priority')
|
|
->get();
|
|
|
|
$issues = collect();
|
|
|
|
foreach ($todos as $todo) {
|
|
// Check rate limit before creating issue
|
|
if (RateLimiter::tooManyAttempts('upstream-issues', 10)) {
|
|
$seconds = RateLimiter::availableIn('upstream-issues');
|
|
Log::warning('Uptelligence: Issue creation rate limit exceeded', [
|
|
'vendor' => $vendor->slug,
|
|
'retry_after_seconds' => $seconds,
|
|
]);
|
|
break;
|
|
}
|
|
|
|
try {
|
|
if ($useGitea) {
|
|
$issue = $this->createGiteaIssue($todo);
|
|
} else {
|
|
$issue = $this->createGitHubIssue($todo);
|
|
}
|
|
|
|
if ($issue) {
|
|
$issues->push($issue);
|
|
RateLimiter::hit('upstream-issues');
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('Uptelligence: Failed to create issue', [
|
|
'vendor' => $vendor->slug,
|
|
'todo_id' => $todo->id,
|
|
'todo_title' => $todo->title,
|
|
'error' => $e->getMessage(),
|
|
'exception_class' => get_class($e),
|
|
]);
|
|
report($e);
|
|
}
|
|
}
|
|
|
|
return $issues;
|
|
}
|
|
|
|
/**
|
|
* Create a GitHub issue for a todo with retry logic.
|
|
*/
|
|
public function createGitHubIssue(UpstreamTodo $todo): ?array
|
|
{
|
|
if (! $this->githubToken || ! $this->validateTargetRepo($todo->vendor->target_repo)) {
|
|
return null;
|
|
}
|
|
|
|
$body = $this->buildIssueBody($todo);
|
|
$labels = $this->buildLabels($todo);
|
|
|
|
[$owner, $repo] = explode('/', $todo->vendor->target_repo);
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->githubToken,
|
|
'Accept' => 'application/vnd.github.v3+json',
|
|
])
|
|
->timeout(30)
|
|
->retry(3, function (int $attempt, \Exception $exception) {
|
|
// Exponential backoff: 1s, 2s, 4s
|
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
|
|
|
Log::warning('Uptelligence: GitHub API retry', [
|
|
'attempt' => $attempt,
|
|
'delay_ms' => $delay,
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
|
|
return $delay;
|
|
}, function (\Exception $exception) {
|
|
// Only retry on connection/timeout errors or 5xx/429 responses
|
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
|
return true;
|
|
}
|
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
|
$status = $exception->response?->status();
|
|
|
|
return $status >= 500 || $status === 429;
|
|
}
|
|
|
|
return false;
|
|
})
|
|
->post("https://api.github.com/repos/{$owner}/{$repo}/issues", [
|
|
'title' => $this->buildIssueTitle($todo),
|
|
'body' => $body,
|
|
'labels' => $labels,
|
|
'assignees' => $this->assignees,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$issue = $response->json();
|
|
|
|
$todo->update([
|
|
'github_issue_number' => $issue['number'],
|
|
]);
|
|
|
|
AnalysisLog::logIssueCreated($todo, $issue['html_url']);
|
|
|
|
return $issue;
|
|
}
|
|
|
|
Log::error('Uptelligence: GitHub issue creation failed', [
|
|
'todo_id' => $todo->id,
|
|
'status' => $response->status(),
|
|
'body' => substr($response->body(), 0, 500),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Create a Gitea issue for a todo with retry logic.
|
|
*/
|
|
public function createGiteaIssue(UpstreamTodo $todo): ?array
|
|
{
|
|
if (! $this->giteaToken || ! $this->giteaUrl || ! $this->validateTargetRepo($todo->vendor->target_repo)) {
|
|
return null;
|
|
}
|
|
|
|
$body = $this->buildIssueBody($todo);
|
|
|
|
[$owner, $repo] = explode('/', $todo->vendor->target_repo);
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'token '.$this->giteaToken,
|
|
'Accept' => 'application/json',
|
|
])
|
|
->timeout(30)
|
|
->retry(3, function (int $attempt, \Exception $exception) {
|
|
// Exponential backoff: 1s, 2s, 4s
|
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
|
|
|
Log::warning('Uptelligence: Gitea API retry', [
|
|
'attempt' => $attempt,
|
|
'delay_ms' => $delay,
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
|
|
return $delay;
|
|
}, function (\Exception $exception) {
|
|
// Only retry on connection/timeout errors or 5xx/429 responses
|
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
|
return true;
|
|
}
|
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
|
$status = $exception->response?->status();
|
|
|
|
return $status >= 500 || $status === 429;
|
|
}
|
|
|
|
return false;
|
|
})
|
|
->post("{$this->giteaUrl}/api/v1/repos/{$owner}/{$repo}/issues", [
|
|
'title' => $this->buildIssueTitle($todo),
|
|
'body' => $body,
|
|
'labels' => [], // Gitea handles labels differently
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$issue = $response->json();
|
|
|
|
$todo->update([
|
|
'github_issue_number' => (string) $issue['number'],
|
|
]);
|
|
|
|
$issueUrl = "{$this->giteaUrl}/{$owner}/{$repo}/issues/{$issue['number']}";
|
|
AnalysisLog::logIssueCreated($todo, $issueUrl);
|
|
|
|
return $issue;
|
|
}
|
|
|
|
Log::error('Uptelligence: Gitea issue creation failed', [
|
|
'todo_id' => $todo->id,
|
|
'status' => $response->status(),
|
|
'body' => substr($response->body(), 0, 500),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build issue title.
|
|
*/
|
|
protected function buildIssueTitle(UpstreamTodo $todo): string
|
|
{
|
|
$icon = $todo->getTypeIcon();
|
|
$prefix = '[Upstream] ';
|
|
|
|
return $prefix.$icon.' '.$todo->title;
|
|
}
|
|
|
|
/**
|
|
* Build issue body with all relevant info.
|
|
*/
|
|
protected function buildIssueBody(UpstreamTodo $todo): string
|
|
{
|
|
$body = "## Upstream Change\n\n";
|
|
$body .= "**Vendor:** {$todo->vendor->name} ({$todo->vendor->vendor_name})\n";
|
|
$body .= "**Version:** {$todo->from_version} → {$todo->to_version}\n";
|
|
$body .= "**Type:** {$todo->type}\n";
|
|
$body .= "**Priority:** {$todo->priority}/10 ({$todo->getPriorityLabel()})\n";
|
|
$body .= "**Effort:** {$todo->getEffortLabel()}\n\n";
|
|
|
|
if ($todo->description) {
|
|
$body .= "## Description\n\n{$todo->description}\n\n";
|
|
}
|
|
|
|
if ($todo->port_notes) {
|
|
$body .= "## Porting Notes\n\n{$todo->port_notes}\n\n";
|
|
}
|
|
|
|
if ($todo->has_conflicts) {
|
|
$body .= "## ⚠️ Potential Conflicts\n\n{$todo->conflict_reason}\n\n";
|
|
}
|
|
|
|
if (! empty($todo->files)) {
|
|
$body .= "## Files Changed\n\n";
|
|
foreach ($todo->files as $file) {
|
|
$mapped = $todo->vendor->mapToHostHub($file);
|
|
if ($mapped) {
|
|
$body .= "- `{$file}` → `{$mapped}`\n";
|
|
} else {
|
|
$body .= "- `{$file}`\n";
|
|
}
|
|
}
|
|
$body .= "\n";
|
|
}
|
|
|
|
if (! empty($todo->dependencies)) {
|
|
$body .= "## Dependencies\n\n";
|
|
foreach ($todo->dependencies as $dep) {
|
|
$body .= "- {$dep}\n";
|
|
}
|
|
$body .= "\n";
|
|
}
|
|
|
|
if (! empty($todo->tags)) {
|
|
$body .= "## Tags\n\n";
|
|
$body .= implode(', ', array_map(fn ($t) => "`{$t}`", $todo->tags))."\n\n";
|
|
}
|
|
|
|
$body .= "---\n";
|
|
$body .= "_Auto-generated by Upstream Intelligence Pipeline_\n";
|
|
$body .= '_AI Confidence: '.round(($todo->ai_confidence ?? 0.85) * 100)."%_\n";
|
|
|
|
return $body;
|
|
}
|
|
|
|
/**
|
|
* Build labels for the issue.
|
|
*/
|
|
protected function buildLabels(UpstreamTodo $todo): array
|
|
{
|
|
$labels = $this->defaultLabels;
|
|
|
|
// Add type label
|
|
$labels[] = 'type:'.$todo->type;
|
|
|
|
// Add priority label
|
|
if ($todo->priority >= 8) {
|
|
$labels[] = 'priority:high';
|
|
} elseif ($todo->priority >= 5) {
|
|
$labels[] = 'priority:medium';
|
|
} else {
|
|
$labels[] = 'priority:low';
|
|
}
|
|
|
|
// Add effort label
|
|
$labels[] = 'effort:'.$todo->effort;
|
|
|
|
// Add quick-win label
|
|
if ($todo->isQuickWin()) {
|
|
$labels[] = 'quick-win';
|
|
}
|
|
|
|
// Add vendor label
|
|
$labels[] = 'vendor:'.$todo->vendor->slug;
|
|
|
|
return $labels;
|
|
}
|
|
|
|
/**
|
|
* Create a weekly digest issue.
|
|
*/
|
|
public function createWeeklyDigest(Vendor $vendor): ?array
|
|
{
|
|
$todos = $vendor->todos()
|
|
->where('status', UpstreamTodo::STATUS_PENDING)
|
|
->whereNull('github_issue_number')
|
|
->where('created_at', '>=', now()->subWeek())
|
|
->orderByDesc('priority')
|
|
->get();
|
|
|
|
if ($todos->isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
$title = "[Weekly Digest] {$vendor->name} - ".now()->format('M d, Y');
|
|
$body = $this->buildDigestBody($vendor, $todos);
|
|
|
|
if (! $this->githubToken || ! $vendor->target_repo) {
|
|
return null;
|
|
}
|
|
|
|
[$owner, $repo] = explode('/', $vendor->target_repo);
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->githubToken,
|
|
'Accept' => 'application/vnd.github.v3+json',
|
|
])->post("https://api.github.com/repos/{$owner}/{$repo}/issues", [
|
|
'title' => $title,
|
|
'body' => $body,
|
|
'labels' => ['upstream', 'digest'],
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
return $response->json();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build weekly digest body.
|
|
*/
|
|
protected function buildDigestBody(Vendor $vendor, Collection $todos): string
|
|
{
|
|
$body = "# Weekly Upstream Digest\n\n";
|
|
$body .= "**Vendor:** {$vendor->name}\n";
|
|
$body .= '**Week of:** '.now()->subWeek()->format('M d').' - '.now()->format('M d, Y')."\n";
|
|
$body .= "**Total Changes:** {$todos->count()}\n\n";
|
|
|
|
// Quick wins
|
|
$quickWins = $todos->filter->isQuickWin();
|
|
if ($quickWins->isNotEmpty()) {
|
|
$body .= "## 🚀 Quick Wins ({$quickWins->count()})\n\n";
|
|
foreach ($quickWins as $todo) {
|
|
$body .= "- {$todo->getTypeIcon()} {$todo->title}\n";
|
|
}
|
|
$body .= "\n";
|
|
}
|
|
|
|
// Security
|
|
$security = $todos->where('type', 'security');
|
|
if ($security->isNotEmpty()) {
|
|
$body .= "## 🔒 Security Updates ({$security->count()})\n\n";
|
|
foreach ($security as $todo) {
|
|
$body .= "- {$todo->title}\n";
|
|
}
|
|
$body .= "\n";
|
|
}
|
|
|
|
// Features
|
|
$features = $todos->where('type', 'feature');
|
|
if ($features->isNotEmpty()) {
|
|
$body .= "## ✨ New Features ({$features->count()})\n\n";
|
|
foreach ($features as $todo) {
|
|
$body .= "- {$todo->title} (Priority: {$todo->priority}/10)\n";
|
|
}
|
|
$body .= "\n";
|
|
}
|
|
|
|
// Bug fixes
|
|
$bugfixes = $todos->where('type', 'bugfix');
|
|
if ($bugfixes->isNotEmpty()) {
|
|
$body .= "## 🐛 Bug Fixes ({$bugfixes->count()})\n\n";
|
|
foreach ($bugfixes as $todo) {
|
|
$body .= "- {$todo->title}\n";
|
|
}
|
|
$body .= "\n";
|
|
}
|
|
|
|
// Other
|
|
$other = $todos->whereNotIn('type', ['feature', 'bugfix', 'security'])->where(fn ($t) => ! $t->isQuickWin());
|
|
if ($other->isNotEmpty()) {
|
|
$body .= "## 📝 Other Changes ({$other->count()})\n\n";
|
|
foreach ($other as $todo) {
|
|
$body .= "- {$todo->getTypeIcon()} {$todo->title}\n";
|
|
}
|
|
$body .= "\n";
|
|
}
|
|
|
|
$body .= "---\n";
|
|
$body .= "_Auto-generated by Upstream Intelligence Pipeline_\n";
|
|
|
|
return $body;
|
|
}
|
|
}
|