Compare commits

...

6 Commits

13 changed files with 1297 additions and 62 deletions

View File

@ -114,6 +114,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🔧 CI | View builds, logs, CI/CD operations |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 📖 Wiki | List, view, create, update, and delete wiki pages |
| 🔍 Search | Search repositories, users |
| 📊 Dataset | Query research datasets by project |
| 👤 User | View user profiles and info |
@ -273,6 +274,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Wiki Management
```bash
# List wiki pages (table of contents)
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
# View a wiki page by page name
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
# Create a wiki page
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
-n getting-started -t "Getting Started" -c "# Getting Started Guide"
# Update a wiki page title and/or content
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# Updated content"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "New Title" -c "New content"
# Delete a wiki page
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
```
### Member Management
```bash

View File

@ -113,6 +113,7 @@
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
| 👤 用户 | 查看用户资料和信息 |
@ -284,6 +285,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Wiki 管理
```bash
# 列出 Wiki 页面(目录结构)
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
# 查看 Wiki 页面
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
# 创建 Wiki 页面
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
-n getting-started -t "快速开始" -c "# 快速开始指南"
# 更新 Wiki 页面标题和/或内容
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" -c "新内容"
# 删除 Wiki 页面
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
```
### 成员管理
```bash

View File

@ -0,0 +1,23 @@
# Wiki Shortcut
新增 `wiki` Shortcut 组,支持 Wiki 页面管理:
- `wiki +list` - 列出 Wiki 页面(目录结构)
- `wiki +view` - 按页面名称查看 Wiki 页面详情
- `wiki +create` - 创建新的 Wiki 页面
- `wiki +update` - 更新 Wiki 页面标题和/或内容
- `wiki +delete` - 删除 Wiki 页面
## 实现要点
- **API 端点**:基于 `/api/wiki/open/{action}` 扁平路径结构,覆盖 5 个 Wiki 管理接口:
- `GET /api/wiki/open/wikiPages` — 目录列表
- `GET /api/wiki/open/getWiki` — 查看页面
- `POST /api/wiki/open/createWiki` — 创建页面
- `PUT /api/wiki/open/updateWiki` — 更新页面
- `DELETE /api/wiki/open/deleteWiki` — 删除页面
- **标识方式**Wiki 页面通过 `pageName`slug标识所有操作需要 `projectId`GitLink 项目数字 ID
- **内容编码**:创建和更新时,内容自动进行 base64 编码后以 `content_base64` 字段发送
- **更新保护**`+update` 要求必须提供 `--title``--page-name``--content` 为可选
- **Shortcut 模式**:使用 `common.Shortcut` + `RuntimeContext` 框架,与其他模块保持一致

View File

@ -115,22 +115,35 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
}
// Check GitLink error-in-body pattern
// Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
var bodyCode float64
var bodyMsg string
if status, ok := raw["status"]; ok {
var statusCode float64
switch v := status.(type) {
case float64:
statusCode = v
bodyCode = v
case int:
statusCode = float64(v)
bodyCode = float64(v)
}
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
suggestion := suggestFix(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
}
bodyMsg, _ = raw["message"].(string)
} else if code, ok := raw["code"]; ok {
switch v := code.(type) {
case float64:
bodyCode = v
case int:
bodyCode = float64(v)
}
bodyMsg, _ = raw["msg"].(string)
if bodyMsg == "" {
bodyMsg, _ = raw["message"].(string)
}
}
if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 {
suggestion := suggestFix(int(bodyCode))
return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{
StatusCode: int(bodyCode),
Code: int(bodyCode),
Message: bodyMsg,
}
}
@ -170,6 +183,10 @@ func shouldAppendJSONSuffix(path string) bool {
return false
}
}
// Wiki open API endpoints do not use .json suffix
if len(parts) >= 3 && parts[0] == "wiki" && parts[1] == "open" {
return false
}
return true
}

View File

@ -169,6 +169,45 @@ func TestClientDoStatusError(t *testing.T) {
}
}
func TestClientDoGatewayCodeError(t *testing.T) {
// Gateway returns {"code":N, "msg":"..."} instead of {"status":N, "message":"..."}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":400,"msg":"Bad Request"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("GET", "/api/test", nil, nil)
if err == nil {
t.Fatal("expected error for code=400")
}
if env == nil {
t.Fatal("expected envelope for code error")
}
if env.OK {
t.Fatal("expected OK=false for code=400")
}
}
func TestClientDoGatewayCode201Success(t *testing.T) {
// Gateway returns code=201 with JSON string data — should be treated as success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":201,"msg":"","data":"{\"title\":\"test\"}"}`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("POST", "/api/test", map[string]string{"title": "test"}, nil)
if err != nil {
t.Fatalf("unexpected error for code=201: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true for code=201")
}
}
func TestClientDoStatusZero(t *testing.T) {
// status=0, 200, 1 are treated as success
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -525,3 +564,18 @@ func TestShouldAppendJSONSuffixSkipsExistingJSONPath(t *testing.T) {
t.Fatal("existing .json path should not get another suffix")
}
}
func TestShouldAppendJSONSuffixSkipsWikiOpenPaths(t *testing.T) {
paths := []string{
"/wiki/open/createWiki",
"/wiki/open/getWiki",
"/wiki/open/updateWiki",
"/wiki/open/deleteWiki",
"/wiki/open/wikiPages",
}
for _, p := range paths {
if shouldAppendJSONSuffix(p) {
t.Errorf("wiki/open path %q should not get .json suffix", p)
}
}
}

View File

