feat(changelog): implement automated changelog fetching from GitHub and enhance changelog read tracking
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
||||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\PullChangelogFromGitHub;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
@@ -64,6 +65,7 @@ class Init extends Command
|
||||
try {
|
||||
$this->cleanupUnnecessaryDynamicProxyConfiguration();
|
||||
$this->pullTemplatesFromCDN();
|
||||
$this->pullChangelogFromGitHub();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||
}
|
||||
@@ -74,6 +76,7 @@ class Init extends Command
|
||||
try {
|
||||
$this->cleanupInProgressApplicationDeployments();
|
||||
$this->pullTemplatesFromCDN();
|
||||
$this->pullChangelogFromGitHub();
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
|
||||
}
|
||||
@@ -109,6 +112,16 @@ class Init extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function pullChangelogFromGitHub()
|
||||
{
|
||||
try {
|
||||
PullChangelogFromGitHub::dispatch();
|
||||
echo "Changelog fetch initiated\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
|
||||
private function optimize()
|
||||
{
|
||||
Artisan::call('optimize:clear');
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\PullChangelogFromGitHub;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
use App\Jobs\RegenerateSslCertJob;
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
@@ -67,6 +68,7 @@ class Kernel extends ConsoleKernel
|
||||
$this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer();
|
||||
|
||||
$this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
||||
$this->scheduleInstance->job(new PullChangelogFromGitHub)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
||||
|
||||
$this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer();
|
||||
$this->scheduleUpdates();
|
||||
|
||||
110
app/Jobs/PullChangelogFromGitHub.php
Normal file
110
app/Jobs/PullChangelogFromGitHub.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class PullChangelogFromGitHub implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 30;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$response = Http::retry(3, 1000)
|
||||
->timeout(30)
|
||||
->get('https://api.github.com/repos/coollabsio/coolify/releases?per_page=10');
|
||||
|
||||
if ($response->successful()) {
|
||||
$releases = $response->json();
|
||||
$changelog = $this->transformReleasesToChangelog($releases);
|
||||
|
||||
// Group entries by month and save them
|
||||
$this->saveChangelogEntries($changelog);
|
||||
} else {
|
||||
send_internal_notification('PullChangelogFromGitHub failed with: '.$response->status().' '.$response->body());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
send_internal_notification('PullChangelogFromGitHub failed with: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function transformReleasesToChangelog(array $releases): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($releases as $release) {
|
||||
// Skip drafts and pre-releases if desired
|
||||
if ($release['draft']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$publishedAt = Carbon::parse($release['published_at']);
|
||||
|
||||
$entry = [
|
||||
'tag_name' => $release['tag_name'],
|
||||
'title' => $release['name'] ?: $release['tag_name'],
|
||||
'content' => $release['body'] ?: 'No release notes available.',
|
||||
'published_at' => $publishedAt->toISOString(),
|
||||
];
|
||||
|
||||
$entries[] = $entry;
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function saveChangelogEntries(array $entries): void
|
||||
{
|
||||
// Create changelogs directory if it doesn't exist
|
||||
$changelogsDir = base_path('changelogs');
|
||||
if (! File::exists($changelogsDir)) {
|
||||
File::makeDirectory($changelogsDir, 0755, true);
|
||||
}
|
||||
|
||||
// Group entries by year-month
|
||||
$groupedEntries = [];
|
||||
foreach ($entries as $entry) {
|
||||
$date = Carbon::parse($entry['published_at']);
|
||||
$monthKey = $date->format('Y-m');
|
||||
|
||||
if (! isset($groupedEntries[$monthKey])) {
|
||||
$groupedEntries[$monthKey] = [];
|
||||
}
|
||||
|
||||
$groupedEntries[$monthKey][] = $entry;
|
||||
}
|
||||
|
||||
// Save each month's entries to separate files
|
||||
foreach ($groupedEntries as $month => $monthEntries) {
|
||||
// Sort entries by published date (newest first)
|
||||
usort($monthEntries, function ($a, $b) {
|
||||
return Carbon::parse($b['published_at'])->timestamp - Carbon::parse($a['published_at'])->timestamp;
|
||||
});
|
||||
|
||||
$monthData = [
|
||||
'entries' => $monthEntries,
|
||||
'last_updated' => now()->toISOString(),
|
||||
];
|
||||
|
||||
$filePath = base_path("changelogs/{$month}.json");
|
||||
File::put($filePath, json_encode($monthData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Jobs\PullChangelogFromGitHub;
|
||||
use App\Services\ChangelogService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
@@ -42,6 +43,20 @@ class SettingsDropdown extends Component
|
||||
app(ChangelogService::class)->markAllAsReadForUser(Auth::user());
|
||||
}
|
||||
|
||||
public function manualFetchChangelog()
|
||||
{
|
||||
if (! isDev()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
PullChangelogFromGitHub::dispatch();
|
||||
$this->dispatch('success', 'Changelog fetch initiated! Check back in a few moments.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to fetch changelog: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings-dropdown', [
|
||||
|
||||
@@ -75,7 +75,7 @@ class Index extends Component
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the error
|
||||
logger()->error('Stripe API error: ' . $e->getMessage());
|
||||
logger()->error('Stripe API error: '.$e->getMessage());
|
||||
// Set a flag to show an error message to the user
|
||||
$this->addError('stripe', 'Could not retrieve subscription information. Please try again later.');
|
||||
} finally {
|
||||
|
||||
@@ -9,7 +9,7 @@ class UserChangelogRead extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'changelog_identifier',
|
||||
'release_tag',
|
||||
'read_at',
|
||||
];
|
||||
|
||||
@@ -26,7 +26,7 @@ class UserChangelogRead extends Model
|
||||
{
|
||||
self::firstOrCreate([
|
||||
'user_id' => $userId,
|
||||
'changelog_identifier' => $identifier,
|
||||
'release_tag' => $identifier,
|
||||
], [
|
||||
'read_at' => now(),
|
||||
]);
|
||||
@@ -35,14 +35,14 @@ class UserChangelogRead extends Model
|
||||
public static function isReadByUser(int $userId, string $identifier): bool
|
||||
{
|
||||
return self::where('user_id', $userId)
|
||||
->where('changelog_identifier', $identifier)
|
||||
->where('release_tag', $identifier)
|
||||
->exists();
|
||||
}
|
||||
|
||||
public static function getReadIdentifiersForUser(int $userId): array
|
||||
{
|
||||
return self::where('user_id', $userId)
|
||||
->pluck('changelog_identifier')
|
||||
->pluck('release_tag')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\LaravelMarkdown\MarkdownRenderer;
|
||||
|
||||
class ChangelogService
|
||||
{
|
||||
@@ -63,7 +64,7 @@ class ChangelogService
|
||||
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
|
||||
|
||||
return $entries->map(function ($entry) use ($readIdentifiers) {
|
||||
$entry->is_read = in_array($entry->version, $readIdentifiers);
|
||||
$entry->is_read = in_array($entry->tag_name, $readIdentifiers);
|
||||
|
||||
return $entry;
|
||||
})->sortBy([
|
||||
@@ -78,7 +79,7 @@ class ChangelogService
|
||||
$entries = $this->getEntries();
|
||||
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
|
||||
|
||||
return $entries->reject(fn ($entry) => in_array($entry->version, $readIdentifiers))->count();
|
||||
return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count();
|
||||
} else {
|
||||
return Cache::remember(
|
||||
'user_unread_changelog_count_'.$user->id,
|
||||
@@ -87,7 +88,7 @@ class ChangelogService
|
||||
$entries = $this->getEntries();
|
||||
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
|
||||
|
||||
return $entries->reject(fn ($entry) => in_array($entry->version, $readIdentifiers))->count();
|
||||
return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count();
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -200,7 +201,7 @@ class ChangelogService
|
||||
$entries = $this->getEntries();
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
UserChangelogRead::markAsRead($user->id, $entry->version);
|
||||
UserChangelogRead::markAsRead($user->id, $entry->tag_name);
|
||||
}
|
||||
|
||||
Cache::forget('user_unread_changelog_count_'.$user->id);
|
||||
@@ -208,7 +209,7 @@ class ChangelogService
|
||||
|
||||
private function validateEntryData(array $data): bool
|
||||
{
|
||||
$required = ['version', 'title', 'content', 'published_at'];
|
||||
$required = ['tag_name', 'title', 'content', 'published_at'];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (! isset($data[$field]) || empty($data[$field])) {
|
||||
@@ -253,41 +254,46 @@ class ChangelogService
|
||||
|
||||
private function parseMarkdown(string $content): string
|
||||
{
|
||||
// Convert markdown to HTML using simple regex patterns
|
||||
$html = $content;
|
||||
$renderer = app(MarkdownRenderer::class);
|
||||
|
||||
$html = $renderer->toHtml($content);
|
||||
|
||||
// Apply custom Tailwind CSS classes for dark mode compatibility
|
||||
$html = $this->applyCustomStyling($html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function applyCustomStyling(string $html): string
|
||||
{
|
||||
// Headers
|
||||
$html = preg_replace('/^### (.*?)$/m', '<h3 class="text-md font-semibold dark:text-white mb-1">$1</h3>', $html);
|
||||
$html = preg_replace('/^## (.*?)$/m', '<h2 class="text-lg font-semibold dark:text-white mb-2">$1</h2>', $html);
|
||||
$html = preg_replace('/^# (.*?)$/m', '<h1 class="text-xl font-bold dark:text-white mb-2">$1</h1>', $html);
|
||||
$html = preg_replace('/<h1[^>]*>/', '<h1 class="text-xl font-bold dark:text-white mb-2">', $html);
|
||||
$html = preg_replace('/<h2[^>]*>/', '<h2 class="text-lg font-semibold dark:text-white mb-2">', $html);
|
||||
$html = preg_replace('/<h3[^>]*>/', '<h3 class="text-md font-semibold dark:text-white mb-1">', $html);
|
||||
|
||||
// Bold text
|
||||
$html = preg_replace('/\*\*(.*?)\*\*/', '<strong class="font-semibold">$1</strong>', $html);
|
||||
$html = preg_replace('/__(.*?)__/', '<strong class="font-semibold">$1</strong>', $html);
|
||||
// Paragraphs
|
||||
$html = preg_replace('/<p[^>]*>/', '<p class="mb-2 dark:text-neutral-300">', $html);
|
||||
|
||||
// Italic text
|
||||
$html = preg_replace('/\*(.*?)\*/', '<em class="italic">$1</em>', $html);
|
||||
$html = preg_replace('/_(.*?)_/', '<em class="italic">$1</em>', $html);
|
||||
// Lists
|
||||
$html = preg_replace('/<ul[^>]*>/', '<ul class="mb-2 ml-4 list-disc">', $html);
|
||||
$html = preg_replace('/<ol[^>]*>/', '<ol class="mb-2 ml-4 list-decimal">', $html);
|
||||
$html = preg_replace('/<li[^>]*>/', '<li class="dark:text-neutral-300">', $html);
|
||||
|
||||
// Code blocks
|
||||
$html = preg_replace('/```(.*?)```/s', '<pre class="bg-gray-100 dark:bg-coolgray-300 p-2 rounded text-sm overflow-x-auto my-2"><code>$1</code></pre>', $html);
|
||||
// Code blocks and inline code
|
||||
$html = preg_replace('/<pre[^>]*>/', '<pre class="bg-gray-100 dark:bg-coolgray-300 p-2 rounded text-sm overflow-x-auto my-2">', $html);
|
||||
$html = preg_replace('/<code[^>]*>/', '<code class="bg-gray-100 dark:bg-coolgray-300 px-1 py-0.5 rounded text-sm">', $html);
|
||||
|
||||
// Inline code
|
||||
$html = preg_replace('/`([^`]+)`/', '<code class="bg-gray-100 dark:bg-coolgray-300 px-1 py-0.5 rounded text-sm">$1</code>', $html);
|
||||
// Links - Apply styling to existing markdown links
|
||||
$html = preg_replace('/<a([^>]*)>/', '<a$1 class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">', $html);
|
||||
|
||||
// Links
|
||||
$html = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2" class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">$1</a>', $html);
|
||||
// Convert plain URLs to clickable links (that aren't already in <a> tags)
|
||||
$html = preg_replace('/(?<!href="|href=\')(?<!>)(?<!\/)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/', '<a href="$1" class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">$1</a>', $html);
|
||||
|
||||
// Line breaks (convert double newlines to paragraphs)
|
||||
$paragraphs = preg_split('/\n\s*\n/', trim($html));
|
||||
$html = '<p class="mb-2">'.implode('</p><p class="mb-2">', $paragraphs).'</p>';
|
||||
// Strong/bold text
|
||||
$html = preg_replace('/<strong[^>]*>/', '<strong class="font-semibold dark:text-white">', $html);
|
||||
|
||||
// Single line breaks
|
||||
$html = preg_replace('/\n/', '<br>', $html);
|
||||
|
||||
// Unordered lists
|
||||
$html = preg_replace('/^\- (.*)$/m', '<li class="ml-4">• $1</li>', $html);
|
||||
$html = preg_replace('/(<li class="ml-4">.*<\/li>)/s', '<ul class="mb-2">$1</ul>', $html);
|
||||
// Emphasis/italic text
|
||||
$html = preg_replace('/<em[^>]*>/', '<em class="italic dark:text-neutral-300">', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user