githubwatch.go 10.0 KB

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