@ -8,22 +8,25 @@ import (
)
const (
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultFormat = "table"
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultGatewayURL = "https://gateway.gitlink.org.cn/api"
DefaultFormat = "table"
)
type Config struct {
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
Lang string `yaml:"lang,omitempty"`
BaseURL string `yaml:"base_url"`
GatewayURL string `yaml:"gateway_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
Lang string `yaml:"lang,omitempty"`
}
func DefaultConfig() *Config {
return &Config{
BaseURL: DefaultBaseURL,
Format: DefaultFormat,
BaseURL: DefaultBaseURL,
GatewayURL: DefaultGatewayURL,
Format: DefaultFormat,
}
}
@ -54,6 +57,9 @@ func Load() (*Config, error) {
if cfg.BaseURL == "" {
cfg.BaseURL = DefaultBaseURL
}
if cfg.GatewayURL == "" {
cfg.GatewayURL = DefaultGatewayURL
}
if cfg.Format == "" {
cfg.Format = DefaultFormat
}
@ -80,6 +86,8 @@ func Get(key string) (string, error) {
switch key {
case "base_url":
return cfg.BaseURL, nil
case "gateway_url":
return cfg.GatewayURL, nil
case "default_format":
return cfg.Format, nil
case "editor":
@ -101,6 +109,8 @@ func Set(key, value string) error {
switch key {
case "base_url":
cfg.BaseURL = value
case "gateway_url":
cfg.GatewayURL = value
case "default_format":
cfg.Format = value
case "editor":

View File

@ -25,6 +25,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
@ -53,6 +54,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"workflow": workflow.Shortcuts(),
@ -77,6 +79,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"workflow": "AI agent workflow analysis",

View File

@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) {
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore",
"dataset", "health", "ignore", "wiki",
}
groupSet := map[string]bool{}

200
shortcuts/wiki/wiki.go Normal file
View File

@ -0,0 +1,200 @@
package wiki
import (
"encoding/base64"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// switchToGateway overrides the client base URL with the gateway URL from config.
func switchToGateway(ctx *common.RuntimeContext) error {
cfg, err := config.Load()
if err != nil {
return err
}
if cfg.GatewayURL == "" {
cfg.GatewayURL = config.DefaultGatewayURL
}
ctx.Client.BaseURL = cfg.GatewayURL
return nil
}
// gatewayFlag returns the common --gateway flag definition.
func gatewayFlag() common.Flag {
return common.Flag{Name: "gateway", Short: "g", Usage: "Use gateway API endpoint", Bool: true}
}
// Shortcuts returns all wiki shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List wiki pages",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", ctx.Arg("project-id"))
env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/wikiPages", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View a wiki page by page name",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
{Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", ctx.Arg("project-id"))
q.Set("pageName", ctx.Arg("page-name"))
env, err := ctx.CallAPIWithQuery("GET", "/wiki/open/getWiki", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a new wiki page",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
{Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true},
{Name: "title", Short: "t", Usage: "Wiki page title", Required: true},
{Name: "content", Short: "c", Usage: "Wiki page content (markdown)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message"},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
content := ctx.Arg("content")
payload := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": ctx.Arg("project-id"),
"pageName": ctx.Arg("page-name"),
"title": ctx.Arg("title"),
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
"message": ctx.Arg("message"),
}
env, err := ctx.CallAPI("POST", "/wiki/open/createWiki", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an existing wiki page",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
{Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true},
{Name: "title", Short: "t", Usage: "Wiki page title", Required: true},
{Name: "content", Short: "c", Usage: "Wiki page content (markdown)"},
{Name: "message", Short: "m", Usage: "Commit message"},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title := ctx.Arg("title")
if title == "" {
return fmt.Errorf("--title is required")
}
content := ctx.Arg("content")
payload := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": ctx.Arg("project-id"),
"pageName": ctx.Arg("page-name"),
"title": title,
"message": ctx.Arg("message"),
}
if content != "" {
payload["content_base64"] = base64.StdEncoding.EncodeToString([]byte(content))
}
env, err := ctx.CallAPI("PUT", "/wiki/open/updateWiki", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a wiki page",
Flags: []common.Flag{
{Name: "project-id", Usage: "GitLink project ID", Required: true},
{Name: "page-name", Short: "n", Usage: "Wiki page name (slug)", Required: true},
gatewayFlag(),
},
Run: func(ctx *common.RuntimeContext) error {
if ctx.Arg("gateway") == "true" {
if err := switchToGateway(ctx); err != nil {
return err
}
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
payload := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": ctx.Arg("project-id"),
"pageName": ctx.Arg("page-name"),
}
env, err := ctx.CallAPI("DELETE", "/wiki/open/deleteWiki", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

224
shortcuts/wiki/wiki_test.go Normal file
View File

@ -0,0 +1,224 @@
package wiki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestWikiList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/wiki/open/wikiPages")
assertEqual(t, r.URL.Query().Get("owner"), "owner")
assertEqual(t, r.URL.Query().Get("repo"), "repo")
assertEqual(t, r.URL.Query().Get("projectId"), "12345")
writeJSON(t, w, map[string]interface{}{"status": 0, "data": []interface{}{}})
}))
defer server.Close()
err := runWikiShortcut(t, server, "list", map[string]string{
"project-id": "12345",
})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestWikiView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/wiki/open/getWiki")
assertEqual(t, r.URL.Query().Get("owner"), "owner")
assertEqual(t, r.URL.Query().Get("repo"), "repo")
assertEqual(t, r.URL.Query().Get("projectId"), "12345")
assertEqual(t, r.URL.Query().Get("pageName"), "home")
writeJSON(t, w, map[string]interface{}{"status": 0, "data": map[string]interface{}{"title": "home"}})
}))
defer server.Close()
err := runWikiShortcut(t, server, "view", map[string]string{
"project-id": "12345",
"page-name": "home",
})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
func TestWikiCreate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/wiki/open/createWiki")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runWikiShortcut(t, server, "create", map[string]string{
"project-id": "12345",
"page-name": "new-page",
"title": "New Page",
"content": "# Hello",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["owner"], "owner")
assertEqual(t, payload["repo"], "repo")
assertEqual(t, payload["pageName"], "new-page")
assertEqual(t, payload["title"], "New Page")
if _, ok := payload["content_base64"]; !ok {
t.Fatal("body missing content_base64")
}
}
func TestWikiUpdate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "PUT", "/wiki/open/updateWiki")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runWikiShortcut(t, server, "update", map[string]string{
"project-id": "12345",
"page-name": "home",
"title": "Updated Title",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, payload["owner"], "owner")
assertEqual(t, payload["pageName"], "home")
assertEqual(t, payload["title"], "Updated Title")
}
func TestWikiUpdateRequiresTitle(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when title is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runWikiShortcut(t, server, "update", map[string]string{
"project-id": "12345",
"page-name": "home",
})
if err == nil {
t.Fatal("expected update without --title to return an error")
}
if err.Error() != "--title is required" {
t.Fatalf("unexpected error message: %s", err.Error())
}
}
func TestWikiUpdateWithContentOnly(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "PUT", "/wiki/open/updateWiki")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runWikiShortcut(t, server, "update", map[string]string{
"project-id": "12345",
"page-name": "home",
"title": "Existing Title",
"content": "# Updated content",
})
if err != nil {
t.Fatalf("update with content failed: %v", err)
}
if _, ok := payload["content_base64"]; !ok {
t.Fatal("body missing content_base64 when --content provided")
}
}
func TestWikiDelete(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "DELETE", "/wiki/open/deleteWiki")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runWikiShortcut(t, server, "delete", map[string]string{
"project-id": "12345",
"page-name": "old-page",
})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
assertEqual(t, payload["owner"], "owner")
assertEqual(t, payload["repo"], "repo")
assertEqual(t, payload["pageName"], "old-page")
}
func runWikiShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWikiShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findWikiShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func assertRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if got != want {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

View File

@ -32,7 +32,7 @@ gitlink-cli auth status
gitlink-cli user +me
```
详见: [gitlink-shared/examples/auth-workflow.md](gitlink-shared/examples/auth-workflow.md)
详见: [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md)
### 2. 查看可用命令
@ -64,25 +64,19 @@ skills/
├── README.md # 本文件
├── gitlink-shared/ # 共享基础规则
│ ├── SKILL.md # 认证、全局参数、安全规则、分支约定
├── REFERENCE.md # API 详细参考、错误处理
├── TROUBLESHOOTING.md # 常见问题排查
└── examples/
│ └── auth-workflow.md # 认证工作流示例
└── references/
├── api-reference.md # API 详细参考、错误处理
├── raw-api-batch.md # 批量 Raw API 调用参考
│ └── troubleshooting.md # 常见问题排查
├── gitlink-repo/ # 仓库管理
│ ├── SKILL.md # 仓库操作指南
│ ├── REFERENCE.md # 仓库 API 参考
│ └── examples/
│ └── repo-workflow.md # 仓库管理工作流
│ └── references/ # 仓库命令参考文档
├── gitlink-issue/ # Issue 管理
│ ├── SKILL.md # Issue 操作指南
│ ├── REFERENCE.md # Issue API 参考
│ └── examples/
│ └── issue-workflow.md # Issue 全流程工作流
│ └── references/ # Issue 命令参考文档
├── gitlink-pr/ # Pull Request
│ ├── SKILL.md # PR 操作指南
│ ├── REFERENCE.md # PR API 参考
│ └── examples/
│ └── pr-workflow.md # PR 工作流
│ └── references/ # PR 命令参考文档
├── gitlink-member/ # 仓库成员管理
│ └── SKILL.md # 成员与邀请链接操作指南
├── gitlink-branch/ # 分支管理
@ -91,25 +85,24 @@ skills/
│ └── branch-workflow.md # 分支工作流
├── gitlink-release/ # 版本发布
│ ├── SKILL.md # Release 操作指南
├── REFERENCE.md # Release API 参考
│ └── examples/
└── release-workflow.md # Release 工作流
└── references/ # Release 命令参考文档
├── gitlink-release-auto/ # 自动化 Release 管理
└── SKILL.md # 自动发版、版本号推荐、Release Notes 生成
├── gitlink-search/ # 搜索功能
│ ├── SKILL.md # 搜索操作指南
│ └── examples/
│ └── search-workflow.md # 搜索工作流
│ └── references/ # 搜索命令参考文档
├── gitlink-user/ # 用户管理
│ └── SKILL.md # 用户操作指南
│ ├── SKILL.md # 用户操作指南
│ └── references/ # 用户命令参考文档
├── gitlink-org/ # 组织管理
│ ├── SKILL.md # 组织操作指南
│ └── examples/
│ └── org-workflow.md # 组织工作流
│ └── references/ # 组织命令参考文档
├── gitlink-ci/ # CI/CD
│ ├── SKILL.md # CI 操作指南
│ └── examples/
│ └── ci-workflow.md # CI 工作流
│ └── SKILL.md # CI 操作指南
├── gitlink-pipeline/ # 流水线工作流
│ └── SKILL.md # Pipeline 操作指南
├── gitlink-wiki/ # Wiki 页面管理
│ └── SKILL.md # Wiki 操作指南
├── gitlink-pm/ # 项目管理
│ └── SKILL.md # PM 操作指南
├── gitlink-health/ # 项目健康度分析
@ -150,6 +143,7 @@ skills/
| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` |
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |
| **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` |
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
| **gitlink-health** | 开源项目健康度 | 详情见SKILL.md |
@ -169,7 +163,7 @@ gitlink-cli repo +info
gitlink-cli repo +info --owner wbtiger --repo gitlink-cli
```
详见: [gitlink-repo/examples/repo-workflow.md](gitlink-repo/examples/repo-workflow.md)
详见: [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md)
### 场景 2创建和管理 Issue
@ -190,7 +184,7 @@ gitlink-cli issue +close -i 123
gitlink-cli issue +batch-close --numbers 123,124 --dry-run
```
详见: [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md)
详见: [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md)
### 场景 3管理分支和发布
@ -208,7 +202,7 @@ gitlink-cli release +create -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..."
gitlink-cli release +view -i <version_id>
```
详见: [gitlink-release/examples/release-workflow.md](gitlink-release/examples/release-workflow.md)
详见: [gitlink-release/SKILL.md](gitlink-release/SKILL.md)
### 场景 4搜索和发现
@ -224,7 +218,7 @@ gitlink-cli org +list
gitlink-cli org +info -i Gitlink
```
详见: [gitlink-search/examples/search-workflow.md](gitlink-search/examples/search-workflow.md)
详见: [gitlink-search/SKILL.md](gitlink-search/SKILL.md)
---
@ -233,8 +227,8 @@ gitlink-cli org +info -i Gitlink
### 快速查找
- **我想了解认证**: [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md)
- **我想查看 API 细节**: [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md)
- **我遇到了错误**: [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md)
- **我想查看 API 细节**: [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md)
- **我遇到了错误**: [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md)
- **我想看工作流示例**: 查看各 Skill 下的 `examples/` 目录
### 按功能分类
@ -242,12 +236,12 @@ gitlink-cli org +info -i Gitlink
**仓库操作**:
- [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 仓库命令
- [gitlink-branch/SKILL.md](gitlink-branch/SKILL.md) - 分支命令
- [gitlink-repo/examples/repo-workflow.md](gitlink-repo/examples/repo-workflow.md) - 完整工作流
- [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 完整工作流
**Issue 和 PR**:
- [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 命令
- [gitlink-pr/SKILL.md](gitlink-pr/SKILL.md) - PR 命令
- [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md) - Issue 工作流
- [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 工作流
**发布和搜索**:
- [gitlink-release/SKILL.md](gitlink-release/SKILL.md) - Release 命令
@ -289,11 +283,11 @@ gitlink-cli auth login
### Q: 如何查看完整的 API 参考?
A: 查看 [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md)
A: 查看 [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md)
### Q: 遇到错误怎么办?
A: 查看 [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md)
A: 查看 [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md)
---
@ -329,7 +323,7 @@ AI 代理可以:
- 所有边界情况处理正确
- 完整的文档和示例
详见: [../doc/SKILLS_TEST_REPORT_2026-04-02.md](../doc/SKILLS_TEST_REPORT_2026-04-02.md)
详见: [../doc/design.md](../doc/design.md)
---
@ -337,8 +331,7 @@ AI 代理可以:
- [主项目 README](../README.md) - gitlink-cli 项目说明
- [设计文档](../doc/design.md) - 架构设计和开发计划
- [测试报告](../doc/SKILLS_TEST_REPORT_2026-04-02.md) - 功能测试报告
- [代码同步方案](../doc/CODE_SYNC_STRATEGY_FINAL.md) - GitHub ↔ GitLink 同步设计
- [API 参考文档](../doc/gitlink_api_reference.md) - GitLink API 参考文档
- [gitlink-bisync](https://www.gitlink.org.cn/wbtiger/gitlink-bisync) - 代码双向同步系统
---
@ -346,8 +339,8 @@ AI 代理可以:
## 📞 获取帮助
- **命令帮助**: `gitlink-cli <command> --help`
- **故障排查**: [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md)
- **API 参考**: [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md)
- **故障排查**: [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md)
- **API 参考**: [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md)
- **工作流示例**: 查看各 Skill 下的 `examples/` 目录
---
@ -355,7 +348,7 @@ AI 代理可以:
## 🎓 下一步
1. 阅读 [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) 了解基础
2. 查看 [gitlink-shared/examples/auth-workflow.md](gitlink-shared/examples/auth-workflow.md) 完成认证
2. 查看 [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) 完成认证
3. 根据需求选择相应的 Skill 文档
4. 参考 `examples/` 目录中的工作流示例
5. 使用 AI 代理自动化你的工作流

View File

@ -0,0 +1,586 @@
---
name: gitlink-competition-manager
version: 1.0.0
description: "编程竞赛管理批量创建队伍仓库、发布题目、追踪提交、生成排行榜、赛后归档。当用户需要管理编程竞赛、ACM 校内赛等竞赛时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli issue --help"
---
# gitlink-competition-manager编程竞赛管理
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
---
## 功能概述
本技能覆盖编程竞赛的完整管理流程:
1. **队伍管理** — 批量创建队伍仓库,配置参赛权限
2. **题目发布** — 创建题目 Issue 模板,设置截止时间
3. **提交追踪** — 监控各队伍提交记录,锁定最终版本
4. **排行榜生成** — 按通过率/用时/代码质量评分排序
5. **防作弊检测** — 跨队伍代码相似度比对 + 提交时间异常检测
6. **赛后归档** — 获奖队伍标记 + 优秀代码展示 + 仓库归档
---
## 一、赛前准备
### 1.1 获取组织信息
```bash
# 获取组织 ID用于批量创建仓库如果属于多个组织需要用户手动选择使用哪个组织
gitlink-cli api GET /api/organizations --format json
# AI 匹配竞赛组织名称,获取组织 ID
```
### 1.2 批量创建队伍仓库
```bash
# 参见 gitlink-batch-repo-create Skill本 Skill 复用其创建逻辑
# 输入格式:队伍清单 CSV 或者 Excel
# CSV 格式示例:(名字后面为参赛账号)
# team_id,team_name,leader,members,repo_name
# T001,算法之光,张三(zhangsan),"张三(zhangsan);李四(lisi);王五(wangwu)",algo-light
# T002,代码刺客,赵六(zhaoliu),"赵六(zhaoliu);钱七(qianqi)",code-assassin
# 创建仓库
gitlink-cli api POST /api/projects --body '{
"user_id": <组织ID>,
"name": "<队伍名>",
"repository_name": "<仓库标识>",
"description": "<竞赛名> - <队伍名> 参赛仓库",
"private": true
}' --format json
# 初始化仓库(可选:竞赛模板)
gitlink-cli api POST /v1/:owner/:repo/contents --body '{
"path": "README.md",
"content": "<base64编码的模板内容>",
"message": "Initialize competition repo",
"branch": "master"
}' --format json
```
**仓库目录结构模板**
```
<repo>/
├── README.md # 队伍信息
├── problems/
│ ├── P001/ # 题目1解答
│ │ ├── solution.py
│ │ └── README.md # 解题思路
│ ├── P002/ # 题目2解答
│ └── ...
├── tests/ # 自测用例
└── .gitignore
```
### 1.3 添加队伍成员为协作者
```bash
# 根据队员用户名获取对应的user_id
gitlink-cli user +info --login <member_login_name> --format json
# 为每个队伍仓库添加成员
gitlink-cli api POST /api/:owner/:repo/collaborators --body "{\"user_id\":\"<member_user_id>\"}" --format json
```
---
## 二、题目发布
### 2.1 创建题目标签
```bash
# 确保竞赛标签存在
gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json
# 创建题目标签体系
gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"题目","description":"竞赛题目","color":"#0075ca"}' --format json
gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"已通过","description":"题目已通过","color":"#0e8a16"}' --format json
gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"未通过","description":"题目未通过","color":"#b60205"}' --format json
gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"待评测","description":"等待评测","color":"#fbca04"}' --format json
```
### 2.2 发布全部题目Issue 模板从用户对话信息中提取如果没有提到发布题目则不发布题目issue
```bash
# 在竞赛主仓库创建题目 Issue
gitlink-cli issue +create \
--owner <owner> --repo <main_repo> \
--title "【题目 P001】两数之和" \
--body "## 题目描述
给定一个整数数组 nums 和一个整数目标值 target请你在该数组中找出和为目标值的那两个整数并返回它们的数组下标。
### 输入格式
第一行n target
第二行n 个整数
### 输出格式
两个下标(空格分隔)
### 样例输入
4 9
2 7 11 15
### 样例输出
0 1
### 数据范围
- 2 ≤ n ≤ 10^4
- -10^9 ≤ nums[i] ≤ 10^9
- 只有一个有效答案
### 分值
100 分
### 提交方式
在 problems/P001/ 目录下提交代码,向 master 发起 PR
### 截止时间
2024-07-15 15:00:00" \
--format json
# 打"题目"标签
gitlink-cli api PATCH /v1/:owner/:repo/issues/:id --body '{"issue_tag_ids":[<tag_id>]}' --format json
```
### 2.3 题目发布清单
```bash
# 获取所有题目 Issue
gitlink-cli issue +list --state open --owner <owner> --repo <main_repo> --format json
# AI 过滤标题以"【题目"开头的 Issue
```
**题目清单格式**
```
=== 竞赛题目清单 ===
竞赛2024 校内算法竞赛
题目数5
发布时间2024-07-15 09:00
| 题号 | 标题 | 分值 | 截止时间 | 难度 |
|------|------|------|---------|------|
| P001 | 两数之和 | 100 | 15:00 | 🟢 简单 |
| P002 | 最长回文子串 | 150 | 15:00 | 🟡 中等 |
| P003 | 合并K个有序链表 | 200 | 15:00 | 🟠 较难 |
| P004 | 最短路径 | 200 | 15:00 | 🟠 较难 |
| P005 | 动态规划优化 | 350 | 15:00 | 🔴 困难 |
总分1000 分
```
---
## 三、提交追踪
### 3.1 监控各队伍提交
```bash
# 获取某队伍仓库的所有 PR
gitlink-cli pr +list --state open --owner <org> --repo <team_repo> --format json
gitlink-cli pr +list --state merged --owner <org> --repo <team_repo> --format json
# 批量获取所有队伍仓库的 PR
# AI 遍历所有队伍仓库,汇总提交状态
```
### 3.2 提交记录汇总
```bash
# 对每个队伍的每个 PR 获取详情
gitlink-cli pr +view --id <pr_id> --owner <org> --repo <team_repo> --format json
# 获取 PR 评论(评测反馈)
gitlink-cli api GET /v1/:owner/:repo/issues/:issue_id/journals --format json
```
**提交记录汇总格式**
```
=== 竞赛提交记录汇总 ===
| 队伍 | P001 | P002 | P003 | P004 | P005 | 总提交数 | 最后提交时间 |
|------|------|------|------|------|------|---------|-------------|
| 算法之光 | ✅ 通过 | ✅ 通过 | ❌ 未通过 | ✅ 通过 | — | 8 | 14:52 |
| 代码刺客 | ✅ 通过 | ✅ 通过 | ✅ 通过 | ❌ 未通过 | ❌ 未通过 | 12 | 14:58 |
| AC之王 | ✅ 通过 | ✅ 通过 | ✅ 通过 | ✅ 通过 | ✅ 通过 | 15 | 14:45 |
| 菜鸟队 | ✅ 通过 | ❌ 未通过 | — | — | — | 3 | 13:20 |
✅ = 已通过 ❌ = 未通过 — = 未提交
```
### 3.3 最终版本锁定
```bash
# 竞赛截止后,锁定各队伍最终提交
# 方式:关闭截止后的新 PR + 标记最终版本
# 获取截止时间后的 PR
gitlink-cli pr +list --state open --owner <org> --repo <team_repo> --format json
# AI 筛选 pr_created_unix > 截止时间 的 PR
# 关闭迟到的提交
gitlink-cli issue +comment --id <issue_id> --owner <org> --repo <team_repo> \
--body "⚠️ 此 PR 提交于截止时间之后,不予评测。" --format json
gitlink-cli issue +close --id <issue_id> --owner <org> --repo <team_repo>
```
---
## 四、排行榜生成
### 4.1 评分计算
```
评分规则通过规则自动计算或者通过CSV、Excel导入得分
方式 A通过率排序默认
总分 = Σ 各题通过分值
排序:总分降序 → 最后通过时间升序
方式 B用时排序ACM 赛制)
总罚时 = Σ (通过题目的提交时间 + 未通过提交次数 × 20分钟)
排序:通过题数降序 → 总罚时升序
方式 C代码质量评分
总分 = 通过分 × 80% + 代码质量分 × 20%
代码质量分 = 代码规范(30) + 可读性(30) + 复杂度(20) + 测试覆盖(20)
```
### 4.2 排行榜格式
```markdown
## 🏆 2024 校内算法竞赛 — 排行榜
**竞赛时间:** 2024-07-15 09:00 - 15:00
**参赛队伍:** 24 支
**题目数量:** 5 题
---
### 🥇 最终排名
| 排名 | 队伍 | 队长 | 通过题数 | 总分 | 最后通过 | 罚时 |
|------|------|------|---------|------|---------|------|
| 🥇 1 | AC之王 | 张三 | 5/5 | 1000 | 14:45 | 325min |
| 🥈 2 | 代码刺客 | 赵六 | 4/5 | 650 | 14:58 | 412min |
| 🥉 3 | 算法之光 | 李四 | 3/5 | 450 | 14:52 | 298min |
| 4 | 冲冲冲 | 王五 | 3/5 | 450 | 14:30 | 356min |
| 5 | 菜鸟队 | 钱七 | 1/5 | 100 | 13:20 | 145min |
| ... | ... | ... | ... | ... | ... | ... |
---
### 📊 题目通过统计
| 题号 | 标题 | 分值 | 通过数 | 通过率 | 平均提交次数 |
|------|------|------|--------|--------|------------|
| P001 | 两数之和 | 100 | 22/24 | 91.7% | 1.3 |
| P002 | 最长回文子串 | 150 | 18/24 | 75.0% | 2.1 |
| P003 | 合并K个有序链表 | 200 | 8/24 | 33.3% | 3.5 |
| P004 | 最短路径 | 200 | 5/24 | 20.8% | 4.2 |
| P005 | 动态规划优化 | 350 | 1/24 | 4.2% | 5.0 |
---
### ⏱️ 提交时间线
| 时间段 | 提交数 | 通过数 | 高峰说明 |
|--------|--------|--------|---------|
| 09:00-10:00 | 45 | 28 | 开局快速通过 P001 |
| 10:00-11:00 | 38 | 15 | P002 攻坚阶段 |
| 11:00-12:00 | 22 | 5 | P003/P004 难度提升 |
| 12:00-13:00 | 8 | 2 | 午休低谷 |
| 13:00-14:00 | 35 | 8 | 下午冲刺 |
| 14:00-15:00 | 52 | 12 | 最后冲刺(含多次未通过) |
---
*由 gitlink-competition-manager Skill 自动生成*
```
---
## 五、防作弊检测
### 5.1 跨队伍代码相似度检测(用户可选)
```
检测流程:
1. 提取所有队伍对同一题目的最终提交代码
2. 两两比对代码相似度
a. 预处理:去除注释、空白、变量名重命名归一化
b. Token 序列比对:计算编辑距离相似度
c. AST 结构比对:比较语法树结构相似度
3. 综合相似度 = Token 相似度 × 60% + AST 相似度 × 40%
4. 标记高相似度对(> 80%)为可疑
```
**检测报告格式**
```markdown
### 🔍 防作弊检测报告
**检测方法:** Token 序列比对 + AST 结构比对
**检测范围:** 24 支队伍 × 5 道题目 = 120 份代码
---
#### ⚠️ 可疑相似度对(> 80%
| 队伍A | 队伍B | 题目 | Token相似度 | AST相似度 | 综合相似度 | 判定 |
|-------|-------|------|-----------|----------|-----------|------|
| 算法之光 | 冲冲冲 | P001 | 92% | 88% | 90.4% | 🔴 高度可疑 |
| 菜鸟队 | 摸鱼队 | P001 | 85% | 82% | 83.8% | 🟡 轻度可疑 |
#### ✅ 正常范围
| 统计项 | 数值 |
|--------|------|
| 比对总数 | 7140 对 |
| 可疑对数 | 2 对 |
| 可疑率 | 0.03% |
| 平均相似度 | 23.5% |
---
#### 🔴 高度可疑详情:算法之光 vs 冲冲冲P001
**相似代码片段**
```python
# 算法之光
def two_sum(nums, target):
seen = {}
for i, num in enumerate(nums):
diff = target - num
if diff in seen:
return [seen[diff], i]
seen[num] = i
# 冲冲冲
def twoSum(nums, target):
visited = {}
for i, num in enumerate(nums):
remain = target - num
if remain in visited:
return [visited[remain], i]
visited[num] = i
```
**分析**变量名不同seen→visited, diff→remain但代码结构和逻辑完全一致仅做了变量重命名。
**建议**:约谈两队了解情况,要求解释解题思路。
```
### 5.2 提交时间异常检测
```
异常检测规则:
1. 短时间大量提交:同一队伍 5 分钟内提交 > 5 次
2. 提交时间高度重合:两支队伍提交时间差 < 30 多次
标记为异常的提交需人工复查。
```
---
## 六、赛后归档
### 6.1 获奖队伍标记
```bash
# 为获奖队伍仓库打标签
# 先确保标签存在
gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json
gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"冠军","description":"竞赛冠军","color":"#ffd700"}' --format json
gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"亚军","description":"竞赛亚军","color":"#c0c0c0"}' --format json
gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"季军","description":"竞赛季军","color":"#cd7f32"}' --format json
# 在主仓库创建获奖公告 Issue
gitlink-cli issue +create \
--owner <owner> --repo <main_repo> \
--title "【公告】2024 校内算法竞赛获奖名单" \
--body "## 🏆 获奖名单
### 🥇 冠军
- 队伍AC之王
- 队长:张三
- 成员:张三、李四、王五
- 通过题数5/5
- 总分1000
### 🥈 亚军
- 队伍:代码刺客
- 队长:赵六
- 成员:赵六、钱七
- 通过题数4/5
### 🥉 季军
- 队伍:算法之光
- 队长:李四
- 成员:李四、王五
- 通过题数3/5
### 仓库链接
- [AC之王仓库](https://www.gitlink.org.cn/<org>/ac-kings)
- [代码刺客仓库](https://www.gitlink.org.cn/<org>/code-assassin)
- [算法之光仓库](https://www.gitlink.org.cn/<org>/algo-light)
恭喜以上队伍!" \
--format json
```
### 6.2 仓库归档
```bash
# 将所有队伍仓库设为只读(通过移除写权限)
# 保留仓库但将协作者权限降为 报告者
gitlink-cli api PUT /api/:owner/:repo/collaborators/change_role --body "{\"user_id\":\"<member_user_id>\",\"role\":\"Reporter\"}" --format json
# 在仓库 README 中追加归档说明(可选)
gitlink-cli issue +comment \
--id <issue_id> --owner <org> --repo <team_repo> \
--body "📦 本仓库已归档。竞赛已结束,仓库转为只读。如有需要请联系组织者。" --format json
```
---
## 七、执行步骤总览
### 7.1 赛前准备
```bash
# Step 1获取组织 ID
gitlink-cli api GET /api/organizations --format json
# Step 2批量创建队伍仓库复用 gitlink-batch-repo-create 逻辑)
for 每个队伍:
gitlink-cli api POST /api/projects --body '{...}' --format json
gitlink-cli api POST /v1/:owner/:repo/contents --body '{...}' --format json # 初始化模板
for 每个队员:
gitlink-cli api POST /api/:owner/:repo/collaborators --body "{...}" --format json
# Step 3创建题目标签体系
gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{...}' --format json
# Step 4发布题目 Issue
for 每道题:
gitlink-cli issue +create --owner <owner> --repo <main_repo> --title "【题目 Pxxx】<标题>" --body "<题目内容>" --format json
gitlink-cli api PATCH /v1/:owner/:repo/issues/:id --body '{"issue_tag_ids":[<tag_id>]}' --format json
# Step 5输出竞赛信息总览队伍列表 + 题目清单 + 仓库链接)
```
### 7.2 赛中监控
```bash
# Step 1获取所有队伍仓库的 PR
for 每个队伍仓库:
gitlink-cli pr +list --state open --owner <org> --repo <team_repo> --format json
gitlink-cli pr +list --state merged --owner <org> --repo <team_repo> --format json
# Step 2汇总提交记录
# Step 3实时排行榜更新
# Step 4输出当前排名 + 提交统计
```
### 7.3 赛后处理
```bash
# Step 1锁定最终提交关闭截止后的 PR
gitlink-cli issue +close --id <issue_id> --owner <org> --repo <team_repo>
# Step 2防作弊检测代码相似度 + 提交时间异常)
# Step 3生成最终排行榜
# Step 4发布获奖公告 Issue
gitlink-cli issue +create --owner <owner> --repo <main_repo> --title "【公告】获奖名单" --body "<获奖信息>" --format json
# Step 5归档队伍仓库降权为报告者
```
---
## 八、可配置参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `scoring_mode` | pass_rate | 评分方式pass_rate / acm / quality |
| `late_penalty` | 0 | 迟交扣分 |
| `plagiarism_threshold` | 80 | 抄袭相似度阈值(% |
| `time_anomaly_threshold` | 5 | 短时间提交异常阈值5分钟内N次 |
| `repo_private` | true | 队伍仓库是否私有 |
| `lock_after_deadline` | true | 截止后是否锁定提交 |
| `archive_after_contest` | true | 赛后是否归档仓库 |
| `team_template` | default | 仓库初始化模板名称 |
---
## 九、常见场景示例
### 场景 A赛前批量准备
```
用户:"帮我准备校内算法竞赛24支队伍5道题7月15日9点开始"
AI 执行:
1. 获取组织 ID
2. 读取队伍清单 CSV → 批量创建 24 个队伍仓库
3. 初始化仓库模板problems/ 目录结构)
4. 添加队员为协作者
5. 创建题目标签体系
6. 逐题创建 Issue含题目描述/分值/截止时间)
7. 输出竞赛信息总览(队伍列表 + 题目清单 + 仓库链接)
```
### 场景 B赛中实时排行
```
用户:"看看现在排行榜什么情况"
AI 执行:
1. 遍历所有队伍仓库获取 PR 数据
2. 统计各队通过/未通过情况
3. 计算排名(按评分模式)
4. 输出当前排行榜 + 提交统计 + 时间线
```
### 场景 C赛后完整处理
```
用户:"竞赛结束了,帮我做赛后处理"
AI 执行:
1. 锁定最终提交(关闭截止后的 PR
2. 防作弊检测(代码相似度 + 提交时间异常)
3. 生成最终排行榜
4. 创建获奖公告 Issue
5. 归档队伍仓库(降权为只读)
6. 输出赛后报告(排行榜 + 防作弊报告 + 归档清单)
```
---
## 十、注意事项
- ✅ **标签预创建**:打标签前必须先查询标签列表,确认目标标签存在,不存在则先创建
- ✅ **issue_tag_ids 完整替换**PATCH 的 issue_tag_ids 是完整替换,需包含已有标签 ID
- ✅ **仓库隐私**:竞赛仓库建议设为 private避免队伍间互相查看代码
- ⚠️ **防作弊局限性**Token+AST 相似度检测不能替代人工审查,仅作为辅助参考
- ⚠️ **API 频率**24 支队伍 × 5 题目 = 120 个仓库的 PR 查询,需分批处理
- ⚠️ **协作者权限**:赛后归档时需逐个修改协作者权限,工作量与队伍数成正比
- ⚠️ **时区**:截止时间以服务器时区为准,建议明确标注 GMT+8

View File

@ -0,0 +1,79 @@
---
name: gitlink-wiki
version: 2.0.0
description: "Wiki 页面管理:查看目录、查看、创建、更新和删除 GitLink Wiki 页面。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli wiki --help"
---
# gitlink-wiki
**重要**: 开始操作前请先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中涵盖认证、权限、全局参数和 GitLink API 行为说明。
**重要**: 执行写入或破坏性操作(如 `+create`、`+update` 或 `+delete`)前,请先确认用户意图。
**重要**: 操作 GitLink 资源请使用 `gitlink-cli`,不要使用 `gh` 等 GitHub 专用工具。
## 快捷命令
| 快捷命令 | 说明 | 操作类型 |
|----------|------|----------|
| `wiki +list` | 列出 Wiki 页面(目录结构) | 只读 |
| `wiki +view` | 按页面名称查看 Wiki 页面详情 | 只读 |
| `wiki +create` | 创建新的 Wiki 页面 | 写入 |
| `wiki +update` | 更新 Wiki 页面标题和/或内容 | 写入 |
| `wiki +delete` | 删除 Wiki 页面 | 破坏性 |
## 使用示例
```bash
# 列出 Wiki 目录
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
# 查看 Wiki 页面
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
# 创建 Wiki 页面
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
-n getting-started -t "快速开始" -c "# 快速开始指南\n\n这是入门文档。"
# 创建时附带提交信息
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
-n api-guide -t "API 指南" -c "# API 指南" -m "Add API guide"
# 仅更新页面标题
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题"
# 仅更新页面内容
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容"
# 同时更新标题和内容
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 \
-n home -t "新标题" -c "新内容"
# 删除 Wiki 页面
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
```
## 参数说明
| 命令 | 关键参数 |
|------|----------|
| `+list` | `--project-id` |
| `+view` | `--project-id`、`--page-name` (`-n`) |
| `+create` | `--project-id`、`--page-name` (`-n`)、`--title` (`-t`)、`--content` (`-c`)、`--message` (`-m`) |
| `+update` | `--project-id`、`--page-name` (`-n`)、`--title` (`-t`),可选 `--content` (`-c`)、`--message` (`-m`) |
| `+delete` | `--project-id`、`--page-name` (`-n`) |
## API 说明
- 所有 Wiki 端点使用 `/api/wiki/{action}` 扁平路径结构(非 REST 嵌套路径)。
- 目录列表: `GET /api/wiki/wikiPages`查询参数owner, repo, projectId
- 查看详情: `GET /api/wiki/getWiki`查询参数owner, repo, projectId, pageName
- 创建页面: `POST /api/wiki/createWiki`JSON bodycontent 需 base64 编码)
- 更新页面: `PUT /api/wiki/updateWiki`JSON bodycontent 需 base64 编码)
- 删除页面: `DELETE /api/wiki/deleteWiki`JSON bodyowner, repo, projectId, pageName
- Wiki 页面通过 `pageName`slug标识而非数字 ID。
- 所有操作都需要 `--project-id`GitLink 项目数字 ID
- 创建和更新时,内容自动进行 base64 编码后以 `content_base64` 字段发送。
- `+update` 要求必须提供 `--title``--page-name``--content` 为可选。