git_forge.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. package feed
  2. import (
  3. "errors"
  4. "fmt"
  5. "log/slog"
  6. "net/http"
  7. "net/url"
  8. "sync"
  9. "time"
  10. )
  11. type githubReleaseLatestResponseJson struct {
  12. TagName string `json:"tag_name"`
  13. PublishedAt string `json:"published_at"`
  14. HtmlUrl string `json:"html_url"`
  15. Reactions struct {
  16. Downvotes int `json:"-1"`
  17. } `json:"reactions"`
  18. }
  19. type gitlabReleaseResponseJson struct {
  20. TagName string `json:"tag_name"`
  21. PublishedAt string `json:"created_at"`
  22. Links struct {
  23. Self string `json:"self"`
  24. } `json:"_links"`
  25. Draft bool `json:"draft"`
  26. PreRelease bool `json:"prerelease"`
  27. Reactions struct {
  28. Downvotes int `json:"-1"`
  29. } `json:"reactions"`
  30. }
  31. func parseGithubTime(t string) time.Time {
  32. parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
  33. if err != nil {
  34. return time.Now()
  35. }
  36. return parsedTime
  37. }
  38. func FetchLatestReleasesFromGitForge(repositories []string, token string, source string) (AppReleases, error) {
  39. switch source {
  40. case "github":
  41. return fetchLatestReleasesFromGithub(repositories, token)
  42. case "gitlab":
  43. return fetchLatestReleasesFromGitlab(repositories, token)
  44. default:
  45. return nil, errors.New(fmt.Sprintf("Release source %s is invalid", source))
  46. }
  47. }
  48. func fetchLatestReleasesFromGitlab(repositories []string, token string) (AppReleases, error) {
  49. appReleases := make(AppReleases, 0, len(repositories))
  50. if len(repositories) == 0 {
  51. return appReleases, nil
  52. }
  53. requests := make([]*http.Request, len(repositories))
  54. for i, repository := range repositories {
  55. request, _ := http.NewRequest("GET", fmt.Sprintf("https://gitlab.com/api/v4/projects/%s/releases/", url.QueryEscape(repository)), nil)
  56. if token != "" {
  57. request.Header.Add("PRIVATE-TOKEN", token)
  58. }
  59. requests[i] = request
  60. }
  61. task := decodeJsonFromRequestTask[[]gitlabReleaseResponseJson](defaultClient)
  62. job := newJob(task, requests).withWorkers(15)
  63. responses, errs, err := workerPoolDo(job)
  64. if err != nil {
  65. return nil, err
  66. }
  67. var failed int
  68. for i := range responses {
  69. if errs[i] != nil {
  70. failed++
  71. slog.Error("Failed to fetch or parse gitlab release", "error", errs[i], "url", requests[i].URL)
  72. continue
  73. }
  74. releases := responses[i]
  75. if len(releases) < 1 {
  76. failed++
  77. slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
  78. continue
  79. }
  80. var liveRelease *gitlabReleaseResponseJson
  81. for i := range releases {
  82. release := &releases[i]
  83. if !release.Draft && !release.PreRelease {
  84. liveRelease = release
  85. break
  86. }
  87. }
  88. if liveRelease == nil {
  89. slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
  90. continue
  91. }
  92. version := liveRelease.TagName
  93. if version[0] != 'v' {
  94. version = "v" + version
  95. }
  96. appReleases = append(appReleases, AppRelease{
  97. Name: repositories[i],
  98. Version: version,
  99. NotesUrl: liveRelease.Links.Self,
  100. TimeReleased: parseGithubTime(liveRelease.PublishedAt),
  101. Downvotes: liveRelease.Reactions.Downvotes,
  102. })
  103. }
  104. if len(appReleases) == 0 {
  105. return nil, ErrNoContent
  106. }
  107. appReleases.SortByNewest()
  108. if failed > 0 {
  109. return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
  110. }
  111. return appReleases, nil
  112. }
  113. func fetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
  114. appReleases := make(AppReleases, 0, len(repositories))
  115. if len(repositories) == 0 {
  116. return appReleases, nil
  117. }
  118. requests := make([]*http.Request, len(repositories))
  119. for i, repository := range repositories {
  120. request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository), nil)
  121. if token != "" {
  122. request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
  123. }
  124. requests[i] = request
  125. }
  126. task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient)
  127. job := newJob(task, requests).withWorkers(15)
  128. responses, errs, err := workerPoolDo(job)
  129. if err != nil {
  130. return nil, err
  131. }
  132. var failed int
  133. for i := range responses {
  134. if errs[i] != nil {
  135. failed++
  136. slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL)
  137. continue
  138. }
  139. liveRelease := &responses[i]
  140. if liveRelease == nil {
  141. slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
  142. continue
  143. }
  144. version := liveRelease.TagName
  145. if version[0] != 'v' {
  146. version = "v" + version
  147. }
  148. appReleases = append(appReleases, AppRelease{
  149. Name: repositories[i],
  150. Version: version,
  151. NotesUrl: liveRelease.HtmlUrl,
  152. TimeReleased: parseGithubTime(liveRelease.PublishedAt),
  153. Downvotes: liveRelease.Reactions.Downvotes,
  154. })
  155. }
  156. if len(appReleases) == 0 {
  157. return nil, ErrNoContent
  158. }
  159. appReleases.SortByNewest()
  160. if failed > 0 {
  161. return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
  162. }
  163. return appReleases, nil
  164. }
  165. type GithubTicket struct {
  166. Number int
  167. CreatedAt time.Time
  168. Title string
  169. }
  170. type RepositoryDetails struct {
  171. Name string
  172. Stars int
  173. Forks int
  174. OpenPullRequests int
  175. PullRequests []GithubTicket
  176. OpenIssues int
  177. Issues []GithubTicket
  178. }
  179. type githubRepositoryDetailsResponseJson struct {
  180. Name string `json:"full_name"`
  181. Stars int `json:"stargazers_count"`
  182. Forks int `json:"forks_count"`
  183. }
  184. type githubTicketResponseJson struct {
  185. Count int `json:"total_count"`
  186. Tickets []struct {
  187. Number int `json:"number"`
  188. CreatedAt string `json:"created_at"`
  189. Title string `json:"title"`
  190. } `json:"items"`
  191. }
  192. func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
  193. repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
  194. if err != nil {
  195. return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
  196. }
  197. 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)
  198. 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)
  199. if token != "" {
  200. token = fmt.Sprintf("Bearer %s", token)
  201. repositoryRequest.Header.Add("Authorization", token)
  202. PRsRequest.Header.Add("Authorization", token)
  203. issuesRequest.Header.Add("Authorization", token)
  204. }
  205. var detailsResponse githubRepositoryDetailsResponseJson
  206. var detailsErr error
  207. var PRsResponse githubTicketResponseJson
  208. var PRsErr error
  209. var issuesResponse githubTicketResponseJson
  210. var issuesErr error
  211. var wg sync.WaitGroup
  212. wg.Add(1)
  213. go (func() {
  214. defer wg.Done()
  215. detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
  216. })()
  217. if maxPRs > 0 {
  218. wg.Add(1)
  219. go (func() {
  220. defer wg.Done()
  221. PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
  222. })()
  223. }
  224. if maxIssues > 0 {
  225. wg.Add(1)
  226. go (func() {
  227. defer wg.Done()
  228. issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
  229. })()
  230. }
  231. wg.Wait()
  232. if detailsErr != nil {
  233. return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
  234. }
  235. details := RepositoryDetails{
  236. Name: detailsResponse.Name,
  237. Stars: detailsResponse.Stars,
  238. Forks: detailsResponse.Forks,
  239. PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
  240. Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
  241. }
  242. err = nil
  243. if maxPRs > 0 {
  244. if PRsErr != nil {
  245. err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
  246. } else {
  247. details.OpenPullRequests = PRsResponse.Count
  248. for i := range PRsResponse.Tickets {
  249. details.PullRequests = append(details.PullRequests, GithubTicket{
  250. Number: PRsResponse.Tickets[i].Number,
  251. CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
  252. Title: PRsResponse.Tickets[i].Title,
  253. })
  254. }
  255. }
  256. }
  257. if maxIssues > 0 {
  258. if issuesErr != nil {
  259. // TODO: fix, overwriting the previous error
  260. err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
  261. } else {
  262. details.OpenIssues = issuesResponse.Count
  263. for i := range issuesResponse.Tickets {
  264. details.Issues = append(details.Issues, GithubTicket{
  265. Number: issuesResponse.Tickets[i].Number,
  266. CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
  267. Title: issuesResponse.Tickets[i].Title,
  268. })
  269. }
  270. }
  271. }
  272. return details, err
  273. }