| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261 |
- // ============================================================================
- // githubwatch.go — GitHub 仓库监控
- //
- // 用 GitHub REST API 监听配置仓库的最新提交与 release;
- // 本地版本低于最新版时提示更新,并列出 Latest 之后的版本(仅展示)。
- // ============================================================================
- package main
- import (
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
- "sync"
- "time"
- )
- // CommitItem 仓库的一条提交记录
- type CommitItem struct {
- Title string `json:"title"` // 提交标题(首行)
- URL string `json:"url"` // 提交详情页地址
- Updated string `json:"updated"` // 提交时间(MM-DD HH:MM)
- }
- // ReleaseItem 一个 GitHub release 的简略信息,用于列出 Latest 之后的版本(Pre-release 等)
- type ReleaseItem struct {
- TagName string `json:"tagName"` // 版本标签,如 v1.3.0-beta.1
- Name string `json:"name"` // release 显示名称(标题),可能比 tag 更完整,如 "Rolling: v2.1.0-..."
- URL string `json:"url"` // release 详情页地址
- IsPrerelease bool `json:"isPrerelease"` // 是否预发布版本
- IsDraft bool `json:"isDraft"` // 是否草稿版本
- PublishedAt string `json:"publishedAt"` // 发布时间(MM-DD HH:MM)
- }
- // RepoFeed 一个 GitHub 仓库的最新提交摘要
- type RepoFeed struct {
- URL string `json:"url"` // 仓库地址(设置中配置)
- Desc string `json:"desc"` // 项目说明
- Repo string `json:"repo"` // owner/repo 短名
- Version string `json:"version"` // 本地已安装版本(设置中配置)
- Latest string `json:"latest"` // 最新正式 release 版本号,无正式 release 时为空
- HasUpdate bool `json:"hasUpdate"` // 是否存在可用更新(本地版本低于最新正式 release)
- ReleaseURL string `json:"releaseURL"` // 最新正式 release 的下载 / 详情页地址
- Newer []ReleaseItem `json:"newer"` // Latest 之后的版本(Pre-release 等,仅列出不参与更新比较)
- Commits []CommitItem `json:"commits"` // 最近提交(最多 5 条)
- Error string `json:"error"` // 抓取失败原因,为空表示正常
- }
- // githubCommit GitHub REST API 返回的单条提交(只取需要的字段)
- type githubCommit struct {
- Commit struct {
- Message string `json:"message"` // 提交说明
- Author struct {
- Date string `json:"date"` // 提交时间(RFC3339)
- } `json:"author"`
- } `json:"commit"`
- HTMLURL string `json:"html_url"` // 提交详情页地址
- }
- // githubRelease GitHub REST API 返回的一个 release(只取需要的字段)
- type githubRelease struct {
- TagName string `json:"tag_name"` // 版本标签,如 v1.2.3
- Name string `json:"name"` // release 显示名称(标题),可能比 tag 更完整
- HTMLURL string `json:"html_url"` // release 详情页(含下载入口)
- Draft bool `json:"draft"` // 草稿不算正式版本
- Prerelease bool `json:"prerelease"` // 预发布版本
- PublishedAt string `json:"published_at"` // 发布时间(RFC3339)
- }
- // fetchGithubFeeds 并行抓取所有配置仓库的提交动态
- func fetchGithubFeeds(repos []RepoItem) []RepoFeed {
- feeds := make([]RepoFeed, len(repos))
- var wg sync.WaitGroup
- for i, repo := range repos {
- wg.Add(1)
- go func(idx int, item RepoItem) {
- defer wg.Done()
- feeds[idx] = fetchRepoFeed(item) // 单个仓库失败不影响其他
- }(i, repo)
- }
- wg.Wait()
- return feeds
- }
- // fetchRepoFeed 抓取单个仓库的提交动态与最新 release 版本
- func fetchRepoFeed(item RepoItem) RepoFeed {
- feed := RepoFeed{URL: item.URL, Desc: item.Desc, Version: item.Version, Commits: []CommitItem{}}
- repo := parseGithubRepo(item.URL)
- if repo == "" {
- feed.Error = "无法从地址解析 owner/repo"
- return feed
- }
- feed.Repo = repo
- // 用 GitHub REST API 获取提交(api.github.com 通常比 github.com/commits.atom 更稳定可达)
- apiURL := fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=5", repo)
- var commits []githubCommit
- if err := fetchGithubJSON(apiURL, &commits); err != nil {
- feed.Error = err.Error()
- return feed
- }
- if len(commits) == 0 {
- feed.Error = "暂无提交记录"
- return feed
- }
- for i, c := range commits {
- if i >= 5 {
- break // 只保留最近 5 条
- }
- feed.Commits = append(feed.Commits, CommitItem{
- Title: firstLine(c.Commit.Message),
- URL: c.HTMLURL,
- Updated: formatAtomTime(c.Commit.Author.Date),
- })
- }
- // 最新 release:拉取失败(如仓库无 release)不阻塞提交显示,仅记录
- feed.applyLatestRelease(repo)
- return feed
- }
- // applyLatestRelease 拉取仓库 release 列表,识别 Latest 正式版与之后的版本(Pre-release 等)。
- // 更新比较(HasUpdate)只基于 Latest 正式版;Latest 之后的版本仅列出展示,不参与比较。
- func (feed *RepoFeed) applyLatestRelease(repo string) {
- // 拉取最近的 release 列表(含 pre-release / draft),按创建时间倒序返回
- releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=30", repo)
- var rels []githubRelease
- if err := fetchGithubJSON(releaseURL, &rels); err != nil {
- return // 无 release / 404 / 限流:不填 Latest,视为无版本信息
- }
- // 第一个非草稿、非预发布的版本即为 Latest(GitHub 按时间倒序,先到先得)
- latestIdx := -1
- for i, r := range rels {
- if r.TagName != "" && !r.Draft && !r.Prerelease {
- latestIdx = i
- break
- }
- }
- if latestIdx >= 0 {
- rel := rels[latestIdx]
- feed.Latest = rel.TagName
- feed.ReleaseURL = rel.HTMLURL
- feed.HasUpdate = versionLess(feed.Version, feed.Latest) // 只比较 Latest 正式版
- }
- // Latest 之后的版本(在列表中排在 Latest 之前的,即更新/更新的预发布、草稿):仅列出
- for i, r := range rels {
- if i >= latestIdx {
- break // 已到 Latest 或更旧的版本,停止
- }
- if r.TagName == "" {
- continue
- }
- feed.Newer = append(feed.Newer, ReleaseItem{
- TagName: r.TagName,
- Name: r.Name,
- URL: r.HTMLURL,
- IsPrerelease: r.Prerelease,
- IsDraft: r.Draft,
- PublishedAt: formatAtomTime(r.PublishedAt),
- })
- }
- }
- // versionLess 判断本地版本 a 是否低于远端版本 b(忽略 v 前缀,按数值段比较)
- func versionLess(a, b string) bool {
- na := normalizeVersion(a)
- nb := normalizeVersion(b)
- if na == "" || nb == "" {
- return false // 任一侧无有效版本号时不判定更新
- }
- return na < nb
- }
- // normalizeVersion 去掉 v 前缀并补零对齐成可比对的数值(如 1.2.3 -> 1.2.3,1.2 -> 1.2.0)
- func normalizeVersion(v string) string {
- v = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(v), "v"))
- v = strings.TrimPrefix(v, "V")
- if v == "" {
- return ""
- }
- // 仅保留数字与点,非版本字符串视为无效
- clean := ""
- for _, r := range v {
- if (r >= '0' && r <= '9') || r == '.' {
- clean += string(r)
- }
- }
- if clean == "" {
- return ""
- }
- parts := strings.Split(clean, ".")
- for len(parts) < 3 {
- parts = append(parts, "0") // 补足三位,保证可比性
- }
- for i, p := range parts {
- for len(p) < 3 {
- p = "0" + p // 每段补零对齐,如 1.10 与 1.9
- }
- parts[i] = p
- }
- return strings.Join(parts, ".")
- }
- // fetchGithubJSON 抓取 GitHub REST API 并解析 JSON(带鉴权友好的请求头)
- func fetchGithubJSON(url string, target any) error {
- resp, err := githubClient(url)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode > 299 {
- return fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) // 403 限流 / 404 仓库不存在等
- }
- return json.NewDecoder(resp.Body).Decode(target)
- }
- // githubClient 发起带 GitHub 友好请求头的 GET(api.github.com 需要规范的 UA 与 Accept)
- func githubClient(url string) (*http.Response, error) {
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- return nil, err
- }
- req.Header.Set("User-Agent", "TengTuToolbox") // GitHub API 要求非空 UA
- req.Header.Set("Accept", "application/vnd.github+json")
- req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
- client := &http.Client{Timeout: 15 * time.Second} // API 响应略大,放宽超时
- return client.Do(req)
- }
- // parseGithubRepo 从仓库地址解析 owner/repo,支持 https://github.com/owner/repo[/...] 与 owner/repo
- func parseGithubRepo(raw string) string {
- raw = strings.TrimSpace(strings.TrimSuffix(raw, ".git"))
- raw = strings.TrimPrefix(raw, "https://")
- raw = strings.TrimPrefix(raw, "http://")
- raw = strings.TrimPrefix(raw, "github.com/")
- parts := strings.Split(strings.Trim(raw, "/"), "/")
- if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
- return ""
- }
- return parts[0] + "/" + parts[1]
- }
- // firstLine 取提交标题的第一行并去除首尾空白(atom 标题常以换行开头,先整体 trim 再切行)
- func firstLine(text string) string {
- line, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
- return strings.TrimSpace(line)
- }
- // formatAtomTime 把 RFC3339 时间格式化为 01-02 15:04
- func formatAtomTime(raw string) string {
- t, err := time.Parse(time.RFC3339, raw)
- if err != nil {
- return raw
- }
- return t.Local().Format("01-02 15:04")
- }
|