Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ The codebase follows a strict layered architecture (top to bottom):
- `internal/config`: Configuration loading and credential management
- `internal/processor`: Business logic for creating/deleting resources
- `internal/template`: Go template rendering for custom output
- `internal/utils`: Utility functions (timestamp generation, visibility conversion)
- `internal/utils`: Utility functions (millisecond timestamp + suffix generation, visibility conversion)

- **`pkg/`**: Public packages that can be imported by external projects
- `pkg/client`: GitLab API client wrapper (built on official SDK)
Expand Down Expand Up @@ -112,8 +112,8 @@ The codebase follows a strict layered architecture (top to bottom):

The tool supports two naming modes for resources:

- **`prefix` mode (default)**: Appends timestamp to username/email/group/project paths
- Example: `tektoncd` → `tektoncd-20251030150000`
- **`prefix` mode (default)**: Appends `millisecond timestamp + suffix` to username/email/group/project paths
- Example: `tektoncd` → `tektoncd-20251030150000123-a1b2`
- Used for test environments and creating multiple similar resources
- **Important**: Cleanup must use the output file from creation (not config file)

Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export GITLAB_SSH_ENDPOINT=ssh://git@your-gitlab.com:ssh-port
--token your-token \
-f config.yaml

# Optional: specify a fixed suffix in prefix mode
./bin/gitlab-cli user create \
--host https://your-gitlab.com \
--token your-token \
--suffix ci01 \
-f config.yaml

# Output results to file
./bin/gitlab-cli user create \
--host https://your-gitlab.com \
Expand All @@ -72,8 +79,8 @@ export GITLAB_SSH_ENDPOINT=ssh://git@your-gitlab.com:ssh-port
-f config.yaml

# ⚠️ Note: Cleanup with prefix mode
# When using nameMode: prefix (adds timestamp), cleanup requires the output file from creation
# Because actual usernames, group names, and project names all include timestamps
# When using nameMode: prefix (adds millisecond timestamp + suffix), cleanup requires the output file from creation
# Because actual usernames, group names, and project names include generated suffixes

# 1. Save output file during creation
./bin/gitlab-cli user create \
Expand Down Expand Up @@ -104,8 +111,10 @@ export GITLAB_SSH_ENDPOINT=ssh://git@your-gitlab.com:ssh-port
The configuration file supports two naming modes:

**1. prefix mode (default)**
- Automatically appends timestamps to username, email, group path, and project path
- Example: `tektoncd` → `tektoncd-20251030150000`
- Automatically appends `millisecond timestamp + suffix` to username, email, group path, and project path
- Default format: `<yyyyMMddHHmmssSSS>-<random4>`
- Example: `tektoncd` → `tektoncd-20251030150000123-a1b2`
- Optional override: use `--suffix <value>` to replace the random suffix
- Use cases: Test environments, creating multiple similar resources
- ⚠️ Cleanup must use the output file from creation

Expand Down
15 changes: 8 additions & 7 deletions docs/TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ GitLab CLI 支持使用自定义模板来格式化输出结果,让你可以按
- `-f, --config`: 输入配置文件(用户、组、项目定义)
- `-o, --output`: 输出文件路径
- `-t, --template`: 模板文件路径(可选)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (docs/consistency): The --suffix flag description is in English while surrounding text is Chinese. Consider adding Chinese translation: --suffix: 可选自定义后缀(在 prefix 模式下替换随机后缀)

- `--suffix`: Optional custom suffix used in `nameMode: prefix` (replaces random suffix part)

**注意**:
- 如果不指定 `--template`,将使用默认的 YAML 格式输出
Expand All @@ -35,17 +36,17 @@ GitLab CLI 支持使用自定义模板来格式化输出结果,让你可以按
在输出数据中,项目有两个路径字段:

