diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0f626e..e924b1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: '1.24.3' + GO_VERSION: '1.24.4' TEMPL_VERSION: 'v0.3.898' jobs: @@ -169,7 +169,7 @@ jobs: strategy: matrix: - go-version: ['1.24.3'] # Removed 1.23 to reduce costs and complexity + go-version: ['1.24.4'] # Removed 1.23 to reduce costs and complexity fail-fast: false steps: diff --git a/Dockerfile b/Dockerfile index f37a79e..228ad58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ COPY internal/ ./internal/ RUN npx tailwindcss -i ./public/css/style.css -o ./public/css/site.css --minify # ===== Go Build Stage ===== -FROM golang:1.24-alpine AS go-builder +FROM golang:1.24.4-alpine AS go-builder # Install templ CLI RUN go install github.com/a-h/templ/cmd/templ@v0.3.898 diff --git a/go.mod b/go.mod index fa123df..52b7393 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/jgndev/jgn.dev -go 1.24.3 +go 1.24.4 require ( github.com/a-h/templ v0.3.898 diff --git a/internal/application/webhook.go b/internal/application/webhook.go index c6a35ad..3043648 100644 --- a/internal/application/webhook.go +++ b/internal/application/webhook.go @@ -11,6 +11,7 @@ import ( "os" "strings" + "github.com/jgndev/jgn.dev/internal/site" "github.com/labstack/echo/v4" ) @@ -96,18 +97,64 @@ func (app *Application) WebhookHandler(c echo.Context) error { }) } - // Refresh content from GitHub - log.Printf("Refreshing content due to webhook from %s", payload.Repository.FullName) - if err := app.ContentManager.RefreshContent(); err != nil { - log.Printf("Failed to refresh content: %v", err) + // Determine which content manager to refresh based on repository + repoName := payload.Repository.Name + log.Printf("Refreshing content due to webhook from %s (repo: %s)", payload.Repository.FullName, repoName) + + // Import site package to access repository names + var refreshErr error + refreshed := false + + // Check if this is the posts repository + if repoName == site.PostRepoName { + log.Printf("Detected posts repository, refreshing ContentManager") + refreshErr = app.ContentManager.RefreshContent() + refreshed = true + } + + // Check if this is the cheatsheets repository + if repoName == site.CheatsheetRepoName { + log.Printf("Detected cheatsheets repository, refreshing CheatsheetManager") + refreshErr = app.CheatsheetManager.RefreshContent() + refreshed = true + } + + // If repository wasn't recognized, refresh both managers as fallback + if !refreshed { + log.Printf("WARNING: Unknown repository '%s', refreshing both managers as fallback", repoName) + + // Try to refresh posts first + if err := app.ContentManager.RefreshContent(); err != nil { + log.Printf("Failed to refresh posts during fallback: %v", err) + refreshErr = err + } else { + log.Printf("Successfully refreshed posts during fallback") + } + + // Try to refresh cheatsheets + if err := app.CheatsheetManager.RefreshContent(); err != nil { + log.Printf("Failed to refresh cheatsheets during fallback: %v", err) + if refreshErr == nil { + refreshErr = err + } + } else { + log.Printf("Successfully refreshed cheatsheets during fallback") + } + } + + // Handle refresh errors + if refreshErr != nil { + log.Printf("Failed to refresh content for repository %s: %v", repoName, refreshErr) return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "failed to refresh content", + "repository": repoName, }) } - log.Printf("Successfully refreshed content from webhook") + log.Printf("Successfully refreshed content from webhook for repository: %s", repoName) return c.JSON(http.StatusOK, map[string]string{ "message": "content refreshed successfully", + "repository": repoName, }) } diff --git a/internal/contentmanager/cheatsheetmanager.go b/internal/contentmanager/cheatsheetmanager.go index e1cd510..497fe70 100644 --- a/internal/contentmanager/cheatsheetmanager.go +++ b/internal/contentmanager/cheatsheetmanager.go @@ -10,6 +10,7 @@ import ( "sort" "strings" "sync" + "time" ) // CheatsheetManager manages the retrieval, storage, and filtering of cheatsheets from a remote GitHub repository. @@ -41,95 +42,107 @@ func NewCheatsheetManager(repoOwner, repoName string) *CheatsheetManager { // listRepoContent retrieves the content of a GitHub repository at a specified path using the GitHub API. // It handles both directory listings and single file retrieval, returning a slice of githubContent or an error. func (cm *CheatsheetManager) listRepoContent(path string) ([]githubContent, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s", cm.repoOwner, cm.repoName, path) - - log.Printf("fetching cheatsheet content from: %s", url) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } + var contents []githubContent + + err := retryWithBackoff(func() error { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s", cm.repoOwner, cm.repoName, path) - req.Header.Set("Accept", "application/vnd.github.v3+json") + log.Printf("fetching cheatsheet content from: %s", url) - // Add authentication if a token is available - if cm.githubToken != "" { - req.Header.Set("Authorization", "token "+cm.githubToken) - } + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } - resp, err := cm.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() + req.Header.Set("Accept", "application/vnd.github.v3+json") - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) - } + // Add authentication if a token is available + if cm.githubToken != "" { + req.Header.Set("Authorization", "token "+cm.githubToken) + } - // Try to decode as an array first (directory listing) - var contents []githubContent - if err := json.NewDecoder(resp.Body).Decode(&contents); err != nil { - // If that fails, it might be a single file - resp.Body.Close() - resp, err = cm.client.Do(req) + resp, err := cm.client.Do(req) if err != nil { - return nil, err + return err } defer resp.Body.Close() - var singleContent githubContent - if err := json.NewDecoder(resp.Body).Decode(&singleContent); err != nil { - return nil, fmt.Errorf("failed to decode response as array or single file: %v", err) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GitHub API returned status %d", resp.StatusCode) } - return []githubContent{singleContent}, nil - } - return contents, nil + // Try to decode as an array first (directory listing) + if err := json.NewDecoder(resp.Body).Decode(&contents); err != nil { + // If that fails, it might be a single file + resp.Body.Close() + resp, err = cm.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var singleContent githubContent + if err := json.NewDecoder(resp.Body).Decode(&singleContent); err != nil { + return fmt.Errorf("failed to decode response as array or single file: %v", err) + } + contents = []githubContent{singleContent} + } + + return nil + }, 3, time.Second) + + return contents, err } // fetchFileContent retrieves the content of a file from a GitHub repository using the GitHub API. // It decodes the content if it is encoded in base64 and returns it as a string. Returns an error if the operation fails. func (cm *CheatsheetManager) fetchFileContent(path string) (string, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s", cm.repoOwner, cm.repoName, path) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "", err - } - - req.Header.Set("Accept", "application/vnd.github.v3+json") - - // Add authentication if a token is available - if cm.githubToken != "" { - req.Header.Set("Authorization", "token "+cm.githubToken) - } + var content string + + err := retryWithBackoff(func() error { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s", cm.repoOwner, cm.repoName, path) - resp, err := cm.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } - var result struct { - Content string `json:"content"` - Encoding string `json:"encoding"` - } + req.Header.Set("Accept", "application/vnd.github.v3+json") - if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err - } + // Add authentication if a token is available + if cm.githubToken != "" { + req.Header.Set("Authorization", "token "+cm.githubToken) + } - if result.Encoding == "base64" { - content, err := base64.StdEncoding.DecodeString(result.Content) + resp, err := cm.client.Do(req) if err != nil { - return "", err + return err } + defer resp.Body.Close() - return string(content), nil - } + var result struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } - return result.Content, nil + if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { + return err + } + + if result.Encoding == "base64" { + decoded, err := base64.StdEncoding.DecodeString(result.Content) + if err != nil { + return err + } + content = string(decoded) + } else { + content = result.Content + } + + return nil + }, 3, time.Second) + + return content, err } // matchesAllTermsCheatsheet checks if all the provided search terms are present in a Cheatsheet's combined text fields. diff --git a/internal/contentmanager/contentmanager.go b/internal/contentmanager/contentmanager.go index 961594a..8bd7138 100644 --- a/internal/contentmanager/contentmanager.go +++ b/internal/contentmanager/contentmanager.go @@ -10,8 +10,28 @@ import ( "sort" "strings" "sync" + "time" ) +// retryWithBackoff executes a function with exponential backoff retry logic +func retryWithBackoff(fn func() error, maxRetries int, baseDelay time.Duration) error { + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if err := fn(); err != nil { + lastErr = err + if attempt < maxRetries { + delay := baseDelay * time.Duration(1<