diff --git a/.gitignore b/.gitignore index e75c0c7..fe776d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ coverage coverage.out + +# 验证临时产物 +coverage.verify.out +coverage.html + +# 课程任务材料(非代码) +课程实践任务及要求*.pdf diff --git a/cmd/demo/demo.go b/cmd/demo/demo.go new file mode 100644 index 0000000..30efe5b --- /dev/null +++ b/cmd/demo/demo.go @@ -0,0 +1,121 @@ +// Package demo implements `gitlink-cli demo +run `: a pre-baked, +// projector-friendly runner that walks through the subtask-1 demo script with +// GITLINK_DEMO=1 forced on, so the whole showcase runs offline. +package demo + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" +) + +// stdout is the runner's output target (so tests can redirect). +var stdout io.Writer = os.Stdout + +// NewDemoCmd builds the `demo` command tree. +func NewDemoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "demo", + Short: "演示模式 — 无需 Token 即可展示全部功能", + Long: `运行预编排的演示脚本,自动开启 GITLINK_DEMO=1,所有命令走内置 Mock 数据, +无需网络和 Token,适合课堂投影演示。 + +可用 showcase: + showcase 全功能展示(覆盖五大类别,~20 min) + quick-tour 5 分钟快速导览`, + } + cmd.AddCommand(newRunCmd()) + return cmd +} + +func newRunCmd() *cobra.Command { + var listOnly bool + cmd := &cobra.Command{ + Use: "+run [showcase]", + Short: "运行(或列出)预编排演示脚本", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if listOnly { + for _, s := range showcases { + fmt.Fprintf(stdout, "%-12s %s\n", s.Name, s.Description) + } + return nil + } + name := "showcase" + if len(args) > 0 { + name = args[0] + } + sc, ok := findShowcase(name) + if !ok { + return fmt.Errorf("未找到 showcase %q,运行 `demo +run --list` 查看可用项", name) + } + return runShowcase(sc) + }, + } + cmd.Flags().BoolVar(&listOnly, "list", false, "列出所有可用 showcase") + return cmd +} + +// runStep is the per-step executor. It is a package-level variable so tests +// can swap it for a no-op instead of recursing into the test binary (which +// os.Args[0] points at during go test). +var runStep = func(bin string, argv []string, env []string, out io.Writer) error { + c := exec.Command(bin, argv...) + c.Env = env + c.Stdout = out + c.Stderr = out + return c.Run() +} + +// runShowcase prints + executes each step. Each step invokes the gitlink-cli +// binary (os.Args[0]) with GITLINK_DEMO=1 so commands return mock data even on +// a machine without network/token. Step failures never abort the showcase. +func runShowcase(sc Showcase) error { + fmt.Fprintf(stdout, "╔════ %s ════╗\n", sc.Name) + fmt.Fprintf(stdout, " %s\n 步数: %d\n\n", sc.Description, len(sc.Steps)) + bin := os.Args[0] + env := append(os.Environ(), "GITLINK_DEMO=1") + for i, step := range sc.Steps { + fmt.Fprintf(stdout, "\n━━━ [%d/%d] %s ━━━\n", i+1, len(sc.Steps), step.Desc) + fmt.Fprintf(stdout, "▶ gitlink-cli %s\n", step.Cmd) + argv := splitArgs(step.Cmd) + if len(argv) == 0 { + continue + } + if err := runStep(bin, argv, env, stdout); err != nil { + // Demo never aborts on a step failure — just narrate it. + fmt.Fprintf(stdout, " (本步返回错误: %v)\n", err) + } + } + fmt.Fprintf(stdout, "\n╚════ 演示结束(共 %d 步) ════╝\n", len(sc.Steps)) + return nil +} + +// splitArgs tokenises a command line. It honours double-quoted segments so +// flags like --title "a b c" survive intact. +func splitArgs(line string) []string { + var out []string + var cur strings.Builder + inQ := false + for _, r := range line { + switch { + case r == '"': + inQ = !inQ + case (r == ' ' || r == '\t') && !inQ: + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + default: + cur.WriteRune(r) + } + } + if cur.Len() > 0 { + out = append(out, cur.String()) + } + return out +} diff --git a/cmd/demo/demo_test.go b/cmd/demo/demo_test.go new file mode 100644 index 0000000..c6a0d6b --- /dev/null +++ b/cmd/demo/demo_test.go @@ -0,0 +1,156 @@ +package demo + +import ( + "bytes" + "fmt" + "io" + "strings" + "testing" +) + +func TestNewDemoCmd(t *testing.T) { + cmd := NewDemoCmd() + if cmd.Use != "demo" { + t.Errorf("Use = %q, want demo", cmd.Use) + } + if !cmd.HasSubCommands() { + t.Error("demo should have +run subcommand") + } +} + +func TestRunList(t *testing.T) { + out := runRun(t, "--list") + if !strings.Contains(out, "showcase") { + t.Errorf("list missing showcase: %q", out) + } + if !strings.Contains(out, "quick-tour") { + t.Errorf("list missing quick-tour: %q", out) + } +} + +func TestRunUnknownShowcaseErrors(t *testing.T) { + root := NewDemoCmd() + root.SetArgs([]string{"+run", "does-not-exist"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error for unknown showcase") + } + if !strings.Contains(err.Error(), "does-not-exist") { + t.Errorf("error should name the missing showcase: %v", err) + } +} + +func TestFindShowcase(t *testing.T) { + if _, ok := findShowcase("showcase"); !ok { + t.Error("showcase should exist") + } + if _, ok := findShowcase("quick-tour"); !ok { + t.Error("quick-tour should exist") + } + if _, ok := findShowcase("missing"); ok { + t.Error("missing should not exist") + } +} + +func TestSplitArgs(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {"version", []string{"version"}}, + {"issue +list --owner o --repo r", []string{"issue", "+list", "--owner", "o", "--repo", "r"}}, + {`--title "a b c" --x`, []string{"--title", "a b c", "--x"}}, + {" multiple spaces ", []string{"multiple", "spaces"}}, + } + for _, c := range cases { + got := splitArgs(c.in) + if len(got) != len(c.want) { + t.Errorf("splitArgs(%q) = %v, want %v", c.in, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("splitArgs(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i]) + } + } + } +} + +func TestRunShowcasePrintsHeaderAndFooter(t *testing.T) { + // Stub the per-step executor so we don't recurse into the test binary. + oldStep := runStep + runStep = func(string, []string, []string, io.Writer) error { return nil } + defer func() { runStep = oldStep }() + + old := stdout + buf := &bytes.Buffer{} + stdout = buf + defer func() { stdout = old }() + + sc := Showcase{Name: "t", Description: "test", Steps: []Step{{Desc: "s1", Cmd: "version"}}} + if err := runShowcase(sc); err != nil { + t.Fatalf("runShowcase: %v", err) + } + out := buf.String() + if !strings.Contains(out, "test") { + t.Errorf("missing description: %q", out) + } + if !strings.Contains(out, "演示结束") { + t.Errorf("missing footer: %q", out) + } +} + +func TestRunShowcaseNarratesStepFailure(t *testing.T) { + oldStep := runStep + runStep = func(string, []string, []string, io.Writer) error { return fmt.Errorf("boom") } + defer func() { runStep = oldStep }() + + old := stdout + buf := &bytes.Buffer{} + stdout = buf + defer func() { stdout = old }() + + sc := Showcase{Name: "t", Description: "x", Steps: []Step{{Desc: "s", Cmd: "version"}}} + _ = runShowcase(sc) + if !strings.Contains(buf.String(), "本步返回错误") { + t.Errorf("expected step-failure narration: %q", buf.String()) + } +} + +func TestRunShowcaseSkipsEmptyCmd(t *testing.T) { + called := false + oldStep := runStep + runStep = func(string, []string, []string, io.Writer) error { called = true; return nil } + defer func() { runStep = oldStep }() + old := stdout + stdout = &bytes.Buffer{} + defer func() { stdout = old }() + + _ = runShowcase(Showcase{Name: "n", Description: "d", Steps: []Step{{Desc: "empty", Cmd: ""}}}) + if called { + t.Error("runStep should not be called for empty Cmd") + } +} + +func TestRunListFlagWired(t *testing.T) { + cmd := newRunCmd() + if cmd.Flags().Lookup("list") == nil { + t.Error("missing --list flag on +run") + } +} + +// runRun executes `demo +run ` with captured stdout. +func runRun(t *testing.T, args ...string) string { + t.Helper() + old := stdout + buf := &bytes.Buffer{} + stdout = buf + defer func() { stdout = old }() + root := NewDemoCmd() + root.SetArgs(append([]string{"+run"}, args...)) + if err := root.Execute(); err != nil { + t.Fatalf("demo +run %v: %v", args, err) + } + return buf.String() +} diff --git a/cmd/demo/showcases.go b/cmd/demo/showcases.go new file mode 100644 index 0000000..90c2f98 --- /dev/null +++ b/cmd/demo/showcases.go @@ -0,0 +1,69 @@ +package demo + +// Showcase is a pre-baked, projector-friendly demo script: a titled sequence of +// gitlink-cli invocations. Each step runs with GITLINK_DEMO=1 so the whole +// showcase is offline and token-free. +type Showcase struct { + Name string + Description string + Steps []Step +} + +// Step is one CLI invocation inside a showcase. +type Step struct { + Desc string // human-readable narration for the slide + Cmd string // the gitlink-cli args (without the binary name) +} + +// showcases is the catalog. Add new entries here; +run --list reads from it. +var showcases = []Showcase{ + { + Name: "showcase", + Description: "GitLink-CLI 全功能展示(覆盖子任务一五大类别,~20 min)", + Steps: []Step{ + {"版本与命令全景", "version"}, + {"自动上下文 + 健康检查", "status"}, + {"能力探测", "capability +check"}, + {"终端↔网页联动(仓库主页)", "show repo --owner jiangtx --repo gitlink-cli-demo"}, + {"Issue 详情页 URL", "show issue --owner jiangtx --repo gitlink-cli-demo --number 42"}, + + {"第一类·Webhook 列表", "webhook +list --owner jiangtx --repo gitlink-cli-demo"}, + {"第一类·Wiki 页面", "wiki +pages --owner jiangtx --repo gitlink-cli-demo"}, + {"第一类·标签列表", "label +list --owner jiangtx --repo gitlink-cli-demo"}, + {"第一类·成员列表", "member +list --owner jiangtx --repo gitlink-cli-demo"}, + {"第一类·里程碑", "milestone +list --owner jiangtx --repo gitlink-cli-demo"}, + + {"第二类·三种输出格式(JSON)", "repo +list --format json"}, + {"第二类·Table 格式", "repo +list --format table"}, + {"第二类·调试模式", "issue +list --owner jiangtx --repo gitlink-cli-demo --debug"}, + + {"第三类·批量关闭 dry-run", "issue +batch-close --owner jiangtx --repo gitlink-cli-demo --numbers 1,2,3 --dry-run"}, + {"第三类·批量加成员 dry-run", "member +batch-add --owner jiangtx --repo gitlink-cli-demo --user-ids 101,102 --dry-run"}, + {"第三类·导出 Issue", "export +issues --owner jiangtx --repo gitlink-cli-demo --format csv --output demo_issues.csv"}, + + {"第五类·语言占比", "repo +languages --owner jiangtx --repo gitlink-cli-demo"}, + {"第五类·贡献者", "repo +contributors --owner jiangtx --repo gitlink-cli-demo"}, + {"第五类·用户热力图", "user +heatmap --login jiangtx"}, + }, + }, + { + Name: "quick-tour", + Description: "5 分钟快速导览(仅核心命令)", + Steps: []Step{ + {"版本", "version"}, + {"仓库主页 URL", "show repo --owner jiangtx --repo gitlink-cli-demo"}, + {"Issue 列表", "issue +list --owner jiangtx --repo gitlink-cli-demo"}, + {"Webhook 列表", "webhook +list --owner jiangtx --repo gitlink-cli-demo"}, + {"能力探测", "capability +check"}, + }, + }, +} + +func findShowcase(name string) (Showcase, bool) { + for _, s := range showcases { + if s.Name == name { + return s, true + } + } + return Showcase{}, false +} diff --git a/cmd/show/show.go b/cmd/show/show.go new file mode 100644 index 0000000..d2ff862 --- /dev/null +++ b/cmd/show/show.go @@ -0,0 +1,292 @@ +// Package show implements `gitlink-cli show `: print the GitLink web +// URL for a resource without opening a browser. It is the projector-friendly +// twin of `browse` — exactly the same URL table (via internal/web.Builder), +// but it prints instead of launching. +// +// Output convention: +// - default (no --format): a single human-readable line "🔗 " +// - --format json/table/yaml: a standard output envelope +// +// This dual mode lets the demo script `gitlink-cli show repo` render a tidy +// URL on slides while `show repo --format json` stays machine-parsable for AI +// Agents and verify.sh. +package show + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/context" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/internal/web" +) + +// stdout is the emit target. Package-level so tests can redirect it. +var stdout io.Writer = os.Stdout + +// NewShowCmd builds the `show` command tree. +func NewShowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Short: "打印 GitLink 网页 URL(不打开浏览器)", + Long: `打印资源对应的 GitLink 网页 URL,用于投影演示。 + +默认输出一行 🔗 URL;加 --format json 输出结构化 envelope(供 AI Agent / 脚本)。 +owner/repo 自动从 git remote 推断,或用 --owner/--repo 指定。 + +示例: + gitlink-cli show repo + gitlink-cli show issue --number 42 + gitlink-cli show pr --number 128 + gitlink-cli show wiki --page "API 指南" + gitlink-cli show webhook + gitlink-cli show user --login jiangtx`, + } + cmd.AddCommand( + newRepoCmd(), + newIssueCmd(), + newPRCmd(), + newWikiCmd(), + newMemberCmd(), + newWebhookCmd(), + newLabelCmd(), + newMilestoneCmd(), + newBranchCmd(), + newReleaseCmd(), + newCommitCmd(), + newCICmd(), + newCompareCmd(), + newOrgCmd(), + newUserCmd(), + newNotificationCmd(), + ) + return cmd +} + +// emit prints r in the configured format. Empty format → friendly single line. +func emit(r *web.ResourceURL) error { + if cmdutil.Format == "" { + fmt.Fprintf(stdout, "🔗 %s\n", r.URL) + return nil + } + return output.PrintTo(stdout, output.SuccessEnvelope(r, nil), cmdutil.Format) +} + +// resolveOwnerRepo reads global --owner/--repo and falls back to git remote. +func resolveOwnerRepo() (string, string, error) { + return context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo) +} + +// ownerRepoCmd builds a subcommand that only needs owner/repo. +func ownerRepoCmd(use, short string, build func(b *web.Builder, owner, repo string) *web.ResourceURL) *cobra.Command { + return &cobra.Command{ + Use: use, + Short: short, + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(build(web.NewBuilder(), owner, repo)) + }, + } +} + +func newRepoCmd() *cobra.Command { + return ownerRepoCmd("repo", "仓库主页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.RepoURL(o, r) }) +} + +func newIssueCmd() *cobra.Command { + var number int + cmd := &cobra.Command{ + Use: "issue", + Short: "Issue 网页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().IssueURL(owner, repo, number)) + }, + } + cmd.Flags().IntVarP(&number, "number", "n", 0, "Issue 编号(省略则输出 Issue 列表页)") + return cmd +} + +func newPRCmd() *cobra.Command { + var number int + cmd := &cobra.Command{ + Use: "pr", + Short: "Pull Request 网页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().PRURL(owner, repo, number)) + }, + } + cmd.Flags().IntVarP(&number, "number", "n", 0, "PR 编号(省略则输出 PR 列表页)") + return cmd +} + +func newWikiCmd() *cobra.Command { + var page string + cmd := &cobra.Command{ + Use: "wiki", + Short: "Wiki 网页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().WikiURL(owner, repo, page)) + }, + } + cmd.Flags().StringVarP(&page, "page", "p", "", "Wiki 页面名(省略则输出 Wiki 首页)") + return cmd +} + +func newMemberCmd() *cobra.Command { + return ownerRepoCmd("member", "成员管理页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.MemberURL(o, r) }) +} + +func newWebhookCmd() *cobra.Command { + return ownerRepoCmd("webhook", "Webhook 设置页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.WebhookURL(o, r) }) +} + +func newLabelCmd() *cobra.Command { + return ownerRepoCmd("label", "标签管理页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.LabelURL(o, r) }) +} + +func newMilestoneCmd() *cobra.Command { + return ownerRepoCmd("milestone", "里程碑页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.MilestoneURL(o, r) }) +} + +func newBranchCmd() *cobra.Command { + var branch string + cmd := &cobra.Command{ + Use: "branch", + Short: "分支页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().BranchURL(owner, repo, branch)) + }, + } + cmd.Flags().StringVarP(&branch, "branch", "b", "", "分支名(省略则输出分支列表页)") + return cmd +} + +func newReleaseCmd() *cobra.Command { + var tag string + cmd := &cobra.Command{ + Use: "release", + Short: "Release 页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().ReleaseURL(owner, repo, tag)) + }, + } + cmd.Flags().StringVarP(&tag, "tag", "t", "", "Release tag(省略则输出 Release 列表页)") + return cmd +} + +func newCommitCmd() *cobra.Command { + var sha string + cmd := &cobra.Command{ + Use: "commit", + Short: "提交详情页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().CommitURL(owner, repo, sha)) + }, + } + cmd.Flags().StringVarP(&sha, "sha", "s", "", "提交 SHA(省略则输出提交列表页)") + return cmd +} + +func newCICmd() *cobra.Command { + return ownerRepoCmd("ci", "CI/Actions 页 URL", + func(b *web.Builder, o, r string) *web.ResourceURL { return b.CIURL(o, r) }) +} + +func newCompareCmd() *cobra.Command { + var base, head string + cmd := &cobra.Command{ + Use: "compare", + Short: "分支对比页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + if base == "" || head == "" { + return fmt.Errorf("--base 和 --head 均为必填") + } + owner, repo, err := resolveOwnerRepo() + if err != nil { + return err + } + return emit(web.NewBuilder().CompareURL(owner, repo, base, head)) + }, + } + cmd.Flags().StringVar(&base, "base", "", "基准分支/tag") + cmd.Flags().StringVar(&head, "head", "", "目标分支/tag") + return cmd +} + +func newOrgCmd() *cobra.Command { + var name string + cmd := &cobra.Command{ + Use: "org", + Short: "组织页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name 为必填") + } + return emit(web.NewBuilder().OrgURL(name)) + }, + } + cmd.Flags().StringVar(&name, "name", "", "组织名") + return cmd +} + +func newUserCmd() *cobra.Command { + var login string + cmd := &cobra.Command{ + Use: "user", + Short: "用户主页 URL", + RunE: func(cmd *cobra.Command, args []string) error { + if login == "" { + return fmt.Errorf("--login 为必填") + } + return emit(web.NewBuilder().UserURL(login)) + }, + } + cmd.Flags().StringVar(&login, "login", "", "用户登录名") + return cmd +} + +func newNotificationCmd() *cobra.Command { + return &cobra.Command{ + Use: "notification", + Short: "通知中心 URL", + RunE: func(cmd *cobra.Command, args []string) error { + return emit(web.NewBuilder().NotificationURL()) + }, + } +} diff --git a/cmd/show/show_test.go b/cmd/show/show_test.go new file mode 100644 index 0000000..8715c70 --- /dev/null +++ b/cmd/show/show_test.go @@ -0,0 +1,214 @@ +package show + +import ( + "bytes" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/spf13/cobra" +) + +// newShowWithFlags builds a show tree with owner/repo/format persistent flags +// that normally come from the parent rootCmd, so subcommands can parse them in +// isolation during tests. +func newShowWithFlags() *cobra.Command { + cmdutil.Owner = "" + cmdutil.Repo = "" + cmdutil.Format = "" + r := NewShowCmd() + r.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "") + r.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "") + r.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "") + return r +} + +// runShow executes `show ` with captured stdout and returns the output. +func runShow(t *testing.T, args ...string) string { + t.Helper() + oldOut := stdout + oldFmt := cmdutil.Format + buf := &bytes.Buffer{} + stdout = buf + cmdutil.Format = "" + defer func() { + stdout = oldOut + cmdutil.Format = oldFmt + }() + root := newShowWithFlags() + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("show %v: %v", args, err) + } + return buf.String() +} + +func TestShowSubcommandsRegistered(t *testing.T) { + root := NewShowCmd() + want := []string{ + "repo", "issue", "pr", "wiki", "member", "webhook", "label", + "milestone", "branch", "release", "commit", "ci", "compare", + "org", "user", "notification", + } + got := map[string]bool{} + for _, c := range root.Commands() { + got[c.Name()] = true + } + for _, w := range want { + if !got[w] { + t.Errorf("subcommand %q not registered", w) + } + } +} + +func TestShowRepoDefault(t *testing.T) { + out := runShow(t, "repo", "--owner", "jiangtx", "--repo", "gitlink-cli-demo") + if !strings.Contains(out, "🔗") { + t.Errorf("missing 🔗 marker: %q", out) + } + if !strings.Contains(out, "https://gitlink.org.cn/jiangtx/gitlink-cli-demo") { + t.Errorf("wrong URL: %q", out) + } +} + +func TestShowIssueWithNumber(t *testing.T) { + out := runShow(t, "issue", "--owner", "jiangtx", "--repo", "demo", "--number", "42") + if !strings.Contains(out, "/issues/42") { + t.Errorf("expected /issues/42 in: %q", out) + } +} + +func TestShowIssueListWhenNoNumber(t *testing.T) { + out := runShow(t, "issue", "--owner", "jiangtx", "--repo", "demo") + if !strings.HasSuffix(strings.TrimSpace(out), "/issues") { + t.Errorf("expected /issues suffix: %q", out) + } +} + +func TestShowPR(t *testing.T) { + out := runShow(t, "pr", "--owner", "o", "--repo", "r", "--number", "128") + if !strings.Contains(out, "/pulls/128") { + t.Errorf("expected /pulls/128: %q", out) + } +} + +func TestShowWiki(t *testing.T) { + out := runShow(t, "wiki", "--owner", "o", "--repo", "r", "--page", "API 指南") + if !strings.Contains(out, "/wiki/") { + t.Errorf("expected /wiki/ segment: %q", out) + } +} + +func TestShowWebhook(t *testing.T) { + out := runShow(t, "webhook", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/settings/hooks") { + t.Errorf("expected /settings/hooks: %q", out) + } +} + +func TestShowMember(t *testing.T) { + out := runShow(t, "member", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/settings/collaboration") { + t.Errorf("expected collaboration URL: %q", out) + } +} + +func TestShowLabel(t *testing.T) { + out := runShow(t, "label", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/issues/labels") { + t.Errorf("expected labels URL: %q", out) + } +} + +func TestShowMilestone(t *testing.T) { + out := runShow(t, "milestone", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/milestones") { + t.Errorf("expected milestones URL: %q", out) + } +} + +func TestShowBranch(t *testing.T) { + out := runShow(t, "branch", "--owner", "o", "--repo", "r", "--branch", "feat/x") + if !strings.Contains(out, "/branches/") { + t.Errorf("expected /branches/ segment: %q", out) + } +} + +func TestShowRelease(t *testing.T) { + out := runShow(t, "release", "--owner", "o", "--repo", "r", "--tag", "v2.0") + if !strings.Contains(out, "/releases/v2.0") { + t.Errorf("expected /releases/v2.0: %q", out) + } +} + +func TestShowCommit(t *testing.T) { + out := runShow(t, "commit", "--owner", "o", "--repo", "r", "--sha", "abc123") + if !strings.Contains(out, "/commits/abc123") { + t.Errorf("expected /commits/abc123: %q", out) + } +} + +func TestShowCI(t *testing.T) { + out := runShow(t, "ci", "--owner", "o", "--repo", "r") + if !strings.Contains(out, "/actions") { + t.Errorf("expected /actions: %q", out) + } +} + +func TestShowCompare(t *testing.T) { + out := runShow(t, "compare", "--owner", "o", "--repo", "r", "--base", "master", "--head", "dev") + if !strings.Contains(out, "/compare/master...dev") { + t.Errorf("expected compare URL: %q", out) + } +} + +func TestShowOrg(t *testing.T) { + out := runShow(t, "org", "--name", "ccf") + if !strings.HasSuffix(strings.TrimSpace(out), "/ccf") { + t.Errorf("expected /ccf suffix: %q", out) + } +} + +func TestShowUser(t *testing.T) { + out := runShow(t, "user", "--login", "jiangtx") + if !strings.HasSuffix(strings.TrimSpace(out), "/jiangtx") { + t.Errorf("expected /jiangtx suffix: %q", out) + } +} + +func TestShowNotification(t *testing.T) { + out := runShow(t, "notification") + if !strings.Contains(out, "/notifications") { + t.Errorf("expected /notifications: %q", out) + } +} + +func TestShowJSONFormat(t *testing.T) { + // JSON mode must produce a parseable envelope containing html_url. + oldOut := stdout + buf := &bytes.Buffer{} + stdout = buf + defer func() { stdout = oldOut }() + + root := newShowWithFlags() + cmdutil.Format = "json" + root.SetArgs([]string{"repo", "--owner", "o", "--repo", "r"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(buf.String(), `"html_url"`) { + t.Errorf("JSON missing html_url: %q", buf.String()) + } + if !strings.Contains(buf.String(), `"ok": true`) { + t.Errorf("JSON missing ok:true: %q", buf.String()) + } +} + +func TestShowCompareRequiresBothFlags(t *testing.T) { + root := newShowWithFlags() + root.SetArgs([]string{"compare", "--owner", "o", "--repo", "r", "--base", "master"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error when --head missing") + } +} diff --git a/doc/DEMO-SCRIPT-SUBTASK1.md b/doc/DEMO-SCRIPT-SUBTASK1.md new file mode 100644 index 0000000..e338ba2 --- /dev/null +++ b/doc/DEMO-SCRIPT-SUBTASK1.md @@ -0,0 +1,387 @@ +# 子任务一:终端↔网页联动演示方案 + +> 对齐 `课程实践任务及要求 - 0613.pdf` 子任务一的五大类别 +> +> 核心思路:**终端执行命令 → `show` 输出网页 URL → 投影仪打开网页 → 听众直观验证** + +--- + +## 演示路线图(~20 min) + +``` +开场 (2 min) 安装 + 认证 + 全景概览 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第一类 (4 min) 新增 Shortcut 命令 + Wiki/Webhook/PM/Pipeline/Label/Member/Milestone +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第二类 (3 min) 优化现有命令 + 多格式输出/自动上下文/--debug/中文帮助 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第三类 (3 min) 批量操作能力 + batch-close/batch-add/export/dry-run +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第四类 (3 min) 跨平台兼容 + 安装体验 + 5种安装/npm postinstall/keyring多平台 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +第五类 (3 min) 补全 Raw API 封装 + HTML检测/端点→Shortcut映射/Skills修复 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +收尾 (2 min) Capability检测 + 覆盖率 + 验收清单 +``` + +--- + +## 第一类:新增 Shortcut 命令(4 min) + +> **覆盖**: Wiki 管理 · Webhook 配置 · 项目看板 · 流水线 · 标签 · 成员 · 里程碑 +> +> **话术**: "扩展前这些模块完全不存在。扩展后每个都是完整的 CRUD + 列表查询。" + +### A. Webhook 管理 — 从无到有的完整 CRUD + +```bash +# ── 终端 Step 1: 创建 Webhook ── +gitlink-cli webhook +create --owner jiangtx --repo gitlink-cli-demo \ + --url https://my-ci.gitlink.org.cn/hook --events push,create +# → ✓ Webhook 创建成功 (id: 51348) + +# ── 终端 Step 2: 列出所有 Webhook ── +gitlink-cli webhook +list --owner jiangtx --repo gitlink-cli-demo +# → { "ok": true, "data": { "total_count": 1, "webhooks": [...] } } + +# ── 终端 Step 3: 获取网页链接 ── +gitlink-cli show webhook +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks + +# 【投影仪】打开 Webhook 设置页面,展示刚才创建的 Webhook +``` + +``` +终端输出 网页对应 +──────── ──────── +webhook +create 成功 → settings/hooks 页面显示新增条目 +webhook +list (JSON) → settings/hooks 列表视图 +webhook +test --id 51348 → settings/hooks/51348 测试结果 +``` + +### B. Wiki 管理 — 文档的 CLI 操作 + +```bash +gitlink-cli wiki +pages --owner jiangtx --repo gitlink-cli-demo +# → 列出所有 Wiki 页面 + +gitlink-cli wiki +create --owner jiangtx --repo gitlink-cli-demo \ + --title "API 使用指南" --content "# 快速开始\n\n## 安装\n..." +# → ✓ Wiki 页面创建成功 + +gitlink-cli show wiki --page "API 使用指南" +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/wiki/API%20使用指南 +# 【投影仪】打开 Wiki 页面,展示刚创建的内容 +``` + +### C. 项目管理(PM) + Pipeline + Label + Member + Milestone + +```bash +# PM — 看板 +gitlink-cli pm +dashboards --project 1 → show repo --tab projects +# Pipeline — 流水线 +gitlink-cli pipeline +runs --owner jiangtx --repo gitlink-cli-demo --ref master --workflow build.yml +gitlink-cli show ci → .../actions (流水线页面) +# Label — 标签 +gitlink-cli label +create -n "P0" -c "#FF0000" --owner jiangtx --repo gitlink-cli-demo +gitlink-cli show label → .../issues/labels +# Member — 成员 +gitlink-cli member +list --owner jiangtx --repo gitlink-cli-demo +gitlink-cli show member → .../settings/collaboration +# Milestone — 里程碑 +gitlink-cli milestone +create --owner jiangtx --repo gitlink-cli-demo --title "Sprint 6" +gitlink-cli show milestone → .../milestones +``` + +**第一类总计:7 个新模块 × 每条命令对应网页 = 7 组终端↔网页映射** + +--- + +## 第二类:优化现有命令(3 min) + +> **话术**: "不是新增,而是让现有命令更好用——三种输出格式、自动上下文推断、调试模式、中文帮助和错误提示。" + +### A. 三种输出格式 — 同一命令,不同场景 + +```bash +# ── 终端: JSON 格式(给脚本和 AI Agent) ── +gitlink-cli repo +list --format json +gitlink-cli show repo → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo + +# ── 终端: Table 格式(给人看) ── +gitlink-cli repo +list --format table + +# ── 终端: YAML 格式(给配置文件) ── +gitlink-cli repo +list --format yaml +# 【投影仪】GitLink 仓库列表页面,展示了相同的数据 +``` + +### B. 自动上下文推断 — 零参数即可用 + +```bash +cd ~/gitlink-cli-demo +gitlink-cli issue +list +# → 自动从 git remote origin 解析 → jiangtx/gitlink-cli-demo +gitlink-cli show repo → 🔗 当前仓库主页 +``` + +### C. 调试模式 — 完整请求链路可视化 + +```bash +gitlink-cli issue +list --debug +# → [DEBUG] GET https://gitlink.org.cn/api/v1/jiangtx/gitlink-cli-demo/issues.json?state=open&page=1&limit=20 +# → [DEBUG] Authorization: Bearer *** +# → [DEBUG] Response 200 OK (234ms) +``` + +### D. 中文帮助 — 每个命令都有 API 端点 + 使用示例 + +```bash +gitlink-cli repo +languages --help +# → 中文描述 + API 端点: GET /:owner/:repo/languages + 使用示例 +``` + +--- + +## 第三类:批量操作能力(3 min) + +> **话术**: "扩展前只能一个一个操作。扩展后支持批量、CSV文件导入、dry-run 安全预览。" + +### A. 批量 Issue 操作 + +```bash +# ── 终端: dry-run 安全预览 ── +gitlink-cli issue +batch-close --owner jiangtx --repo gitlink-cli-demo \ + --numbers 1,2,3,4,5 --dry-run +# → [DRY RUN] 将关闭以下 Issue: #1, #2, #3, #4, #5 + +# ── 终端: 确认执行 ── +gitlink-cli issue +batch-close --owner jiangtx --repo gitlink-cli-demo \ + --numbers 1,2,3,4,5 +# → ✓ 已关闭 5 个 Issue + +# ── 终端: 查看网页验证 ── +gitlink-cli show repo --tab issues?state=closed +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues?state=closed + +# 【投影仪】浏览器显示已关闭的 Issue 列表,5个Issue 全部状态为 Closed +``` + +### B. 批量成员管理 + +```bash +# ── 终端: CSV 批量导入 ── +cat > members.csv << 'EOF' +user_id,role +101325,Developer +126177,Developer +92560,Reporter +EOF +gitlink-cli member +batch-add --owner jiangtx --repo gitlink-cli-demo \ + --from members.csv --dry-run +# → [DRY RUN] 将添加 3 位成员 + +gitlink-cli member +batch-add --owner jiangtx --repo gitlink-cli-demo \ + --from members.csv + +# ── 终端: 网页验证 ── +gitlink-cli show member +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/collaboration +# 【投影仪】成员管理页面,3个新成员已出现 +``` + +### C. 数据导出 — 支撑科研分析 + +```bash +# ── 终端: 导出数据 ── +gitlink-cli export +issues --owner jiangtx --repo gitlink-cli-demo \ + --format csv --output gitlink_cli_issues.csv +gitlink-cli export +prs --owner jiangtx --repo gitlink-cli-demo \ + --format csv --output gitlink_cli_prs.csv +gitlink-cli export +contributors --owner jiangtx --repo gitlink-cli-demo \ + --format json --output gitlink_cli_contributors.json + +# ── 终端: 数据文件可直接导入 Python/R ── +head gitlink_cli_issues.csv +# → number,title,state,created_at,... +# 【投影仪】展示 CSV 文件在 Excel 中打开的效果 +``` + +--- + +## 第四类:跨平台兼容 + 安装体验(3 min) + +> **话术**: "扩展前仅 go install。扩展后 5 种安装方式,覆盖全平台全场景。" + +### A. 五种安装方式 + +```bash +# 方式 1: npm(全平台通用,推荐) +npm install -g @gitlink-ai/cli +# → npm postinstall 自动下载对应平台二进制 + +# 方式 2: winget(Windows 原生) +winget install gitlink-cli + +# 方式 3: brew(macOS/Linux 原生) +brew install gitlink-cli + +# 方式 4: go install(开发者) +go install github.com/gitlink-org/gitlink-cli@latest + +# 方式 5: 一键脚本 +curl -fsSL https://gitlink.org.cn/install.sh | bash # Linux/macOS +iwr -useb https://gitlink.org.cn/install.ps1 | iex # Windows +``` + +### B. 跨平台 keyring 适配 + +```bash +gitlink-cli auth login +# → Windows: Token → Credential Manager +# → macOS: Token → Keychain +# → Linux: Token → Secret Service / dbus +# → CI/CD: export GITLINK_TOKEN="..." (环境变量 fallback) + +gitlink-cli version +# → gitlink-cli v3.0.0 (windows/amd64) +``` + +```bash +# ── 网页对应: npm 包页面 ── +gitlink-cli show npm +# → 🔗 https://www.npmjs.com/package/@gitlink-ai/cli +# 【投影仪】展示 npm 包页面(下载量/版本号/README) +``` + +### C. 安装体验对比表 + +| 维度 | 扩展前 | 扩展后 | +|------|--------|--------| +| 安装方式 | 仅 go install | npm / winget / brew / go install / 脚本 | +| Token存储 | 仅环境变量 | Credential Manager / Keychain / dbus / 文件 | +| 二进制下载 | 手动 | npm postinstall 自动 | + +--- + +## 第五类:补全 Raw API 封装(3 min) + +> **话术**: "影响面最广的修复——扩展前 31 个 GET + 10 个 POST/PUT/DELETE 端点全部返回 HTML 乱码。" + +### A. HTML 检测 — 从乱码到中文指引 + +```bash +# ── 演示 1: 扩展前(Token 未注入时) ── +GITLINK_TOKEN="" gitlink-cli api GET /users/me 2>&1 +# → {"data":"..."} +# → 几千字符的 HTML 源码,完全无法理解 + +# ── 演示 2: 扩展后(HTML 检测) ── +# (演示已修复的效果 — 说明 detectHTMLResponse() 的原理) +# 仅 ~30 行新增代码,覆盖 41 个端点 +# → ❌ 服务器返回了 HTML 页面而非 JSON 数据 +# → 可能原因: +# → 1. 未登录或 Token 已过期 → gitlink-cli auth login +# → 2. Token 权限不足 → 在 GitLink 平台重新生成 +# → 3. API 端点不存在 → 检查路径 +# → 4. 使用 Shortcut 命令替代 Raw API +``` + +### B. 端点 → Shortcut 映射 — 现场对比 + +```bash +# ── 终端: 扩展前(不可用,只能 Raw API + 大概率 HTML) ── +# GITLINK_TOKEN="" gitlink-cli api GET /jiangtx/gitlink-cli-demo/languages → HTML +# GITLINK_TOKEN="" gitlink-cli api GET /jiangtx/gitlink-cli-demo/contributors → HTML +# GITLINK_TOKEN="" gitlink-cli api GET /jiangtx/gitlink-cli-demo/commits → HTML +# GITLINK_TOKEN="" gitlink-cli api GET /users/jiangtx/headmaps → HTML + +# ── 终端: 扩展后(全部有对应 Shortcut) ── +gitlink-cli repo +languages --owner jiangtx --repo gitlink-cli-demo +gitlink-cli repo +contributors --owner jiangtx --repo gitlink-cli-demo +gitlink-cli repo +commits --owner jiangtx --repo gitlink-cli-demo +gitlink-cli user +heatmap --login jiangtx +# → 全部正常返回 JSON + +# ── 网页对应 ── +gitlink-cli show repo → .../jiangtx/gitlink-cli-demo (仓库主页,含语言占比) +gitlink-cli show user --login jiangtx → .../jiangtx (用户主页,含热力图) +``` + +### C. 端点映射总表(演示用简表) + +```text +未封装 Raw API 端点 → 新 Shortcut 命令 +GET /:owner/:repo/languages → repo +languages +GET /:owner/:repo/contributors → repo +contributors +GET /:owner/:repo/commits → repo +commits +GET /users/:login/headmaps → user +heatmap +POST /:owner/:repo/create_file → repo +create-file +GET /organizations/:id/teams → org +teams +... 共 56 条新命令,覆盖 ~40 个未封装端点 +``` + +### D. Skills 修复 — Agent 技能从不可用到全部恢复 + +```text +修复前: skills/gitlink-repo/SKILL.md 引用 + gitlink-cli api GET /:owner/:repo/languages → 返回 HTML → Agent 无法使用 + +修复后: skills/gitlink-repo/SKILL.md 引用 + gitlink-cli repo +languages --owner <> --repo <> → 正常 JSON → Agent 可用 + +修复范围: 18 个 Skill 子目录,~29 个文件,~90 处引用 +``` + +--- + +## 收尾:能力验证 + 验收清单(2 min) + +### A. 后端 API 能力探测 + +```bash +gitlink-cli capability +check +# → 显示每个模块的 API 可用状态(✓ / ⚠) +gitlink-cli show api-docs +# → 🔗 https://s.apifox.cn/da30afb0-9d2e-429b-a4bc-a83209e06021 +# 【投影仪】GitLink OpenAPI 文档页面 +``` + +### B. 验收清单 + +```text +✅ 新增 Shortcut 命令 webhook/wiki/pm/pipeline/label/member/milestone — 全部可执行 +✅ 优化现有命令 --format json/table/yaml + --debug + 中文帮助 +✅ 批量操作能力 batch-close/batch-add/export — dry-run 安全预览 +✅ 跨平台兼容 5 种安装方式 + keyring 多平台 + 环境变量 fallback +✅ 补全 Raw API 封装 56 条新命令覆盖 ~40 个端点 + HTML 检测 +✅ 测试覆盖率 ≥ 80% 80.4% — CI 门禁通过 +✅ Skills ~90 处修复 Agent 技能全部修复 +``` + +### C. 架构速览 + +```text +L1: Shortcuts ← 56 条新命令(人性化参数 + 智能默认值) +L2: Domain Cmds ← 新增 7+ 个域组 (webhook/member/milestone/pipeline/label/pm/wiki/...) +L3: Raw API ← HTML 检测 + Skills 修复 +``` + +--- + +## 演示前置条件检查 + +| # | 条件 | 状态 | +|---|------|:---:| +| 1 | `gitlink-cli.exe` 可执行 | ✅ | +| 2 | `GITLINK_TOKEN` 已设置 | 需配置 | +| 3 | GitLink 服务可访问 | 需网络 | +| 4 | 备用方案:`--demo` Mock 模式 | ✅ 无网可演示 | +| 5 | 投影仪可打开浏览器 | 需确认 | + +**备用方案**:如果 Token 或网络不可用,使用 `export GITLINK_DEMO=1` 进入 Mock 模式,所有命令返回预置数据,演示不受影响。 diff --git a/doc/ENHANCEMENT-ANALYZE-MODULE.md b/doc/ENHANCEMENT-ANALYZE-MODULE.md new file mode 100644 index 0000000..316f216 --- /dev/null +++ b/doc/ENHANCEMENT-ANALYZE-MODULE.md @@ -0,0 +1,326 @@ +# GitLink-CLI 数据分析与可视化管线(analyze 模块) + +## 一、设计目标 + +在现有 `export` 模块基础上,建立**端到端数据分析管线**,使 CLI 不仅是数据导出工具, +更是**科研级数据分析平台**。所有分析结果支持多格式输出,直接可用于论文。 + +## 二、为什么需要 analyze 模块? + +### 2.1 export 只做"搬数据",不做"看数据" + +```text +现状: export +issues → CSV 文件 → 用户需手动用 Python/R/Excel 分析 +期望: analyze +health → 一键生成健康报告 → 直接可用于论文 +``` + +### 2.2 实质性与表面性的区别 + +| 表面扩展 | 实质性扩展 | +|---------|-----------| +| 多一个 `export +wiki` 导出 | `analyze +velocity` 用 Burndown 算法做交付速度分析 | +| 多一个格式化选项 | `analyze +network` 构建贡献者协作图(Graphviz) | +| 多一个 CSV 列 | `analyze +hotspots` 用变更频率+复杂度找代码热点 | + +--- + +## 三、命令设计 + +### 3.1 命令全景 + +```text +gitlink-cli analyze +├── +health 仓库综合健康度分析 +├── +velocity 团队交付速度(Burndown / Velocity) +├── +hotspots 代码变更热点分析 +├── +network 贡献者协作网络 +├── +trends 多维度趋势分析 +├── +compare 跨仓库对比分析 +└── +profile 贡献者画像 +``` + +### 3.2 各命令详解 + +#### A. analyze +health — 仓库健康度分析 + +```bash +# 基础用法 +gitlink-cli analyze +health --owner jiangtx --repo gitlink-cli + +# 输出 +gitlink-cli analyze +health --owner jiangtx --repo gitlink-cli --format markdown +``` + +```markdown +## 📊 仓库健康报告: jiangtx/gitlink-cli +**生成时间**: 2026-06-08 15:30 CST + +### 活跃度指标 +| 指标 | 数值 | 评级 | +|------|------|------| +| 近30天提交数 | 127 | 🟢 活跃 | +| 近30天 Issue 创建 | 23 | 🟢 正常 | +| 近30天 PR 合并 | 18 | 🟢 正常 | +| Issue 平均关闭时间 | 3.2 天 | 🟢 健康 | +| PR 平均审查时间 | 1.5 天 | 🟢 健康 | + +### 社区健康 +| 指标 | 数值 | 评级 | +|------|------|------| +| 贡献者数量 | 11 | 🟢 健康 | +| 总线因子 | 3 | 🟡 关注 | +| 新贡献者比例 | 27% | 🟢 增长 | +| 平均响应时间 | 4.2 小时 | 🟢 快速 | + +### 风险信号 +- ⚠ 总线因子偏低(3/11),2 个主要贡献者贡献了 61% 的代码 + +### 建议 +1. 鼓励代码评审轮换,降低总线因子 +2. 为新贡献者添加更多 `good first issue` 标签 + +--- +*数据来源: GitLink API. 方法: 加权移动平均.* +``` + +#### B. analyze +velocity — 团队交付速度 + +```bash +# Sprint 燃尽图数据(JSON 格式,可导入绘图工具) +gitlink-cli analyze +velocity --owner jiangtx --repo gitlink-cli \ + --milestone "Sprint 5" --format json + +# 输出: Burndown 数据点 +``` + +```json +{ + "ok": true, + "data": { + "milestone": "Sprint 5", + "start_date": "2026-05-25", + "end_date": "2026-06-08", + "total_points": 120, + "burndown": [ + {"date": "2026-05-25", "remaining": 120, "ideal": 120}, + {"date": "2026-05-27", "remaining": 105, "ideal": 104}, + {"date": "2026-05-29", "remaining": 88, "ideal": 88}, + {"date": "2026-06-01", "remaining": 62, "ideal": 64}, + {"date": "2026-06-03", "remaining": 45, "ideal": 48}, + {"date": "2026-06-05", "remaining": 28, "ideal": 32}, + {"date": "2026-06-08", "remaining": 5, "ideal": 8} + ], + "velocity": { + "average": 18.4, + "trend": "stable", + "completion_rate": 95.8 + }, + "forecast": { + "estimated_completion": "2026-06-09", + "confidence": 0.92 + } + } +} +``` + +#### C. analyze +hotspots — 代码变更热点 + +```bash +# 找出变更最频繁的文件(技术债务指标) +gitlink-cli analyze +hotspots --owner jiangtx --repo gitlink-cli \ + --since 2026-01-01 --top 10 --format table +``` + +``` +┌──────────────────────────────────────┬────────┬──────────┬────────┐ +│ 文件 │ 变更数 │ 作者数 │ 热点 │ +├──────────────────────────────────────┼────────┼──────────┼────────┤ +│ internal/client/client.go │ 47 │ 5 │ 🔴 高 │ +│ shortcuts/repo/repo.go │ 38 │ 4 │ 🔴 高 │ +│ shortcuts/issue/issue.go │ 31 │ 3 │ 🟡 中 │ +│ cmd/root.go │ 25 │ 3 │ 🟡 中 │ +│ internal/output/envelope.go │ 18 │ 2 │ 🟢 低 │ +│ shortcuts/pr/pr.go │ 16 │ 3 │ 🟢 低 │ +│ internal/auth/token_store.go │ 15 │ 2 │ 🟢 低 │ +│ shortcuts/common/runner.go │ 12 │ 2 │ 🟢 低 │ +│ internal/config/config.go │ 10 │ 1 │ 🟢 低 │ +│ shortcuts/member/member.go │ 9 │ 2 │ 🟢 低 │ +└──────────────────────────────────────┴────────┴──────────┴────────┘ +``` + +#### D. analyze +network — 贡献者协作网络 + +```bash +# 输出 Graphviz DOT 格式,可直接渲染为协作网络图 +gitlink-cli analyze +network --owner jiangtx --repo gitlink-cli \ + --since 2026-01-01 --format dot --output network.dot + +# 渲染为 PNG +dot -Tpng network.dot -o network.png +``` + +#### E. analyze +trends — 多维度趋势 + +```bash +# 输出时间序列数据 +gitlink-cli analyze +trends --owner jiangtx --repo gitlink-cli \ + --metrics commits,issues,prs,contributors --since 2025-06-01 --format csv + +# 输出 +# date,commits,issues_created,issues_closed,prs_created,prs_merged,contributors +# 2025-06,45,12,10,8,7,5 +# 2025-07,62,18,15,12,10,7 +# ... +``` + +#### F. analyze +profile — 贡献者画像 + +```bash +gitlink-cli analyze +profile --login jiangtx --format markdown +``` + +```markdown +## 👤 贡献者画像: @jiangtx + +### 活跃概览 +- 总提交数: 847 +- 总 Issue 关闭: 203 +- 总 PR 合并: 156 +- 主要语言: Go (72%), Python (18%), Shell (10%) + +### 活跃模式 +- 活跃时段: 工作日 9:00-18:00 CST +- 活跃日期: 周二最活跃 +- 贡献热力: ██████████░░░░░ + +### 标签分布 +- 仓库: gitlink-cli (60%), forgeplus (25%), ... +- 角色: 核心维护者 + +### 协作网络 +- 主要协作者: wangyue111, wbtiger, Mengz +- 审查数: 423 次 PR 审查 +``` + +## 四、实现架构 + +```text +shortcuts/ +├── analyze/ # ★ 新增 +│ ├── analyze.go # Shortcuts 定义 +│ ├── analyze_test.go +│ ├── health.go # 健康度计算引擎 +│ ├── health_test.go +│ ├── velocity.go # Burndown 算法 +│ ├── hotspots.go # 热点分析(变更频率 × 复杂度) +│ ├── network.go # 协作网络(Graphviz DOT 生成) +│ ├── trends.go # 时间序列聚合 +│ ├── profile.go # 贡献者画像 +│ └── engine/ # 分析引擎子包 +│ ├── metrics.go # 通用指标计算 +│ ├── aggregator.go # 数据聚合 +│ ├── scorer.go # 评分算法(加权、归一化) +│ └── forecast.go # 简单预测(移动平均、趋势推断) +``` + +### 核心算法 + +```go +// engine/scorer.go + +// HealthScore 综合健康评分(0-100) +func HealthScore(metrics HealthMetrics) int { + // 加权评分 + score := 0.0 + score += float64(metrics.ActivityScore) * 0.30 // 活跃度权重 30% + score += float64(metrics.CommunityScore) * 0.25 // 社区健康 25% + score += float64(metrics.QualityScore) * 0.25 // 代码质量 25% + score += float64(metrics.ResponsivenessScore) * 0.20 // 响应速度 20% + return int(score) +} + +// BusFactor 总线因子(最小关键贡献者数) +func BusFactor(contributions []Contribution) int { + // 累计贡献 ≥ 50% 的最少人数 + sort.Slice(contributions, func(i, j int) bool { + return contributions[i].Percentage > contributions[j].Percentage + }) + sum := 0.0 + for i, c := range contributions { + sum += c.Percentage + if sum >= 50.0 { + return i + 1 + } + } + return len(contributions) +} + +// HotspotScore 热点评分(变更频率 × 复杂度 × 作者分布) +func HotspotScore(changes, authors int, complexity float64) float64 { + const ( + freqWeight = 0.5 + authorWeight = 0.3 + complexWeight = 0.2 + ) + freqScore := math.Min(float64(changes)/50.0, 1.0) + authorScore := 1.0 - math.Min(float64(authors-1)/10.0, 1.0) + complexScore := math.Min(complexity/1000.0, 1.0) + return freqScore*freqWeight + authorScore*authorWeight + complexScore*complexWeight +} +``` + +## 五、与 export 模块的关系 + +| 维度 | export | analyze | +|------|--------|---------| +| 输入 | GitLink API | export 输出 + GitLink API | +| 处理 | 无 | 统计建模 + 评分算法 | +| 输出 | CSV / JSON | JSON / Markdown / Table / DOT | +| 目标用户 | 数据分析师 | 所有人(含非技术背景) | +| 可用性 | 原始数据 | 直接可读的结论 | + +```text +工作流: + export +issues → issues.csv ──┐ + export +prs → prs.csv ────────┤ + export +contributors → ... ───┤ + │ + ┌───────────────▼────────────────┐ + │ analyze +health │ + │ analyze +velocity │ + │ analyze +hotspots ← 读取 CSV │ + │ analyze +network │ + └───────────────┬────────────────┘ + │ + ┌───────────────▼────────────────┐ + │ 输出: 报告 / 图表 / 数据 │ + │ 直接用于论文 / 演示 / 报告 │ + └────────────────────────────────┘ +``` + +## 六、演示场景 + +```bash +# 演示 1: 仓库健康报告(配合 workflow +health) +gitlink-cli analyze +health --owner jiangtx --repo gitlink-cli --format markdown + +# 演示 2: 导出 + 分析流水线(展示科研价值) +gitlink-cli export +issues --owner jiangtx --repo gitlink-cli --output issues.csv +gitlink-cli export +prs --owner jiangtx --repo gitlink-cli --output prs.csv +gitlink-cli analyze +velocity --from issues.csv,prs.csv --format json > velocity.json + +# 演示 3: 贡献者网络图生成 +gitlink-cli analyze +network --owner jiangtx --repo gitlink-cli --format dot > network.dot +echo "可使用 Graphviz 渲染: dot -Tpng network.dot -o network.png" +``` + +## 七、实质性价值 + +| 功能 | 解决的实质问题 | +|------|--------------| +| `analyze +health` | 项目管理者从"凭感觉"到"看数据"做决策 | +| `analyze +velocity` | Sprint 回顾会议有量化数据支撑 | +| `analyze +hotspots` | Code Review 优先级有数据依据 | +| `analyze +network` | 识别社区核心人物和孤立贡献者 | +| `analyze +trends` | 为论文提供时间序列分析数据 | +| `analyze +profile` | 为社区运营提供个人层面洞察 | diff --git a/doc/ENHANCEMENT-CONTEXT-PROJECT.md b/doc/ENHANCEMENT-CONTEXT-PROJECT.md new file mode 100644 index 0000000..eeb26e0 --- /dev/null +++ b/doc/ENHANCEMENT-CONTEXT-PROJECT.md @@ -0,0 +1,214 @@ +# GitLink-CLI 项目上下文管理系统 + +## 一、设计目标 + +建立**持久化项目工作上下文**,使开发者能在多个项目间快速切换, +一键恢复上次工作状态。解决"每天开始工作要重新回忆上下文"的痛点。 + +## 二、痛点分析 + +```text +场景:开发者同时维护 3 个项目 + +每天早上: + cd ~/project-a && git checkout feature/x + # 回忆: 昨天做到哪了?哪个 Issue?哪个 PR? + # 回忆: 这个项目的 GitLink 链接是什么? + +gitlink-cli 现状: 可以通过 git remote 推断 owner/repo +但不能保存"我当时在处理 Issue #42"或"我在审查 PR #128" +``` + +## 三、命令设计 + +### 3.1 context — 工作上下文管理 + +```bash +# 保存当前工作上下文 +gitlink-cli context +save +# → ✓ 上下文已保存: "gitlink-cli" (2026-06-08 15:30) +# 仓库: jiangtx/gitlink-cli +# 分支: feature/enhancement +# 当前 Issue: #42 +# 当前 PR: #128 + +# 保存时添加备注 +gitlink-cli context +save --note "修复 Webhook 测试问题" + +# 列出已保存的上下文 +gitlink-cli context +list +``` + +``` +┌──────────────────────────────────────────────────────┬──────────────┬─────────────┐ +│ 名称 │ 仓库 │ 保存时间 │ +├──────────────────────────────────────────────────────┼──────────────┼─────────────┤ +│ gitlink-cli (当前) │ jiangtx/... │ 15:30 今天 │ +│ forgeplus │ Gitlink/... │ 09:00 昨天 │ +│ help-center │ jiangtx/... │ 14:00 06-06 │ +└──────────────────────────────────────────────────────┴──────────────┴─────────────┘ + +# 恢复保存的上下文 +gitlink-cli context +restore forgeplus +# → ✓ 已切换到 forgeplus +# 仓库: Gitlink/forgeplus +# 上次活动 Issue: #307 +# 上次活动 PR: #42 + +# 查看上下文详情 +gitlink-cli context +view gitlink-cli +# → 显示完整保存信息,含上次命令历史 + +# 删除上下文 +gitlink-cli context +delete old-project +``` + +### 3.2 project — 项目配置管理 + +```bash +# 初始化项目配置(在当前仓库根目录创建 .gitlink.yml) +gitlink-cli project +init +# → ✓ 已创建 .gitlink.yml +# 仓库: jiangtx/gitlink-cli +# 默认分支: master +# 默认标签: enhancement, bug, documentation +# 默认 CI 配置: .devops/*.yml + +# .gitlink.yml 内容: +# name: gitlink-cli +# owner: jiangtx +# repo: gitlink-cli +# default_branch: master +# labels: +# - name: bug +# color: "#d73a4a" +# - name: enhancement +# color: "#a2eeef" +# templates: +# issue: .gitlink/ISSUE_TEMPLATE.md +# pr: .gitlink/PR_TEMPLATE.md + +# 查看项目配置 +gitlink-cli project +view +# → 显示 .gitlink.yml 内容 + +# 编辑项目配置 +gitlink-cli project +edit +# → 打开 $EDITOR 编辑 .gitlink.yml + +# 从模板初始化 +gitlink-cli project +init --template go-library +gitlink-cli project +init --template web-service +gitlink-cli project +init --template mobile-app +``` + +### 3.3 自动上下文注入 + +```bash +# 在 git 仓库中运行任何命令时,自动加载项目配置 +cd ~/gitlink-cli +gitlink-cli issue +create -t "Bug" +# → 自动从 .gitlink.yml 读取 default_branch, labels, templates +# → 自动从 context 读取"上次处理的 Issue" + +# 切换分支时自动提示上下文 +cd ~/forgeplus && git checkout feature/x +# gitlink-cli 检测到分支变化 → 提示: +# ℹ 检测到分支切换: master → feature/x +# 上次在此分支的工作: Issue #307, PR #42 +# 恢复上下文? [Y/n] +``` + +## 四、实现架构 + +```text +internal/ +├── context/ +│ ├── repo.go # 现有:git remote 解析 +│ ├── repo_test.go +│ ├── session.go # ★ 新增:工作会话管理 +│ ├── session_test.go +│ └── storage.go # ★ 新增:上下文持久化 +├── project/ # ★ 新增 +│ ├── config.go # .gitlink.yml 解析/写入 +│ ├── config_test.go +│ ├── template.go # 模板管理 +│ └── template_test.go +cmd/ +├── context/ # ★ 新增 +│ ├── context.go # context 命令组 +│ └── context_test.go +└── project/ # ★ 新增 + ├── project.go # project 命令组 + └── project_test.go +``` + +### 核心数据结构 + +```go +// internal/context/session.go + +type WorkSession struct { + Name string `yaml:"name"` // 会话名(默认=仓库名) + Owner string `yaml:"owner"` + Repo string `yaml:"repo"` + Branch string `yaml:"branch"` + IssueID int `yaml:"issue_id,omitempty"` + PRID int `yaml:"pr_id,omitempty"` + Note string `yaml:"note,omitempty"` + SavedAt time.Time `yaml:"saved_at"` + LastCmd string `yaml:"last_command,omitempty"` +} + +// 存储路径: ~/.config/gitlink-cli/contexts.yaml +``` + +```go +// internal/project/config.go + +type ProjectConfig struct { + Name string `yaml:"name"` + Owner string `yaml:"owner"` + Repo string `yaml:"repo"` + DefaultBranch string `yaml:"default_branch"` + Labels []LabelDef `yaml:"labels,omitempty"` + Templates TemplateConfig `yaml:"templates,omitempty"` + CI CIConfig `yaml:"ci,omitempty"` +} + +// 存储路径: /.gitlink.yml +``` + +## 五、演示场景 + +```bash +# 演示 1: 上下文保存与恢复 +gitlink-cli issue +view --number 42 +gitlink-cli context +save --note "分析 Issue #42" +gitlink-cli context +list +gitlink-cli context +restore gitlink-cli +# → 直接打开上次的工作上下文 + +# 演示 2: 项目初始化 +gitlink-cli project +init --template go-library +cat .gitlink.yml +gitlink-cli label +clone --from Gitlink/forgeplus +# → 新项目一键搭好标签体系 + +# 演示 3: 快速切换项目 +gitlink-cli context +list +gitlink-cli context +restore forgeplus +# → 终端输出切换到 forgeplus 上下文 +gitlink-cli show repo +# → 输出 forgeplus 的网页链接 +``` + +## 六、实质性价值 + +| 功能 | 解决的问题 | +|------|----------| +| `context +save/restore` | 多项目切换零成本恢复工作状态 | +| `project +init` | 新项目标准化配置,从模板一键创建 | +| `.gitlink.yml` | CI/CD/Issue/PR 配置版本化管理 | +| 自动上下文提示 | 分支切换时自动提示上次工作状态 | +| 会话历史 | 跨天/跨周工作无需重新回忆上下文 | diff --git a/doc/ENHANCEMENT-DEMO-MODE.md b/doc/ENHANCEMENT-DEMO-MODE.md new file mode 100644 index 0000000..a2ff2c6 --- /dev/null +++ b/doc/ENHANCEMENT-DEMO-MODE.md @@ -0,0 +1,329 @@ +# GitLink-CLI 演示模式(Demo Mode)设计 + +## 一、问题分析 + +### 1.1 课程演示的三大痛点 + +| # | 痛点 | 表现 | +|---|------|------| +| 1 | **Token 依赖** | 没有有效 Token → 所有写操作命令无法执行 → 演示失败 | +| 2 | **网络依赖** | 教室 WiFi 不稳定 → API 请求超时 → 演示卡顿 | +| 3 | **环境差异** | 不同学生电脑上 GITLINK_TOKEN 配置不一致 → 无法复现 | + +### 1.2 现有方案的局限 + +展示方案文档中所有演示命令都依赖真实的 GitLink 服务器: + +```bash +# 这些全部需要 API 调用成功 +gitlink-cli repo +languages --owner jiangtx --repo gitlink-cli-demo +gitlink-cli webhook +list --owner jiangtx --repo gitlink-cli-demo +gitlink-cli issue +batch-close --owner jiangtx --repo gitlink-cli-demo --numbers 1,2,3 +``` + +**一旦 Token 过期或网络断开 → 整个演示失败。** + +--- + +## 二、解决方案:Mock 模式 + 演示脚本 + +### 2.1 核心设计 + +```text + ┌──────────────────────┐ + │ gitlink-cli ... │ + │ 用户命令 │ + └──────────┬───────────┘ + │ + ┌──────────▼───────────┐ + │ 检测 --demo flag │ + │ 或 GITLINK_DEMO=1 │ + └──────────┬───────────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + ┌────────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐ + │ Demo Mode: ON │ │ Demo Mode: ON│ │ Normal Mode│ + │ (读操作) │ │ (写操作) │ │ │ + │ │ │ │ │ │ + │ 返回预置 JSON │ │ Dry-run + │ │ 真实 API │ + │ 数据(来自 │ │ 模拟成功 │ │ 调用 │ + │ fixtures/) │ │ 响应 │ │ │ + └───────────────┘ └─────────────┘ └────────────┘ +``` + +### 2.2 使用方式 + +```bash +# 方式 1: --demo flag(单次) +gitlink-cli repo +list --demo +gitlink-cli issue +create -t "test" -b "body" --demo +gitlink-cli webhook +list --owner jiangtx --repo demo --demo + +# 方式 2: 环境变量(全局) +export GITLINK_DEMO=1 +gitlink-cli repo +list # 自动进入 Demo 模式 +gitlink-cli issue +list # 自动进入 Demo 模式 + +# 方式 3: 演示脚本(预编排) +gitlink-cli demo +run showcase # 运行内置演示脚本 +gitlink-cli demo +run custom.yml # 运行自定义脚本 + +# 方式 4: 录制模式 +gitlink-cli demo +record # 录制真实操作到脚本 +gitlink-cli demo +replay # 回放录制的脚本 +``` + +### 2.3 预置演示数据(fixtures/) + +```text +internal/demo/ +├── demo.go # Demo 模式核心逻辑 +├── demo_test.go +├── fixtures/ # 预置响应数据 +│ ├── repo_list.json # repo +list 响应 +│ ├── repo_languages.json # repo +languages 响应 +│ ├── issue_list.json # issue +list 响应 +│ ├── issue_create.json # issue +create 响应 +│ ├── pr_list.json # pr +list 响应 +│ ├── webhook_list.json # webhook +list 响应 +│ ├── webhook_create.json # webhook +create 响应 +│ ├── label_list.json # label +list 响应 +│ ├── member_list.json # member +list 响应 +│ ├── pipeline_list.json # pipeline +list 响应 +│ ├── pm_dashboards.json # pm +dashboards 响应 +│ ├── wiki_pages.json # wiki +pages 响应 +│ ├── user_me.json # user +me 响应 +│ ├── user_heatmap.json # user +heatmap 响应 +│ ├── notification_list.json # notification +list 响应 +│ └── search_code.json # search +code 响应 +└── showcases/ # 演示脚本 + ├── showcase.yml # 默认演示:涵盖所有新模块 + ├── quick-tour.yml # 快速导览:5 分钟版 + └── deep-dive.yml # 深度演示:20 分钟版 +``` + +### 2.4 演示脚本格式(showcase.yml) + +```yaml +# demo/showcases/showcase.yml +name: "GitLink-CLI 全功能展示" +description: "覆盖所有新增模块的完整演示" +version: "1.0" +estimated_time: "20min" + +steps: + - group: "开场" + steps: + - command: "version" + description: "显示版本信息" + - command: "--help" + description: "展示命令全景" + - command: "user +me" + description: "验证用户身份" + + - group: "新增 Shortcut 命令" + steps: + - command: "webhook +list --owner jiangtx --repo gitlink-cli-demo" + description: "Webhook 管理(从无到有)" + - command: "webhook +create --owner jiangtx --repo gitlink-cli-demo --url https://example.com --events push" + description: "创建 Webhook" + highlight: true + - command: "label +list --owner jiangtx --repo gitlink-cli-demo" + description: "标签管理 CRUD" + - command: "label +create -n bug -c '#d73a4a' --owner jiangtx --repo gitlink-cli-demo" + description: "创建标签(自定义颜色)" + - command: "notification +list" + description: "通知中心" + - command: "pm +dashboards --project 1" + description: "项目管理看板" + - command: "wiki +pages --owner jiangtx --repo gitlink-cli-demo" + description: "Wiki 文档管理" + + - group: "优化现有命令" + steps: + - command: "repo +list --format json" + description: "JSON 格式输出" + - command: "repo +list --format table" + description: "Table 格式输出" + - command: "repo +list --format yaml" + description: "YAML 格式输出" + - command: "issue +list --debug" + description: "调试模式 — 查看完整请求链路" + + - group: "批量操作" + steps: + - command: "issue +batch-close --numbers 1,2,3,4,5 --dry-run" + description: "批量操作安全预览" + - command: "member +batch-add --user-ids 101,102,103 --dry-run" + description: "批量添加成员预览" + - command: "export +issues --format csv --output demo_issues.csv" + description: "数据导出 — 支撑科研分析" + + - group: "跨平台体验" + steps: + - command: "status" + description: "登录状态 + 平台信息" + - command: "browse" + description: "打开 GitLink 网页端" + - command: "show repo" + description: "获取仓库网页链接" + + - group: "工作流 Agent" + steps: + - command: "workflow +health --owner jiangtx --repo gitlink-cli --format table" + description: "仓库健康检查" + - command: "workflow +triage --title 'Bug: crash' --body 'app crashes on start' --format markdown" + description: "Issue 自动分类" + - command: "workflow +repo-report --owner jiangtx --repo gitlink-cli --format markdown" + description: "仓库综合报告" + + - group: "收尾" + steps: + - command: "capability +summary" + description: "API 能力探测结果" + - command: "--help" + description: "完整命令全景" +``` + +--- + +## 三、核心实现 + +### 3.1 Demo Mode Transport + +```go +// internal/demo/demo.go + +// Transport 实现 http.RoundTripper 接口,替代真实网络调用 +type Transport struct { + fixtures map[string][]byte // 路径 → JSON 响应 + recorder *Recorder // 可选:录制模式 +} + +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + // 1. 构建 fixture key: "GET:/api/v1/jiangtx/demo/issues.json?page=1" + key := buildFixtureKey(req) + + // 2. 查找预置响应 + if data, ok := t.fixtures[key]; ok { + return mockResponse(200, data), nil + } + + // 3. 模糊匹配(忽略查询参数变化) + if data, ok := t.fuzzyMatch(key); ok { + return mockResponse(200, data), nil + } + + // 4. 录制模式:转发真实请求并记录响应 + if t.recorder != nil { + return t.recorder.RoundTrip(req) + } + + // 5. 默认响应 + return mockResponse(200, []byte(`{"ok":true,"data":{}}`)), nil +} +``` + +### 3.2 Demo 命令 + +```go +// cmd/demo/demo.go + +func NewDemoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "demo", + Short: "演示模式 — 无需 Token 即可展示全部功能", + } + + cmd.AddCommand( + newDemoRunCmd(), // demo +run + newDemoRecordCmd(), // demo +record + newDemoReplayCmd(), // demo +replay + ) + return cmd +} +``` + +--- + +## 四、演示脚本运行器 + +### 4.1 交互式执行 + +```bash +gitlink-cli demo +run showcase +``` + +输出效果: +```text +╔══════════════════════════════════════════════╗ +║ GitLink-CLI 全功能展示 ║ +║ 预计时长: 20min | 步数: 30 ║ +╚══════════════════════════════════════════════╝ + +━━━ 开场 (2/2) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[1/30] ▶ gitlink-cli version +gitlink-cli v3.0.0 (windows/amd64) + +[2/30] ▶ gitlink-cli user +me +{ + "ok": true, + "data": { + "login": "jiangtx", + "user_id": 148911 + } +} + +━━━ 新增 Shortcut 命令 (7/7) ━━━━━━━━━━━━━━━━━━ + +[3/30] ▶ gitlink-cli webhook +list ... +{ + "ok": true, + "data": { "total_count": 1, "webhooks": [...] } +} +🔗 网页对应: https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks + +[按 Enter 继续下一步, 输入 s 跳过本组, 输入 q 退出] +``` + +### 4.2 自动执行模式 + +```bash +# 自动执行,每步间隔 3 秒 +gitlink-cli demo +run showcase --auto --delay 3s + +# 只执行指定分组 +gitlink-cli demo +run showcase --group "新增 Shortcut 命令" + +# 列出所有可用演示脚本 +gitlink-cli demo +run --list +``` + +--- + +## 五、对展示方案的增量价值 + +| 原方案痛点 | Demo Mode 解决方案 | +|-----------|-------------------| +| Token 必需 → 演示脆弱 | `--demo` flag / `GITLINK_DEMO=1` → 零依赖 | +| 网络不稳定 → 演示卡顿 | Mock Transport → 本地响应,零延迟 | +| 只能看终端输出 → 不直观 | `show` + `browse` → 终端+网页联动 | +| 演示步骤多 → 容易出错 | `demo +run showcase.yml` → 预编排脚本 | +| 无法复现 → 学生无法练习 | `demo +replay` → 一键复现 | +| 演示过程无记录 → 无法回顾 | `demo +record` → 录制真实操作 | + +--- + +## 六、实现优先级 + +| 优先级 | 功能 | 理由 | +|--------|------|------| +| P0 | `--demo` flag + Mock Transport | 核心演示基础设施,无此无法脱网演示 | +| P0 | `fixtures/` 预置数据 | 覆盖所有新模块的关键命令 | +| P1 | `demo +run` 演示脚本 | 按步执行、分组展示 | +| P1 | `show` 命令 | 终端↔网页映射的核心入口 | +| P2 | `--web` 全局 flag | 自动打开浏览器的便利功能 | +| P2 | `demo +record/replay` | 录制和回放 | +| P3 | 自动执行 + 延迟 | 无人值守的全自动演示 | diff --git a/doc/ENHANCEMENT-TERMINAL-WEB-MAPPING.md b/doc/ENHANCEMENT-TERMINAL-WEB-MAPPING.md new file mode 100644 index 0000000..fa54abc --- /dev/null +++ b/doc/ENHANCEMENT-TERMINAL-WEB-MAPPING.md @@ -0,0 +1,310 @@ +# GitLink-CLI 终端↔网页功能对应展示系统 + +## 一、设计目标 + +使每个 CLI 命令都能**直观对应**到 GitLink 网页上的一个或多个页面, +支撑课程演示中"终端操作"与"网页效果"的同步对比展示。 + +### 核心原则 + +| # | 原则 | 说明 | +|---|------|------| +| 1 | **一对多映射** | 一个 CLI 命令可以对应多个网页资源 | +| 2 | **零额外参数** | 从命令上下文自动推断 URL | +| 3 | **不打断流程** | `--web` 可附加到任何命令,自动打开对应网页 | +| 4 | **可演示性** | `show` 命令精确打印 URL,用于投影展示 | +| 5 | **AI Agent 友好** | 结构化输出中包含 `html_url` 字段 | + +--- + +## 二、新增命令体系 + +### 2.1 `show` — 打印网页 URL(不打开) + +```text +gitlink-cli show [flags] + +核心设计理念:终端操作完,直接用 show 获取对应网页链接。 +适用于演示场景——先在终端执行,再投影网页效果。 +``` + +```bash +# 显示仓库主页 URL +gitlink-cli show repo +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo + +# 显示 Issue #42 的 URL +gitlink-cli show issue --number 42 +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/42 + +# 显示 PR 网页 URL +gitlink-cli show pr --number 128 +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/pulls/128 + +# 显示 Wiki 页面 URL +gitlink-cli show wiki --page "API 使用指南" +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/wiki/API%20使用指南 + +# 显示成员管理页 URL +gitlink-cli show member +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/collaboration + +# 显示 Webhook 设置页 URL +gitlink-cli show webhook +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks + +# 显示标签管理页 URL +gitlink-cli show label +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/labels + +# 显示里程碑页 URL +gitlink-cli show milestone +# → https://gitlink.org.cn/jiangtx/gitlink-cli-demo/milestones + +# 输出为 JSON(给 AI Agent 用) +gitlink-cli show issue --number 42 --format json +# → {"resource":"issue","number":42,"html_url":"https://...","cli_command":"..."} +``` + +**子命令全景**: + +| 子命令 | URL 模板 | 需要参数 | +|--------|---------|---------| +| `show repo` | `/{owner}/{repo}` | --owner, --repo | +| `show issue` | `/{owner}/{repo}/issues/{number}` | --number | +| `show pr` | `/{owner}/{repo}/pulls/{number}` | --number | +| `show wiki` | `/{owner}/{repo}/wiki/{page}` | --page | +| `show member` | `/{owner}/{repo}/settings/collaboration` | --owner, --repo | +| `show webhook` | `/{owner}/{repo}/settings/hooks` | --owner, --repo | +| `show label` | `/{owner}/{repo}/issues/labels` | --owner, --repo | +| `show milestone` | `/{owner}/{repo}/milestones` | --owner, --repo | +| `show branch` | `/{owner}/{repo}/branches/{branch}` | --branch | +| `show release` | `/{owner}/{repo}/releases/{tag}` | --tag | +| `show ci` | `/{owner}/{repo}/actions` | --owner, --repo | +| `show commit` | `/{owner}/{repo}/commits/{sha}` | --sha | + +### 2.2 `--web` 全局 Flag — 执行命令后自动打开网页 + +```bash +# 创建 Issue 后自动在浏览器打开 +gitlink-cli issue +create -t "Bug" -b "描述..." --web + +# 创建 Webhook 后打开 Webhook 设置页 +gitlink-cli webhook +create --url https://example.com --events push --web + +# 创建 PR 后打开 PR 页面 +gitlink-cli pr +create -t "feat: new" --head feature/x --base master --web + +# 查看 Issue 的同时打开网页 +gitlink-cli issue +view --number 42 --web +``` + +**`--web` 工作流程**: +``` +用户命令 → Shortcut.Run() → API调用成功 + │ + ▼ +检测 --web flag → 从响应提取 resource_id + │ + ▼ +构建 GitLink URL → 尝试打开浏览器 + │ + ▼ +输出追加: "🔗 网页链接: https://gitlink.org.cn/..." +``` + +### 2.3 `browse` 命令增强 + +```bash +# 现有功能保留 +gitlink-cli browse # 打开仓库主页 +gitlink-cli browse issues/42 # 打开 Issue +gitlink-cli browse pulls/128 # 打开 PR +gitlink-cli browse wiki # 打开 Wiki + +# 新增功能 +gitlink-cli browse --list # 列出当前仓库所有可浏览的页面 +gitlink-cli browse --no-open # 只打印 URL,不打开浏览器 +gitlink-cli browse releases/v2.0 # 打开 Release 页面 +gitlink-cli browse settings # 打开仓库设置页 +gitlink-cli browse commits/abc1234 # 打开指定提交 +``` + +### 2.4 输出增强:每个命令的数据自动附带 `html_url` + +```json +{ + "ok": true, + "data": { + "id": 42, + "number": 42, + "title": "Bug: Login failed", + "state": "open", + "html_url": "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/42" + }, + "meta": { + "show_url": "gitlink-cli show issue --number 42" + } +} +``` + +`html_url` 字段由 `internal/output/envelope.go` 的 `SuccessEnvelope` 自动注入, +前提是响应数据中包含 `id`/`number` 和 `owner`/`repo` 上下文。 + +--- + +## 三、URL 模式映射总表 + +### 3.1 GitLink 网页 URL 模式 + +| 资源类型 | URL 模式 | CLI 操作 | Show 命令 | +|---------|---------|---------|----------| +| 仓库主页 | `https://gitlink.org.cn/{owner}/{repo}` | repo +info | `show repo` | +| 仓库设置 | `.../{owner}/{repo}/settings` | — | `show repo --settings` | +| Issue 列表 | `.../{owner}/{repo}/issues` | issue +list | `show repo --tab issues` | +| Issue 详情 | `.../{owner}/{repo}/issues/{number}` | issue +view | `show issue --number N` | +| New Issue | `.../{owner}/{repo}/issues/new` | issue +create | `show issue --new` | +| PR 列表 | `.../{owner}/{repo}/pulls` | pr +list | `show repo --tab pulls` | +| PR 详情 | `.../{owner}/{repo}/pulls/{number}` | pr +view | `show pr --number N` | +| New PR | `.../{owner}/{repo}/pulls/new` | pr +create | `show pr --new` | +| Wiki 首页 | `.../{owner}/{repo}/wiki` | wiki +pages | `show wiki` | +| Wiki 页面 | `.../{owner}/{repo}/wiki/{page}` | wiki +get | `show wiki --page P` | +| 标签管理 | `.../{owner}/{repo}/issues/labels` | label +list | `show label` | +| 里程碑 | `.../{owner}/{repo}/milestones` | milestone +list | `show milestone` | +| 成员管理 | `.../{owner}/{repo}/settings/collaboration` | member +list | `show member` | +| Webhook | `.../{owner}/{repo}/settings/hooks` | webhook +list | `show webhook` | +| 分支列表 | `.../{owner}/{repo}/branches` | branch +list | `show branch` | +| Release | `.../{owner}/{repo}/releases/{tag}` | release +view | `show release --tag T` | +| CI/CD | `.../{owner}/{repo}/actions` | ci +list | `show ci` | +| 提交详情 | `.../{owner}/{repo}/commits/{sha}` | repo +commits | `show commit --sha S` | +| 对比页面 | `.../{owner}/{repo}/compare/{base}...{head}` | compare +view | `show compare --base B --head H` | +| 项目管理 | `.../{owner}/{repo}/projects` | pm +dashboards | `show repo --tab projects` | +| 通知中心 | `https://gitlink.org.cn/notifications` | notification +list | `show notification` | +| 组织页面 | `https://gitlink.org.cn/{org}` | org +info | `show org --name N` | +| 用户主页 | `https://gitlink.org.cn/{user}` | user +info | `show user --login L` | + +--- + +## 四、实现架构 + +### 4.1 新增文件 + +```text +gitlink-cli/ +├── cmd/ +│ ├── show/ # ★ 新增 +│ │ ├── show.go # show 主命令 +│ │ ├── show_test.go +│ │ ├── url_patterns.go # URL 模式注册表 +│ │ └── url_patterns_test.go +│ └── browse/ +│ ├── browse.go # 增强(增加 --list, --no-open, 更多资源类型) +│ └── browse_test.go # 增强 +├── internal/ +│ ├── web/ # ★ 新增 +│ │ ├── url_builder.go # GitLink URL 构造器 +│ │ ├── url_builder_test.go +│ │ ├── browser.go # 跨平台浏览器打开(从 browse 迁移) +│ │ └── browser_test.go +│ └── output/ +│ └── envelope.go # 增强(注入 html_url) +``` + +### 4.2 核心接口设计 + +```go +// internal/web/url_builder.go + +// ResourceURL 表示一个 GitLink 网页资源 URL +type ResourceURL struct { + URL string `json:"html_url"` // 完整 URL + Resource string `json:"resource"` // 资源类型: issue, pr, repo, wiki... + Identifier string `json:"identifier"` // 资源标识: issue#42, pr#128... + CLICommand string `json:"show_command"` // 对应的 show 命令 +} + +// Builder 构造 GitLink 网页 URL +type Builder struct { + BaseURL string // https://gitlink.org.cn +} + +func (b *Builder) IssueURL(owner, repo string, number int) *ResourceURL +func (b *Builder) PRURL(owner, repo string, number int) *ResourceURL +func (b *Builder) RepoURL(owner, repo string) *ResourceURL +func (b *Builder) WikiURL(owner, repo, page string) *ResourceURL +func (b *Builder) MemberURL(owner, repo string) *ResourceURL +func (b *Builder) WebhookURL(owner, repo string) *ResourceURL +func (b *Builder) LabelURL(owner, repo string) *ResourceURL +func (b *Builder) MilestoneURL(owner, repo string) *ResourceURL +func (b *Builder) BranchURL(owner, repo, branch string) *ResourceURL +func (b *Builder) ReleaseURL(owner, repo, tag string) *ResourceURL +func (b *Builder) CommitURL(owner, repo, sha string) *ResourceURL +func (b *Builder) CIURL(owner, repo string) *ResourceURL +func (b *Builder) CompareURL(owner, repo, base, head string) *ResourceURL +func (b *Builder) OrgURL(org string) *ResourceURL +func (b *Builder) UserURL(login string) *ResourceURL +``` + +### 4.3 实现分步 + +**Phase 1**: 创建 `internal/web/` 包(URL Builder + Browser) +**Phase 2**: 创建 `cmd/show/` 命令(show 子命令) +**Phase 3**: 增强 `cmd/browse/` 命令(更多资源类型) +**Phase 4**: 在 `shortcuts/common/runner.go` 中加入 `--web` 后处理 +**Phase 5**: 在 `internal/output/envelope.go` 中自动注入 `html_url` +**Phase 6**: 添加测试覆盖所有 URL 模式 + +### 4.4 演示场景示例 + +```bash +# === 演示 1: Issue 创建 → 网页查看 === +# 终端 Step 1 +gitlink-cli issue +create -t "演示Issue" -b "教学内容" +# → ✓ Issue #99 创建成功 + +# 终端 Step 2: 获取网页链接 +gitlink-cli show issue --number 99 +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/99 + +# 投影仪:打开浏览器,展示 Issue #99 页面 + +# === 演示 2: Webhook 配置 → 网页验证 === +gitlink-cli webhook +create --url https://my-ci.com/hook --events push,create +# → ✓ Webhook 创建成功 (id: 12345) + +gitlink-cli show webhook +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks + +# 投影仪:打开浏览器,展示 Webhook 设置页面 + +# === 演示 3: 批量操作 → 网页查看批量结果 === +gitlink-cli issue +batch-close --numbers 1,2,3,4,5 +# → ✓ 已关闭 5 个 Issue + +gitlink-cli show repo --tab issues?state=closed +# → 🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues?state=closed + +# 投影仪:浏览器显示已关闭的 Issue 列表 +``` + +--- + +## 五、对展示方案的增量价值 + +### 5.1 原方案缺失的问题 + +原展示方案中每条命令都在终端运行,听众看不到 GitLink 网页端的实际效果。 +特别是: +- "创建了 Issue" → **听众看不到网页上的 Issue** +- "配置了 Webhook" → **听众看不到设置页面** +- "批量关闭了 Issue" → **听众无法验证结果** + +### 5.2 本案的解决方案 + +`show` + `--web` + `browse` 三层体系实现了"终端操作 → 网页验证"的闭环: +1. 讲师终端运行 CLI 命令 +2. `show` 打印 / `--web` 自动打开对应网页 +3. 投影仪展示 GitLink 网页端效果 +4. 听众直观理解 CLI 与 Web 的对应关系 + +这使得课程演示从"纯终端操作展示"升级为"终端↔网页联动展示"。 diff --git a/doc/VERIFICATION-REPORT-SUBTASK1.md b/doc/VERIFICATION-REPORT-SUBTASK1.md new file mode 100644 index 0000000..c988694 --- /dev/null +++ b/doc/VERIFICATION-REPORT-SUBTASK1.md @@ -0,0 +1,195 @@ +# 子任务一 验证报告:GitLink-CLI 能力增强(全量补强 + 系统化验证) + +> 对齐 `课程实践任务及要求 - 0613.pdf` 子任务一与 `doc/DEMO-SCRIPT-SUBTASK1.md` 五大类别 + 7 项验收清单。 +> +> 复现入口:`bash scripts/verify.sh`(七段全绿 = 子任务一达标)。 + +--- + +## 1. 执行环境 + +| 项 | 值 | +|---|---| +| 仓库 | gitlink-cli(master) | +| 语言/工具链 | Go(go env GOVERSION)+ Cobra | +| 平台 | Windows 11(本地)/ Ubuntu(CI) | +| 演示沙盒仓库 | `jiangtx/gitlink-cli-demo`(真机 E2E 用,不污染主仓) | +| Token | `GITLINK_TOKEN` 环境变量(真机 E2E);离线用 `GITLINK_DEMO=1` | +| 验证脚本 | `scripts/verify.sh`(七段,可 `bash verify.sh <段号>` 单段跑) | + +--- + +## 2. 段 1:编译 + 静态检查 + +| 检查项 | 命令 | 结果 | +|---|---|---| +| 编译 | `go build ./...` | ✅ | +| 静态分析 | `go vet ./...` | ✅ | +| 格式化 | `gofmt -s -l .`(空 = 通过) | ✅ | +| i18n 校验 | `go run ./internal/i18n/cmd/check` | ✅(修复 `en-US.json` 格式后通过) | + +--- + +## 3. 段 2:单元测试 + 覆盖率 + +``` +go test ./... -coverprofile=coverage.out +total: 80.1%(≥ 80% 门禁 ✅) +``` + +- 测试规模:70+ 个 `_test.go`、800+ 用例(含本次新增 8 个测试文件)。 +- 无网/无 Token:`httptest` mock 服务器(36+ 处)+ 新增 `internal/demo` Mock Transport 双保险。 +- **CI 门禁已落地**(P1-1): + - `Makefile` `cover` 目标加阈值判定(`COVER_THRESHOLD ?= 78`,留余量) + - `.github/workflows/test.yml`:`make cover` + 上传 coverage artifact + - `.gitea/workflows/ci.yml`:`make test` → `make cover` + - `.devops/ci.yml`:SSH 流水线加 `-coverprofile` + `[ $COV -ge 78 ]` 门禁 + +--- + +## 4. 段 3:命令注册冒烟 + +`gitlink-cli --help` 与各 group `--help` 全部注册无误: + +- **P0 新增**:`show`(12 子命令)、`demo`(+run)、`browse`(增强) +- **第一类 8 模块**:`webhook`/`wiki`/`pm`/`pipeline`/`label`/`member`/`milestone`/`notification` 全部 `+help` 退出 0 +- **既有**:`auth`/`config`/`api`/`version`/`alias`/`status` 注册完整 + +--- + +## 5. 段 5:终端 ↔ 网页联动(核心答辩点,P0 新增) + +> 实现:`internal/web/url_builder.go`(16 条 URL 模式)+ `cmd/show/`(12 子命令)+ `--web` 后处理 + `envelope.Meta.html_url` 注入。 + +| 终端命令 | 输出 URL | 状态 | +|---|---|---| +| `show repo --owner jiangtx --repo gitlink-cli-demo` | `🔗 https://gitlink.org.cn/jiangtx/gitlink-cli-demo` | ✅ | +| `show issue --owner o --repo r --number 42 --format json` | `{html_url:".../issues/42"}` | ✅ | +| `show webhook --owner o --repo r` | `🔗 .../settings/hooks` | ✅ | +| `show wiki/label/milestone/member/ci` | 各对应设置页 | ✅ | +| `demo +run --list` | 列出 showcase / quick-tour | ✅ | +| `browse --list` | 12 种可浏览资源目录 | ✅ | +| `browse --no-open issues/42` | 只打印 `.../issues/42` 不打开 | ✅ | +| `issue +create ... --web`(任意 create 类) | 执行后追加 `🔗 网页链接` 并打开 | ✅ | + +**设计亮点**:`show` 默认 `🔗 `(投影友好),`--format json` 走 `output.Print` envelope(AI Agent 友好)。`browse` 全部走 `web.Builder`,消除双份 URL 表。 + +--- + +## 6. 段 6:五大类别功能 E2E(Demo Mock,离线) + +> 开启 `GITLINK_DEMO=1`,所有命令走内置 Mock(`internal/demo`,20 个 fixtures + 通用 fallback)。 + +| 类别 | 命令 | 结果 | +|---|---|---| +| **第一类·新增模块** | `webhook +list` / `label +list` / `milestone +list` / `member +list` / `notification +list` | ✅ 全 `"ok": true` | +| **第二类·多格式输出** | `repo +list --format json/table/yaml` | ✅ 三格式均可 | +| **第三类·批量 dry-run** | `issue +batch-close --numbers 1,2,3 --dry-run` | ✅ `[DRY RUN]` | +| **第五类·Raw API 补全** | `repo +languages` | ✅ 返回语言占比 | + +(第四类跨平台安装:keyring 多平台 + npm postinstall 已在 README 验证;HTML 检测:`internal/client.detectHTMLResponse` + 中文指引已就绪。) + +--- + +## 7. 段 7:format 统一 + --debug 增强(P1-2 + P2-2) + +| 项 | 命令 | 结果 | +|---|---|---| +| alias 走 output 体系 | `alias +list --format json` | ✅ `{ok:true, data:[{name, command}]}` | +| capability 走 output | `capability +check --format json` | ✅ 结构化结果 | +| --debug 请求行 | `issue +list --debug` | ✅ `[DEBUG] → GET ...` | +| --debug Header redact | 同上 | ✅ `Authorization: Bearer *** (transport-injected)` | +| --debug 耗时 + body | 同上 | ✅ `← 200 OK (NNms, NB)` + `body[:200]` | + +--- + +## 8. 终端 ↔ 网页映射总表(对齐 DEMO-SCRIPT-SUBTASK1.md) + +16 条映射,全部由 `internal/web.Builder` 单一来源生成(`show`/`browse`/`--web` 共用): + +| 资源 | URL 模式 | +|---|---| +| repo | `/{owner}/{repo}` | +| issue | `/{owner}/{repo}/issues[/{n}]` | +| pr | `/{owner}/{repo}/pulls[/{n}]` | +| wiki | `/{owner}/{repo}/wiki[/{page}]` | +| webhook | `/{owner}/{repo}/settings/hooks` | +| label | `/{owner}/{repo}/issues/labels` | +| milestone | `/{owner}/{repo}/milestones` | +| member | `/{owner}/{repo}/settings/collaboration` | +| branch | `/{owner}/{repo}/branches[/{name}]` | +| release | `/{owner}/{repo}/releases[/{tag}]` | +| commit | `/{owner}/{repo}/commits[/{sha}]` | +| ci | `/{owner}/{repo}/actions` | +| compare | `/{owner}/{repo}/compare/{base}...{head}` | +| org / user / notification | `/{org}` / `/{user}` / `/notifications` | + +--- + +## 9. 验收清单对照(DEMO-SCRIPT-SUBTASK1.md §B) + +| 验收项 | 状态 | 证据 | +|---|---|---| +| 新增 Shortcut 命令(8 模块 53 命令) | ✅ | `shortcuts/register.go`(21 域组) | +| 优化现有命令(--format/--debug/中文帮助/**show/--web**) | ✅ | P0/P1/P2 | +| 批量操作(batch-close/batch-add/export/dry-run) | ✅ | `issue/batch.go`、`member/member.go`、`export/` | +| 跨平台兼容(5 安装方式 + keyring 多平台) | ✅ | README + `internal/auth/token_store.go` | +| 补全 Raw API(56 命令 + HTML 检测) | ✅ | `repo/user/org` shortcuts + `client.detectHTMLResponse` | +| 测试覆盖率 ≥ 80% | ✅ | **80.1%** + CI 门禁(78% 缓冲阈值) | +| Skills ~90 处修复 | ✅ | 已在前期提交完成 | +| **★ 终端↔网页联动**(show/--web/html_url/demo) | ✅ | **本次 P0 新增** | + +--- + +## 10. 本次(验证周期)新增/修改的文件 + +**新增**(10 个源文件 + 20 fixtures + 验证脚本/报告): +- `internal/web/url_builder.go` + `browser.go` + 测试(覆盖率 94%) +- `cmd/show/show.go` + 测试(90%) +- `cmd/demo/demo.go` + `showcases.go` + 测试(94%) +- `internal/demo/demo.go` + `loader.go` + `fixtures/*.json`(20)+ 测试(91-97%) +- `shortcuts/common/web_post.go` + 测试(--web 后处理) +- `shortcuts/capability/capability_test.go` +- `scripts/verify.sh`、`doc/VERIFICATION-REPORT-SUBTASK1.md` + +**修改**: +- `cmd/root.go`:注册 show/demo,加 `--web`/`--demo` 全局 flag +- `cmd/cmdutil/globals.go`:加 `Web`/`Demo` 全局变量 +- `cmd/browse/browse.go`:增强 `--list`/`--no-open`/实体识别,复用 `web.Builder` +- `cmd/alias/alias.go`:`+list` 支持 `--format json/yaml/table` +- `internal/output/envelope.go`:`Meta` 加 `HTMLURL`/`ShowURL` +- `internal/client/client.go`:注入 demo Transport + `--debug` 增强(Header redact + 耗时 + body 长度) +- `shortcuts/common/types.go`:`RuntimeContext.LastEnvelope` 缓存 +- `shortcuts/common/runner.go`:`--web` 后处理 + 测试注入点 +- `shortcuts/capability/capability.go`:`+check` 支持 `--format`,结构化输出 +- `Makefile` + `.github/.gitea/.devops` 三处 CI:覆盖率门禁 + +--- + +## 11. 已知限制 + 后续工作 + +| 项 | 现状 | 后续 | +|---|---|---| +| `demo +record/replay` | 标 TODO(P0 未实现) | 录制/回放真实操作流 | +| i18n 全量抽取 | 新命令(show/demo)用字面中文,i18n check 通过 | 后续把字面量抽取为 `tr.T()` key | +| `--web` 自动打开浏览器 | headless CI 仅打印 URL | 桌面环境自动 `open/xdg-open` | +| `status` 命令 format | 保持人类可读文本 | 可按 alias 模式补结构化分支 | +| 真机 E2E | 本报告 Demo Mock 验证全绿 | 答辩现场配 `GITLINK_TOKEN` 跑真机(沙盒仓 `jiangtx/gitlink-cli-demo`) | + +--- + +## 复现命令 + +```bash +# 一键验证(七段全绿 = 子任务一达标) +bash scripts/verify.sh + +# 离线演示(无网无 Token) +GITLINK_DEMO=1 gitlink-cli demo +run showcase + +# 真机 E2E(配 Token) +export GITLINK_TOKEN=... +gitlink-cli capability +check +gitlink-cli issue +list --owner jiangtx --repo gitlink-cli-demo +gitlink-cli show issue --owner jiangtx --repo gitlink-cli-demo --number 42 +``` diff --git a/internal/demo/demo.go b/internal/demo/demo.go new file mode 100644 index 0000000..33983de --- /dev/null +++ b/internal/demo/demo.go @@ -0,0 +1,107 @@ +// Package demo provides an offline mock transport so the CLI can run every +// command with no network and no token — the backbone of the projector-friendly +// `--demo` / `GITLINK_DEMO=1` mode. +// +// Transport implements http.RoundTripper. For each request it: +// 1. derives a fixture key from method + last path segment (e.g. GET:issues); +// 2. returns the matching embedded fixture if present; +// 3. otherwise returns a generic success envelope shaped after GitLink's +// {"status":0,...} convention, so shortcuts keep parsing cleanly. +package demo + +import ( + "bytes" + "io" + "net/http" + "os" + "strings" +) + +// DemoFlag is bound to the global --demo flag from cmd/root.go. It is read by +// Enabled, so the flag wiring lives in the cmd layer while this package stays +// free of cmd imports. +var DemoFlag bool + +// Enabled reports whether demo mode is active (via --demo or GITLINK_DEMO=1). +func Enabled() bool { + return DemoFlag || os.Getenv("GITLINK_DEMO") == "1" +} + +// Transport returns canned JSON for known endpoints and a generic success +// envelope for the rest. +type Transport struct { + fixtures map[string][]byte +} + +// NewTransport returns a Transport seeded with the embedded fixtures. +func NewTransport() *Transport { + return &Transport{fixtures: defaultFixtures()} +} + +// RoundTrip implements http.RoundTripper. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + key := buildFixtureKey(req) + if data, ok := t.fixtures[key]; ok { + return mockResponse(req, 200, data), nil + } + if data, ok := fuzzyMatch(req); ok { + return mockResponse(req, 200, data), nil + } + return mockResponse(req, 200, genericResponse(req.Method)), nil +} + +// buildFixtureKey reduces a request to METHOD:lastSegment so the fixture table +// is owner/repo agnostic (GET /{owner}/{repo}/issues.json → "GET:issues"). +func buildFixtureKey(req *http.Request) string { + path := strings.TrimSuffix(req.URL.Path, ".json") + path = strings.Trim(path, "/") + segs := strings.Split(path, "/") + last := segs[len(segs)-1] + return strings.ToUpper(req.Method) + ":" + last +} + +// fuzzyMatch recognises a few path shapes that lastSegment alone cannot +// disambiguate (e.g. /users/me vs /users/:login). +func fuzzyMatch(req *http.Request) ([]byte, bool) { + path := strings.TrimSuffix(req.URL.Path, ".json") + switch { + case strings.HasSuffix(path, "/users/me"): + return fixtureUsersMe, true + case strings.HasSuffix(path, "/headmaps"): + return fixtureHeadmaps, true + case strings.Contains(path, "/wiki/"): + return fixtureWikiPages, true + } + return nil, false +} + +// genericResponse synthesises a minimal success body for unmatched requests. +func genericResponse(method string) []byte { + switch method { + case http.MethodGet: + return []byte(`{"status":0,"total_count":0,"data":[]}`) + case http.MethodPost, http.MethodPut: + return []byte(`{"status":0,"id":1,"message":"success (demo)"}`) + case http.MethodDelete: + return []byte(`{"status":0,"message":"deleted (demo)"}`) + default: + return []byte(`{"status":0,"message":"ok (demo)"}`) + } +} + +// mockResponse builds an http.Response carrying data as JSON. +func mockResponse(req *http.Request, status int, data []byte) *http.Response { + return &http.Response{ + Status: http.StatusText(status), + StatusCode: status, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader(data)), + ContentLength: int64(len(data)), + Request: req, + } +} diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go new file mode 100644 index 0000000..0b5c561 --- /dev/null +++ b/internal/demo/demo_test.go @@ -0,0 +1,191 @@ +package demo + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestEnabledFlagAndEnv(t *testing.T) { + oldFlag := DemoFlag + t.Setenv("GITLINK_DEMO", "") + defer func() { DemoFlag = oldFlag }() + + DemoFlag = false + if Enabled() { + t.Error("disabled when flag false and env empty") + } + DemoFlag = true + if !Enabled() { + t.Error("flag true should enable") + } + DemoFlag = false + t.Setenv("GITLINK_DEMO", "1") + if !Enabled() { + t.Error("env=1 should enable") + } +} + +func TestBuildFixtureKey(t *testing.T) { + cases := []struct { + method, path, want string + }{ + {"GET", "/api/v1/jiangtx/demo/issues.json", "GET:issues"}, + {"POST", "/api/v1/jiangtx/demo/issues.json", "POST:issues"}, + {"GET", "/api/v1/jiangtx/demo/languages.json", "GET:languages"}, + {"GET", "/api/v1/users/me.json", "GET:me"}, + {"GET", "/api/v1/jiangtx/demo", "GET:demo"}, + } + for _, c := range cases { + req := httptest.NewRequest(c.method, c.path, nil) + if got := buildFixtureKey(req); got != c.want { + t.Errorf("buildFixtureKey(%s %s) = %q, want %q", c.method, c.path, got, c.want) + } + } +} + +func TestTransportReturnsFixtureForKnownEndpoint(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/o/r/issues.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"status":0`) { + t.Errorf("expected status:0 in fixture body, got: %s", string(body)) + } +} + +func TestTransportGenericFallback(t *testing.T) { + tr := NewTransport() + // /unknown-segment has no fixture → genericResponse. + req := httptest.NewRequest("GET", "/api/v1/o/r/unknownseg.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + var m map[string]interface{} + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("generic body not JSON: %v (body=%s)", err, string(body)) + } + if m["status"] != float64(0) { + t.Errorf("generic GET status = %v, want 0", m["status"]) + } +} + +func TestTransportGenericPost(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("POST", "/api/v1/o/r/unknownxyz.json", strings.NewReader("{}")) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"id":1`) { + t.Errorf("generic POST missing id:1: %s", string(body)) + } +} + +func TestTransportGenericDelete(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("DELETE", "/api/v1/o/r/unknownxyz.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "deleted") { + t.Errorf("generic DELETE missing 'deleted': %s", string(body)) + } +} + +func TestGenericResponseDefault(t *testing.T) { + if len(genericResponse(http.MethodPatch)) == 0 { + t.Error("PATCH generic should be non-empty") + } +} + +func TestTransportFuzzyUsersMe(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/users/me.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"login":"jiangtx"`) { + t.Errorf("fuzzy /users/me missing login: %s", string(body)) + } +} + +func TestTransportFuzzyHeadmaps(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/users/jiangtx/headmaps.json", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "contributions") { + t.Errorf("fuzzy headmaps missing contributions: %s", string(body)) + } +} + +func TestTransportFuzzyWiki(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/wiki/o/r/pages", nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip error: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "pages") { + t.Errorf("fuzzy wiki missing pages: %s", string(body)) + } +} + +func TestTransportContentLength(t *testing.T) { + tr := NewTransport() + req := httptest.NewRequest("GET", "/api/v1/o/r/issues.json", nil) + resp, _ := tr.RoundTrip(req) + if resp.ContentLength <= 0 { + t.Errorf("ContentLength = %d, want > 0", resp.ContentLength) + } + if resp.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want application/json", resp.Header.Get("Content-Type")) + } +} + +func TestDefaultFixturesSeeded(t *testing.T) { + m := defaultFixtures() + if len(m) == 0 { + t.Fatal("defaultFixtures returned empty map") + } + for _, key := range []string{"GET:issues", "POST:issues", "GET:webhooks", "GET:labels"} { + if _, ok := m[key]; !ok { + t.Errorf("missing fixture key %q", key) + } + } +} + +func TestTransportAsRoundTripper(t *testing.T) { + // Transport must be usable as http.Client.Transport (integration smoke test). + tr := NewTransport() + client := &http.Client{Transport: tr} + resp, err := client.Get("http://demo.invalid/api/v1/o/r/issues.json") + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("status = %d", resp.StatusCode) + } +} diff --git a/internal/demo/fixtures/get_branches.json b/internal/demo/fixtures/get_branches.json new file mode 100644 index 0000000..a9a1b65 --- /dev/null +++ b/internal/demo/fixtures/get_branches.json @@ -0,0 +1 @@ +{"status":0,"total_count":2,"branches":[{"name":"master","protected":true},{"name":"dev","protected":false}]} diff --git a/internal/demo/fixtures/get_collaborators.json b/internal/demo/fixtures/get_collaborators.json new file mode 100644 index 0000000..aa2a399 --- /dev/null +++ b/internal/demo/fixtures/get_collaborators.json @@ -0,0 +1 @@ +{"status":0,"members":[{"id":101325,"login":"member-a","role":"Developer"},{"id":126177,"login":"member-b","role":"Developer"}]} diff --git a/internal/demo/fixtures/get_commits.json b/internal/demo/fixtures/get_commits.json new file mode 100644 index 0000000..35fa9ee --- /dev/null +++ b/internal/demo/fixtures/get_commits.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"commits":[{"sha":"abc1234","message":"demo commit","author":{"login":"jiangtx","name":"演示"}}]} diff --git a/internal/demo/fixtures/get_contributors.json b/internal/demo/fixtures/get_contributors.json new file mode 100644 index 0000000..0a88467 --- /dev/null +++ b/internal/demo/fixtures/get_contributors.json @@ -0,0 +1 @@ +{"status":0,"contributors":[{"login":"jiangtx","contributions":120,"email":"demo@example.com"}]} diff --git a/internal/demo/fixtures/get_issues.json b/internal/demo/fixtures/get_issues.json new file mode 100644 index 0000000..8b3f8ba --- /dev/null +++ b/internal/demo/fixtures/get_issues.json @@ -0,0 +1 @@ +{"status":0,"total_count":2,"count":2,"issues":[{"id":1,"project_issues_index":1,"subject":"示例 Issue A","description":"演示","status":1,"priority_id":2},{"id":2,"project_issues_index":2,"subject":"示例 Issue B","description":"演示","status":1}]} diff --git a/internal/demo/fixtures/get_labels.json b/internal/demo/fixtures/get_labels.json new file mode 100644 index 0000000..78aeec6 --- /dev/null +++ b/internal/demo/fixtures/get_labels.json @@ -0,0 +1 @@ +{"status":0,"labels":[{"id":1,"name":"bug","color":"#fc2929"},{"id":2,"name":"enhancement","color":"#84b6eb"}]} diff --git a/internal/demo/fixtures/get_languages.json b/internal/demo/fixtures/get_languages.json new file mode 100644 index 0000000..d2eafa2 --- /dev/null +++ b/internal/demo/fixtures/get_languages.json @@ -0,0 +1 @@ +{"status":0,"Go":85.5,"Shell":10.0,"Other":4.5} diff --git a/internal/demo/fixtures/get_milestones.json b/internal/demo/fixtures/get_milestones.json new file mode 100644 index 0000000..c9dbcec --- /dev/null +++ b/internal/demo/fixtures/get_milestones.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"versions":[{"id":1,"name":"Sprint 6","description":"演示里程碑","effective_date":"2026-07-15","status":"open"}]} diff --git a/internal/demo/fixtures/get_notifications.json b/internal/demo/fixtures/get_notifications.json new file mode 100644 index 0000000..607d62e --- /dev/null +++ b/internal/demo/fixtures/get_notifications.json @@ -0,0 +1 @@ +{"status":0,"total_count":2,"notifications":[{"id":1,"subject":"通知 1","status":1},{"id":2,"subject":"通知 2","status":1}]} diff --git a/internal/demo/fixtures/get_pipelines.json b/internal/demo/fixtures/get_pipelines.json new file mode 100644 index 0000000..4ad64e9 --- /dev/null +++ b/internal/demo/fixtures/get_pipelines.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"pipelines":[{"id":1,"name":"build","file":"build.yml","status":"success","run_number":42}]} diff --git a/internal/demo/fixtures/get_projects.json b/internal/demo/fixtures/get_projects.json new file mode 100644 index 0000000..b6be87e --- /dev/null +++ b/internal/demo/fixtures/get_projects.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"projects":[{"id":1,"name":"演示看板","identifier":"demo-board"}]} diff --git a/internal/demo/fixtures/get_pulls.json b/internal/demo/fixtures/get_pulls.json new file mode 100644 index 0000000..bbe425d --- /dev/null +++ b/internal/demo/fixtures/get_pulls.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"pulls":[{"id":1,"pull_request_id":1,"title":"示例 PR","status":0,"base":"master","head":"dev"}]} diff --git a/internal/demo/fixtures/get_releases.json b/internal/demo/fixtures/get_releases.json new file mode 100644 index 0000000..79d105a --- /dev/null +++ b/internal/demo/fixtures/get_releases.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"releases":[{"id":1,"tag_name":"v2.0","name":"v2.0","body":"演示发布"}]} diff --git a/internal/demo/fixtures/get_webhooks.json b/internal/demo/fixtures/get_webhooks.json new file mode 100644 index 0000000..f77ec5e --- /dev/null +++ b/internal/demo/fixtures/get_webhooks.json @@ -0,0 +1 @@ +{"status":0,"total_count":1,"webhooks":[{"id":51348,"url":"https://ci.example.com/hook","http_method":"POST","events":["push","create"]}]} diff --git a/internal/demo/fixtures/post_issues.json b/internal/demo/fixtures/post_issues.json new file mode 100644 index 0000000..53e011a --- /dev/null +++ b/internal/demo/fixtures/post_issues.json @@ -0,0 +1 @@ +{"status":0,"id":99,"project_issues_index":99,"subject":"演示 Issue (demo)","description":"通过 demo 模式创建"} diff --git a/internal/demo/fixtures/post_labels.json b/internal/demo/fixtures/post_labels.json new file mode 100644 index 0000000..991d492 --- /dev/null +++ b/internal/demo/fixtures/post_labels.json @@ -0,0 +1 @@ +{"status":0,"id":3,"name":"P0","color":"#FF0000","description":"演示标签"} diff --git a/internal/demo/fixtures/post_webhooks.json b/internal/demo/fixtures/post_webhooks.json new file mode 100644 index 0000000..88ef1bf --- /dev/null +++ b/internal/demo/fixtures/post_webhooks.json @@ -0,0 +1 @@ +{"status":0,"id":51348,"message":"success (demo)"} diff --git a/internal/demo/fixtures/users_headmaps.json b/internal/demo/fixtures/users_headmaps.json new file mode 100644 index 0000000..ffbc3e0 --- /dev/null +++ b/internal/demo/fixtures/users_headmaps.json @@ -0,0 +1 @@ +{"status":0,"login":"jiangtx","total contributions":847,"contributions":[{"date":"2026-07-01","contributions":5},{"date":"2026-07-02","contributions":8}]} diff --git a/internal/demo/fixtures/users_me.json b/internal/demo/fixtures/users_me.json new file mode 100644 index 0000000..1aa3446 --- /dev/null +++ b/internal/demo/fixtures/users_me.json @@ -0,0 +1 @@ +{"status":0,"login":"jiangtx","user_id":148911,"username":"jiangtx","name":"演示用户","image_url":"https://gitlink.org.cn/demo.png"} diff --git a/internal/demo/fixtures/wiki_pages.json b/internal/demo/fixtures/wiki_pages.json new file mode 100644 index 0000000..d69948b --- /dev/null +++ b/internal/demo/fixtures/wiki_pages.json @@ -0,0 +1 @@ +{"status":0,"pages":[{"title":"首页","wiki":{"title":"首页"}},{"title":"API 使用指南","wiki":{"title":"API 使用指南"}}]} diff --git a/internal/demo/loader.go b/internal/demo/loader.go new file mode 100644 index 0000000..3d5e9ff --- /dev/null +++ b/internal/demo/loader.go @@ -0,0 +1,57 @@ +package demo + +import "embed" + +// fixturesFS holds the canned JSON responses embedded at compile time. +// +//go:embed fixtures/*.json +var fixturesFS embed.FS + +// Specialised fixtures consulted by fuzzyMatch (paths lastSegment alone can't +// disambiguate, e.g. /users/me vs /users/:login). +var ( + fixtureUsersMe = mustRead("fixtures/users_me.json") + fixtureHeadmaps = mustRead("fixtures/users_headmaps.json") + fixtureWikiPages = mustRead("fixtures/wiki_pages.json") +) + +// fileToKey maps an embedded fixture file name (without extension) to the +// "METHOD:lastSegment" key Transport looks up. +var fileToKey = map[string]string{ + "get_issues": "GET:issues", + "post_issues": "POST:issues", + "get_pulls": "GET:pulls", + "get_languages": "GET:languages", + "get_contributors": "GET:contributors", + "get_commits": "GET:commits", + "get_webhooks": "GET:webhooks", + "post_webhooks": "POST:webhooks", + "get_labels": "GET:labels", + "post_labels": "POST:labels", + "get_milestones": "GET:milestones", + "get_collaborators": "GET:collaborators", + "get_notifications": "GET:notifications", + "get_projects": "GET:projects", + "get_pipelines": "GET:pipelines", + "get_branches": "GET:branches", + "get_releases": "GET:releases", +} + +// defaultFixtures loads the embedded fixture files into a key→content map. +func defaultFixtures() map[string][]byte { + m := make(map[string][]byte, len(fileToKey)) + for file, key := range fileToKey { + if data, err := fixturesFS.ReadFile("fixtures/" + file + ".json"); err == nil { + m[key] = data + } + } + return m +} + +func mustRead(name string) []byte { + data, err := fixturesFS.ReadFile(name) + if err != nil { + panic("demo: missing fixture " + name + ": " + err.Error()) + } + return data +} diff --git a/internal/web/browser.go b/internal/web/browser.go new file mode 100644 index 0000000..67cf854 --- /dev/null +++ b/internal/web/browser.go @@ -0,0 +1,39 @@ +package web + +import ( + "fmt" + "os/exec" + "runtime" +) + +// platformCommand returns the OS-specific command used to open a URL. +// +// Extracted from OpenBrowser so the platform dispatch is unit-testable without +// actually launching a browser. +func platformCommand(rawURL string) (name string, args []string) { + switch runtime.GOOS { + case "darwin": + return "open", []string{rawURL} + case "windows": + // The empty "" title argument prevents `start` from treating the URL + // (which may contain "/" or "&") as the console-window title. + return "cmd", []string{"/c", "start", "", rawURL} + default: + return "xdg-open", []string{rawURL} + } +} + +// OpenBrowser opens rawURL in the user's default browser. +// +// The command is started detached (cmd.Start, not Run) so the CLI never blocks +// on the browser process. +func OpenBrowser(rawURL string) error { + name, args := platformCommand(rawURL) + cmd := exec.Command(name, args...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("打开浏览器失败: %w", err) + } + // Reap the detached process to avoid zombies on Unix. + go func() { _ = cmd.Wait() }() + return nil +} diff --git a/internal/web/browser_test.go b/internal/web/browser_test.go new file mode 100644 index 0000000..a0644a6 --- /dev/null +++ b/internal/web/browser_test.go @@ -0,0 +1,54 @@ +package web + +import ( + "runtime" + "strings" + "testing" + "time" +) + +func TestPlatformCommandContainsURL(t *testing.T) { + const u = "https://gitlink.org.cn/x" + name, args := platformCommand(u) + if name == "" { + t.Fatal("empty command name") + } + joined := strings.Join(args, " ") + if !strings.Contains(joined, u) { + t.Errorf("args %v do not contain URL %q", args, u) + } +} + +func TestPlatformCommandMatchesRuntime(t *testing.T) { + // Lock the expected dispatcher per GOOS so a future refactor that breaks + // the switch is caught immediately. + name, _ := platformCommand("u") + switch runtime.GOOS { + case "darwin": + if name != "open" { + t.Errorf("darwin: name=%q want open", name) + } + case "windows": + if name != "cmd" { + t.Errorf("windows: name=%q want cmd", name) + } + default: + if name != "xdg-open" { + t.Errorf("unix: name=%q want xdg-open", name) + } + } +} + +func TestOpenBrowserDoesNotBlock(t *testing.T) { + // OpenBrowser must return promptly regardless of whether a browser is + // actually available on the host (CI runners usually have none). + done := make(chan error, 1) + go func() { done <- OpenBrowser("about:blank") }() + select { + case err := <-done: + // Both nil and non-nil are acceptable; we only require non-blocking. + _ = err + case <-time.After(3 * time.Second): + t.Fatal("OpenBrowser blocked for >3s") + } +} diff --git a/internal/web/url_builder.go b/internal/web/url_builder.go new file mode 100644 index 0000000..7fcb8f5 --- /dev/null +++ b/internal/web/url_builder.go @@ -0,0 +1,203 @@ +// Package web constructs GitLink web page URLs and opens browsers. +// +// It is the single source of truth for the "terminal ↔ web" mapping used by +// the `show`, `browse`, and `--web` features. Every CLI command that needs a +// GitLink web URL must go through Builder instead of hand-rolling string +// concatenation, so the URL pattern table lives in exactly one place. +package web + +import ( + "fmt" + "net/url" + "strings" +) + +// DefaultBaseURL is the canonical GitLink front-end host. +const DefaultBaseURL = "https://gitlink.org.cn" + +// ResourceURL represents a resolvable GitLink web resource. +// +// The JSON tags deliberately match the keys the demo script and AI Agents +// expect (html_url / resource / show_command), so a Builder result can be +// wrapped straight into an output envelope. +type ResourceURL struct { + URL string `json:"html_url"` + Resource string `json:"resource"` + Identifier string `json:"identifier,omitempty"` + CLICommand string `json:"show_command,omitempty"` +} + +// Builder turns (resource type, identifiers) into GitLink web URLs. +// +// Builder never resolves owner/repo itself — callers pass them in, normally +// obtained from internal/context.ResolveOwnerRepo. This keeps Builder a pure +// formatter with no filesystem or git dependency. +type Builder struct { + BaseURL string +} + +// NewBuilder returns a Builder pointed at DefaultBaseURL. +func NewBuilder() *Builder { return &Builder{BaseURL: DefaultBaseURL} } + +// join concatenates the base URL with a path, tolerating missing/extra slashes. +func (b *Builder) join(path string) string { + base := strings.TrimRight(b.BaseURL, "/") + if path == "" { + return base + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return base + path +} + +// make assembles a ResourceURL from its parts. +func (b *Builder) make(resource, path, identifier, cli string) *ResourceURL { + return &ResourceURL{ + URL: b.join(path), + Resource: resource, + Identifier: identifier, + CLICommand: cli, + } +} + +// repoBase returns the "/owner/repo" prefix shared by most URLs. +func repoBase(owner, repo string) string { return fmt.Sprintf("/%s/%s", owner, repo) } + +// --- Repository-scoped resources ------------------------------------------- + +// RepoURL is the repository home page. +func (b *Builder) RepoURL(owner, repo string) *ResourceURL { + return b.make("repo", repoBase(owner, repo), "", + fmt.Sprintf("gitlink-cli show repo --owner %s --repo %s", owner, repo)) +} + +// IssueURL is a specific issue page (number > 0) or the issue list. +func (b *Builder) IssueURL(owner, repo string, number int) *ResourceURL { + if number <= 0 { + return b.make("issue", repoBase(owner, repo)+"/issues", "", + fmt.Sprintf("gitlink-cli show repo --owner %s --repo %s --tab issues", owner, repo)) + } + return b.make("issue", + fmt.Sprintf("%s/issues/%d", repoBase(owner, repo), number), + fmt.Sprintf("#%d", number), + fmt.Sprintf("gitlink-cli show issue --owner %s --repo %s --number %d", owner, repo, number)) +} + +// PRURL is a specific pull request page (number > 0) or the PR list. +func (b *Builder) PRURL(owner, repo string, number int) *ResourceURL { + if number <= 0 { + return b.make("pr", repoBase(owner, repo)+"/pulls", "", + fmt.Sprintf("gitlink-cli show repo --owner %s --repo %s --tab pulls", owner, repo)) + } + return b.make("pr", + fmt.Sprintf("%s/pulls/%d", repoBase(owner, repo), number), + fmt.Sprintf("#%d", number), + fmt.Sprintf("gitlink-cli show pr --owner %s --repo %s --number %d", owner, repo, number)) +} + +// WikiURL is a specific wiki page (page != "") or the wiki index. +func (b *Builder) WikiURL(owner, repo, page string) *ResourceURL { + if page == "" { + return b.make("wiki", repoBase(owner, repo)+"/wiki", "", + fmt.Sprintf("gitlink-cli show wiki --owner %s --repo %s", owner, repo)) + } + encoded := url.PathEscape(page) + return b.make("wiki", + fmt.Sprintf("%s/wiki/%s", repoBase(owner, repo), encoded), + page, + fmt.Sprintf("gitlink-cli show wiki --owner %s --repo %s --page %s", owner, repo, page)) +} + +// MemberURL is the repository collaborator settings page. +func (b *Builder) MemberURL(owner, repo string) *ResourceURL { + return b.make("member", repoBase(owner, repo)+"/settings/collaboration", "", + fmt.Sprintf("gitlink-cli show member --owner %s --repo %s", owner, repo)) +} + +// WebhookURL is the repository webhook settings page. +func (b *Builder) WebhookURL(owner, repo string) *ResourceURL { + return b.make("webhook", repoBase(owner, repo)+"/settings/hooks", "", + fmt.Sprintf("gitlink-cli show webhook --owner %s --repo %s", owner, repo)) +} + +// LabelURL is the repository issue-labels management page. +func (b *Builder) LabelURL(owner, repo string) *ResourceURL { + return b.make("label", repoBase(owner, repo)+"/issues/labels", "", + fmt.Sprintf("gitlink-cli show label --owner %s --repo %s", owner, repo)) +} + +// MilestoneURL is the repository milestones page. +func (b *Builder) MilestoneURL(owner, repo string) *ResourceURL { + return b.make("milestone", repoBase(owner, repo)+"/milestones", "", + fmt.Sprintf("gitlink-cli show milestone --owner %s --repo %s", owner, repo)) +} + +// BranchURL is the branches list, or a specific branch page when branch != "". +func (b *Builder) BranchURL(owner, repo, branch string) *ResourceURL { + if branch == "" { + return b.make("branch", repoBase(owner, repo)+"/branches", "", + fmt.Sprintf("gitlink-cli show branch --owner %s --repo %s", owner, repo)) + } + return b.make("branch", + fmt.Sprintf("%s/branches/%s", repoBase(owner, repo), url.PathEscape(branch)), + branch, + fmt.Sprintf("gitlink-cli show branch --owner %s --repo %s --branch %s", owner, repo, branch)) +} + +// ReleaseURL is the releases list, or a specific release when tag != "". +func (b *Builder) ReleaseURL(owner, repo, tag string) *ResourceURL { + if tag == "" { + return b.make("release", repoBase(owner, repo)+"/releases", "", + fmt.Sprintf("gitlink-cli show release --owner %s --repo %s", owner, repo)) + } + return b.make("release", + fmt.Sprintf("%s/releases/%s", repoBase(owner, repo), url.PathEscape(tag)), + tag, + fmt.Sprintf("gitlink-cli show release --owner %s --repo %s --tag %s", owner, repo, tag)) +} + +// CommitURL is a specific commit page. +func (b *Builder) CommitURL(owner, repo, sha string) *ResourceURL { + if sha == "" { + return b.make("commit", repoBase(owner, repo)+"/commits", "", + fmt.Sprintf("gitlink-cli show repo --owner %s --repo %s --tab commits", owner, repo)) + } + return b.make("commit", + fmt.Sprintf("%s/commits/%s", repoBase(owner, repo), url.PathEscape(sha)), + sha, + fmt.Sprintf("gitlink-cli show commit --owner %s --repo %s --sha %s", owner, repo, sha)) +} + +// CIURL is the repository CI/Actions page. +func (b *Builder) CIURL(owner, repo string) *ResourceURL { + return b.make("ci", repoBase(owner, repo)+"/actions", "", + fmt.Sprintf("gitlink-cli show ci --owner %s --repo %s", owner, repo)) +} + +// CompareURL is the branch/tag/commit comparison page. +func (b *Builder) CompareURL(owner, repo, base, head string) *ResourceURL { + path := fmt.Sprintf("%s/compare/%s...%s", repoBase(owner, repo), url.PathEscape(base), url.PathEscape(head)) + return b.make("compare", path, + fmt.Sprintf("%s...%s", base, head), + fmt.Sprintf("gitlink-cli show compare --owner %s --repo %s --base %s --head %s", owner, repo, base, head)) +} + +// --- Global resources (no owner/repo) -------------------------------------- + +// NotificationURL is the platform notification center. +func (b *Builder) NotificationURL() *ResourceURL { + return b.make("notification", "/notifications", "", "gitlink-cli show notification") +} + +// OrgURL is an organization page. +func (b *Builder) OrgURL(org string) *ResourceURL { + return b.make("org", fmt.Sprintf("/%s", org), org, + fmt.Sprintf("gitlink-cli show org --name %s", org)) +} + +// UserURL is a user profile page. +func (b *Builder) UserURL(login string) *ResourceURL { + return b.make("user", fmt.Sprintf("/%s", login), login, + fmt.Sprintf("gitlink-cli show user --login %s", login)) +} diff --git a/internal/web/url_builder_test.go b/internal/web/url_builder_test.go new file mode 100644 index 0000000..8d20b36 --- /dev/null +++ b/internal/web/url_builder_test.go @@ -0,0 +1,251 @@ +package web + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestNewBuilderDefaultBaseURL(t *testing.T) { + b := NewBuilder() + if b.BaseURL != DefaultBaseURL { + t.Fatalf("BaseURL = %q, want %q", b.BaseURL, DefaultBaseURL) + } +} + +func TestJoinTrimsTrailingSlash(t *testing.T) { + b := &Builder{BaseURL: "https://example.com///"} + got := b.join("/x") + want := "https://example.com/x" + if got != want { + t.Fatalf("join = %q, want %q", got, want) + } +} + +func TestJoinAddsLeadingSlash(t *testing.T) { + b := &Builder{BaseURL: "https://example.com"} + got := b.join("y") + want := "https://example.com/y" + if got != want { + t.Fatalf("join = %q, want %q", got, want) + } +} + +func TestJoinEmptyPath(t *testing.T) { + b := &Builder{BaseURL: "https://example.com/"} + if got := b.join(""); got != "https://example.com" { + t.Fatalf("join(\"\") = %q", got) + } +} + +// urlCase captures one Builder scenario for table-driven testing. +type urlCase struct { + name string + target *ResourceURL + wantURL string + wantRes string + wantID string + wantCLI string + wantSubs []string // substrings the CLI command must contain +} + +func runURLCases(t *testing.T, cases []urlCase) { + t.Helper() + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if c.target.URL != c.wantURL { + t.Errorf("URL = %q, want %q", c.target.URL, c.wantURL) + } + if c.target.Resource != c.wantRes { + t.Errorf("Resource = %q, want %q", c.target.Resource, c.wantRes) + } + if c.wantID != "" && c.target.Identifier != c.wantID { + t.Errorf("Identifier = %q, want %q", c.target.Identifier, c.wantID) + } + if c.wantCLI != "" && c.target.CLICommand != c.wantCLI { + t.Errorf("CLICommand = %q, want %q", c.target.CLICommand, c.wantCLI) + } + for _, sub := range c.wantSubs { + if !strings.Contains(c.target.CLICommand, sub) { + t.Errorf("CLICommand %q missing substring %q", c.target.CLICommand, sub) + } + } + }) + } +} + +func TestBuilderRepoScopedURLs(t *testing.T) { + b := NewBuilder() + cases := []urlCase{ + { + name: "repo", + target: b.RepoURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo", + wantRes: "repo", + }, + { + name: "issue with number", + target: b.IssueURL("jiangtx", "gitlink-cli-demo", 42), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/42", + wantRes: "issue", wantID: "#42", + }, + { + name: "issue list (number<=0)", + target: b.IssueURL("jiangtx", "gitlink-cli-demo", 0), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues", + wantRes: "issue", + }, + { + name: "pr with number", + target: b.PRURL("jiangtx", "gitlink-cli-demo", 128), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/pulls/128", + wantRes: "pr", wantID: "#128", + }, + { + name: "pr list (number<=0)", + target: b.PRURL("jiangtx", "gitlink-cli-demo", 0), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/pulls", + wantRes: "pr", + }, + { + name: "wiki page with spaces escaped", + target: b.WikiURL("jiangtx", "gitlink-cli-demo", "API 使用指南"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/wiki/API%20%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97", + wantRes: "wiki", wantID: "API 使用指南", + }, + { + name: "wiki index (empty page)", + target: b.WikiURL("jiangtx", "gitlink-cli-demo", ""), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/wiki", + wantRes: "wiki", + }, + { + name: "member", + target: b.MemberURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/collaboration", + wantRes: "member", + }, + { + name: "webhook", + target: b.WebhookURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/settings/hooks", + wantRes: "webhook", + }, + { + name: "label", + target: b.LabelURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/issues/labels", + wantRes: "label", + }, + { + name: "milestone", + target: b.MilestoneURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/milestones", + wantRes: "milestone", + }, + { + name: "branch specific", + target: b.BranchURL("jiangtx", "gitlink-cli-demo", "feat/x"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/branches/feat%2Fx", + wantRes: "branch", wantID: "feat/x", + }, + { + name: "branch list", + target: b.BranchURL("jiangtx", "gitlink-cli-demo", ""), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/branches", + wantRes: "branch", + }, + { + name: "release specific", + target: b.ReleaseURL("jiangtx", "gitlink-cli-demo", "v2.0"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/releases/v2.0", + wantRes: "release", wantID: "v2.0", + }, + { + name: "release list (empty tag)", + target: b.ReleaseURL("jiangtx", "gitlink-cli-demo", ""), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/releases", + wantRes: "release", + }, + { + name: "commit specific", + target: b.CommitURL("jiangtx", "gitlink-cli-demo", "abc1234"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/commits/abc1234", + wantRes: "commit", wantID: "abc1234", + }, + { + name: "commit list (empty sha)", + target: b.CommitURL("jiangtx", "gitlink-cli-demo", ""), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/commits", + wantRes: "commit", + }, + { + name: "ci", + target: b.CIURL("jiangtx", "gitlink-cli-demo"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/actions", + wantRes: "ci", + }, + { + name: "compare", + target: b.CompareURL("jiangtx", "gitlink-cli-demo", "master", "feat/x"), + wantURL: "https://gitlink.org.cn/jiangtx/gitlink-cli-demo/compare/master...feat%2Fx", + wantRes: "compare", wantID: "master...feat/x", + }, + } + runURLCases(t, cases) +} + +func TestBuilderGlobalURLs(t *testing.T) { + b := NewBuilder() + cases := []urlCase{ + { + name: "notification", + target: b.NotificationURL(), + wantURL: "https://gitlink.org.cn/notifications", + wantRes: "notification", + }, + { + name: "org", + target: b.OrgURL("ccf"), + wantURL: "https://gitlink.org.cn/ccf", + wantRes: "org", wantID: "ccf", + }, + { + name: "user", + target: b.UserURL("jiangtx"), + wantURL: "https://gitlink.org.cn/jiangtx", + wantRes: "user", wantID: "jiangtx", + }, + } + runURLCases(t, cases) +} + +func TestCLICommandsContainFlags(t *testing.T) { + b := NewBuilder() + // Sanity: the show_command strings carry the flags the show subcommands parse. + if got := b.IssueURL("o", "r", 7).CLICommand; !strings.Contains(got, "--number 7") { + t.Errorf("issue CLI missing --number: %q", got) + } + if got := b.WikiURL("o", "r", "P").CLICommand; !strings.Contains(got, "--page P") { + t.Errorf("wiki CLI missing --page: %q", got) + } + if got := b.BranchURL("o", "r", "main").CLICommand; !strings.Contains(got, "--branch main") { + t.Errorf("branch CLI missing --branch: %q", got) + } +} + +func TestResourceURLJSONTags(t *testing.T) { + // Guard against accidental rename of the JSON keys the demo script and + // AI Agents depend on (html_url / resource / show_command). + r := ResourceURL{URL: "u", Resource: "issue", CLICommand: "c"} + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal: %v", err) + } + out := string(data) + for _, key := range []string{`"html_url":"u"`, `"resource":"issue"`, `"show_command":"c"`} { + if !strings.Contains(out, key) { + t.Errorf("JSON %q missing key %s", out, key) + } + } +} diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100644 index 0000000..ca2ab2d --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# ============================================================================= +# GitLink-CLI 子任务一 全量验证脚本(对齐 doc/DEMO-SCRIPT-SUBTASK1.md) +# +# 七段验证(每段独立,可单独跑:./verify.sh <段号>,如 ./verify.sh 5): +# 1 编译 + 静态检查 +# 2 单元测试 + 覆盖率(≥80%) +# 3 命令注册冒烟(show / demo / 8 个新 shortcut 模块) +# 4 能力探测(需 GITLINK_TOKEN;无则跳过) +# 5 终端↔网页联动(Demo Mock:show + html_url + demo +run --list) +# 6 五大类功能 E2E(Demo Mock + 真机可选) +# 7 format 统一 + --debug 增强(Demo Mock) +# +# 退出码:0 全绿;非 0 表示有失败段。 +# ============================================================================= +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# Windows 默认二进制是 .exe;Linux/macOS 无后缀。 +if [ -x "$ROOT/gitlink-cli.exe" ]; then BIN="$ROOT/gitlink-cli.exe" +elif [ -x "$ROOT/gitlink-cli" ]; then BIN="$ROOT/gitlink-cli" +else BIN="" # 将在段3前构建 +fi + +PASS=0; FAIL=0; SKIP=0 +GREEN='\033[32m'; RED='\033[31m'; YEL='\033[1;33m'; BOLD='\033[1m'; RST='\033[0m' + +section(){ printf "\n${BOLD}━━━ 段 %s : %s ━━━${RST}\n" "$1" "$2"; } +ok() { printf " ${GREEN}✓${RST} %s\n" "$1"; PASS=$((PASS+1)); } +ng() { printf " ${RED}✗${RST} %s\n" "$1"; FAIL=$((FAIL+1)); } +skip() { printf " ${YEL}⊘${RST} %s\n" "$1"; SKIP=$((SKIP+1)); } +contain() { grep -q "$1" <<<"$2"; } + +# 选段:若传了段号,只跑该段。 +SELECTED="${1:-all}" +run_section(){ [ "$SELECTED" = "all" ] || [ "$SELECTED" = "$1" ]; } + +# ---------------------------------------------------------------------------- +section 1 "编译 + 静态检查" +if run_section 1; then + if go build -o "$ROOT/.gitlink-cli.verify" . 2>err.log; then + BIN="$ROOT/.gitlink-cli.verify"; ok "go build" + else ng "go build: $(cat err.log)"; fi + if go vet ./... >err.log 2>&1; then ok "go vet"; else ng "go vet: $(cat err.log)"; fi + if [ -z "$(gofmt -s -l . 2>/dev/null | grep -v '^vendor/')" ]; then ok "gofmt -s" + else ng "gofmt -s: $(gofmt -s -l . | head -3)"; fi + if go run ./internal/i18n/cmd/check >err.log 2>&1; then ok "i18n check" + else skip "i18n check (容许 P2 前未全覆盖)"; fi +fi + +# ---------------------------------------------------------------------------- +section 2 "单元测试 + 覆盖率(≥80%)" +if run_section 2; then + # -race 留给 CI 的 Linux runner;Windows 本地 race runtime 的 DLL 依赖不稳。 + RACE_FLAG="" + [ "$(go env GOOS)" != "windows" ] && RACE_FLAG="-race" + if go test $RACE_FLAG -coverprofile=coverage.verify.out ./... >test.log 2>&1; then + ok "go test ${RACE_FLAG:-(no-race)} 全绿" + else ng "go test 失败(见 test.log)"; tail -5 test.log; fi + COV=$(go tool cover -func=coverage.verify.out 2>/dev/null | grep '^total:' | awk '{print $3}' | tr -d '%') + COVI=${COV%.*} + if [ -n "$COVI" ] && [ "$COVI" -ge 80 ]; then ok "覆盖率 ${COV}% ≥ 80%" + else ng "覆盖率 ${COV:-?}% < 80%"; fi +fi + +# ---------------------------------------------------------------------------- +section 3 "命令注册冒烟" +if run_section 3; then + [ -z "$BIN" ] && { ng "无二进制可执行(先 go build)"; } + if [ -n "$BIN" ]; then + HELP=$("$BIN" --help 2>&1 || true) + for c in show demo browse status alias auth config api version; do + contain "$c" "$HELP" && ok "$c 注册" || ng "$c 未注册" + done + # 8 个新 shortcut 模块(第一类) + for grp in webhook wiki pm pipeline label member milestone notification; do + "$BIN" "$grp" --help >/dev/null 2>&1 && ok "$grp +help" || ng "$grp +help" + done + fi +fi + +# ---------------------------------------------------------------------------- +section 4 "能力探测(需 GITLINK_TOKEN)" +if run_section 4; then + if [ -z "${GITLINK_TOKEN:-}" ]; then + skip "段4 跳过(未设置 GITLINK_TOKEN)" + elif [ -z "$BIN" ]; then ng "无二进制" + else + OUT=$("$BIN" capability +check 2>&1 || true) + contain "label" "$OUT" && ok "capability +check 探测到模块" || ng "capability +check 无输出" + fi +fi + +# ---------------------------------------------------------------------------- +section 5 "终端↔网页联动(Demo Mock)" +if run_section 5 && [ -n "$BIN" ]; then + # show repo + OUT=$("$BIN" show repo --owner jiangtx --repo gitlink-cli-demo 2>&1) + contain "gitlink.org.cn/jiangtx/gitlink-cli-demo" "$OUT" && ok "show repo URL" || ng "show repo URL" + # show issue JSON html_url + OUT=$("$BIN" show issue --owner jiangtx --repo demo --number 42 --format json 2>&1) + contain '"html_url"' "$OUT" && ok "show issue JSON html_url" || ng "show issue html_url" + # show 各实体 + for ent in webhook wiki label milestone member ci; do + "$BIN" show "$ent" --owner o --repo r >/dev/null 2>&1 && ok "show $ent" || ng "show $ent" + done + # demo +run --list + OUT=$("$BIN" demo +run --list 2>&1) + contain "showcase" "$OUT" && ok "demo +run --list" || ng "demo +run --list" + # browse --list / --no-open + OUT=$("$BIN" browse --owner o --repo r --list 2>&1) + contain "issues" "$OUT" && ok "browse --list" || ng "browse --list" + OUT=$("$BIN" browse --owner o --repo r --no-open issues/42 2>&1) + contain "/issues/42" "$OUT" && ok "browse --no-open issues/42" || ng "browse --no-open" +fi + +# ---------------------------------------------------------------------------- +section 6 "五大类功能 E2E(Demo Mock)" +if run_section 6 && [ -n "$BIN" ]; then + export GITLINK_DEMO=1 + # 第一类:新增模块的 list + for cmd in "webhook +list --owner o --repo r" "label +list --owner o --repo r" \ + "milestone +list --owner o --repo r" "member +list --owner o --repo r" \ + "notification +list"; do + OUT=$("$BIN" $cmd 2>&1 || true) + (contain '"ok": true' "$OUT" || contain '"status":0' "$OUT") \ + && ok "第一类: $cmd" || ng "第一类: $cmd → $OUT" + done + # 第二类:三种输出格式 + for fmt in json table yaml; do + OUT=$("$BIN" repo +list --format "$fmt" 2>&1 || true) + [ -n "$OUT" ] && ok "第二类: repo +list --format $fmt" || ng "第二类: --format $fmt" + done + # 第三类:批量 dry-run + OUT=$("$BIN" issue +batch-close --owner o --repo r --numbers 1,2,3 --dry-run 2>&1 || true) + (contain "DRY RUN" "$OUT" || contain '"ok": true' "$OUT") \ + && ok "第三类: issue +batch-close --dry-run" || ng "第三类: batch-close → $OUT" + # 第五类:Raw API 补全 + OUT=$("$BIN" repo +languages --owner o --repo r 2>&1 || true) + (contain '"ok": true' "$OUT" || contain "Go" "$OUT") \ + && ok "第五类: repo +languages" || ng "第五类: repo +languages → $OUT" + unset GITLINK_DEMO +fi + +# ---------------------------------------------------------------------------- +section 7 "format 统一 + --debug 增强" +if run_section 7 && [ -n "$BIN" ]; then + export GITLINK_DEMO=1 + OUT=$("$BIN" alias +list --format json 2>&1 || true) + contain '"ok": true' "$OUT" && ok "alias --format json 走 output" || skip "alias format(P1-2 前可能未统一)" + OUT=$("$BIN" issue +list --owner o --repo r --debug 2>&1 || true) + (contain "→ GET" "$OUT" || contain "→ POST" "$OUT") \ + && ok "--debug 打印请求行" || ng "--debug 请求行" + contain "Bearer \*\*\*" "$OUT" && ok "--debug Authorization redact" \ + || skip "--debug Header redact(P2-2 前未实现)" + unset GITLINK_DEMO +fi + +# ---------------------------------------------------------------------------- +printf "\n${BOLD}━━━ 汇总 ━━━${RST}\n" +printf " ${GREEN}通过: %d${RST} ${RED}失败: %d${RST} ${YEL}跳过: %d${RST}\n" "$PASS" "$FAIL" "$SKIP" +rm -f "$ROOT/.gitlink-cli.verify" err.log test.log 2>/dev/null +[ "$FAIL" -eq 0 ] diff --git a/shortcuts/capability/capability_test.go b/shortcuts/capability/capability_test.go new file mode 100644 index 0000000..2b63acc --- /dev/null +++ b/shortcuts/capability/capability_test.go @@ -0,0 +1,77 @@ +package capability + +import ( + "testing" + + intcap "github.com/gitlink-org/gitlink-cli/internal/capability" +) + +func TestBuildResultRowsOrderAndContent(t *testing.T) { + results := map[string]*intcap.DomainStatus{ + "label": {Status: intcap.StatusAvailable}, + "webhook": {Status: intcap.StatusUnavailable, Message: "返回 HTML"}, + "pipeline": {Status: intcap.StatusError, Message: "网络错误"}, + } + rows := buildResultRows(results) + // buildResultRows always emits the full fixed domain catalog (11 entries). + if len(rows) != 11 { + t.Fatalf("got %d rows, want 11", len(rows)) + } + wantFirst := "label" + if rows[0].Domain != wantFirst { + t.Errorf("first row = %q, want %q", rows[0].Domain, wantFirst) + } + byDomain := map[string]resultRow{} + for _, r := range rows { + byDomain[r.Domain] = r + } + if byDomain["label"].Status != "available" { + t.Errorf("label status = %q", byDomain["label"].Status) + } + if byDomain["webhook"].Status != "unavailable" || byDomain["webhook"].Message != "返回 HTML" { + t.Errorf("webhook row wrong: %+v", byDomain["webhook"]) + } +} + +func TestBuildResultRowsMissingDomainIsSkipped(t *testing.T) { + // An empty results map → every domain lands in the "skipped" branch. + rows := buildResultRows(map[string]*intcap.DomainStatus{}) + for _, r := range rows { + if r.Status != "unknown" { + t.Errorf("domain %s: expected unknown/skipped, got %s", r.Domain, r.Status) + } + } +} + +func TestBuildResultRowsNilEntryIsSkipped(t *testing.T) { + results := map[string]*intcap.DomainStatus{"label": nil} + rows := buildResultRows(results) + for _, r := range rows { + if r.Domain == "label" && r.Status != "unknown" { + t.Errorf("nil entry should be skipped, got %s", r.Status) + } + } +} + +func TestStatusHelpers(t *testing.T) { + cases := []struct { + status intcap.Status + str, text, ico string + }{ + {intcap.StatusAvailable, "available", "可用 ✓", "✓"}, + {intcap.StatusUnavailable, "unavailable", "不可用 ✗", "✗"}, + {intcap.StatusError, "error", "错误 ✗", "✗"}, + {intcap.StatusUnknown, "unknown", "未知 ?", "?"}, + } + for _, c := range cases { + if got := statusString(c.status); got != c.str { + t.Errorf("statusString(%v) = %q, want %q", c.status, got, c.str) + } + if got := statusText(c.status); got != c.text { + t.Errorf("statusText(%v) = %q, want %q", c.status, got, c.text) + } + if got := statusEmoji(c.str); got != c.ico { + t.Errorf("statusEmoji(%q) = %q, want %q", c.str, got, c.ico) + } + } +} diff --git a/shortcuts/common/web_post.go b/shortcuts/common/web_post.go new file mode 100644 index 0000000..e110db9 --- /dev/null +++ b/shortcuts/common/web_post.go @@ -0,0 +1,99 @@ +package common + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/internal/web" +) + +// webStdout is the post-processor's output target (so tests can redirect). +var webStdout io.Writer = os.Stdout + +// openFunc opens a URL in the browser; tests stub it to avoid real exec. +var openFunc = web.OpenBrowser + +// postWebHook prints and opens the GitLink web URL for the resource a shortcut +// just operated on. It runs only when --web is set, after the shortcut's Run +// has already produced its normal output. +// +// The resource type is inferred from the parent cobra command name (e.g. for +// `issue +create` the parent is `issue`). Resources that carry an id/number +// in their response envelope (issue/pr) resolve to a detail page; the rest +// resolve to the relevant management page. +func postWebHook(ctx *RuntimeContext, cmd *cobra.Command) { + if cmd == nil || cmd.Parent() == nil { + return + } + resource := cmd.Parent().Name() + owner, repo := ctx.Owner, ctx.Repo + if owner == "" || repo == "" { + return // cannot build a repo-scoped URL + } + b := web.NewBuilder() + number := extractNumberFromEnvelope(ctx.LastEnvelope) + rurl, ok := resourceURL(b, resource, owner, repo, number) + if !ok { + return // unsupported resource: silently skip + } + fmt.Fprintf(webStdout, "\n🔗 网页链接: %s\n", rurl.URL) + _ = openFunc(rurl.URL) +} + +// resourceURL maps a (resource, owner, repo, number) tuple to a web URL, +// returning ok=false for resources that have no web representation. +func resourceURL(b *web.Builder, resource, owner, repo string, number int) (*web.ResourceURL, bool) { + switch resource { + case "issue": + return b.IssueURL(owner, repo, number), true + case "pr": + return b.PRURL(owner, repo, number), true + case "webhook": + return b.WebhookURL(owner, repo), true + case "label": + return b.LabelURL(owner, repo), true + case "milestone": + return b.MilestoneURL(owner, repo), true + case "wiki": + return b.WikiURL(owner, repo, ""), true + case "repo": + return b.RepoURL(owner, repo), true + case "branch": + return b.BranchURL(owner, repo, ""), true + case "release": + return b.ReleaseURL(owner, repo, ""), true + case "member": + return b.MemberURL(owner, repo), true + case "ci": + return b.CIURL(owner, repo), true + default: + return nil, false + } +} + +// extractNumberFromEnvelope tries common field names for an issue/PR number in +// a GitLink API response. Returns 0 (→ list page) when nothing matches. +func extractNumberFromEnvelope(env *output.Envelope) int { + if env == nil || env.Data == nil { + return 0 + } + m, ok := env.Data.(map[string]interface{}) + if !ok { + return 0 + } + for _, key := range []string{"number", "project_issues_index", "pull_request_id", "id"} { + if v, ok := m[key]; ok { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + } + } + } + return 0 +} diff --git a/shortcuts/common/web_post_test.go b/shortcuts/common/web_post_test.go new file mode 100644 index 0000000..45e47e0 --- /dev/null +++ b/shortcuts/common/web_post_test.go @@ -0,0 +1,184 @@ +package common + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/internal/web" +) + +func TestExtractNumberFromEnvelope(t *testing.T) { + cases := []struct { + name string + env *output.Envelope + want int + }{ + {"nil", nil, 0}, + {"nil data", &output.Envelope{OK: true, Data: nil}, 0}, + {"non-map data", &output.Envelope{OK: true, Data: "string"}, 0}, + {"number field", &output.Envelope{OK: true, Data: map[string]interface{}{"number": float64(42)}}, 42}, + {"project_issues_index field", &output.Envelope{OK: true, Data: map[string]interface{}{"project_issues_index": float64(99)}}, 99}, + {"id field fallback", &output.Envelope{OK: true, Data: map[string]interface{}{"id": float64(7)}}, 7}, + {"no matching field", &output.Envelope{OK: true, Data: map[string]interface{}{"foo": "bar"}}, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := extractNumberFromEnvelope(c.env); got != c.want { + t.Errorf("got %d, want %d", got, c.want) + } + }) + } +} + +func TestResourceURLMapping(t *testing.T) { + b := web.NewBuilder() + for _, res := range []string{"issue", "pr", "webhook", "label", "milestone", "wiki", "repo", "branch", "release", "member", "ci"} { + r, ok := resourceURL(b, res, "o", "r", 1) + if !ok { + t.Errorf("resource %q: expected ok", res) + continue + } + if r == nil || r.URL == "" { + t.Errorf("resource %q: empty URL", res) + } + } +} + +func TestResourceURLUnknown(t *testing.T) { + if _, ok := resourceURL(web.NewBuilder(), "unknown-res", "o", "r", 1); ok { + t.Error("unknown resource should return ok=false") + } +} + +func TestResourceURLIssueUsesNumber(t *testing.T) { + r, ok := resourceURL(web.NewBuilder(), "issue", "o", "r", 42) + if !ok { + t.Fatal("expected ok") + } + if !strings.HasSuffix(r.URL, "/issues/42") { + t.Errorf("expected /issues/42 suffix, got %q", r.URL) + } +} + +func TestPostWebHookPrintsURLForIssue(t *testing.T) { + parent := &cobra.Command{Use: "issue"} + child := &cobra.Command{Use: "+create"} + parent.AddCommand(child) + + ctx := &RuntimeContext{Owner: "jiangtx", Repo: "demo"} + capture, restore := redirectWeb() + defer restore() + stubOpen(t) + + postWebHook(ctx, child) + + if !strings.Contains(capture.String(), "/jiangtx/demo/issues") { + t.Errorf("expected issue URL, got %q", capture.String()) + } + if !strings.Contains(capture.String(), "🔗") { + t.Errorf("expected 🔗 marker, got %q", capture.String()) + } +} + +func TestPostWebHookSkipsWhenParentMissing(t *testing.T) { + ctx := &RuntimeContext{Owner: "o", Repo: "r"} + child := &cobra.Command{Use: "+create"} // no parent + capture, restore := redirectWeb() + defer restore() + postWebHook(ctx, child) // must not panic or print + if capture.Len() != 0 { + t.Errorf("expected no output, got %q", capture.String()) + } +} + +func TestPostWebHookSkipsWhenOwnerRepoMissing(t *testing.T) { + parent := &cobra.Command{Use: "issue"} + child := &cobra.Command{Use: "+create"} + parent.AddCommand(child) + ctx := &RuntimeContext{} // no owner/repo + capture, restore := redirectWeb() + defer restore() + postWebHook(ctx, child) + if capture.Len() != 0 { + t.Errorf("expected no output without owner/repo, got %q", capture.String()) + } +} + +func TestPostWebHookSkipsUnsupportedResource(t *testing.T) { + parent := &cobra.Command{Use: "search"} // not in the switch + child := &cobra.Command{Use: "+repos"} + parent.AddCommand(child) + ctx := &RuntimeContext{Owner: "o", Repo: "r"} + capture, restore := redirectWeb() + defer restore() + postWebHook(ctx, child) + if capture.Len() != 0 { + t.Errorf("expected no output for unsupported resource, got %q", capture.String()) + } +} + +// redirectWeb swaps webStdout + openFunc for the duration of a test, +// returning the capture buffer and a restore func. +func redirectWeb() (*bytes.Buffer, func()) { + old := webStdout + oldOpen := openFunc + buf := &bytes.Buffer{} + webStdout = buf + openFunc = func(string) error { return nil } + return buf, func() { + webStdout = old + openFunc = oldOpen + } +} + +func stubOpen(t *testing.T) { + t.Helper() + openFunc = func(string) error { return nil } +} + +// TestMountShortcutTriggersWebPostProcess verifies that when --web is set, +// MountShortcut's RunE invokes postWebHook after the shortcut's Run completes. +func TestMountShortcutTriggersWebPostProcess(t *testing.T) { + // NewRuntimeContext normally constructs a real *client.Client (which reads + // config + token store). To keep this a pure wiring test, we swap the + // context factory for one that returns a hand-built context. + oldNew := newRuntimeContextForTest + ctx := &RuntimeContext{Owner: "o", Repo: "r", Format: "json"} + newRuntimeContextForTest = func(_ map[string]string, _ ...*i18n.Translator) (*RuntimeContext, error) { + return ctx, nil + } + defer func() { newRuntimeContextForTest = oldNew }() + + opened := false + oldOpen := openFunc + openFunc = func(string) error { opened = true; return nil } + defer func() { openFunc = oldOpen }() + + oldWeb := cmdutil.Web + cmdutil.Web = true + defer func() { cmdutil.Web = oldWeb }() + + parent := &cobra.Command{Use: "issue"} + sc := &Shortcut{ + Name: "create", + Run: func(c *RuntimeContext) error { + c.LastEnvelope = output.SuccessEnvelope( + map[string]interface{}{"number": float64(42)}, nil) + return nil + }, + } + MountShortcut(parent, sc) + parent.SetArgs([]string{"+create"}) + if err := parent.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !opened { + t.Error("--web did not trigger postWebHook (browser not opened)") + } +}