- **Path**: 完整路径,包含 group 或 username 前缀
- 组级项目:`backend-group-20251105112212/demo-20251105112213`
- 用户级项目:`tektoncd-20251105112211/my-personal-project-20251105112216`
- 组级项目:`backend-group-20251105112212123-a1b2/demo-20251105112213123-c3d4`
- 用户级项目:`tektoncd-20251105112211123-a1b2/my-personal-project-20251105112216123-c3d4`

- **ProjectPath**: 项目本身的路径,不包含前缀
- 组级项目:`demo-20251105112213`
- 用户级项目:`my-personal-project-20251105112216`
- 组级项目:`demo-20251105112213123-c3d4`
- 用户级项目:`my-personal-project-20251105112216123-c3d4`

**关于时间戳后缀**:
- 当使用 `nameMode: prefix` 时(默认),路径会自动添加时间戳后缀
- 当使用 `nameMode: name` 时,路径保持与配置文件中一致,不添加时间戳
- **注意**:nameMode 只影响路径(Path),不影响项目的显示名称(Name
- When `nameMode: prefix` is used (default), paths automatically append `millisecond timestamp + suffix`
- When `nameMode: name` is used, paths stay exactly as configured
- `nameMode` only affects path fields (`Path` / `ProjectPath`), not display names (`Name`)

### 可用数据

Expand Down
9 changes: 8 additions & 1 deletion internal/cli/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func buildUserCreateCommand(cfg *config.CLIConfig) *cobra.Command {
cmd.Flags().StringVar(&cfg.GitLabSSHEndpoint, "ssh-endpoint", "", "GitLab SSH endpoint (e.g., ssh://git@host:22)")
cmd.Flags().StringVarP(&cfg.OutputFile, "output", "o", "", "输出结果到 YAML 文件")
cmd.Flags().StringVarP(&cfg.TemplateFile, "template", "t", "", "使用模板文件格式化输出")
cmd.Flags().StringVar(&cfg.NameSuffix, "suffix", "", "Custom suffix appended after millisecond timestamp in prefix mode")

return cmd
}
Expand Down Expand Up @@ -145,8 +146,14 @@ func runUserCreate(cfg *config.CLIConfig) error {
}

log.Printf("\n找到 %d 个用户配置\n\n", len(userConfig.Users))
if cfg.NameSuffix != "" {
log.Printf("使用自定义后缀: %s\n", cfg.NameSuffix)
}

proc := &processor.ResourceProcessor{Client: gitlabClient}
proc := &processor.ResourceProcessor{
Client: gitlabClient,
NameSuffix: cfg.NameSuffix,
}

// 收集所有用户的输出结果
var userOutputs []types.UserOutput
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type CLIConfig struct {
TemplateFile string // 模板文件路径
DaysOld int // 只删除创建日期超过指定天数的用户(cleanup 命令使用)
GitLabSSHEndpoint string // GitLab SSH endpoint (e.g., ssh://git@host:22)
NameSuffix string // Optional custom suffix used in prefix naming mode.
}

// LoadGitLabCredentials 从环境变量或命令行参数加载 GitLab 凭证
Expand Down
27 changes: 15 additions & 12 deletions internal/processor/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import (

// ResourceProcessor 封装资源创建和删除的业务逻辑
type ResourceProcessor struct {
// Client handles GitLab API interactions.
Client *client.GitLabClient
// NameSuffix optionally overrides the random suffix appended in prefix mode.
NameSuffix string
}

// ========================================
Expand All @@ -35,10 +38,10 @@ func (p *ResourceProcessor) ProcessUserCreation(userSpec types.UserSpec) (*types
actualEmail = userSpec.Email
log.Printf(" 使用 name 模式(不添加时间戳)\n")
} else {
// prefix 模式:添加时间戳
actualUsername = utils.GenerateUsernameWithTimestamp(userSpec.Username)
actualEmail = utils.GenerateEmailWithTimestamp(userSpec.Email)
log.Printf(" 使用 prefix 模式(添加时间戳)\n")
// prefix 模式:添加毫秒时间戳和后缀
actualUsername = utils.GenerateUsernameWithTimestamp(userSpec.Username, p.NameSuffix)
actualEmail = utils.GenerateEmailWithTimestamp(userSpec.Email, p.NameSuffix)
log.Printf(" 使用 prefix 模式(添加毫秒时间戳+后缀)\n")
}

log.Printf(" 用户名: %s\n", actualUsername)
Expand Down Expand Up @@ -103,8 +106,8 @@ func (p *ResourceProcessor) ProcessUserCreation(userSpec types.UserSpec) (*types

// createPersonalAccessToken 为用户创建 Personal Access Token,返回 token 值和实际使用的过期时间
func (p *ResourceProcessor) createPersonalAccessToken(userID int, username string, tokenSpec *types.TokenSpec) (string, string, error) {
// 生成 token 名称,格式: username-token-timestamp
tokenName := fmt.Sprintf("%s-token-%d", username, time.Now().Unix())
// 生成 token 名称,格式: username-token-<millisecond-timestamp>-<suffix>
tokenName := fmt.Sprintf("%s-token-%s", username, utils.GenerateTemporalSuffix(p.NameSuffix))

// 设置过期时间:如果未指定,默认为第2天
expiresAt := tokenSpec.ExpiresAt
Expand Down Expand Up @@ -207,9 +210,9 @@ func (p *ResourceProcessor) ensureGroup(username string, groupSpec types.GroupSp
} else {
// prefix 模式:添加时间戳
if groupSpec.Path == "" {
actualGroupPath = utils.GenerateGroupPathWithTimestamp(groupSpec.Name)
actualGroupPath = utils.GenerateGroupPathWithTimestamp(groupSpec.Name, p.NameSuffix)
} else {
actualGroupPath = utils.GenerateGroupPathWithTimestamp(groupSpec.Path)
actualGroupPath = utils.GenerateGroupPathWithTimestamp(groupSpec.Path, p.NameSuffix)
}
log.Printf(" 使用 prefix 模式,生成组 path: %s\n", actualGroupPath)
}
Expand Down Expand Up @@ -267,9 +270,9 @@ func (p *ResourceProcessor) createUserProjectsWithOutput(username string, projec
} else {
// prefix 模式:添加时间戳
if projSpec.Path == "" {
actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Name)
actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Name, p.NameSuffix)
} else {
actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Path)
actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Path, p.NameSuffix)
}
log.Printf(" 使用 prefix 模式,生成项目 path: %s\n", actualProjectPath)
}
Expand Down Expand Up @@ -341,9 +344,9 @@ func (p *ResourceProcessor) createProjectsWithOutput(username string, groupID in
} else {
// prefix 模式:添加时间戳
if projSpec.Path == "" {
actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Name)
actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Name, p.NameSuffix)
} else {
actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Path)
actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Path, p.NameSuffix)
}
log.Printf(" 使用 prefix 模式,生成项目 path: %s\n", actualProjectPath)
}
Expand Down
127 changes: 89 additions & 38 deletions internal/utils/utils.go
Original file line number Diff line number Diff line change
@@ -1,100 +1,151 @@
package utils

