Browse Source

Allow fetching releases from multiple sources

Svilen Markov 10 months ago
parent
commit
01af97ddab

+ 19 - 4
docs/configuration.md

@@ -989,11 +989,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:
@@ -1005,13 +1007,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 |
-| source | string | no | github |
 
 ##### `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.
@@ -1036,6 +1048,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

@@ -468,8 +468,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;
@@ -622,6 +620,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;

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

@@ -2,14 +2,27 @@
 
 {{ 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 }}
+            <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
+}

+ 0 - 344
internal/feed/git_forge.go

@@ -1,344 +0,0 @@
-package feed
-
-import (
-	"errors"
-	"fmt"
-	"log/slog"
-	"net/http"
-	"net/url"
-	"sync"
-	"time"
-)
-
-type githubReleaseLatestResponseJson struct {
-	TagName     string `json:"tag_name"`
-	PublishedAt string `json:"published_at"`
-	HtmlUrl     string `json:"html_url"`
-	Reactions   struct {
-		Downvotes int `json:"-1"`
-	} `json:"reactions"`
-}
-
-type gitlabReleaseResponseJson struct {
-	TagName     string `json:"tag_name"`
-	PublishedAt string `json:"created_at"`
-	Links       struct {
-		Self string `json:"self"`
-	} `json:"_links"`
-	Draft       bool   `json:"draft"`
-	PreRelease  bool   `json:"prerelease"`
-	Reactions   struct {
-		Downvotes int `json:"-1"`
-	} `json:"reactions"`
-}
-
-func parseGithubTime(t string) time.Time {
-	parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
-
-	if err != nil {
-		return time.Now()
-	}
-
-	return parsedTime
-}
-
-func FetchLatestReleasesFromGitForge(repositories []string, token string, source string) (AppReleases, error) {
-	switch source {
-	case "github":
-		return fetchLatestReleasesFromGithub(repositories, token)
-	case "gitlab":
-		return fetchLatestReleasesFromGitlab(repositories, token)
-	default:
-		return nil, errors.New(fmt.Sprintf("Release source %s is invalid", source))
-	}
-}
-
-func fetchLatestReleasesFromGitlab(repositories []string, token string) (AppReleases, error) {
-	appReleases := make(AppReleases, 0, len(repositories))
-
-	if len(repositories) == 0 {
-		return appReleases, nil
-	}
-
-	requests := make([]*http.Request, len(repositories))
-
-	for i, repository := range repositories {
-		request, _ := http.NewRequest("GET", fmt.Sprintf("https://gitlab.com/api/v4/projects/%s/releases/", url.QueryEscape(repository)), nil)
-
-		if token != "" {
-			request.Header.Add("PRIVATE-TOKEN", token)
-		}
-
-		requests[i] = request
-	}
-
-	task := decodeJsonFromRequestTask[[]gitlabReleaseResponseJson](defaultClient)
-	job := newJob(task, requests).withWorkers(15)
-	responses, errs, err := workerPoolDo(job)
-
-	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 gitlab release", "error", errs[i], "url", requests[i].URL)
-			continue
-		}
-
-		releases := responses[i]
-
-		if len(releases) < 1 {
-			failed++
-			slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
-			continue
-		}
-
-		var liveRelease *gitlabReleaseResponseJson
-
-		for i := range releases {
-			release := &releases[i]
-
-			if !release.Draft && !release.PreRelease {
-				liveRelease = release
-				break
-			}
-		}
-
-		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.Links.Self,
-			TimeReleased: parseGithubTime(liveRelease.PublishedAt),
-			Downvotes:    liveRelease.Reactions.Downvotes,
-		})
-	}
-
-	if len(appReleases) == 0 {
-		return nil, ErrNoContent
-	}
-
-	appReleases.SortByNewest()
-
-	if failed > 0 {
-		return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
-	}
-
-	return appReleases, nil
-}
-
-
-func fetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
-	appReleases := make(AppReleases, 0, len(repositories))
-
-	if len(repositories) == 0 {
-		return appReleases, nil
-	}
-
-	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
-	}
-
-	task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient)
-	job := newJob(task, requests).withWorkers(15)
-	responses, errs, err := workerPoolDo(job)
-
-	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()
-
-	if failed > 0 {
-		return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
-	}
-
-	return appReleases, nil
-}
-
-type GithubTicket struct {
-	Number    int
-	CreatedAt time.Time
-	Title     string
-}
-
-type RepositoryDetails struct {
-	Name             string
-	Stars            int
-	Forks            int
-	OpenPullRequests int
-	PullRequests     []GithubTicket
-	OpenIssues       int
-	Issues           []GithubTicket
-}
-
-type githubRepositoryDetailsResponseJson struct {
-	Name  string `json:"full_name"`
-	Stars int    `json:"stargazers_count"`
-	Forks int    `json:"forks_count"`
-}
-
-type githubTicketResponseJson struct {
-	Count   int `json:"total_count"`
-	Tickets []struct {
-		Number    int    `json:"number"`
-		CreatedAt string `json:"created_at"`
-		Title     string `json:"title"`
-	} `json:"items"`
-}
-
-func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
-	repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
-
-	if err != nil {
-		return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
-	}
-
-	PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
-	issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
-
-	if token != "" {
-		token = fmt.Sprintf("Bearer %s", token)
-		repositoryRequest.Header.Add("Authorization", token)
-		PRsRequest.Header.Add("Authorization", token)
-		issuesRequest.Header.Add("Authorization", token)
-	}
-
-	var detailsResponse githubRepositoryDetailsResponseJson
-	var detailsErr error
-	var PRsResponse githubTicketResponseJson
-	var PRsErr error
-	var issuesResponse githubTicketResponseJson
-	var issuesErr error
-	var wg sync.WaitGroup
-
-	wg.Add(1)
-	go (func() {
-		defer wg.Done()
-		detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
-	})()
-
-	if maxPRs > 0 {
-		wg.Add(1)
-		go (func() {
-			defer wg.Done()
-			PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
-		})()
-	}
-
-	if maxIssues > 0 {
-		wg.Add(1)
-		go (func() {
-			defer wg.Done()
-			issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
-		})()
-	}
-
-	wg.Wait()
-
-	if detailsErr != nil {
-		return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
-	}
-
-	details := RepositoryDetails{
-		Name:         detailsResponse.Name,
-		Stars:        detailsResponse.Stars,
-		Forks:        detailsResponse.Forks,
-		PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
-		Issues:       make([]GithubTicket, 0, len(issuesResponse.Tickets)),
-	}
-
-	err = nil
-
-	if maxPRs > 0 {
-		if PRsErr != nil {
-			err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
-		} else {
-			details.OpenPullRequests = PRsResponse.Count
-
-			for i := range PRsResponse.Tickets {
-				details.PullRequests = append(details.PullRequests, GithubTicket{
-					Number:    PRsResponse.Tickets[i].Number,
-					CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
-					Title:     PRsResponse.Tickets[i].Title,
-				})
-			}
-		}
-	}
-
-	if maxIssues > 0 {
-		if issuesErr != nil {
-			// TODO: fix, overwriting the previous error
-			err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
-		} else {
-			details.OpenIssues = issuesResponse.Count
-
-			for i := range issuesResponse.Tickets {
-				details.Issues = append(details.Issues, GithubTicket{
-					Number:    issuesResponse.Tickets[i].Number,
-					CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
-					Title:     issuesResponse.Tickets[i].Title,
-				})
-			}
-		}
-	}
-
-	return details, err
-}

