github.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. package service
  2. import (
  3. "context"
  4. "log"
  5. "net/http"
  6. "slices"
  7. "sort"
  8. "strings"
  9. "sync"
  10. "time"
  11. "github.com/shurcooL/githubv4"
  12. )
  13. type LabelName = string
  14. const (
  15. LabelNameEnhancement LabelName = "enhancement"
  16. LabelNameInProgress LabelName = "in progress"
  17. LabelNameReleased LabelName = "released"
  18. )
  19. type RoadmapLabelName = string
  20. const (
  21. RoadmapLabelNameInConsideration RoadmapLabelName = "in_consideration"
  22. RoadmapLabelNameInProgress RoadmapLabelName = "in_progress"
  23. RoadmapLabelNameReleased RoadmapLabelName = "released"
  24. )
  25. type Label struct {
  26. Name string `json:"name"`
  27. Color string `json:"color"`
  28. }
  29. type User struct {
  30. Login string `json:"login"`
  31. AvatarUrl string `json:"avatar_url"`
  32. }
  33. type IssueState = string
  34. const (
  35. IssueStateOpened IssueState = "OPEN"
  36. IssueStateClosed IssueState = "CLOSED"
  37. )
  38. // Issue represents a GitHub issue with minimal fields.
  39. type Issue struct {
  40. ID string `json:"id"`
  41. Title string `json:"title"`
  42. Body string `json:"-"`
  43. State IssueState `json:"state"`
  44. Url string `json:"url"`
  45. Labels []Label `json:"labels"`
  46. CommentCount int `json:"comment_count"`
  47. ThumbsUpCount int `json:"thumbs_up"`
  48. Author User `json:"author"`
  49. CreatedAt int64 `json:"created_at"`
  50. UpdatedAt int64 `json:"updated_at"`
  51. }
  52. func (i Issue) InConsideration() bool {
  53. if i.State != IssueStateOpened {
  54. return false
  55. }
  56. if !slices.Contains(i.LabelNames(), LabelNameEnhancement) {
  57. return false
  58. }
  59. if slices.Contains(i.LabelNames(), LabelNameInProgress) {
  60. return false
  61. }
  62. if slices.Contains(i.LabelNames(), LabelNameReleased) {
  63. return false
  64. }
  65. return true
  66. }
  67. func (i Issue) InProgress() bool {
  68. return i.State == IssueStateOpened && slices.Contains(i.LabelNames(), LabelNameInProgress)
  69. }
  70. func (i Issue) Released() bool {
  71. return slices.Contains(i.LabelNames(), LabelNameReleased)
  72. }
  73. func (i Issue) LabelNames() []string {
  74. var names []string
  75. for _, v := range i.Labels {
  76. names = append(names, v.Name)
  77. }
  78. return names
  79. }
  80. // Discussion represents a GitHub discussion.
  81. type Discussion struct {
  82. ID string `json:"id"`
  83. Url string `json:"url"`
  84. Title string `json:"title"`
  85. BodyText string `json:"-"`
  86. Labels []Label `json:"labels"`
  87. ThumbsUpCount int `json:"thumbs_up"`
  88. CommentCount int `json:"comment_count"`
  89. UpvoteCount int `json:"upvote_count"`
  90. Author User `json:"author"`
  91. CommentUsers []User `json:"comment_users"`
  92. CreatedAt int64 `json:"created_at"`
  93. IsAnswered bool `json:"is_answered"`
  94. Category Category `json:"category"`
  95. }
  96. type Category struct {
  97. ID string `json:"id"`
  98. Name string `json:"name"`
  99. Emoji string `json:"emoji"`
  100. EmojiHTML string `json:"emoji_html" graphql:"emojiHTML"`
  101. }
  102. type Repo struct {
  103. ID string `json:"id"`
  104. StarCount int `json:"star_count"`
  105. }
  106. type GitHubAPI interface {
  107. Query(ctx context.Context, q interface{}, variables map[string]interface{}) error
  108. }
  109. // GitHubService provides methods to interact with the GitHub API.
  110. type GitHubService struct {
  111. token string
  112. cache sync.Map
  113. cacheTTL time.Duration
  114. owner string
  115. repo string
  116. }
  117. type GithubConfig struct {
  118. Token string
  119. Owner string
  120. Repo string
  121. CacheTTL time.Duration
  122. }
  123. // headerTransport is custom Transport used to add header information to the request
  124. type headerTransport struct {
  125. transport *http.Transport
  126. headers map[string]string
  127. }
  128. // RoundTrip implements the http.RoundTripper interface
  129. func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
  130. for key, value := range h.headers {
  131. req.Header.Add(key, value)
  132. }
  133. return h.transport.RoundTrip(req)
  134. }
  135. // NewGitHubService creates a new instance of GitHubService with authorized client.
  136. func NewGitHubService(cfg *GithubConfig) *GitHubService {
  137. s := &GitHubService{
  138. token: cfg.Token,
  139. cache: sync.Map{},
  140. cacheTTL: cfg.CacheTTL,
  141. owner: cfg.Owner,
  142. repo: cfg.Repo,
  143. }
  144. go s.loop()
  145. return s
  146. }
  147. func (s *GitHubService) loop() {
  148. s.refreshCache()
  149. t := time.NewTicker(s.cacheTTL * time.Minute)
  150. defer t.Stop()
  151. for range t.C {
  152. s.refreshCache()
  153. }
  154. }
  155. func (s *GitHubService) client(proxy bool) GitHubAPI {
  156. httpClient := &http.Client{
  157. Transport: &headerTransport{
  158. transport: &http.Transport{},
  159. headers: map[string]string{"Authorization": "Bearer " + s.token},
  160. },
  161. }
  162. if proxy {
  163. httpClient.Transport.(*headerTransport).transport = &http.Transport{
  164. Proxy: http.ProxyFromEnvironment,
  165. }
  166. }
  167. return githubv4.NewClient(httpClient)
  168. }
  169. func (s *GitHubService) request(ctx context.Context, q interface{}, variables map[string]interface{}) (err error) {
  170. err = s.client(true).Query(ctx, q, variables)
  171. if err != nil {
  172. log.Printf("request using proxy fails and falls back to non-proxy mode: %#v", err)
  173. err = s.client(false).Query(ctx, q, variables)
  174. }
  175. return
  176. }
  177. func (s *GitHubService) refreshCache() {
  178. issues, err := s.fetchIssues(context.Background(), nil)
  179. if err != nil {
  180. log.Printf("failed to fetch issues %v", err)
  181. return
  182. }
  183. s.cache.Store("issues", issues)
  184. discussions, err := s.fetchDiscussions(context.Background(), nil)
  185. if err != nil {
  186. log.Printf("failed to fetch discussions %v", err)
  187. return
  188. }
  189. s.cache.Store("discussions", discussions)
  190. repo, err := s.fetchRepo(context.Background())
  191. if err != nil {
  192. log.Printf("failed to fetch repo %v", err)
  193. return
  194. }
  195. s.cache.Store("repo", repo)
  196. }
  197. // GetIssues tries to get the issues from cache; if not available, fetches from GitHub API.
  198. func (s *GitHubService) GetIssues(ctx context.Context, filter string) (map[string][]*Issue, error) {
  199. cachedIssues, found := s.cache.Load("issues")
  200. if found {
  201. return s.filterIssues(cachedIssues.([]*Issue), filter)
  202. }
  203. issues, err := s.fetchIssues(ctx, nil)
  204. if err != nil {
  205. return nil, err
  206. }
  207. return s.filterIssues(issues, filter)
  208. }
  209. func (s *GitHubService) filterIssues(issues []*Issue, filter string) (map[string][]*Issue, error) {
  210. filteredIssues := issues
  211. if filter != "" {
  212. filteredIssues = make([]*Issue, 0)
  213. for _, issue := range issues {
  214. if strings.Contains(issue.Title, filter) || strings.Contains(issue.Body, filter) {
  215. filteredIssues = append(filteredIssues, issue)
  216. }
  217. }
  218. }
  219. out := make(map[string][]*Issue)
  220. for _, issue := range filteredIssues {
  221. if issue.InConsideration() {
  222. out[RoadmapLabelNameInConsideration] = append(out[RoadmapLabelNameInConsideration], issue)
  223. }
  224. if issue.InProgress() {
  225. out[RoadmapLabelNameInProgress] = append(out[RoadmapLabelNameInProgress], issue)
  226. }
  227. if issue.Released() {
  228. out[RoadmapLabelNameReleased] = append(out[RoadmapLabelNameReleased], issue)
  229. }
  230. }
  231. sort.Slice(out[RoadmapLabelNameInConsideration], func(i, j int) bool {
  232. return out[RoadmapLabelNameInConsideration][i].ThumbsUpCount > out[RoadmapLabelNameInConsideration][j].ThumbsUpCount
  233. })
  234. sort.Slice(out[RoadmapLabelNameInProgress], func(i, j int) bool {
  235. return out[RoadmapLabelNameInProgress][i].ThumbsUpCount > out[RoadmapLabelNameInProgress][j].ThumbsUpCount
  236. })
  237. sort.Slice(out[RoadmapLabelNameReleased], func(i, j int) bool {
  238. return out[RoadmapLabelNameReleased][i].UpdatedAt > out[RoadmapLabelNameReleased][j].UpdatedAt
  239. })
  240. return out, nil
  241. }
  242. // GetRepositoryIssues queries GitHub for issues of a repository.
  243. func (s *GitHubService) fetchIssues(ctx context.Context, afterCursor *githubv4.String) ([]*Issue, error) {
  244. var query struct {
  245. Repository struct {
  246. Issues struct {
  247. Nodes []struct {
  248. ID string
  249. Title string
  250. Body string
  251. Url string
  252. State string
  253. CreatedAt githubv4.DateTime
  254. UpdatedAt githubv4.DateTime
  255. Author User
  256. Labels struct {
  257. Nodes []struct {
  258. Color string
  259. Name string
  260. }
  261. } `graphql:"labels(first: 10)"`
  262. Comments struct {
  263. TotalCount int
  264. }
  265. Reactions struct {
  266. TotalCount int
  267. } `graphql:"reactions(content: THUMBS_UP)"`
  268. }
  269. PageInfo struct {
  270. EndCursor githubv4.String
  271. HasNextPage bool
  272. }
  273. } `graphql:"issues(first: 100, after: $afterCursor, orderBy: {field: UPDATED_AT, direction: DESC})"`
  274. } `graphql:"repository(owner: $owner, name: $name)"`
  275. }
  276. variables := map[string]interface{}{
  277. "owner": githubv4.String(s.owner),
  278. "name": githubv4.String(s.repo),
  279. "afterCursor": afterCursor,
  280. }
  281. err := s.request(ctx, &query, variables)
  282. if err != nil {
  283. return nil, err
  284. }
  285. issues := make([]*Issue, 0)
  286. for _, node := range query.Repository.Issues.Nodes {
  287. issue := &Issue{
  288. ID: node.ID,
  289. Title: node.Title,
  290. Body: node.Body,
  291. Url: node.Url,
  292. State: node.State,
  293. CreatedAt: node.CreatedAt.Unix(),
  294. UpdatedAt: node.UpdatedAt.Unix(),
  295. Author: node.Author,
  296. CommentCount: node.Comments.TotalCount,
  297. ThumbsUpCount: node.Reactions.TotalCount,
  298. }
  299. issue.Labels = make([]Label, len(node.Labels.Nodes))
  300. for i, label := range node.Labels.Nodes {
  301. issue.Labels[i] = Label{Name: label.Name, Color: label.Color}
  302. }
  303. issues = append(issues, issue)
  304. }
  305. if query.Repository.Issues.PageInfo.HasNextPage {
  306. moreIssues, err := s.fetchIssues(ctx, &query.Repository.Issues.PageInfo.EndCursor)
  307. if err != nil {
  308. return nil, err
  309. }
  310. issues = append(issues, moreIssues...)
  311. }
  312. return issues, nil
  313. }
  314. // GetDiscussions tries to get the discussions from cache; if not available, fetches from GitHub API.
  315. func (s *GitHubService) GetDiscussions(ctx context.Context, filter string) ([]*Discussion, error) {
  316. if cachedData, found := s.cache.Load("discussions"); found {
  317. return s.filterDiscussions(cachedData.([]*Discussion), filter)
  318. }
  319. discussions, err := s.fetchDiscussions(ctx, nil)
  320. if err != nil {
  321. return nil, err
  322. }
  323. return s.filterDiscussions(discussions, filter)
  324. }
  325. func (s *GitHubService) filterDiscussions(discussions []*Discussion, filter string) ([]*Discussion, error) {
  326. if filter != "" {
  327. filteredDiscussions := make([]*Discussion, 0)
  328. for _, discussion := range discussions {
  329. if strings.Contains(discussion.Title, filter) || strings.Contains(discussion.BodyText, filter) {
  330. filteredDiscussions = append(filteredDiscussions, discussion)
  331. }
  332. }
  333. return filteredDiscussions, nil
  334. }
  335. return discussions, nil
  336. }
  337. // fetchDiscussionsFromGitHub queries GitHub for discussions of a repository.
  338. func (s *GitHubService) fetchDiscussions(ctx context.Context, afterCursor *githubv4.String) ([]*Discussion, error) {
  339. var query struct {
  340. Repository struct {
  341. Discussions struct {
  342. Nodes []struct {
  343. ID string
  344. Url string
  345. UpvoteCount int
  346. Title string
  347. BodyText string
  348. Author User
  349. CreatedAt githubv4.DateTime
  350. IsAnswered bool
  351. Labels struct {
  352. Nodes []struct {
  353. Color string
  354. Name string
  355. }
  356. } `graphql:"labels(first: 10)"`
  357. Reactions struct {
  358. TotalCount int
  359. } `graphql:"reactions(content: THUMBS_UP)"`
  360. Comments struct {
  361. Nodes []struct {
  362. Author User
  363. }
  364. } `graphql:"comments(first: 10)"`
  365. Category Category
  366. }
  367. PageInfo struct {
  368. EndCursor githubv4.String
  369. HasNextPage bool
  370. }
  371. } `graphql:"discussions(first: 100, after: $afterCursor, orderBy: {field: CREATED_AT, direction: DESC})"`
  372. } `graphql:"repository(owner: $owner, name: $name)"`
  373. }
  374. variables := map[string]interface{}{
  375. "owner": githubv4.String(s.owner),
  376. "name": githubv4.String(s.repo),
  377. "afterCursor": afterCursor,
  378. }
  379. err := s.request(ctx, &query, variables)
  380. if err != nil {
  381. return nil, err
  382. }
  383. discussions := make([]*Discussion, 0)
  384. for _, node := range query.Repository.Discussions.Nodes {
  385. discussion := &Discussion{
  386. ID: node.ID,
  387. Url: node.Url,
  388. Title: node.Title,
  389. BodyText: node.BodyText,
  390. }
  391. discussion.Labels = make([]Label, len(node.Labels.Nodes))
  392. for i, label := range node.Labels.Nodes {
  393. discussion.Labels[i] = Label{Name: label.Name, Color: label.Color}
  394. }
  395. exist := make(map[string]struct{})
  396. discussion.CommentUsers = make([]User, 0, len(node.Comments.Nodes))
  397. discussion.CommentUsers = append(discussion.CommentUsers, node.Author)
  398. exist[node.Author.Login] = struct{}{}
  399. for _, comment := range node.Comments.Nodes {
  400. if _, ok := exist[comment.Author.Login]; ok {
  401. continue
  402. }
  403. exist[comment.Author.Login] = struct{}{}
  404. discussion.CommentUsers = append(discussion.CommentUsers, comment.Author)
  405. }
  406. discussion.ThumbsUpCount = node.Reactions.TotalCount
  407. discussion.CommentCount = len(node.Comments.Nodes)
  408. discussion.UpvoteCount = node.UpvoteCount
  409. discussion.Author = node.Author
  410. discussion.CreatedAt = node.CreatedAt.Unix()
  411. discussion.IsAnswered = node.IsAnswered
  412. discussion.Category = node.Category
  413. discussions = append(discussions, discussion)
  414. }
  415. if query.Repository.Discussions.PageInfo.HasNextPage {
  416. moreDiscussions, err := s.fetchDiscussions(ctx, &query.Repository.Discussions.PageInfo.EndCursor)
  417. if err != nil {
  418. return nil, err
  419. }
  420. discussions = append(discussions, moreDiscussions...)
  421. }
  422. return discussions, nil
  423. }
  424. func (s *GitHubService) GetRepo(ctx context.Context) (*Repo, error) {
  425. if cachedData, found := s.cache.Load("repo"); found {
  426. return cachedData.(*Repo), nil
  427. }
  428. repo, err := s.fetchRepo(ctx)
  429. if err != nil {
  430. return nil, err
  431. }
  432. s.cache.Store("repo", repo)
  433. return repo, nil
  434. }
  435. func (s *GitHubService) fetchRepo(ctx context.Context) (*Repo, error) {
  436. var query struct {
  437. Repository struct {
  438. ID string
  439. StargazerCount int
  440. } `graphql:"repository(owner: $owner, name: $name)"`
  441. }
  442. variables := map[string]interface{}{
  443. "owner": githubv4.String(s.owner),
  444. "name": githubv4.String(s.repo),
  445. }
  446. err := s.request(ctx, &query, variables)
  447. if err != nil {
  448. return nil, err
  449. }
  450. return &Repo{ID: query.Repository.ID, StarCount: query.Repository.StargazerCount}, nil
  451. }