Quellcode durchsuchen

Merge pull request #165 from Fumesover/release/v0.6.0

releases: Add support for gitlab
Svilen Markov vor 10 Monaten
Ursprung
Commit
303438834b

+ 19 - 3
docs/configuration.md

@@ -1105,11 +1105,13 @@ Example:
 
 ```yaml
 - type: releases
+  show-source-icon: true
   repositories:
-    - immich-app/immich
     - go-gitea/gitea
-    - dani-garcia/vaultwarden
     - jellyfin/jellyfin
+    - glanceapp/glance
+    - gitlab:fdroid/fdroidclient
+    - dockerhub:gotify/server
 ```
 
 Preview:
@@ -1121,12 +1123,23 @@ Preview:
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
 | repositories | array | yes |  |
+| show-source-icon | boolean | no | false |  |
 | token | string | no | |
+| gitlab-token | string | no | |
 | limit | integer | no | 10 |
 | collapse-after | integer | no | 5 |
 
 ##### `repositories`
-A list of repositores for which to fetch the latest release for. Only the name/repo is required, not the full URL.
+A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab and Docker Hub. Example:
+
+```yaml
+repositories:
+  - gitlab:inkscape/inkscape
+  - dockerhub:glanceapp/glance
+```
+
+##### `show-source-icon`
+Shows an icon of the source (GitHub/GitLab/Docker Hub) next to the repository name when set to `true`.
 
 ##### `token`
 Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here.
@@ -1151,6 +1164,9 @@ and then use it in your `glance.yml` like this:
 
 This way you can safely check your `glance.yml` in version control without exposing the token.
 
+##### `gitlab-token`
+Same as the above but used when fetching GitLab releases.
+
 ##### `limit`
 The maximum number of releases to show.
 

BIN
docs/images/releases-widget-preview.png


+ 6 - 2
internal/assets/static/main.css

@@ -543,8 +543,6 @@ kbd:active {
 @container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
 @container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
 
-
-
 .widget-error-header {
     display: flex;
     align-items: center;
@@ -707,6 +705,12 @@ kbd:active {
     color: var(--color-text-highlight);
 }
 
+.release-source-icon {
+    width: 16px;
+    height: 16px;
+    flex-shrink: 0;
+}
+
 .market-chart {
     margin-left: auto;
     width: 6.5rem;

+ 20 - 6
internal/assets/templates/releases.html

@@ -2,14 +2,28 @@
 
 {{ define "widget-content" }}
 <ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
-    {{ range $i, $release := .Releases }}
+    {{ range .Releases }}
     <li>
-        <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
+        <div class="flex items-center gap-10">
+            <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
+            {{ if $.ShowSourceIcon }}
+            {{/* TODO: add the icons as assets and link to them here instead of hardcoding */}}
+            <svg class="release-source-icon" fill="var(--color-text-subdue)" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+                {{ if eq .Source "github" }}
+                <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
+                {{ else if eq .Source "gitlab" }}
+                <path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/>
+                {{ else if eq .Source "dockerhub" }}
+                <path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>
+                {{ end }}
+            </svg>
+            {{ end }}
+        </div>
         <ul class="list-horizontal-text">
-            <li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
-            <li>{{ $release.Version }}</li>
-            {{ if gt $release.Downvotes 3 }}
-            <li>{{ $release.Downvotes | formatNumber }} ⚠</li>
+            <li {{ dynamicRelativeTimeAttrs .TimeReleased }}></li>
+            <li>{{ .Version }}</li>
+            {{ if gt .Downvotes 3 }}
+            <li>{{ .Downvotes | formatNumber }} ⚠</li>
             {{ end }}
         </ul>
     </li>

+ 58 - 0
internal/feed/dockerhub.go

@@ -0,0 +1,58 @@
+package feed
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+)
+
+type dockerHubRepositoryTagsResponse struct {
+	Results []struct {
+		Name       string `json:"name"`
+		LastPushed string `json:"tag_last_pushed"`
+	} `json:"results"`
+}
+
+const dockerHubReleaseNotesURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
+
+func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) {
+	parts := strings.Split(request.Repository, "/")
+
+	if len(parts) != 2 {
+		return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
+	}
+
+	httpRequest, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags", parts[0], parts[1]),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if request.Token != nil {
+		httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
+	}
+
+	response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if len(response.Results) == 0 {
+		return nil, fmt.Errorf("no tags found for repository: %s", request.Repository)
+	}
+
+	tag := response.Results[0]
+
+	return &AppRelease{
+		Source:       ReleaseSourceDockerHub,
+		NotesUrl:     fmt.Sprintf(dockerHubReleaseNotesURLFormat, request.Repository, tag.Name),
+		Name:         request.Repository,
+		Version:      tag.Name,
+		TimeReleased: parseRFC3339Time(tag.LastPushed),
+	}, nil
+}

+ 23 - 68
internal/feed/github.go

