Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: added starred repositories option for releases widget #65

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand All @@ -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.
Expand Down
148 changes: 146 additions & 2 deletions internal/feed/github.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package feed

import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
Expand All @@ -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)

Expand All @@ -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 {
Expand All @@ -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))
Expand Down
27 changes: 20 additions & 7 deletions internal/widget/releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down