+ 184 - 0
internal/feed/github.go

@@ -0,0 +1,184 @@
+package feed
+
+import (
+	"fmt"
+	"net/http"
+	"sync"
+	"time"
+)
+
+type githubReleaseLatestResponseJson struct {
+	TagName     string `json:"tag_name"`
+	PublishedAt string `json:"published_at"`
+	HtmlUrl     string `json:"html_url"`
+	Reactions   struct {
+		Downvotes int `json:"-1"`
+	} `json:"reactions"`
+}
+
+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 nil, err
+	}
+
+	if request.Token != nil {
+		httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
+	}
+
+	response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	version := response.TagName
+
+	if len(version) > 0 && version[0] != 'v' {
+		version = "v" + version
+	}
+
+	return &AppRelease{
+		Source:       ReleaseSourceGithub,
+		Name:         request.Repository,
+		Version:      version,
+		NotesUrl:     response.HtmlUrl,
+		TimeReleased: parseRFC3339Time(response.PublishedAt),
+		Downvotes:    response.Reactions.Downvotes,
+	}, nil
+}
+
+type GithubTicket struct {
+	Number    int
+	CreatedAt time.Time
+	Title     string
+}
+
+type RepositoryDetails struct {
+	Name             string
+	Stars            int
+	Forks            int
+	OpenPullRequests int
+	PullRequests     []GithubTicket
+	OpenIssues       int
+	Issues           []GithubTicket
+}
+
+type githubRepositoryDetailsResponseJson struct {
+	Name  string `json:"full_name"`
+	Stars int    `json:"stargazers_count"`
+	Forks int    `json:"forks_count"`
+}
+
+type githubTicketResponseJson struct {
+	Count   int `json:"total_count"`
+	Tickets []struct {
+		Number    int    `json:"number"`
+		CreatedAt string `json:"created_at"`
+		Title     string `json:"title"`
+	} `json:"items"`
+}
+
+func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
+	repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
+
+	if err != nil {
+		return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
+	}
+
+	PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
+	issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
+
+	if token != "" {
+		token = fmt.Sprintf("Bearer %s", token)
+		repositoryRequest.Header.Add("Authorization", token)
+		PRsRequest.Header.Add("Authorization", token)
+		issuesRequest.Header.Add("Authorization", token)
+	}
+
+	var detailsResponse githubRepositoryDetailsResponseJson
+	var detailsErr error
+	var PRsResponse githubTicketResponseJson
+	var PRsErr error
+	var issuesResponse githubTicketResponseJson
+	var issuesErr error
+	var wg sync.WaitGroup
+
+	wg.Add(1)
+	go (func() {
+		defer wg.Done()
+		detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
+	})()
+
+	if maxPRs > 0 {
+		wg.Add(1)
+		go (func() {
+			defer wg.Done()
+			PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
+		})()
+	}
+
+	if maxIssues > 0 {
+		wg.Add(1)
+		go (func() {
+			defer wg.Done()
+			issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
+		})()
+	}
+
+	wg.Wait()
+
+	if detailsErr != nil {
+		return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
+	}
+
+	details := RepositoryDetails{
+		Name:         detailsResponse.Name,
+		Stars:        detailsResponse.Stars,
+		Forks:        detailsResponse.Forks,
+		PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
+		Issues:       make([]GithubTicket, 0, len(issuesResponse.Tickets)),
+	}
+
+	err = nil
+
+	if maxPRs > 0 {
+		if PRsErr != nil {
+			err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
+		} else {
+			details.OpenPullRequests = PRsResponse.Count
+
+			for i := range PRsResponse.Tickets {
+				details.PullRequests = append(details.PullRequests, GithubTicket{
+					Number:    PRsResponse.Tickets[i].Number,
+					CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
+					Title:     PRsResponse.Tickets[i].Title,
+				})
+			}
+		}
+	}
+
+	if maxIssues > 0 {
+		if issuesErr != nil {
+			// TODO: fix, overwriting the previous error
+			err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
+		} else {
+			details.OpenIssues = issuesResponse.Count
+
+			for i := range issuesResponse.Tickets {
+				details.Issues = append(details.Issues, GithubTicket{
+					Number:    issuesResponse.Tickets[i].Number,
+					CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
+					Title:     issuesResponse.Tickets[i].Title,
+				})
+			}
+		}
+	}
+
+	return details, err
+}

+ 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

@@ -40,6 +40,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

+ 49 - 10
internal/widget/releases.go

@@ -2,7 +2,9 @@ package widget
 
 import (
 	"context"
+	"errors"
 	"html/template"
+	"strings"
 	"time"
 
 	"github.com/glanceapp/glance/internal/assets"
@@ -10,13 +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"`
-	Source        string            `yaml:"source"`
+	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 {
@@ -30,15 +34,50 @@ func (widget *Releases) Initialize() error {
 		widget.CollapseAfter = 5
 	}
 
-	if widget.Source == "" {
-		widget.Source = "github"
+	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.FetchLatestReleasesFromGitForge(widget.Repositories, string(widget.Token), widget.Source)
+	releases, err := feed.FetchLatestReleases(widget.releaseRequests)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return