diff --git a/docs/configuration.md b/docs/configuration.md
index 56fc8e0..c21be09 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -14,6 +14,7 @@
- [Weather](#weather)
- [Monitor](#monitor)
- [Releases](#releases)
+ - [Repository Overview](#repository-overview)
- [Bookmarks](#bookmarks)
- [Calendar](#calendar)
- [Stocks](#stocks)
@@ -791,6 +792,43 @@ 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.
+### Repository Overview
+Display general information about a repository as well as a list of the latest open pull requests and issues.
+
+Example:
+
+```yaml
+- type: repository-overview
+ repository: glanceapp/glance
+ pull-requests-limit: 5
+ issues-limit: 3
+```
+
+Preview:
+
+
+
+#### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| repository | string | yes | |
+| token | string | no | |
+| pull-requests-limit | integer | no | 3 |
+| issues-limit | integer | no | 3 |
+
+##### `repository`
+The owner and repository name that will have their information displayed.
+
+##### `token`
+Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if your cache time is low or you have many instances of this widget. 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.
+
+##### `pull-requests-limit`
+The maximum number of latest open pull requests to show. Set to `-1` to not show any.
+
+##### `issues-limit`
+The maximum number of latest open issues to show. Set to `-1` to not show any.
+
### Bookmarks
Display a list of links which can be grouped.
diff --git a/docs/images/repository-overview-preview.png b/docs/images/repository-overview-preview.png
new file mode 100644
index 0000000..5c43db5
Binary files /dev/null and b/docs/images/repository-overview-preview.png differ
diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css
index 833f07f..7aa9356 100644
--- a/internal/assets/static/main.css
+++ b/internal/assets/static/main.css
@@ -108,7 +108,7 @@
.list-gap-24 { --list-half-gap: 1.2rem; }
.list > *:not(:first-child) {
- margin-top: calc(var(--list-half-gap) * 2 + 1px);
+ margin-top: calc(var(--list-half-gap) * 2);
}
.list-with-separator > *:not(:first-child) {
@@ -1104,6 +1104,7 @@ body {
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-elevate { margin-top: -0.2em; }
+.text-compact { word-spacing: -0.18em; }
.rtl { direction: rtl; }
.shrink { flex-shrink: 1; }
.shrink-0 { flex-shrink: 0; }
diff --git a/internal/assets/templates.go b/internal/assets/templates.go
index b349d52..0dde279 100644
--- a/internal/assets/templates.go
+++ b/internal/assets/templates.go
@@ -31,6 +31,7 @@ var (
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
+ RepositoryOverviewTemplate = compileTemplate("repository-overview.html", "widget-base.html")
)
var globalTemplateFunctions = template.FuncMap{
diff --git a/internal/assets/templates/repository-overview.html b/internal/assets/templates/repository-overview.html
new file mode 100644
index 0000000..9122a8e
--- /dev/null
+++ b/internal/assets/templates/repository-overview.html
@@ -0,0 +1,44 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+{{ .RepositoryDetails.Name }}
+
+ - {{ .RepositoryDetails.Stars | formatNumber }} stars
+ - {{ .RepositoryDetails.Forks | formatNumber }} forks
+
+
+{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
+
+Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)
+
+
+ {{ range .RepositoryDetails.PullRequests }}
+ - {{ .CreatedAt | relativeTime }}
+ {{ end }}
+
+
+ {{ range .RepositoryDetails.PullRequests }}
+ - {{ .Title }}
+ {{ end }}
+
+
+{{ end }}
+
+{{ if gt (len .RepositoryDetails.Issues) 0 }}
+
+Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)
+
+
+ {{ range .RepositoryDetails.Issues }}
+ - {{ .CreatedAt | relativeTime }}
+ {{ end }}
+
+
+ {{ range .RepositoryDetails.Issues }}
+ - {{ .Title }}
+ {{ end }}
+
+
+{{ end }}
+
+{{ end }}
diff --git a/internal/feed/github.go b/internal/feed/github.go
index 4a34182..43a2459 100644
--- a/internal/feed/github.go
+++ b/internal/feed/github.go
@@ -4,6 +4,7 @@ import (
"fmt"
"log/slog"
"net/http"
+ "sync"
"time"
)
@@ -115,3 +116,133 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
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/widget/repository-overview.go b/internal/widget/repository-overview.go
new file mode 100644
index 0000000..8c50b31
--- /dev/null
+++ b/internal/widget/repository-overview.go
@@ -0,0 +1,52 @@
+package widget
+
+import (
+ "context"
+ "html/template"
+ "time"
+
+ "github.com/glanceapp/glance/internal/assets"
+ "github.com/glanceapp/glance/internal/feed"
+)
+
+type RepositoryOverview struct {
+ widgetBase `yaml:",inline"`
+ RequestedRepository string `yaml:"repository"`
+ Token OptionalEnvString `yaml:"token"`
+ PullRequestsLimit int `yaml:"pull-requests-limit"`
+ IssuesLimit int `yaml:"issues-limit"`
+ RepositoryDetails feed.RepositoryDetails
+}
+
+func (widget *RepositoryOverview) Initialize() error {
+ widget.withTitle("Repository").withCacheDuration(1 * time.Hour)
+
+ if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 {
+ widget.PullRequestsLimit = 3
+ }
+
+ if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 {
+ widget.IssuesLimit = 3
+ }
+
+ return nil
+}
+
+func (widget *RepositoryOverview) Update(ctx context.Context) {
+ details, err := feed.FetchRepositoryDetailsFromGithub(
+ widget.RequestedRepository,
+ string(widget.Token),
+ widget.PullRequestsLimit,
+ widget.IssuesLimit,
+ )
+
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
+ return
+ }
+
+ widget.RepositoryDetails = details
+}
+
+func (widget *RepositoryOverview) Render() template.HTML {
+ return widget.render(widget, assets.RepositoryOverviewTemplate)
+}
diff --git a/internal/widget/widget.go b/internal/widget/widget.go
index 367d822..48ebb4c 100644
--- a/internal/widget/widget.go
+++ b/internal/widget/widget.go
@@ -43,6 +43,8 @@ func New(widgetType string) (Widget, error) {
return &TwitchGames{}, nil
case "twitch-channels":
return &TwitchChannels{}, nil
+ case "repository-overview":
+ return &RepositoryOverview{}, nil
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}