@@ -2,7 +2,6 @@ package feed
 
 import (
 	"fmt"
-	"log/slog"
 	"net/http"
 	"sync"
 	"time"
@@ -17,85 +16,41 @@ type githubReleaseLatestResponseJson struct {
 	} `json:"reactions"`
 }
 
-func parseGithubTime(t string) time.Time {
-	parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
+func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
+	httpRequest, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
+		nil,
+	)
 
 	if err != nil {
-		return time.Now()
-	}
-
-	return parsedTime
-}
-
-func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
-	appReleases := make(AppReleases, 0, len(repositories))
-
-	if len(repositories) == 0 {
-		return appReleases, nil
+		return nil, err
 	}
 
-	requests := make([]*http.Request, len(repositories))
-
-	for i, repository := range repositories {
-		request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository), nil)
-
-		if token != "" {
-			request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
-		}
-
-		requests[i] = request
+	if request.Token != nil {
+		httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
 	}
 
-	task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient)
-	job := newJob(task, requests).withWorkers(15)
-	responses, errs, err := workerPoolDo(job)
+	response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
 
 	if err != nil {
 		return nil, err
 	}
 
-	var failed int
-
-	for i := range responses {
-		if errs[i] != nil {
-			failed++
-			slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL)
-			continue
-		}
-
-		liveRelease := &responses[i]
-
-		if liveRelease == nil {
-			slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
-			continue
-		}
-
-		version := liveRelease.TagName
-
-		if version[0] != 'v' {
-			version = "v" + version
-		}
-
-		appReleases = append(appReleases, AppRelease{
-			Name:         repositories[i],
-			Version:      version,
-			NotesUrl:     liveRelease.HtmlUrl,
-			TimeReleased: parseGithubTime(liveRelease.PublishedAt),
-			Downvotes:    liveRelease.Reactions.Downvotes,
-		})
-	}
-
-	if len(appReleases) == 0 {
-		return nil, ErrNoContent
-	}
-
-	appReleases.SortByNewest()
+	version := response.TagName
 
-	if failed > 0 {
-		return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
+	if len(version) > 0 && version[0] != 'v' {
+		version = "v" + version
 	}
 
-	return appReleases, nil
+	return &AppRelease{
+		Source:       ReleaseSourceGithub,
+		Name:         request.Repository,
+		Version:      version,
+		NotesUrl:     response.HtmlUrl,
+		TimeReleased: parseRFC3339Time(response.PublishedAt),
+		Downvotes:    response.Reactions.Downvotes,
+	}, nil
 }
 
 type GithubTicket struct {
@@ -201,7 +156,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 			for i := range PRsResponse.Tickets {
 				details.PullRequests = append(details.PullRequests, GithubTicket{
 					Number:    PRsResponse.Tickets[i].Number,
-					CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
+					CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
 					Title:     PRsResponse.Tickets[i].Title,
 				})
 			}
@@ -218,7 +173,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 			for i := range issuesResponse.Tickets {
 				details.Issues = append(details.Issues, GithubTicket{
 					Number:    issuesResponse.Tickets[i].Number,
-					CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
+					CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
 					Title:     issuesResponse.Tickets[i].Title,
 				})
 			}

+ 54 - 0
internal/feed/gitlab.go

@@ -0,0 +1,54 @@
+package feed
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+)
+
+type gitlabReleaseResponseJson struct {
+	TagName    string `json:"tag_name"`
+	ReleasedAt string `json:"released_at"`
+	Links      struct {
+		Self string `json:"self"`
+	} `json:"_links"`
+}
+
+func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) {
+	httpRequest, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf(
+			"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
+			url.QueryEscape(request.Repository),
+		),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if request.Token != nil {
+		httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token)
+	}
+
+	response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	version := response.TagName
+
+	if len(version) > 0 && version[0] != 'v' {
+		version = "v" + version
+	}
+
+	return &AppRelease{
+		Source:       ReleaseSourceGitlab,
+		Name:         request.Repository,
+		Version:      version,
+		NotesUrl:     response.Links.Self,
+		TimeReleased: parseRFC3339Time(response.ReleasedAt),
+	}, nil
+}

+ 1 - 0
internal/feed/primitives.go

