diff --git a/docs/configuration.md b/docs/configuration.md index e698fdd7..9b6bdba6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -768,17 +768,23 @@ Optional URL to an image which will be used as the icon for the site. Can be an Whether to open the link in the same or a new tab. ### Releases -Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown. +Display a list of releases for specific repositories or for your starred repositories on GitHub. Draft releases and prereleases will not be shown. Example: ```yaml +# You can specify a list of repositories - type: releases repositories: - immich-app/immich - go-gitea/gitea - dani-garcia/vaultwarden - jellyfin/jellyfin + +# Or you can use your starred repositories +- type: releases + starred: true + token: your-github-token ``` Preview: @@ -789,14 +795,20 @@ Preview: | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | -| repositories | array | yes | | +| repositories | array | no | [] | +| starred | bool | no | false | | token | string | no | | | limit | integer | no | 10 | | collapse-after | integer | no | 5 | +| releases-search-limit | integer | no | 10 | ##### `repositories` A list of repositores for which to fetch the latest release for. Only the name/repo is required, not the full URL. +##### `starred` +When set to `true` it will fetch the latest releases from all of your starred repositories. Depending on the number of repositories you have starred, this can have an effect on the loading time. +When set to true, you must also set the `token` property, as the starred repositories list is personalized to the user. + ##### `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. @@ -823,8 +835,12 @@ This way you can safely check your `glance.yml` in version control without expos ##### `limit` The maximum number of releases to show. -#### `collapse-after` +##### `collapse-after` How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. + +##### `releases-search-limit` +This is the number of releases Glance will fetch for each repository until it finds the first release that is not a draft or prerelease. +You may decrease this value, to improve performance, at the risk of missing some releases. ### 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/github.go index 43a24591..40d88a02 100644 --- a/internal/feed/github.go +++ b/internal/feed/github.go @@ -1,6 +1,8 @@ package feed import ( + "bytes" + "encoding/json" "fmt" "log/slog" "net/http" @@ -19,6 +21,38 @@ type githubReleaseResponseJson struct { } `json:"reactions"` } +type starredRepositoriesResponseJson struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + Data struct { + Viewer struct { + StarredRepositories struct { + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []struct { + NameWithOwner string `json:"nameWithOwner"` + Releases struct { + Nodes []struct { + Name string `json:"name"` + URL string `json:"url"` + IsDraft bool `json:"isDraft"` + IsPrerelease bool `json:"isPrerelease"` + PublishedAt string `json:"publishedAt"` + TagName string `json:"tagName"` + Reactions struct { + TotalCount int `json:"totalCount"` + } `json:"reactions"` + } `json:"nodes"` + } `json:"releases"` + } `json:"nodes"` + } `json:"starredRepositories"` + } `json:"viewer"` + } `json:"data"` +} + func parseGithubTime(t string) time.Time { parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t) @@ -29,7 +63,117 @@ func parseGithubTime(t string) time.Time { return parsedTime } -func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) { +func FetchStarredRepositoriesReleasesFromGithub(token string, maxReleases int) (AppReleases, error) { + if token == "" { + return nil, fmt.Errorf("%w: no github token provided", ErrNoContent) + } + + afterCursor := "" + + releases := make(AppReleases, 0, 10) + + graphqlClient := http.Client{ + Timeout: time.Second * 10, + } + + for true { + graphQLQuery := fmt.Sprintf(`query StarredReleases { + viewer { + starredRepositories(first: 50, after: "%s") { + pageInfo { + hasNextPage + endCursor + } + nodes { + nameWithOwner + releases(first: %d, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + name + url + publishedAt + tagName + url + isDraft + isPrerelease + reactions { + totalCount + } + } + } + } + } + } + }`, afterCursor, maxReleases) + + jsonBody := map[string]string{ + "query": graphQLQuery, + } + + requestBody, err := json.Marshal(jsonBody) + + if err != nil { + return nil, fmt.Errorf("%w: could not marshal request body: %s", ErrNoContent, err) + } + + request, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewBuffer(requestBody)) + + if err != nil { + return nil, fmt.Errorf("%w: could not create request", err) + } + + request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + + response, err := decodeJsonFromRequest[starredRepositoriesResponseJson](&graphqlClient, request) + + if err != nil { + return nil, fmt.Errorf("%w: could not get starred releases: %s", ErrNoContent, err) + } + + if (response.Errors != nil) && (len(response.Errors) > 0) { + return nil, fmt.Errorf("%w: could not get starred releases: %s", ErrNoContent, response.Errors[0].Message) + } + + for _, repository := range response.Data.Viewer.StarredRepositories.Nodes { + for _, release := range repository.Releases.Nodes { + if release.IsDraft || release.IsPrerelease { + continue + } + + version := release.TagName + + if version[0] != 'v' { + version = "v" + version + } + + releases = append(releases, AppRelease{ + Name: repository.NameWithOwner, + Version: version, + NotesUrl: release.URL, + TimeReleased: parseGithubTime(release.PublishedAt), + Downvotes: release.Reactions.TotalCount, + }) + + break + } + } + + afterCursor = response.Data.Viewer.StarredRepositories.PageInfo.EndCursor + + if !response.Data.Viewer.StarredRepositories.PageInfo.HasNextPage { + break + } + } + + if len(releases) == 0 { + return nil, ErrNoContent + } + + releases.SortByNewest() + + return releases, nil +} + +func FetchLatestReleasesFromGithub(repositories []string, token string, maxReleases int) (AppReleases, error) { appReleases := make(AppReleases, 0, len(repositories)) if len(repositories) == 0 { @@ -39,7 +183,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele requests := make([]*http.Request, len(repositories)) for i, repository := range repositories { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil) + request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repository, maxReleases), nil) if token != "" { request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) diff --git a/internal/widget/releases.go b/internal/widget/releases.go index 77fe1031..56abb969 100644 --- a/internal/widget/releases.go +++ b/internal/widget/releases.go @@ -10,12 +10,14 @@ 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:"-"` + Repositories []string `yaml:"repositories"` + Token OptionalEnvString `yaml:"token"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + ReleasesSearchLimit int `yaml:"releases-search-limit"` + Starred bool `yaml:"starred"` } func (widget *Releases) Initialize() error { @@ -33,7 +35,18 @@ func (widget *Releases) Initialize() error { } func (widget *Releases) Update(ctx context.Context) { - releases, err := feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token)) + var err error + var releases []feed.AppRelease + + if widget.ReleasesSearchLimit <= 0 { + widget.ReleasesSearchLimit = 10 + } + + if widget.Starred { + releases, err = feed.FetchStarredRepositoriesReleasesFromGithub(string(widget.Token), widget.ReleasesSearchLimit) + } else { + releases, err = feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token), widget.ReleasesSearchLimit) + } if !widget.canContinueUpdateAfterHandlingErr(err) { return