SafeLine/backend/internal/service/github.go
2023-11-22 14:46:39 +08:00

376 lines
10 KiB
Go

package service
import (
"context"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/shurcooL/githubv4"
)
type Label struct {
Name string `json:"name"`
Color string `json:"color"`
}
type User struct {
Login string `json:"login"`
AvatarUrl string `json:"avatar_url"`
}
// Issue represents a GitHub issue with minimal fields.
type Issue struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"-"`
Url string `json:"url"`
Labels []Label `json:"labels"`
CommentCount int `json:"comment_count"`
ThumbsUpCount int `json:"thumbs_up"`
Author User `json:"author"`
CreatedAt int64 `json:"created_at"`
}
// Discussion represents a GitHub discussion.
type Discussion struct {
ID string `json:"id"`
Url string `json:"url"`
Title string `json:"title"`
BodyText string `json:"-"`
Labels []Label `json:"labels"`
ThumbsUpCount int `json:"thumbs_up"`
CommentCount int `json:"comment_count"`
UpvoteCount int `json:"upvote_count"`
Author User `json:"author"`
CommentUsers []User `json:"comment_users"`
CreatedAt int64 `json:"created_at"`
IsAnswered bool `json:"is_answered"`
Category Category `json:"category"`
}
type Category struct {
ID string `json:"id"`
Name string `json:"name"`
Emoji string `json:"emoji"`
EmojiHTML string `json:"emoji_html" graphql:"emojiHTML"`
}
type GitHubAPI interface {
Query(ctx context.Context, q interface{}, variables map[string]interface{}) error
}
// GitHubService provides methods to interact with the GitHub API.
type GitHubService struct {
token string
cache sync.Map
cacheTTL time.Duration
owner string
repo string
}
type GithubConfig struct {
Token string
Owner string
Repo string
CacheTTL time.Duration
}
// headerTransport is custom Transport used to add header information to the request
type headerTransport struct {
transport *http.Transport
headers map[string]string
}
// RoundTrip implements the http.RoundTripper interface
func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for key, value := range h.headers {
req.Header.Add(key, value)
}
return h.transport.RoundTrip(req)
}
// NewGitHubService creates a new instance of GitHubService with authorized client.
func NewGitHubService(cfg *GithubConfig) *GitHubService {
s := &GitHubService{
token: cfg.Token,
cache: sync.Map{},
cacheTTL: cfg.CacheTTL,
owner: cfg.Owner,
repo: cfg.Repo,
}
go s.loop()
return s
}
func (s *GitHubService) loop() {
s.refreshCache()
t := time.NewTicker(s.cacheTTL * time.Minute)
defer t.Stop()
for range t.C {
s.refreshCache()
}
}
func (s *GitHubService) client(proxy bool) GitHubAPI {
httpClient := &http.Client{
Transport: &headerTransport{
transport: &http.Transport{},
headers: map[string]string{"Authorization": "Bearer " + s.token},
},
}
if proxy {
httpClient.Transport.(*headerTransport).transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
}
}
return githubv4.NewClient(httpClient)
}
func (s *GitHubService) request(ctx context.Context, q interface{}, variables map[string]interface{}) (err error) {
err = s.client(true).Query(ctx, q, variables)
if err != nil {
log.Printf("request using proxy fails and falls back to non-proxy mode: %#v", err)
err = s.client(false).Query(ctx, q, variables)
}
return
}
func (s *GitHubService) refreshCache() {
issues, err := s.fetchIssues(context.Background(), nil)
if err != nil {
log.Printf("failed to fetch issues %v", err)
return
}
s.cache.Store("issues", issues)
discussions, err := s.fetchDiscussions(context.Background(), nil)
if err != nil {
log.Printf("failed to fetch discussions %v", err)
return
}
s.cache.Store("discussions", discussions)
}
// GetIssues tries to get the issues from cache; if not available, fetches from GitHub API.
func (s *GitHubService) GetIssues(ctx context.Context, filter string) (issues []*Issue, err error) {
cachedIssues, found := s.cache.Load("issues")
if found {
return s.filterIssues(cachedIssues.([]*Issue), filter)
}
issues, err = s.fetchIssues(ctx, nil)
if err != nil {
return nil, err
}
return s.filterIssues(issues, filter)
}
func (s *GitHubService) filterIssues(issues []*Issue, filter string) ([]*Issue, error) {
if filter != "" {
filteredIssues := make([]*Issue, 0)
for _, issue := range issues {
if strings.Contains(issue.Title, filter) || strings.Contains(issue.Body, filter) {
filteredIssues = append(filteredIssues, issue)
}
}
return filteredIssues, nil
}
return issues, nil
}
// GetRepositoryIssues queries GitHub for issues of a repository.
func (s *GitHubService) fetchIssues(ctx context.Context, afterCursor *githubv4.String) ([]*Issue, error) {
var query struct {
Repository struct {
Issues struct {
Nodes []struct {
ID string
Title string
Body string
Url string
CreatedAt githubv4.DateTime
Author User
Labels struct {
Nodes []struct {
Color string
Name string
}
} `graphql:"labels(first: 10)"`
Comments struct {
TotalCount int
}
Reactions struct {
TotalCount int
} `graphql:"reactions(content: THUMBS_UP)"`
}
PageInfo struct {
EndCursor githubv4.String
HasNextPage bool
}
} `graphql:"issues(first: 100, after: $afterCursor, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC})"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(s.owner),
"name": githubv4.String(s.repo),
"afterCursor": afterCursor,
}
err := s.request(ctx, &query, variables)
if err != nil {
return nil, err
}
issues := make([]*Issue, 0)
for _, node := range query.Repository.Issues.Nodes {
issue := &Issue{
ID: node.ID,
Title: node.Title,
Body: node.Body,
Url: node.Url,
}
issue.Labels = make([]Label, len(node.Labels.Nodes))
for i, label := range node.Labels.Nodes {
issue.Labels[i] = Label{Name: label.Name, Color: label.Color}
}
issue.CommentCount = node.Comments.TotalCount
issue.ThumbsUpCount = node.Reactions.TotalCount
issue.Author = node.Author
issue.CreatedAt = node.CreatedAt.Unix()
issues = append(issues, issue)
}
if query.Repository.Issues.PageInfo.HasNextPage {
moreIssues, err := s.fetchIssues(ctx, &query.Repository.Issues.PageInfo.EndCursor)
if err != nil {
return nil, err
}
issues = append(issues, moreIssues...)
}
return issues, nil
}
// GetDiscussions tries to get the discussions from cache; if not available, fetches from GitHub API.
func (s *GitHubService) GetDiscussions(ctx context.Context, filter string) ([]*Discussion, error) {
if cachedData, found := s.cache.Load("discussions"); found {
return s.filterDiscussions(cachedData.([]*Discussion), filter)
}
discussions, err := s.fetchDiscussions(ctx, nil)
if err != nil {
return nil, err
}
return s.filterDiscussions(discussions, filter)
}
func (s *GitHubService) filterDiscussions(discussions []*Discussion, filter string) ([]*Discussion, error) {
if filter != "" {
filteredDiscussions := make([]*Discussion, 0)
for _, discussion := range discussions {
if strings.Contains(discussion.Title, filter) || strings.Contains(discussion.BodyText, filter) {
filteredDiscussions = append(filteredDiscussions, discussion)
}
}
return filteredDiscussions, nil
}
return discussions, nil
}
// fetchDiscussionsFromGitHub queries GitHub for discussions of a repository.
func (s *GitHubService) fetchDiscussions(ctx context.Context, afterCursor *githubv4.String) ([]*Discussion, error) {
var query struct {
Repository struct {
Discussions struct {
Nodes []struct {
ID string
Url string
UpvoteCount int
Title string
BodyText string
Author User
CreatedAt githubv4.DateTime
IsAnswered bool
Labels struct {
Nodes []struct {
Color string
Name string
}
} `graphql:"labels(first: 10)"`
Reactions struct {
TotalCount int
} `graphql:"reactions(content: THUMBS_UP)"`
Comments struct {
Nodes []struct {
Author User
}
} `graphql:"comments(first: 10)"`
Category Category
}
PageInfo struct {
EndCursor githubv4.String
HasNextPage bool
}
} `graphql:"discussions(first: 100, after: $afterCursor, orderBy: {field: CREATED_AT, direction: DESC})"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(s.owner),
"name": githubv4.String(s.repo),
"afterCursor": afterCursor,
}
err := s.request(ctx, &query, variables)
if err != nil {
return nil, err
}
discussions := make([]*Discussion, 0)
for _, node := range query.Repository.Discussions.Nodes {
discussion := &Discussion{
ID: node.ID,
Url: node.Url,
Title: node.Title,
BodyText: node.BodyText,
}
discussion.Labels = make([]Label, len(node.Labels.Nodes))
for i, label := range node.Labels.Nodes {
discussion.Labels[i] = Label{Name: label.Name, Color: label.Color}
}
exist := make(map[string]struct{})
discussion.CommentUsers = make([]User, 0, len(node.Comments.Nodes))
discussion.CommentUsers = append(discussion.CommentUsers, node.Author)
exist[node.Author.Login] = struct{}{}
for _, comment := range node.Comments.Nodes {
if _, ok := exist[comment.Author.Login]; ok {
continue
}
exist[comment.Author.Login] = struct{}{}
discussion.CommentUsers = append(discussion.CommentUsers, comment.Author)
}
discussion.ThumbsUpCount = node.Reactions.TotalCount
discussion.CommentCount = len(node.Comments.Nodes)
discussion.UpvoteCount = node.UpvoteCount
discussion.Author = node.Author
discussion.CreatedAt = node.CreatedAt.Unix()
discussion.IsAnswered = node.IsAnswered
discussion.Category = node.Category
discussions = append(discussions, discussion)
}
if query.Repository.Discussions.PageInfo.HasNextPage {
moreDiscussions, err := s.fetchDiscussions(ctx, &query.Repository.Discussions.PageInfo.EndCursor)
if err != nil {
return nil, err
}
discussions = append(discussions, moreDiscussions...)
}
return discussions, nil
}