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"` 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, 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 }