Browse Source

Add Repository Overview widget

Svilen Markov 1 year ago
parent
commit
c5e3eed64b

+ 38 - 0
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:
+
+![](images/repository-overview-preview.png)
+
+#### 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.
 

BIN
docs/images/repository-overview-preview.png


+ 2 - 1
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; }

+ 1 - 0
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{

+ 44 - 0
internal/assets/templates/repository-overview.html

@@ -0,0 +1,44 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
+<ul class="list-horizontal-text">
+    <li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
+    <li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
+</ul>
+
+{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
+<hr class="margin-block-10">
+<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
+<div class="flex gap-7 size-h5 margin-top-3">
+    <ul class="list list-gap-2">
+        {{ range .RepositoryDetails.PullRequests }}
+        <li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
+        {{ end }}
+    </ul>
+    <ul class="list list-gap-2 min-width-0">
+        {{ range .RepositoryDetails.PullRequests }}
+        <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
+        {{ end }}
+    </ul>
+</div>
+{{ end }}
+
+{{ if gt (len .RepositoryDetails.Issues) 0 }}
+<hr class="margin-block-10">
+<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
+<div class="flex gap-7 size-h5 margin-top-3">
+    <ul class="list list-gap-2">
+        {{ range .RepositoryDetails.Issues }}
+        <li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
+        {{ end }}
+    </ul>
+    <ul class="list list-gap-2 min-width-0">
+        {{ range .RepositoryDetails.Issues }}
+        <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
+        {{ end }}
+    </ul>
+</div>
+{{ end }}
+
+{{ end }}

+ 131 - 0
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
+}

+ 52 - 0
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)
+}

+ 2 - 0
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)
 	}