664 lines
18 KiB
Go
664 lines
18 KiB
Go
package seo
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"seo-optimizer/internal/agent"
|
|
"seo-optimizer/internal/config"
|
|
"seo-optimizer/internal/logger"
|
|
"seo-optimizer/internal/storage"
|
|
"seo-optimizer/internal/wordpress"
|
|
"seo-optimizer/pkg/models"
|
|
)
|
|
|
|
// Optimizer coordinates the entire SEO optimization workflow
|
|
type Optimizer struct {
|
|
wpClient *wordpress.Client
|
|
llmClient agent.LLMClient
|
|
llmProvider string
|
|
contentBuilder *ContentBuilder
|
|
selector *wordpress.PostSelector
|
|
storage *storage.OptimizationStorage
|
|
config *config.Config
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// OptimizeRunOptions defines optional behavior for optimization runs.
|
|
type OptimizeRunOptions struct {
|
|
CheckCooldown bool
|
|
Feedback string
|
|
}
|
|
|
|
// SkipError indicates a deliberate skip (not a failure).
|
|
type SkipError struct {
|
|
Reason string
|
|
}
|
|
|
|
func (e *SkipError) Error() string {
|
|
return e.Reason
|
|
}
|
|
|
|
// NewOptimizer creates a new optimizer instance
|
|
func NewOptimizer(
|
|
wpClient *wordpress.Client,
|
|
llmClient agent.LLMClient,
|
|
llmProvider string,
|
|
store *storage.OptimizationStorage,
|
|
cfg *config.Config,
|
|
log *logger.Logger,
|
|
) *Optimizer {
|
|
if log == nil {
|
|
log = logger.NewDefaultLogger()
|
|
}
|
|
|
|
return &Optimizer{
|
|
wpClient: wpClient,
|
|
llmClient: llmClient,
|
|
llmProvider: llmProvider,
|
|
contentBuilder: NewContentBuilder(log),
|
|
selector: wordpress.NewPostSelector(wpClient, log),
|
|
storage: store,
|
|
config: cfg,
|
|
logger: log.WithComponent("optimizer"),
|
|
}
|
|
}
|
|
|
|
// OptimizeNextPost executes the full optimization cycle for the next eligible post
|
|
func (o *Optimizer) OptimizeNextPost(ctx context.Context, language string) error {
|
|
o.logger.Info("Starting optimization cycle", "language", language)
|
|
if o.hasPendingDrafts() {
|
|
reason := "pending drafts exist"
|
|
o.logger.Info("Skipping optimization", "reason", reason)
|
|
return &SkipError{Reason: reason}
|
|
}
|
|
|
|
// 1. Select next post
|
|
criteria := wordpress.SelectionCriteria{
|
|
Language: language,
|
|
MinContentLength: o.config.Optimization.MinContentLength,
|
|
MinAgeDays: o.config.Optimization.MinAgeDays,
|
|
MaxAgeDays: o.config.Optimization.MaxAgeDays,
|
|
CooldownDays: o.config.Optimization.CooldownDays,
|
|
}
|
|
|
|
post, err := o.selector.SelectNextPost(ctx, criteria)
|
|
if err != nil {
|
|
return fmt.Errorf("post selection failed: %w", err)
|
|
}
|
|
|
|
if post == nil {
|
|
o.logger.Info("No eligible posts found for optimization", "language", language)
|
|
return nil
|
|
}
|
|
|
|
o.logger.Info("Selected post for optimization",
|
|
"post_id", post.ID,
|
|
"title", post.Title.Rendered,
|
|
"slug", post.Slug,
|
|
"modified", post.Modified,
|
|
)
|
|
err = o.optimizePost(ctx, post, language, &OptimizeRunOptions{
|
|
CheckCooldown: false,
|
|
})
|
|
var skipErr *SkipError
|
|
if err != nil && !errors.As(err, &skipErr) {
|
|
return err
|
|
}
|
|
|
|
if o.config.Optimization.SyncTranslations {
|
|
if err := o.optimizeTranslationPosts(ctx, post, language); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// OptimizeSpecificPost allows manual optimization of a specific post via API
|
|
func (o *Optimizer) OptimizeSpecificPost(ctx context.Context, postID int, language string) error {
|
|
o.logger.Info("Starting specific post optimization",
|
|
"post_id", postID,
|
|
"language", language,
|
|
)
|
|
if o.hasPendingDrafts() {
|
|
reason := "pending drafts exist"
|
|
o.logger.Info("Skipping optimization", "post_id", postID, "reason", reason)
|
|
return &SkipError{Reason: reason}
|
|
}
|
|
|
|
// 1. Fetch the specific post
|
|
post, err := o.wpClient.GetPost(ctx, postID, language)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch post: %w", err)
|
|
}
|
|
|
|
o.logger.Info("Retrieved post",
|
|
"title", post.Title.Rendered,
|
|
"slug", post.Slug,
|
|
)
|
|
|
|
err = o.optimizePost(ctx, post, language, &OptimizeRunOptions{
|
|
CheckCooldown: true,
|
|
})
|
|
var skipErr *SkipError
|
|
if err != nil && !errors.As(err, &skipErr) {
|
|
return err
|
|
}
|
|
|
|
if o.config.Optimization.SyncTranslations {
|
|
if err := o.optimizeTranslationPosts(ctx, post, language); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// OptimizeSpecificPostWithFeedback regenerates a draft using explicit feedback.
|
|
func (o *Optimizer) OptimizeSpecificPostWithFeedback(ctx context.Context, postID int, language, feedback string) error {
|
|
o.logger.Info("Starting feedback optimization",
|
|
"post_id", postID,
|
|
"language", language,
|
|
)
|
|
|
|
post, err := o.wpClient.GetPost(ctx, postID, language)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch post: %w", err)
|
|
}
|
|
|
|
return o.optimizePost(ctx, post, language, &OptimizeRunOptions{
|
|
CheckCooldown: false,
|
|
Feedback: feedback,
|
|
})
|
|
}
|
|
|
|
func (o *Optimizer) optimizePost(ctx context.Context, post *wordpress.Post, language string, options *OptimizeRunOptions) error {
|
|
if post == nil {
|
|
return fmt.Errorf("post is nil")
|
|
}
|
|
|
|
effectiveLanguage := language
|
|
if effectiveLanguage == "" {
|
|
effectiveLanguage = post.Lang
|
|
}
|
|
|
|
if post.Status != "publish" {
|
|
return fmt.Errorf("post is not published (status: %s)", post.Status)
|
|
}
|
|
|
|
checkCooldown := options == nil || options.CheckCooldown
|
|
if checkCooldown && o.wasRecentlyOptimized(post, o.config.Optimization.CooldownDays) {
|
|
reason := "post optimized within cooldown window"
|
|
o.logger.Info("Skipping optimization",
|
|
"post_id", post.ID,
|
|
"title", post.Title.Rendered,
|
|
"slug", post.Slug,
|
|
"reason", reason,
|
|
)
|
|
if err := o.saveSkippedRecord(post, effectiveLanguage, "recently_optimized", nil); err != nil {
|
|
o.logger.Warn("Failed to save skipped optimization record", "error", err)
|
|
}
|
|
return &SkipError{Reason: reason}
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
// 2. Gather context for the LLM
|
|
internalPosts, err := o.wpClient.GetInternalPosts(ctx, effectiveLanguage)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch internal posts: %w", err)
|
|
}
|
|
|
|
o.logger.Debug("Retrieved internal posts for linking",
|
|
"count", len(internalPosts),
|
|
)
|
|
|
|
// Get categories and tags for context
|
|
categories, err := o.wpClient.GetCategories(ctx)
|
|
if err != nil {
|
|
o.logger.Warn("Failed to fetch categories", "error", err)
|
|
categories = []wordpress.Category{}
|
|
}
|
|
|
|
tags, err := o.wpClient.GetTags(ctx)
|
|
if err != nil {
|
|
o.logger.Warn("Failed to fetch tags", "error", err)
|
|
tags = []wordpress.Tag{}
|
|
}
|
|
|
|
// Build category and tag maps
|
|
categoryMap := make(map[int]string)
|
|
for _, cat := range categories {
|
|
categoryMap[cat.ID] = cat.Name
|
|
}
|
|
|
|
tagMap := make(map[int]string)
|
|
for _, tag := range tags {
|
|
tagMap[tag.ID] = tag.Name
|
|
}
|
|
|
|
// 3. Call LLM for optimization
|
|
o.logger.Info("Calling LLM for optimization",
|
|
"provider", o.llmProvider,
|
|
"post_id", post.ID,
|
|
"title", post.Title.Rendered,
|
|
"slug", post.Slug,
|
|
)
|
|
|
|
feedback := ""
|
|
if options != nil {
|
|
feedback = options.Feedback
|
|
}
|
|
optimization, err := o.llmClient.OptimizePost(ctx, post, internalPosts, categoryMap, tagMap, &agent.OptimizeOptions{
|
|
Feedback: feedback,
|
|
Profile: o.config.Profile,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("llm optimization failed: %w", err)
|
|
}
|
|
|
|
// Skip if the post is too recent and no modifications were required
|
|
if o.shouldSkipRecentNoChanges(post, optimization) {
|
|
reason := "post too recent and no changes required"
|
|
o.logger.Info("Skipping optimization",
|
|
"post_id", post.ID,
|
|
"title", post.Title.Rendered,
|
|
"slug", post.Slug,
|
|
"reason", reason,
|
|
)
|
|
if err := o.saveSkippedRecord(post, effectiveLanguage, "no_changes_recent", optimization); err != nil {
|
|
o.logger.Warn("Failed to save skipped optimization record", "error", err)
|
|
}
|
|
if err := o.selector.MarkAsOptimized(ctx, post.ID); err != nil {
|
|
o.logger.Warn("Failed to mark post as optimized", "error", err)
|
|
}
|
|
return &SkipError{Reason: reason}
|
|
}
|
|
|
|
// 4. Build optimized content
|
|
o.logger.Info("Building optimized content")
|
|
|
|
optimizedContent, err := o.contentBuilder.BuildOptimizedContent(
|
|
post.Content.Rendered,
|
|
optimization,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("content building failed: %w", err)
|
|
}
|
|
|
|
// 5. Create draft revision in WordPress
|
|
if o.config.Runtime.DryRun {
|
|
o.logger.Info("Dry-run enabled, skipping WordPress draft creation")
|
|
} else {
|
|
o.logger.Info("Creating draft revision in WordPress")
|
|
}
|
|
|
|
updates := wordpress.PostUpdate{
|
|
Status: "draft",
|
|
Title: optimization.SEOMeta.Title,
|
|
Content: optimizedContent,
|
|
Excerpt: optimization.SEOMeta.Description,
|
|
Meta: map[string]interface{}{
|
|
// Store SEO meta fields (RankMath)
|
|
wordpress.MetaRankMathTitle: optimization.SEOMeta.Title,
|
|
wordpress.MetaRankMathDescription: optimization.SEOMeta.Description,
|
|
|
|
// Store optimization metadata
|
|
wordpress.MetaSEOOptimizedAt: time.Now().Unix(),
|
|
wordpress.MetaSEOOptimizationSummary: optimization.Summary,
|
|
|
|
// Store full optimization data for reference
|
|
wordpress.MetaSEOOptimizationData: o.serializeOptimizationData(optimization),
|
|
},
|
|
}
|
|
|
|
draftPostID := 0
|
|
if !o.config.Runtime.DryRun {
|
|
var err error
|
|
draftPostID, err = o.wpClient.CreateDraftRevision(ctx, post.ID, updates)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create draft revision: %w", err)
|
|
}
|
|
|
|
o.logger.Info("Draft revision created",
|
|
"draft_post_id", draftPostID,
|
|
"original_post_id", post.ID,
|
|
)
|
|
}
|
|
|
|
// 6. Save optimization record to storage
|
|
record := &storage.OptimizationRecord{
|
|
PostID: post.ID,
|
|
OriginalPostID: post.ID,
|
|
DraftPostID: draftPostID,
|
|
PostTitle: post.Title.Rendered,
|
|
Language: effectiveLanguage,
|
|
OptimizedAt: time.Now(),
|
|
Status: "pending",
|
|
Optimization: optimization,
|
|
}
|
|
|
|
if err := o.storage.Save(record); err != nil {
|
|
o.logger.Error("Failed to save optimization record", "error", err)
|
|
}
|
|
|
|
// 7. Mark as optimized (prevent re-optimization too soon)
|
|
if err := o.selector.MarkAsOptimized(ctx, post.ID); err != nil {
|
|
o.logger.Warn("Failed to mark post as optimized", "error", err)
|
|
}
|
|
|
|
duration := time.Since(startTime)
|
|
|
|
// 8. Log success with summary
|
|
o.logger.Info("Optimization completed successfully",
|
|
"post_id", post.ID,
|
|
"title", post.Title.Rendered,
|
|
"language", effectiveLanguage,
|
|
"duration", duration,
|
|
"changes", len(optimization.ContentOptimization.Changes),
|
|
"internal_links", len(optimization.ContentOptimization.InternalLinks),
|
|
"faq_created", optimization.FAQBlock.ShouldCreate,
|
|
"faq_questions", len(optimization.FAQBlock.Questions),
|
|
"fact_checks", len(optimization.Validation.FactChecks),
|
|
"broken_links_fixed", len(optimization.Validation.BrokenLinks),
|
|
)
|
|
|
|
o.logger.Info("Optimization summary",
|
|
"post_id", post.ID,
|
|
"summary", optimization.Summary,
|
|
)
|
|
|
|
// 9. Send webhook notification (if configured)
|
|
if o.config.Notifications.WebhookURL != "" {
|
|
go o.sendWebhookNotification(post, optimization, duration)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (o *Optimizer) optimizeTranslationPosts(ctx context.Context, basePost *wordpress.Post, baseLanguage string) error {
|
|
targets := o.translationTargets(basePost, baseLanguage)
|
|
if len(targets) == 0 {
|
|
return nil
|
|
}
|
|
|
|
o.logger.Info("Optimizing translation posts",
|
|
"post_id", basePost.ID,
|
|
"count", len(targets),
|
|
)
|
|
|
|
var errs []error
|
|
for _, target := range targets {
|
|
translationPost, err := o.wpClient.GetPost(ctx, target.ID, target.Language)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to fetch translation post %d (%s): %w", target.ID, target.Language, err))
|
|
continue
|
|
}
|
|
|
|
if err := o.optimizePost(ctx, translationPost, target.Language, &OptimizeRunOptions{
|
|
CheckCooldown: true,
|
|
}); err != nil {
|
|
var skipErr *SkipError
|
|
if errors.As(err, &skipErr) {
|
|
continue
|
|
}
|
|
errs = append(errs, fmt.Errorf("translation post %d (%s): %w", target.ID, target.Language, err))
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type translationTarget struct {
|
|
Language string
|
|
ID int
|
|
}
|
|
|
|
func (o *Optimizer) translationTargets(post *wordpress.Post, currentLanguage string) []translationTarget {
|
|
if post == nil || len(post.Translations) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if currentLanguage == "" {
|
|
currentLanguage = post.Lang
|
|
}
|
|
|
|
var targets []translationTarget
|
|
for lang, id := range post.Translations {
|
|
if id == 0 || id == post.ID {
|
|
continue
|
|
}
|
|
if currentLanguage != "" && lang == currentLanguage {
|
|
continue
|
|
}
|
|
targets = append(targets, translationTarget{
|
|
Language: lang,
|
|
ID: id,
|
|
})
|
|
}
|
|
|
|
sort.Slice(targets, func(i, j int) bool {
|
|
return targets[i].Language < targets[j].Language
|
|
})
|
|
|
|
return targets
|
|
}
|
|
|
|
func (o *Optimizer) SyncTranslationsEnabled() bool {
|
|
if o == nil || o.config == nil {
|
|
return false
|
|
}
|
|
return o.config.Optimization.SyncTranslations
|
|
}
|
|
|
|
// IsDryRun returns whether the optimizer is running in dry-run mode.
|
|
func (o *Optimizer) IsDryRun() bool {
|
|
if o == nil || o.config == nil {
|
|
return false
|
|
}
|
|
return o.config.Runtime.DryRun
|
|
}
|
|
|
|
func (o *Optimizer) hasPendingDrafts() bool {
|
|
if o == nil || o.storage == nil {
|
|
return false
|
|
}
|
|
records, err := o.storage.ListPending()
|
|
if err != nil {
|
|
o.logger.Warn("Failed to list pending drafts", "error", err)
|
|
return false
|
|
}
|
|
return len(records) > 0
|
|
}
|
|
|
|
func (o *Optimizer) shouldSkipRecentNoChanges(post *wordpress.Post, optimization *models.OptimizationResponse) bool {
|
|
if o.config.Optimization.MinAgeDays <= 0 {
|
|
return false
|
|
}
|
|
|
|
postTime := o.getPostModifiedTime(post)
|
|
if postTime.IsZero() {
|
|
return false
|
|
}
|
|
|
|
minAgeCutoff := time.Now().AddDate(0, 0, -o.config.Optimization.MinAgeDays)
|
|
if postTime.Before(minAgeCutoff) {
|
|
return false
|
|
}
|
|
|
|
return o.isNoChangeOptimization(post, optimization)
|
|
}
|
|
|
|
func (o *Optimizer) isNoChangeOptimization(post *wordpress.Post, optimization *models.OptimizationResponse) bool {
|
|
if optimization == nil {
|
|
return false
|
|
}
|
|
|
|
changesEmpty := len(optimization.ContentOptimization.Changes) == 0
|
|
linksEmpty := len(optimization.ContentOptimization.InternalLinks) == 0
|
|
faqEmpty := !optimization.FAQBlock.ShouldCreate || len(optimization.FAQBlock.Questions) == 0
|
|
|
|
currentTitle := o.getMetaString(post.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
|
|
if currentTitle == "" {
|
|
currentTitle = post.Title.Rendered
|
|
}
|
|
currentDescription := o.getMetaString(post.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
|
|
|
|
metaUnchanged := strings.TrimSpace(optimization.SEOMeta.Title) == strings.TrimSpace(currentTitle) &&
|
|
strings.TrimSpace(optimization.SEOMeta.Description) == strings.TrimSpace(currentDescription)
|
|
|
|
return changesEmpty && linksEmpty && faqEmpty && metaUnchanged
|
|
}
|
|
|
|
func (o *Optimizer) getMetaString(meta map[string]interface{}, keys ...string) string {
|
|
if meta == nil {
|
|
return ""
|
|
}
|
|
|
|
for _, key := range keys {
|
|
if value, ok := meta[key]; ok && value != nil {
|
|
if str, ok := value.(string); ok && str != "" {
|
|
return str
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (o *Optimizer) getPostModifiedTime(post *wordpress.Post) time.Time {
|
|
if post == nil {
|
|
return time.Time{}
|
|
}
|
|
if !post.Modified.Time.IsZero() {
|
|
return post.Modified.Time
|
|
}
|
|
if !post.Date.Time.IsZero() {
|
|
return post.Date.Time
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func (o *Optimizer) wasRecentlyOptimized(post *wordpress.Post, cooldownDays int) bool {
|
|
if post == nil || post.Meta == nil || cooldownDays <= 0 {
|
|
return false
|
|
}
|
|
|
|
optimizedAtValue, exists := post.Meta[wordpress.MetaSEOOptimizedAt]
|
|
if !exists || optimizedAtValue == nil {
|
|
return false
|
|
}
|
|
|
|
var optimizedAtUnix int64
|
|
switch v := optimizedAtValue.(type) {
|
|
case float64:
|
|
optimizedAtUnix = int64(v)
|
|
case int64:
|
|
optimizedAtUnix = v
|
|
case int:
|
|
optimizedAtUnix = int64(v)
|
|
case string:
|
|
fmt.Sscanf(v, "%d", &optimizedAtUnix)
|
|
default:
|
|
return false
|
|
}
|
|
|
|
if optimizedAtUnix == 0 {
|
|
return false
|
|
}
|
|
|
|
optimizedAt := time.Unix(optimizedAtUnix, 0)
|
|
cooldownUntil := optimizedAt.AddDate(0, 0, cooldownDays)
|
|
return time.Now().Before(cooldownUntil)
|
|
}
|
|
|
|
func (o *Optimizer) saveSkippedRecord(
|
|
post *wordpress.Post,
|
|
language string,
|
|
reason string,
|
|
optimization *models.OptimizationResponse,
|
|
) error {
|
|
if post == nil {
|
|
return fmt.Errorf("post is nil")
|
|
}
|
|
|
|
record := &storage.OptimizationRecord{
|
|
PostID: post.ID,
|
|
OriginalPostID: post.ID,
|
|
DraftPostID: 0,
|
|
PostTitle: post.Title.Rendered,
|
|
Language: language,
|
|
OptimizedAt: time.Now(),
|
|
Status: "skipped",
|
|
Optimization: optimization,
|
|
SkipReason: reason,
|
|
}
|
|
|
|
return o.storage.Save(record)
|
|
}
|
|
|
|
// serializeOptimizationData converts optimization response to JSON string for storage
|
|
func (o *Optimizer) serializeOptimizationData(optimization *models.OptimizationResponse) string {
|
|
data, err := json.Marshal(optimization)
|
|
if err != nil {
|
|
o.logger.Error("Failed to serialize optimization data", "error", err)
|
|
return ""
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// sendWebhookNotification sends a notification webhook with optimization results
|
|
func (o *Optimizer) sendWebhookNotification(
|
|
post *wordpress.Post,
|
|
optimization *models.OptimizationResponse,
|
|
duration time.Duration,
|
|
) {
|
|
payload := map[string]interface{}{
|
|
"post_id": post.ID,
|
|
"title": post.Title.Rendered,
|
|
"slug": post.Slug,
|
|
"link": post.Link,
|
|
"language": post.Lang,
|
|
"seo_title": optimization.SEOMeta.Title,
|
|
"seo_desc": optimization.SEOMeta.Description,
|
|
"changes": len(optimization.ContentOptimization.Changes),
|
|
"links": len(optimization.ContentOptimization.InternalLinks),
|
|
"faq_created": optimization.FAQBlock.ShouldCreate,
|
|
"faq_questions": len(optimization.FAQBlock.Questions),
|
|
"summary": optimization.Summary,
|
|
"duration_seconds": duration.Seconds(),
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
}
|
|
|
|
jsonData, err := json.Marshal(payload)
|
|
if err != nil {
|
|
o.logger.Error("Failed to marshal webhook payload", "error", err)
|
|
return
|
|
}
|
|
|
|
resp, err := http.Post(o.config.Notifications.WebhookURL, "application/json", bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
o.logger.Error("Failed to send webhook", "error", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
o.logger.Debug("Webhook sent successfully", "status", resp.StatusCode)
|
|
} else {
|
|
o.logger.Warn("Webhook returned non-2xx status", "status", resp.StatusCode)
|
|
}
|
|
}
|