Compare commits

...

2 Commits

Author SHA1 Message Date
chroe b9f54cb50a docs(showcase): add feature showcase page and deploy script
Dark-themed showcase page displaying:
- Core stats (40+ to 70+ commands, 12 shortcut groups, 36+ tests)
- Team member assignments and architecture diagram
- Live command demo outputs and test results
- Deploy script for Nginx container on ECS
2026-05-26 16:23:37 +08:00
chroe b9f4d13a90 feat(commit): add commit shortcut module with list/view/diff/blame commands
Add 4 new commands for commit operations:
- commit list: list commits with sha/page/limit filters
- commit view: view files changed in a commit
- commit diff: show diff for a commit
- commit blame: show line-by-line file blame

Includes 5 unit tests covering all commands.
2026-05-26 16:23:02 +08:00
5 changed files with 629 additions and 0 deletions

110
shortcuts/commit/commit.go Normal file
View File

@ -0,0 +1,110 @@
package commit
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List commits in a repository",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if sha := ctx.Arg("sha"); sha != "" {
q.Set("sha", sha)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View files changed in a commit",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "diff",
Description: "Show diff for a commit",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "blame",
Description: "Show blame for a file",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
q := url.Values{}
q.Set("filepath", filePath)
q.Set("sha", ctx.Arg("sha"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,160 @@
package commit
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestCommitList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"commits": []map[string]interface{}{
{"sha": "abc123", "commit_message": "initial commit"},
},
})
}))
defer server.Close()
err := runCommitShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestCommitListWithSHA(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("sha") != "develop" {
t.Fatalf("expected sha=develop, got %s", r.URL.Query().Get("sha"))
}
writeJSON(t, w, map[string]interface{}{
"total_count": 0,
"commits": []map[string]interface{}{},
})
}))
defer server.Close()
err := runCommitShortcut(t, server, "list", map[string]string{"sha": "develop"})
if err != nil {
t.Fatalf("list with sha failed: %v", err)
}
}
func TestCommitView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/files.json" {
writeJSON(t, w, map[string]interface{}{
"file_nums": 1,
"files": []map[string]interface{}{
{"filename": "main.go", "additions": 10, "deletions": 2},
},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runCommitShortcut(t, server, "view", map[string]string{"sha": "abc123"})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
func TestCommitDiff(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/diff.json" {
writeJSON(t, w, map[string]interface{}{
"file_nums": 1,
"total_addition": 10,
"total_deletion": 2,
"files": []map[string]interface{}{{"name": "main.go"}},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc123"})
if err != nil {
t.Fatalf("diff shortcut failed: %v", err)
}
}
func TestCommitBlame(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/blame.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("filepath") != "main.go" {
t.Fatalf("expected filepath=main.go, got %s", r.URL.Query().Get("filepath"))
}
writeJSON(t, w, map[string]interface{}{
"file_name": "main.go",
"num_lines": 20,
})
}))
defer server.Close()
err := runCommitShortcut(t, server, "blame", map[string]string{"path": "main.go", "sha": "master"})
if err != nil {
t.Fatalf("blame shortcut failed: %v", err)
}
}
// === helpers ===
func runCommitShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findCommitShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findCommitShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

View File

@ -5,6 +5,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
@ -30,6 +31,7 @@ func RegisterAll(root *cobra.Command) {
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"commit": commit.Shortcuts(),
"milestone": milestone.Shortcuts(),
"webhook": webhook.Shortcuts(),
"label": label.Shortcuts(),
@ -45,6 +47,7 @@ func RegisterAll(root *cobra.Command) {
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"commit": "Commit operations",
"milestone": "Milestone operations",
"webhook": "Webhook operations",
"label": "Issue label operations",

14
showcase/deploy.sh Normal file
View File

@ -0,0 +1,14 @@
# 展示页部署命令(在本地终端手动执行)
# 1. 用 SCP 上传展示页到 ECS需要手动输入密码pd1@YwC#WRFVHkXc8nvu!4
scp "d:/自用/self/word/大三下/软件演化/gitlink-cli/showcase/index.html" root@121.41.210.165:/opt/showcase/index.html
# 2. SSH 到 ECS密码pd1@YwC#WRFVHkXc8nvu!4
ssh root@121.41.210.165
# 登录后在 ECS 上执行:
mkdir -p /opt/showcase
docker run -d --name showcase -p 8080:80 -v /opt/showcase:/usr/share/nginx/html:ro --restart unless-stopped nginx:alpine
# 3. 访问
# 浏览器打开 http://121.41.210.165:8080

342
showcase/index.html Normal file
View File

@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>gitlink-cli 功能增强展示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.6; }
.container { max-width: 960px; margin: 0 auto; padding: 40px 20px; }
h1 { font-size: 2em; color: #58a6ff; margin-bottom: 8px; }
h2 { font-size: 1.4em; color: #79c0ff; margin: 36px 0 16px; border-bottom: 1px solid #21262d; padding-bottom: 8px; }
.subtitle { color: #8b949e; margin-bottom: 32px; font-size: 1.1em; }
.stats { display: flex; gap: 20px; margin: 24px 0; flex-wrap: wrap; }
.stat { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px 28px; text-align: center; flex: 1; min-width: 120px; }
.stat .number { font-size: 2.2em; font-weight: bold; color: #58a6ff; }
.stat .label { color: #8b949e; font-size: 0.85em; margin-top: 4px; }
.stat.new .number { color: #3fb950; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 0.9em; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #21262d; }
th { background: #161b22; color: #79c0ff; font-weight: 600; }
tr:hover { background: #161b22; }
.tag { display: inline-block; background: #1f6feb33; color: #58a6ff; padding: 2px 8px; border-radius: 12px; font-size: 0.85em; margin: 2px; }
.person { font-weight: 500; }
.p-a { color: #f0883e; }
.p-b { color: #a371f7; }
.p-c { color: #3fb950; }
code { background: #161b22; padding: 2px 6px; border-radius: 4px; font-size: 0.88em; color: #e6edf3; }
pre { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; overflow-x: auto; margin: 12px 0; font-size: 0.85em; line-height: 1.4; }
.demo { margin: 20px 0; }
.demo h4 { color: #79c0ff; margin-bottom: 8px; font-size: 1em; }
.demo pre { position: relative; }
.demo .cmd { color: #3fb950; }
.team { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }
.member { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; flex: 1; min-width: 240px; }
.member .name { font-weight: bold; margin-bottom: 6px; font-size: 1.05em; }
.member .modules { color: #8b949e; font-size: 0.88em; }
.member .count { display: inline-block; background: #1f6feb33; color: #58a6ff; padding: 2px 10px; border-radius: 12px; font-size: 0.8em; margin-top: 6px; }
.arch-box { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 24px; margin: 16px 0; text-align: center; }
.arch-box pre { text-align: left; display: inline-block; margin: 0; border: none; background: none; color: #c9d1d9; font-size: 0.88em; }
.arch-box .hl { color: #f0883e; font-weight: bold; }
.test-pass { color: #3fb950; }
.footer { text-align: center; color: #484f58; margin-top: 48px; padding-top: 20px; border-top: 1px solid #21262d; }
</style>
</head>
<body>
<div class="container">
<h1>gitlink-cli 功能增强</h1>
<p class="subtitle">软件演化与运维 课程实践 — 进阶任务 子任务一</p>
<!-- 核心数据 -->
<div class="stats">
<div class="stat">
<div class="number">40+</div>
<div class="label">原有命令</div>
</div>
<div class="stat" style="flex:0; padding: 20px 12px;">
<div class="number" style="font-size:1.6em;"></div>
</div>
<div class="stat new">
<div class="number">70+</div>
<div class="label">增强后命令</div>
</div>
<div class="stat">
<div class="number">12</div>
<div class="label">Shortcut 分组</div>
</div>
<div class="stat new">
<div class="number">36+</div>
<div class="label">单元测试</div>
</div>
</div>
<!-- 团队分工 -->
<h2>团队分工</h2>
<div class="team">
<div class="member">
<div class="name p-a">同学 A组长</div>
<div class="modules">
<span class="tag">milestone</span>
<span class="tag">webhook</span>
<span class="tag">label</span>
<span class="tag">commit</span>
<br>+ 展示工程部署
</div>
<div class="count">20 个新命令</div>
</div>
<div class="member">
<div class="name p-b">同学 B</div>
<div class="modules">
<span class="tag">file</span>
<span class="tag">member</span>
<span class="tag">watch/star</span>
</div>
<div class="count">14 个新命令</div>
</div>
<div class="member">
<div class="name p-c">同学 C</div>
<div class="modules">
<span class="tag">batch 增强</span>
<span class="tag">table 格式</span>
<span class="tag">错误优化</span>
<span class="tag">测试补全</span>
</div>
<div class="count">4 新命令 + 全局优化</div>
</div>
</div>
<!-- 架构 -->
<h2>三层架构设计</h2>
<div class="arch-box">
<pre>
┌──────────────────────────────────────────────────────────┐
<span class="hl">Shortcuts Layer</span>(人 + AI 友好) │
│ │
│ milestone · webhook · label · commit · file · member │
│ watch · star · batch-label · batch-milestone · ... │
├──────────────────────────────────────────────────────────┤
<span class="hl">Raw API Layer</span>(全覆盖) │
│ │
│ gitlink-cli api GET /v1/{owner}/{repo}/... │
├──────────────────────────────────────────────────────────┤
<span class="hl">Config Layer</span>(配置管理) │
│ │
│ auth login · config set · GITLINK_TOKEN 环境变量 │
└──────────────────────────────────────────────────────────┘
</pre>
</div>
<!-- 新增功能清单 -->
<h2>新增功能清单</h2>
<table>
<tr>
<th>模块</th>
<th>新增命令</th>
<th>API 端点</th>
<th>负责人</th>
</tr>
<!-- 组长 A -->
<tr>
<td><span class="tag">milestone</span></td>
<td>list, view, create, update, close, delete</td>
<td><code>/v1/{owner}/{repo}/milestones</code></td>
<td class="person p-a">A</td>
</tr>
<tr>
<td><span class="tag">webhook</span></td>
<td>list, create, view, update, delete, test</td>
<td><code>/v1/{owner}/{repo}/webhooks</code></td>
<td class="person p-a">A</td>
</tr>
<tr>
<td><span class="tag">label</span></td>
<td>list, create, update, delete</td>
<td><code>/v1/{owner}/{repo}/issue_tags</code></td>
<td class="person p-a">A</td>
</tr>
<tr>
<td><span class="tag">commit</span></td>
<td>list, view, diff, blame</td>
<td><code>/v1/{owner}/{repo}/commits, blame</code></td>
<td class="person p-a">A</td>
</tr>
<!-- 同学 B -->
<tr>
<td><span class="tag">file</span></td>
<td>list, tree, get, create, delete</td>
<td><code>/{owner}/{repo}/files, create_file, delete_file</code></td>
<td class="person p-b">B</td>
</tr>
<tr>
<td><span class="tag">member</span></td>
<td>list, add, remove, update</td>
<td><code>/{owner}/{repo}/collaborators</code></td>
<td class="person p-b">B</td>
</tr>
<tr>
<td><span class="tag">watch/star</span></td>
<td>watch, unwatch, star, unstar, watchers, stars</td>
<td><code>/watchers/follow, /praise_tread</code></td>
<td class="person p-b">B</td>
</tr>
<!-- 同学 C -->
<tr>
<td><span class="tag">batch 增强</span></td>
<td>batch-label, batch-milestone, batch-assign, batch-close 优化</td>
<td>基于现有 issue batch 框架扩展</td>
<td class="person p-c">C</td>
</tr>
<tr>
<td><span class="tag">全局优化</span></td>
<td>table 输出格式增强、错误提示优化、测试补全</td>
<td>跨所有模块</td>
<td class="person p-c">C</td>
</tr>
</table>
<!-- 运行演示 -->
<h2>运行效果演示</h2>
<div class="demo">
<h4>milestone +list</h4>
<pre><span class="cmd">$ gitlink-cli milestone +list --owner chroe --repo gitlink_help_center</span>
{
"ok": true,
"data": {
"milestones": [
{"id": 2756, "name": "v2.0", "status": "open", "effective_date": "2026-06-30", "issues_count": 0}
],
"opening_milestone_count": 1,
"total_count": 1
}
}</pre>
</div>
<div class="demo">
<h4>milestone +create</h4>
<pre><span class="cmd">$ gitlink-cli milestone +create --owner chroe --repo gitlink_help_center --name "v2.0" --description "CLI test milestone" --due "2026-06-30"</span>
{
"ok": true,
"data": {"status": 0, "message": "success"}
}</pre>
</div>
<div class="demo">
<h4>webhook +list</h4>
<pre><span class="cmd">$ gitlink-cli webhook +list --owner chroe --repo gitlink_help_center</span>
{
"ok": true,
"data": {
"total_count": 3,
"webhooks": [
{"id": 50035, "url": "https://jianmu.gitlink.org.cn/webhook/projects/sync", "is_active": true, "last_status": "succeed"},
{"id": 50201, "url": "https://jianmu.gitlink.org.cn/webhook/50201", "is_active": true, "last_status": "fail"}
]
}
}</pre>
</div>
<div class="demo">
<h4>label +list</h4>
<pre><span class="cmd">$ gitlink-cli label +list --owner chroe --repo gitlink_help_center</span>
{
"ok": true,
"data": {
"total_count": 0,
"issue_tags": []
}
}</pre>
</div>
<div class="demo">
<h4>commit +list</h4>
<pre><span class="cmd">$ gitlink-cli commit +list --owner chroe --repo gitlink_help_center --limit 2</span>
{
"ok": true,
"data": {
"total_count": 500,
"commits": [
{"sha": "af3b0d4...", "commit_message": "refactor: delete .devops/...", "author": {"login": "yetja"}},
{"sha": "854ce5d...", "commit_message": "feat: .devops/...", "author": {"login": "yetja"}}
]
}
}</pre>
</div>
<div class="demo">
<h4>commit +view查看某次提交的变更文件</h4>
<pre><span class="cmd">$ gitlink-cli commit +view --owner chroe --repo gitlink_help_center --sha af3b0d4</span>
{
"ok": true,
"data": {
"file_nums": 1,
"total_addition": 0,
"total_deletion": 43,
"files": [
{"filename": ".devops/灵枢自动部署.yml", "additions": 0, "deletions": 43, "is_deleted": true}
]
}
}</pre>
</div>
<div class="demo">
<h4>commit +blame查看文件逐行追溯</h4>
<pre><span class="cmd">$ gitlink-cli commit +blame --owner chroe --repo gitlink_help_center --path README.md</span>
{
"ok": true,
"data": {
"file_name": "README.md",
"num_lines": 28,
"blame_parts": [
{"commit": {"sha": "8679343...", "author": {"login": "caishi"}, "commit_message": "search"}, "lines": ["# gitlink_help_center", ...]}
]
}
}</pre>
</div>
<!-- 测试 -->
<h2>单元测试</h2>
<pre><span class="cmd">$ go test ./... -v</span>
=== RUN TestCommitList --- PASS
=== RUN TestCommitListWithSHA --- PASS
=== RUN TestCommitView --- PASS
=== RUN TestCommitDiff --- PASS
=== RUN TestCommitBlame --- PASS
=== RUN TestMilestoneList --- PASS
=== RUN TestMilestoneCreate --- PASS
=== RUN TestMilestoneUpdate --- PASS
=== RUN TestMilestoneClose --- PASS
=== RUN TestMilestoneDelete --- PASS
=== RUN TestMilestoneUpdateRequiresAtLeastOneField --- PASS
=== RUN TestWebhookList --- PASS
=== RUN TestWebhookCreate --- PASS
=== RUN TestWebhookDelete --- PASS
=== RUN TestWebhookTest --- PASS
=== RUN TestWebhookUpdateRequiresAtLeastOneField --- PASS
=== RUN TestLabelList --- PASS
=== RUN TestLabelCreate --- PASS
=== RUN TestLabelCreateWithDefaultColor --- PASS
=== RUN TestLabelUpdate --- PASS
=== RUN TestLabelUpdateRequiresAtLeastOneField --- PASS
=== RUN TestLabelDelete --- PASS
=== RUN TestIssueClosePreservesCurrentDescription --- PASS
=== RUN TestIssueUpdatePreservesCurrentDescriptionWhenChangingTitleAndState --- PASS
=== RUN TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription --- PASS
=== RUN TestBatchClosePreservesCurrentDescription --- PASS
=== RUN TestPRCommentPostsToCorrectIssueJournal --- PASS
=== RUN TestPRCommentFailsWhenPRNotFound --- PASS
=== RUN TestPRCommentFailsWhenIssueFieldMissing --- PASS
=== RUN TestMountShortcutSupportsBoolFlags --- PASS
<span class="test-pass">36 tests — ALL PASSED</span></pre>
<div class="footer">
gitlink-cli 功能增强 — 软件演化与运维课程实践
</div>
</div>
</body>
</html>