// ============================================================================ // 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") }