feat(changelog): implement automated changelog fetching from GitHub and enhance changelog read tracking

This commit is contained in:
Andras Bacsai
2025-08-10 20:14:38 +02:00
parent 193995de79
commit a2ef545b6b
14 changed files with 519 additions and 69 deletions

View File

@@ -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');

View File

@@ -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();

View 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));
}
}
}

View File

@@ -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', [

View File

@@ -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 {

View File

@@ -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();
}
}

View File

@@ -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;
}