import (
"crypto/rand"
"fmt"
"regexp"
"strings"
"time"
)

// GetVisibility 获取可见性设置,默认为 private
const (
// defaultRandomSuffixLength controls the random suffix length when no custom suffix is provided.
defaultRandomSuffixLength = 4
)

var (
// shortSuffixAlphabet contains safe characters for username/email/path suffixes.
shortSuffixAlphabet = []byte("abcdefghijklmnopqrstuvwxyz0123456789")
)

// GetVisibility returns the visibility value, defaulting to private.
func GetVisibility(v string) string {
if v == "" {
return "private"
}
return v
}

// GenerateTimestampSuffix 生成时间戳后缀(格式:20060102150405)
// GenerateTimestampSuffix returns a millisecond-level timestamp suffix in yyyyMMddHHmmssSSS format.
func GenerateTimestampSuffix() string {
return time.Now().Format("20060102150405")
now := time.Now()
millisecond := now.Nanosecond() / int(time.Millisecond)
return fmt.Sprintf("%s%03d", now.Format("20060102150405"), millisecond)
}

// GenerateTemporalSuffix returns a unique suffix in the format timestamp-randomOrCustom.
func GenerateTemporalSuffix(customSuffix string) string {
normalizedSuffix := normalizeCustomSuffix(customSuffix)
if normalizedSuffix == "" {
normalizedSuffix = generateShortRandomSuffix(defaultRandomSuffixLength)
}
return fmt.Sprintf("%s-%s", GenerateTimestampSuffix(), normalizedSuffix)
}

