Files
Jonathan Miller 1b9b82d59a
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 24s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
fix(build): pass ctx to buildWordPressChangelog for ResolveReleaseStream
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 07:47:42 -05:00

231 lines
7.6 KiB
Go

// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"fmt"
"html"
"strings"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
// WordPressPluginInfo is the JSON response format compatible with the
// YahnisElsts plugin-update-checker (PUC) library — the standard for
// commercial WordPress plugin self-hosted updates.
type WordPressPluginInfo struct {
Name string `json:"name"`
Slug string `json:"slug"`
Version string `json:"version"`
DownloadURL string `json:"download_url,omitempty"`
Homepage string `json:"homepage,omitempty"`
Requires string `json:"requires,omitempty"`
Tested string `json:"tested,omitempty"`
RequiresPHP string `json:"requires_php,omitempty"`
Author string `json:"author,omitempty"`
AuthorHomepage string `json:"author_homepage,omitempty"`
LastUpdated string `json:"last_updated,omitempty"`
UpgradeNotice string `json:"upgrade_notice,omitempty"`
Sections map[string]string `json:"sections,omitempty"`
Icons map[string]string `json:"icons,omitempty"`
Banners map[string]string `json:"banners,omitempty"`
}
// GenerateWordPressJSON builds a PUC-compatible JSON response for the latest
// stable release. The license key (if provided) is embedded in the download URL.
func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, licenseKey string) (*WordPressPluginInfo, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
return nil, fmt.Errorf("FindReleases: %w", err)
}
if err := repo.LoadOwner(ctx); err != nil {
return nil, fmt.Errorf("LoadOwner: %w", err)
}
baseURL := strings.TrimSuffix(setting.AppURL, "/")
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
// Load extension metadata.
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
slug := strings.ToLower(repo.Name)
displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name)
maintainer := repo.Owner.Name
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
homepage := repoLink
requiresPHP := ""
if cfg != nil {
if cfg.ExtensionName != "" {
slug = cfg.ExtensionName
}
if cfg.DisplayName != "" {
displayName = cfg.DisplayName
}
if cfg.Maintainer != "" {
maintainer = cfg.Maintainer
}
if cfg.MaintainerURL != "" {
maintainerURL = cfg.MaintainerURL
}
if cfg.SupportURL != "" {
homepage = cfg.SupportURL
} else if cfg.InfoURL != "" {
homepage = cfg.InfoURL
}
if cfg.PHPMinimum != "" {
requiresPHP = cfg.PHPMinimum
}
}
// Resolve streams and find the latest stable release.
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
var latestStable *repo_model.Release
for _, rel := range releases {
if rel.IsDraft || rel.IsTag {
continue
}
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
if ch == "stable" {
if latestStable == nil || rel.CreatedUnix > latestStable.CreatedUnix {
latestStable = rel
}
}
}
if latestStable == nil {
return &WordPressPluginInfo{
Name: displayName,
Slug: slug,
Version: "0.0.0",
}, nil
}
// Load attachments.
if err := latestStable.LoadAttributes(ctx); err != nil {
return nil, fmt.Errorf("LoadAttributes: %w", err)
}
// Find zip download URL.
var downloadURL string
for _, att := range latestStable.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, latestStable.TagName, att.Name)
break
}
}
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, latestStable.TagName)
}
// Append license key to download URL if provided.
if licenseKey != "" {
if strings.Contains(downloadURL, "?") {
downloadURL += "&dlid=" + licenseKey
} else {
downloadURL += "?dlid=" + licenseKey
}
}
version := extractVersion(latestStable.TagName)
if version == "" || isStreamName(latestStable.TagName, streams) {
version = extractVersion(latestStable.Title)
}
if version == "" {
version = latestStable.TagName
}
lastUpdated := time.Unix(int64(latestStable.CreatedUnix), 0).Format("2006-01-02 3:04pm MST")
// Build sections from release notes.
sections := map[string]string{}
if latestStable.Note != "" {
sections["changelog"] = buildWordPressChangelog(ctx, releases, streams)
}
if cfg != nil && cfg.Description != "" {
sections["description"] = "<p>" + html.EscapeString(cfg.Description) + "</p>"
}
// Build icon/banner URLs from repo assets.
icons := buildAssetURLs(repoLink, repo.DefaultBranch, "icon", []string{"128x128", "256x256"})
banners := buildBannerURLs(repoLink, repo.DefaultBranch)
return &WordPressPluginInfo{
Name: displayName,
Slug: slug,
Version: version,
DownloadURL: downloadURL,
Homepage: homepage,
RequiresPHP: requiresPHP,
Author: maintainer,
AuthorHomepage: maintainerURL,
LastUpdated: lastUpdated,
Sections: sections,
Icons: icons,
Banners: banners,
}, nil
}
// buildWordPressChangelog builds an HTML changelog from multiple releases.
func buildWordPressChangelog(ctx context.Context, releases []*repo_model.Release, streams []licenses.StreamDef) string {
var b strings.Builder
count := 0
for _, rel := range releases {
if rel.IsDraft || rel.IsTag || rel.Note == "" {
continue
}
ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams)
if ch != "stable" {
continue
}
version := extractVersion(rel.TagName)
date := time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02")
b.WriteString(fmt.Sprintf("<h4>%s - %s</h4>\n", version, date))
// Convert markdown list items to HTML list.
lines := strings.Split(rel.Note, "\n")
b.WriteString("<ul>\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
b.WriteString(fmt.Sprintf("<li>%s</li>\n", html.EscapeString(strings.TrimLeft(trimmed[2:], " "))))
}
}
b.WriteString("</ul>\n")
count++
if count >= 10 {
break // limit to last 10 releases
}
}
return b.String()
}
// buildAssetURLs generates icon URLs from conventional paths in the repo.
func buildAssetURLs(repoLink, branch, prefix string, sizes []string) map[string]string {
icons := make(map[string]string)
for i, size := range sizes {
key := fmt.Sprintf("%dx", i+1)
icons[key] = fmt.Sprintf("%s/raw/branch/%s/assets/%s-%s.png", repoLink, branch, prefix, size)
}
return icons
}
// buildBannerURLs generates banner URLs from conventional paths.
func buildBannerURLs(repoLink, branch string) map[string]string {
return map[string]string{
"low": fmt.Sprintf("%s/raw/branch/%s/assets/banner-772x250.png", repoLink, branch),
"high": fmt.Sprintf("%s/raw/branch/%s/assets/banner-1544x500.png", repoLink, branch),
}
}