@@ -41,6 +41,7 @@ type Weather struct {
 }
 
 type AppRelease struct {
+	Source       ReleaseSource
 	Name         string
 	Version      string
 	NotesUrl     string

+ 69 - 0
internal/feed/releases.go

@@ -0,0 +1,69 @@
+package feed
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+)
+
+type ReleaseSource string
+
+const (
+	ReleaseSourceGithub    ReleaseSource = "github"
+	ReleaseSourceGitlab    ReleaseSource = "gitlab"
+	ReleaseSourceDockerHub ReleaseSource = "dockerhub"
+)
+
+type ReleaseRequest struct {
+	Source     ReleaseSource
+	Repository string
+	Token      *string
+}
+
+func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
+	job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
+	results, errs, err := workerPoolDo(job)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var failed int
+
+	releases := make(AppReleases, 0, len(requests))
+
+	for i := range results {
+		if errs[i] != nil {
+			failed++
+			slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i])
+			continue
+		}
+
+		releases = append(releases, *results[i])
+	}
+
+	if failed == len(requests) {
+		return nil, ErrNoContent
+	}
+
+	releases.SortByNewest()
+
+	if failed > 0 {
+		return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
+	}
+
+	return releases, nil
+}
+
+func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) {
+	switch request.Source {
+	case ReleaseSourceGithub:
+		return fetchLatestGithubRelease(request)
+	case ReleaseSourceGitlab:
+		return fetchLatestGitLabRelease(request)
+	case ReleaseSourceDockerHub:
+		return fetchLatestDockerHubRelease(request)
+	}
+
+	return nil, errors.New("unsupported source")
+}

+ 11 - 1
internal/feed/utils.go

@@ -7,6 +7,7 @@ import (
 	"regexp"
 	"slices"
 	"strings"
+	"time"
 )
 
 var (
@@ -79,7 +80,6 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
 	return values
 }
 
-
 var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
 
 func stripURLScheme(url string) string {
@@ -95,3 +95,13 @@ func limitStringLength(s string, max int) (string, bool) {
 
 	return s, false
 }
+
+func parseRFC3339Time(t string) time.Time {
+	parsed, err := time.Parse(time.RFC3339, t)
+
+	if err != nil {
+		return time.Now()
+	}
+
+	return parsed
+}

+ 4 - 0
internal/widget/fields.go

@@ -152,6 +152,10 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
 	return nil
 }
 
+func (f *OptionalEnvString) String() string {
+	return string(*f)
+}
+
 func toSimpleIconIfPrefixed(icon string) (string, bool) {
 	if !strings.HasPrefix(icon, "si:") {
 		return icon, false

+ 51 - 7
internal/widget/releases.go

@@ -2,7 +2,9 @@ package widget
 
 import (
 	"context"
+	"errors"
 	"html/template"
+	"strings"
 	"time"
 
 	"github.com/glanceapp/glance/internal/assets"
@@ -10,12 +12,15 @@ import (
 )
 
 type Releases struct {
-	widgetBase    `yaml:",inline"`
-	Releases      feed.AppReleases  `yaml:"-"`
-	Repositories  []string          `yaml:"repositories"`
-	Token         OptionalEnvString `yaml:"token"`
-	Limit         int               `yaml:"limit"`
-	CollapseAfter int               `yaml:"collapse-after"`
+	widgetBase      `yaml:",inline"`
+	Releases        feed.AppReleases       `yaml:"-"`
+	releaseRequests []*feed.ReleaseRequest `yaml:"-"`
+	Repositories    []string               `yaml:"repositories"`
+	Token           OptionalEnvString      `yaml:"token"`
+	GitLabToken     OptionalEnvString      `yaml:"gitlab-token"`
+	Limit           int                    `yaml:"limit"`
+	CollapseAfter   int                    `yaml:"collapse-after"`
+	ShowSourceIcon  bool                   `yaml:"show-source-icon"`
 }
 
 func (widget *Releases) Initialize() error {
@@ -29,11 +34,50 @@ func (widget *Releases) Initialize() error {
 		widget.CollapseAfter = 5
 	}
 
+	var tokenAsString = widget.Token.String()
+	var gitLabTokenAsString = widget.GitLabToken.String()
+
+	for _, repository := range widget.Repositories {
+		parts := strings.Split(repository, ":")
+		var request *feed.ReleaseRequest
+
+		if len(parts) == 1 {
+			request = &feed.ReleaseRequest{
+				Source:     feed.ReleaseSourceGithub,
+				Repository: repository,
+			}
+
+			if widget.Token != "" {
+				request.Token = &tokenAsString
+			}
+		} else if len(parts) == 2 {
+			if parts[0] == string(feed.ReleaseSourceGitlab) {
+				request = &feed.ReleaseRequest{
+					Source:     feed.ReleaseSourceGitlab,
+					Repository: parts[1],
+				}
+
+				if widget.GitLabToken != "" {
+					request.Token = &gitLabTokenAsString
+				}
+			} else if parts[0] == string(feed.ReleaseSourceDockerHub) {
+				request = &feed.ReleaseRequest{
+					Source:     feed.ReleaseSourceDockerHub,
+					Repository: parts[1],
+				}
+			} else {
+				return errors.New("invalid repository source " + parts[0])
+			}
+		}
+
+		widget.releaseRequests = append(widget.releaseRequests, request)
+	}
+
 	return nil
 }
 
 func (widget *Releases) Update(ctx context.Context) {
-	releases, err := feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token))
+	releases, err := feed.FetchLatestReleases(widget.releaseRequests)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return