// GenerateUsernameWithTimestamp 基于前缀生成符合 GitLab 要求的 username
// 格式:prefix-timestamp
func GenerateUsernameWithTimestamp(prefix string) string {
timestamp := GenerateTimestampSuffix()
username := fmt.Sprintf("%s-%s", prefix, timestamp)
// 确保符合 GitLab username 规则:只能包含字母、数字、下划线、点号、破折号
// GenerateUsernameWithTimestamp builds a GitLab-safe username as prefix-temporalSuffix.
func GenerateUsernameWithTimestamp(prefix, customSuffix string) string {
temporalSuffix := GenerateTemporalSuffix(customSuffix)
username := fmt.Sprintf("%s-%s", prefix, temporalSuffix)
// Ensure username follows GitLab username rules.
username = sanitizeUsername(username)
// 限制长度为 255
// Enforce GitLab username max length.
if len(username) > 255 {
username = username[:255]
}
return username
}

// GenerateEmailWithTimestamp 基于前缀生成唯一的 email
// 格式:prefix-timestamp@domain
func GenerateEmailWithTimestamp(emailPrefix string) string {
// 解析邮箱前缀和域名
// GenerateEmailWithTimestamp builds an email as localPart-temporalSuffix@domain.
func GenerateEmailWithTimestamp(emailPrefix, customSuffix string) string {
// Split local part and domain.
parts := strings.Split(emailPrefix, "@")
if len(parts) != 2 {
// 如果不是有效的邮箱格式,使用默认域名
return fmt.Sprintf("%s-%s@test.example.com", emailPrefix, GenerateTimestampSuffix())
// Fall back to a default test domain when input is not an email.
return fmt.Sprintf("%s-%s@test.example.com", emailPrefix, GenerateTemporalSuffix(customSuffix))
}

localPart := parts[0]
domain := parts[1]
timestamp := GenerateTimestampSuffix()
temporalSuffix := GenerateTemporalSuffix(customSuffix)

return fmt.Sprintf("%s-%s@%s", localPart, timestamp, domain)
return fmt.Sprintf("%s-%s@%s", localPart, temporalSuffix, domain)
}

// GenerateGroupPathWithTimestamp 基于前缀生成符合 GitLab 要求的 group path
// 格式:prefix-timestamp
func GenerateGroupPathWithTimestamp(prefix string) string {
timestamp := GenerateTimestampSuffix()
path := fmt.Sprintf("%s-%s", prefix, timestamp)
// 确保符合 GitLab group path 规则:只能包含小写字母、数字、下划线、破折号
// GenerateGroupPathWithTimestamp builds a GitLab-safe group path as prefix-temporalSuffix.
func GenerateGroupPathWithTimestamp(prefix, customSuffix string) string {
temporalSuffix := GenerateTemporalSuffix(customSuffix)
path := fmt.Sprintf("%s-%s", prefix, temporalSuffix)
// Ensure path follows GitLab group path rules.
path = sanitizeGroupPath(path)
// 限制长度为 255
// Enforce GitLab path max length.
if len(path) > 255 {
path = path[:255]
}
return path
}

// GenerateProjectPathWithTimestamp 基于前缀生成符合 GitLab 要求的 project path
// 格式:prefix-timestamp(项目 path 规则与组 path 相同)
func GenerateProjectPathWithTimestamp(prefix string) string {
return GenerateGroupPathWithTimestamp(prefix)
// GenerateProjectPathWithTimestamp builds a GitLab-safe project path as prefix-temporalSuffix.
func GenerateProjectPathWithTimestamp(prefix, customSuffix string) string {
return GenerateGroupPathWithTimestamp(prefix, customSuffix)
}

// normalizeCustomSuffix sanitizes a custom suffix to safe characters and lowercase.
func normalizeCustomSuffix(customSuffix string) string {
trimmedSuffix := strings.TrimSpace(customSuffix)
if trimmedSuffix == "" {
return ""
}

safeSuffix := regexp.MustCompile(`[^a-zA-Z0-9_-]`).ReplaceAllString(trimmedSuffix, "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning (performance/regex-compilation): Regex is compiled on every call. Consider pre-compiling at package level:

var safeCharsRegex = regexp.MustCompile(`[^a-zA-Z0-9_-]`)

return strings.ToLower(safeSuffix)
}

// generateShortRandomSuffix returns a random lowercase alphanumeric suffix.
func generateShortRandomSuffix(length int) string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (test/missing): Consider adding unit tests for GenerateTemporalSuffix, generateShortRandomSuffix, and normalizeCustomSuffix to ensure timestamp format and random generation work correctly.

if length <= 0 {
length = defaultRandomSuffixLength
}

rawRandomBytes := make([]byte, length)
_, randomErr := rand.Read(rawRandomBytes)
if randomErr != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (refactor/fallback): The fallback uses time.Now().UnixNano() which could theoretically collide in high-concurrency. Consider adding a counter or additional entropy in the fallback path.

fallbackFromTime := fmt.Sprintf("%d", time.Now().UnixNano())
if len(fallbackFromTime) > length {
return fallbackFromTime[len(fallbackFromTime)-length:]
}
return fallbackFromTime
}

randomSuffix := make([]byte, length)
for i := range rawRandomBytes {
randomSuffix[i] = shortSuffixAlphabet[int(rawRandomBytes[i])%len(shortSuffixAlphabet)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning (performance/uniform-distribution): Modulo bias detected. Using byte % 36 introduces slight statistical bias since 256 values don't divide evenly by 36. Consider rejection sampling or a larger alphabet.

// Alternative approach using rejection sampling:
idx := int(rawRandomBytes[i]) * len(shortSuffixAlphabet) / 256
randomSuffix[i] = shortSuffixAlphabet[idx]

}

return string(randomSuffix)
}

// sanitizeUsername 清理 username,确保符合 GitLab 规则
// 允许:字母、数字、下划线、点号、破折号
// sanitizeUsername sanitizes username to comply with GitLab username rules.
func sanitizeUsername(username string) string {
// 移除不允许的字符
// Remove unsupported characters.
reg := regexp.MustCompile(`[^a-zA-Z0-9_.-]`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning (performance/regex-compilation): Same issue - regex compiled on every call. Pre-compile at package level for better performance.

username = reg.ReplaceAllString(username, "")

// 确保不以破折号或点号开头/结尾
// Ensure username does not start/end with hyphen or dot.
username = strings.Trim(username, "-.")

return username
}

// sanitizeGroupPath 清理 group path,确保符合 GitLab 规则
// 允许:小写字母、数字、下划线、破折号
// sanitizeGroupPath sanitizes group/project path to comply with GitLab path rules.
func sanitizeGroupPath(path string) string {
// 转换为小写
// Normalize to lowercase.
path = strings.ToLower(path)

// 移除不允许的字符
// Remove unsupported characters.
reg := regexp.MustCompile(`[^a-z0-9_-]`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning (performance/regex-compilation): Same issue - regex compiled on every call.

path = reg.ReplaceAllString(path, "")

// 确保不以破折号开头/结尾
// Ensure path does not start/end with hyphen.
path = strings.Trim(path, "-")

return path
Expand Down
Loading