githubwatch.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. // ============================================================================
  2. // githubwatch.go — GitHub 仓库监控
  3. //
  4. // 用 GitHub REST API 监听配置仓库的最新提交与 release;
  5. // 本地版本低于最新版时提示更新,并列出 Latest 之后的版本(仅展示)。
  6. // ============================================================================
  7. package main
  8. import (
  9. "encoding/json"
  10. "fmt"
  11. "net/http"
  12. "strings"
  13. "sync"
  14. "time"
  15. )
  16. // CommitItem 仓库的一条提交记录
  17. type CommitItem struct {
  18. Title string `json:"title"` // 提交标题(首行)
  19. URL string `json:"url"` // 提交详情页地址
  20. Updated string `json:"updated"` // 提交时间(MM-DD HH:MM)
  21. }
  22. // ReleaseItem 一个 GitHub release 的简略信息,用于列出 Latest 之后的版本(Pre-release 等)
  23. type ReleaseItem struct {
  24. TagName string `json:"tagName"` // 版本标签,如 v1.3.0-beta.1
  25. Name string `json:"name"` // release 显示名称(标题),可能比 tag 更完整,如 "Rolling: v2.1.0-..."
  26. URL string `json:"url"` // release 详情页地址
  27. IsPrerelease bool `json:"isPrerelease"` // 是否预发布版本
  28. IsDraft bool `json:"isDraft"` // 是否草稿版本
  29. PublishedAt string `json:"publishedAt"` // 发布时间(MM-DD HH:MM)
  30. }
  31. // RepoFeed 一个 GitHub 仓库的最新提交摘要
  32. type RepoFeed struct {
  33. URL string `json:"url"` // 仓库地址(设置中配置)
  34. Desc string `json:"desc"` // 项目说明
  35. Repo string `json:"repo"` // owner/repo 短名
  36. Version string `json:"version"` // 本地已安装版本(设置中配置)
  37. Latest string `json:"latest"` // 最新正式 release 版本号,无正式 release 时为空
  38. HasUpdate bool `json:"hasUpdate"` // 是否存在可用更新(本地版本低于最新正式 release)
  39. ReleaseURL string `json:"releaseURL"` // 最新正式 release 的下载 / 详情页地址
  40. Newer []ReleaseItem `json:"newer"` // Latest 之后的版本(Pre-release 等,仅列出不参与更新比较)
  41. Commits []CommitItem `json:"commits"` // 最近提交(最多 5 条)
  42. Error string `json:"error"` // 抓取失败原因,为空表示正常
  43. }
  44. // githubCommit GitHub REST API 返回的单条提交(只取需要的字段)
  45. type githubCommit struct {
  46. Commit struct {
  47. Message string `json:"message"` // 提交说明
  48. Author struct {
  49. Date string `json:"date"` // 提交时间(RFC3339)
  50. } `json:"author"`
  51. } `json:"commit"`
  52. HTMLURL string `json:"html_url"` // 提交详情页地址
  53. }
  54. // githubRelease GitHub REST API 返回的一个 release(只取需要的字段)
  55. type githubRelease struct {
  56. TagName string `json:"tag_name"` // 版本标签,如 v1.2.3
  57. Name string `json:"name"` // release 显示名称(标题),可能比 tag 更完整
  58. HTMLURL string `json:"html_url"` // release 详情页(含下载入口)
  59. Draft bool `json:"draft"` // 草稿不算正式版本
  60. Prerelease bool `json:"prerelease"` // 预发布版本
  61. PublishedAt string `json:"published_at"` // 发布时间(RFC3339)
  62. }
  63. // fetchGithubFeeds 并行抓取所有配置仓库的提交动态
  64. func fetchGithubFeeds(repos []RepoItem) []RepoFeed {
  65. feeds := make([]RepoFeed, len(repos))
  66. var wg sync.WaitGroup
  67. for i, repo := range repos {
  68. wg.Add(1)
  69. go func(idx int, item RepoItem) {
  70. defer wg.Done()
  71. feeds[idx] = fetchRepoFeed(item) // 单个仓库失败不影响其他
  72. }(i, repo)
  73. }
  74. wg.Wait()
  75. return feeds
  76. }
  77. // fetchRepoFeed 抓取单个仓库的提交动态与最新 release 版本
  78. func fetchRepoFeed(item RepoItem) RepoFeed {
  79. feed := RepoFeed{URL: item.URL, Desc: item.Desc, Version: item.Version, Commits: []CommitItem{}}
  80. repo := parseGithubRepo(item.URL)
  81. if repo == "" {
  82. feed.Error = "无法从地址解析 owner/repo"
  83. return feed
  84. }
  85. feed.Repo = repo
  86. // 用 GitHub REST API 获取提交(api.github.com 通常比 github.com/commits.atom 更稳定可达)
  87. apiURL := fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=5", repo)
  88. var commits []githubCommit
  89. if err := fetchGithubJSON(apiURL, &commits); err != nil {
  90. feed.Error = err.Error()
  91. return feed
  92. }
  93. if len(commits) == 0 {
  94. feed.Error = "暂无提交记录"
  95. return feed
  96. }
  97. for i, c := range commits {
  98. if i >= 5 {
  99. break // 只保留最近 5 条
  100. }
  101. feed.Commits = append(feed.Commits, CommitItem{
  102. Title: firstLine(c.Commit.Message),
  103. URL: c.HTMLURL,
  104. Updated: formatAtomTime(c.Commit.Author.Date),
  105. })
  106. }
  107. // 最新 release:拉取失败(如仓库无 release)不阻塞提交显示,仅记录
  108. feed.applyLatestRelease(repo)
  109. return feed
  110. }
  111. // applyLatestRelease 拉取仓库 release 列表,识别 Latest 正式版与之后的版本(Pre-release 等)。
  112. // 更新比较(HasUpdate)只基于 Latest 正式版;Latest 之后的版本仅列出展示,不参与比较。
  113. func (feed *RepoFeed) applyLatestRelease(repo string) {
  114. // 拉取最近的 release 列表(含 pre-release / draft),按创建时间倒序返回
  115. releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=30", repo)
  116. var rels []githubRelease
  117. if err := fetchGithubJSON(releaseURL, &rels); err != nil {
  118. return // 无 release / 404 / 限流:不填 Latest,视为无版本信息
  119. }
  120. // 第一个非草稿、非预发布的版本即为 Latest(GitHub 按时间倒序,先到先得)
  121. latestIdx := -1
  122. for i, r := range rels {
  123. if r.TagName != "" && !r.Draft && !r.Prerelease {
  124. latestIdx = i
  125. break
  126. }
  127. }
  128. if latestIdx >= 0 {
  129. rel := rels[latestIdx]
  130. feed.Latest = rel.TagName
  131. feed.ReleaseURL = rel.HTMLURL
  132. feed.HasUpdate = versionLess(feed.Version, feed.Latest) // 只比较 Latest 正式版
  133. }
  134. // Latest 之后的版本(在列表中排在 Latest 之前的,即更新/更新的预发布、草稿):仅列出
  135. for i, r := range rels {
  136. if i >= latestIdx {
  137. break // 已到 Latest 或更旧的版本,停止
  138. }
  139. if r.TagName == "" {
  140. continue
  141. }
  142. feed.Newer = append(feed.Newer, ReleaseItem{
  143. TagName: r.TagName,
  144. Name: r.Name,
  145. URL: r.HTMLURL,
  146. IsPrerelease: r.Prerelease,
  147. IsDraft: r.Draft,
  148. PublishedAt: formatAtomTime(r.PublishedAt),
  149. })
  150. }
  151. }
  152. // versionLess 判断本地版本 a 是否低于远端版本 b(忽略 v 前缀,按数值段比较)
  153. func versionLess(a, b string) bool {
  154. na := normalizeVersion(a)
  155. nb := normalizeVersion(b)
  156. if na == "" || nb == "" {
  157. return false // 任一侧无有效版本号时不判定更新
  158. }
  159. return na < nb
  160. }
  161. // normalizeVersion 去掉 v 前缀并补零对齐成可比对的数值(如 1.2.3 -> 1.2.3,1.2 -> 1.2.0)
  162. func normalizeVersion(v string) string {
  163. v = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(v), "v"))
  164. v = strings.TrimPrefix(v, "V")
  165. if v == "" {
  166. return ""
  167. }
  168. // 仅保留数字与点,非版本字符串视为无效
  169. clean := ""
  170. for _, r := range v {
  171. if (r >= '0' && r <= '9') || r == '.' {
  172. clean += string(r)
  173. }
  174. }
  175. if clean == "" {
  176. return ""
  177. }
  178. parts := strings.Split(clean, ".")
  179. for len(parts) < 3 {
  180. parts = append(parts, "0") // 补足三位,保证可比性
  181. }
  182. for i, p := range parts {
  183. for len(p) < 3 {
  184. p = "0" + p // 每段补零对齐,如 1.10 与 1.9
  185. }
  186. parts[i] = p
  187. }
  188. return strings.Join(parts, ".")
  189. }
  190. // fetchGithubJSON 抓取 GitHub REST API 并解析 JSON(带鉴权友好的请求头)
  191. func fetchGithubJSON(url string, target any) error {
  192. resp, err := githubClient(url)
  193. if err != nil {
  194. return err
  195. }
  196. defer resp.Body.Close()
  197. if resp.StatusCode < 200 || resp.StatusCode > 299 {
  198. return fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) // 403 限流 / 404 仓库不存在等
  199. }
  200. return json.NewDecoder(resp.Body).Decode(target)
  201. }
  202. // githubClient 发起带 GitHub 友好请求头的 GET(api.github.com 需要规范的 UA 与 Accept)
  203. func githubClient(url string) (*http.Response, error) {
  204. req, err := http.NewRequest(http.MethodGet, url, nil)
  205. if err != nil {
  206. return nil, err
  207. }
  208. req.Header.Set("User-Agent", "TengTuToolbox") // GitHub API 要求非空 UA
  209. req.Header.Set("Accept", "application/vnd.github+json")
  210. req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
  211. client := &http.Client{Timeout: 15 * time.Second} // API 响应略大,放宽超时
  212. return client.Do(req)
  213. }
  214. // parseGithubRepo 从仓库地址解析 owner/repo,支持 https://github.com/owner/repo[/...] 与 owner/repo
  215. func parseGithubRepo(raw string) string {
  216. raw = strings.TrimSpace(strings.TrimSuffix(raw, ".git"))
  217. raw = strings.TrimPrefix(raw, "https://")
  218. raw = strings.TrimPrefix(raw, "http://")
  219. raw = strings.TrimPrefix(raw, "github.com/")
  220. parts := strings.Split(strings.Trim(raw, "/"), "/")
  221. if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
  222. return ""
  223. }
  224. return parts[0] + "/" + parts[1]
  225. }
  226. // firstLine 取提交标题的第一行并去除首尾空白(atom 标题常以换行开头,先整体 trim 再切行)
  227. func firstLine(text string) string {
  228. line, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
  229. return strings.TrimSpace(line)
  230. }
  231. // formatAtomTime 把 RFC3339 时间格式化为 01-02 15:04
  232. func formatAtomTime(raw string) string {
  233. t, err := time.Parse(time.RFC3339, raw)
  234. if err != nil {
  235. return raw
  236. }
  237. return t.Local().Format("01-02 15:04")
  238. }