From 7d1ede8c91f13b45ca287247d06b13fb4c21653f Mon Sep 17 00:00:00 2001 From: Albin Parou Date: Wed, 3 Jul 2024 08:17:51 +0200 Subject: [PATCH 1/5] releases: Add support for gitlab --- docs/configuration.md | 6 +- internal/feed/{github.go => git_forge.go} | 117 +++++++++++++++++++++- internal/widget/releases.go | 7 +- 3 files changed, 127 insertions(+), 3 deletions(-) rename internal/feed/{github.go => git_forge.go} (67%) diff --git a/docs/configuration.md b/docs/configuration.md index 2dffb2e..393e4c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1008,6 +1008,7 @@ Preview: | 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. @@ -1039,7 +1040,10 @@ This way you can safely check your `glance.yml` in version control without expos The maximum number of releases to show. #### `collapse-after` -How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. +how many releases are visible before the "show more" button appears. set to `-1` to never collapse. + +#### `source` +Either `github` or `gitlab`. Wether to retrieve the releases from github repositories or gitlab repositories. ### Repository Display general information about a repository as well as a list of the latest open pull requests and issues. diff --git a/internal/feed/github.go b/internal/feed/git_forge.go similarity index 67% rename from internal/feed/github.go rename to internal/feed/git_forge.go index 4d7dc73..e46ecc5 100644 --- a/internal/feed/github.go +++ b/internal/feed/git_forge.go @@ -1,9 +1,11 @@ package feed import ( + "errors" "fmt" "log/slog" "net/http" + "net/url" "sync" "time" ) @@ -17,6 +19,19 @@ type githubReleaseLatestResponseJson struct { } `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) @@ -27,7 +42,107 @@ func parseGithubTime(t string) time.Time { return parsedTime } -func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) { +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 { diff --git a/internal/widget/releases.go b/internal/widget/releases.go index 77fe103..cd28aaf 100644 --- a/internal/widget/releases.go +++ b/internal/widget/releases.go @@ -16,6 +16,7 @@ type Releases struct { Token OptionalEnvString `yaml:"token"` Limit int `yaml:"limit"` CollapseAfter int `yaml:"collapse-after"` + Source string `yaml:"source"` } func (widget *Releases) Initialize() error { @@ -29,11 +30,15 @@ func (widget *Releases) Initialize() error { widget.CollapseAfter = 5 } + if widget.Source == "" { + widget.Source = "github" + } + return nil } func (widget *Releases) Update(ctx context.Context) { - releases, err := feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token)) + releases, err := feed.FetchLatestReleasesFromGitForge(widget.Repositories, string(widget.Token), widget.Source) if !widget.canContinueUpdateAfterHandlingErr(err) { return From 01af97ddab5e6e417fabacbe970ea1cde1a8128f Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Tue, 27 Aug 2024 03:26:16 +0100 Subject: [PATCH 2/5] Allow fetching releases from multiple sources --- docs/configuration.md | 23 +- docs/images/releases-widget-preview.png | Bin 15240 -> 19654 bytes internal/assets/static/main.css | 8 +- internal/assets/templates/releases.html | 25 +- internal/feed/dockerhub.go | 58 ++++ internal/feed/git_forge.go | 344 ------------------------ internal/feed/github.go | 184 +++++++++++++ internal/feed/gitlab.go | 54 ++++ internal/feed/primitives.go | 1 + internal/feed/releases.go | 69 +++++ internal/feed/utils.go | 12 +- internal/widget/fields.go | 4 + internal/widget/releases.go | 59 +++- 13 files changed, 474 insertions(+), 367 deletions(-) create mode 100644 internal/feed/dockerhub.go delete mode 100644 internal/feed/git_forge.go create mode 100644 internal/feed/github.go create mode 100644 internal/feed/gitlab.go create mode 100644 internal/feed/releases.go diff --git a/docs/configuration.md b/docs/configuration.md index 393e4c4..bec7451 100644 --- a/docs/configuration.md +++ b/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. diff --git a/docs/images/releases-widget-preview.png b/docs/images/releases-widget-preview.png index 47acfd034e9f2d858afa1bd630dc64495be278d2..ec712bb41338c1aba06270dc597260a194dabae2 100644 GIT binary patch literal 19654 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 diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index cfc96fc..7f2455b 100644 --- a/internal/assets/static/main.css +++ b/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; diff --git a/internal/assets/templates/releases.html b/internal/assets/templates/releases.html index d6a8974..078309f 100644 --- a/internal/assets/templates/releases.html +++ b/internal/assets/templates/releases.html @@ -2,14 +2,27 @@ {{ define "widget-content" }}
    - {{ range $i, $release := .Releases }} + {{ range .Releases }}
  • - {{ .Name }} +
    + {{ .Name }} + {{ if $.ShowSourceIcon }} + + {{ if eq .Source "github" }} + + {{ else if eq .Source "gitlab" }} + + {{ else if eq .Source "dockerhub" }} + + {{ end }} + + {{ end }} +
      -
    • -
    • {{ $release.Version }}
    • - {{ if gt $release.Downvotes 3 }} -
    • {{ $release.Downvotes | formatNumber }} ⚠
    • +
    • +
    • {{ .Version }}
    • + {{ if gt .Downvotes 3 }} +
    • {{ .Downvotes | formatNumber }} ⚠
    • {{ end }}
  • diff --git a/internal/feed/dockerhub.go b/internal/feed/dockerhub.go new file mode 100644 index 0000000..45e67b7 --- /dev/null +++ b/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 +} diff --git a/internal/feed/git_forge.go b/internal/feed/git_forge.go deleted file mode 100644 index e46ecc5..0000000 --- a/internal/feed/git_forge.go +++ /dev/null @@ -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 -} diff --git a/internal/feed/github.go b/internal/feed/github.go new file mode 100644 index 0000000..8adbfd8 --- /dev/null +++ b/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 +} diff --git a/internal/feed/gitlab.go b/internal/feed/gitlab.go new file mode 100644 index 0000000..4e0c1e8 --- /dev/null +++ b/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 +} diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go index 6e0c98f..7848d9a 100644 --- a/internal/feed/primitives.go +++ b/internal/feed/primitives.go @@ -40,6 +40,7 @@ type Weather struct { } type AppRelease struct { + Source ReleaseSource Name string Version string NotesUrl string diff --git a/internal/feed/releases.go b/internal/feed/releases.go new file mode 100644 index 0000000..516801e --- /dev/null +++ b/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") +} diff --git a/internal/feed/utils.go b/internal/feed/utils.go index 16c376b..f86b497 100644 --- a/internal/feed/utils.go +++ b/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 +} diff --git a/internal/widget/fields.go b/internal/widget/fields.go index cbbfce2..9ae1eda 100644 --- a/internal/widget/fields.go +++ b/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 diff --git a/internal/widget/releases.go b/internal/widget/releases.go index cd28aaf..c824ed6 100644 --- a/internal/widget/releases.go +++ b/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 From 48ef60e0eb7ae7deb190ddde565eb4338d7d73ac Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Tue, 27 Aug 2024 03:33:37 +0100 Subject: [PATCH 3/5] Add todo --- internal/assets/templates/releases.html | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/assets/templates/releases.html b/internal/assets/templates/releases.html index 078309f..13e6a0d 100644 --- a/internal/assets/templates/releases.html +++ b/internal/assets/templates/releases.html @@ -7,6 +7,7 @@
    {{ .Name }} {{ if $.ShowSourceIcon }} + {{/* TODO: add the icons as assets and link to them here instead of hardcoding */}} {{ if eq .Source "github" }} From 5c42e60faf37eb3ef950fbb79fd2a8b6b5ea2033 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Tue, 27 Aug 2024 03:35:35 +0100 Subject: [PATCH 4/5] Update docs --- docs/configuration.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index bec7451..3636a92 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1055,10 +1055,7 @@ Same as the above but used when fetching GitLab releases. The maximum number of releases to show. #### `collapse-after` -how many releases are visible before the "show more" button appears. set to `-1` to never collapse. - -#### `source` -Either `github` or `gitlab`. Wether to retrieve the releases from github repositories or gitlab repositories. +How many releases are visible before the "show more" button appears. set to `-1` to never collapse. ### Repository Display general information about a repository as well as a list of the latest open pull requests and issues. From 70b7b7615a14a97b0409909bb5bba8cee57d25cf Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Tue, 27 Aug 2024 03:36:36 +0100 Subject: [PATCH 5/5] Fix capitalization --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3636a92..28529d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1055,7 +1055,7 @@ Same as the above but used when fetching GitLab releases. The maximum number of releases to show. #### `collapse-after` -How many releases are visible before the "show more" button appears. set to `-1` to never collapse. +How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. ### Repository Display general information about a repository as well as a list of the latest open pull requests and issues.