diff --git a/change-insight/lib/github/common.go b/change-insight/lib/github/common.go index e06670a1..d361a7bc 100644 --- a/change-insight/lib/github/common.go +++ b/change-insight/lib/github/common.go @@ -1,6 +1,7 @@ package github import ( + "fmt" "log" "net/http" "os" @@ -41,7 +42,7 @@ func apiCall(apiUrl string) (*http.Response, error) { } req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("Authorization", "token " + getGitHubToken()) + req.Header.Set("Authorization", "token "+getGitHubToken()) resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/chatops-lark/cmd/server/main.go b/chatops-lark/cmd/server/main.go index 9d38b845..e89ba88a 100644 --- a/chatops-lark/cmd/server/main.go +++ b/chatops-lark/cmd/server/main.go @@ -38,8 +38,6 @@ func main() { cfg.AppSecret = *appSecret } - // Set default values and validate - cfg.SetDefaults() if err := cfg.Validate(); err != nil { log.Fatal().Err(err).Msg("Invalid configuration") } diff --git a/chatops-lark/config.yaml.example b/chatops-lark/config.yaml.example index df4c1dc8..a6536e75 100644 --- a/chatops-lark/config.yaml.example +++ b/chatops-lark/config.yaml.example @@ -6,12 +6,14 @@ app_secret: # or set it from cli options `--app-secret` # Cherry pick configuration cherry_pick_invite: - audit_webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" + audit: + webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" github_token: "ghp_xxx" # Ask command configuration ask: - # audit_webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" + audit: + webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" llm: azure_config: api_key: "your-api-key" @@ -30,12 +32,21 @@ ask: # DevBuild configuration devbuild: - # audit_webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" + audit: + webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" api_url: "https://tibuild.pingcap.net/api/devbuilds" +hotfix: + api_url: "https://tibuild.pingcap.net/api/v2/hotfix" # API endpoint for hotfix operations + audit: + webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" # Webhook URL for audit logs + title: "Custom audit title" # Optional title for audit messages + result: true # Include command results in audit logs + # RepoAdmins command configuration repo_admins: - audit_webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" + audit: + webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" github_token: "ghp_xxx" # Debug mode diff --git a/chatops-lark/pkg/audit/audit-card.yaml.tmpl b/chatops-lark/pkg/audit/audit-card.yaml.tmpl index 1038941c..8f686ba1 100644 --- a/chatops-lark/pkg/audit/audit-card.yaml.tmpl +++ b/chatops-lark/pkg/audit/audit-card.yaml.tmpl @@ -3,11 +3,19 @@ config: elements: - tag: markdown content: |- - {{.UserEmail}} trigger command `{{.Command}}` with args: + **🧑‍💻{{.UserEmail}} trigger command `{{.Command}}` with args:** {{ range .Args }} - {{ . }} {{ end }} - tag: hr + {{ with .Result }} + - tag: markdown + content: | + **📃Command result:** + + {{ . | trim | indent 6 }} + - tag: hr + {{ end }} - tag: note elements: - tag: plain_text @@ -15,5 +23,5 @@ elements: header: template: turquoise title: - content: chatbot audit message + content: {{ .Title | default "chatbot audit message" }} tag: plain_text diff --git a/chatops-lark/pkg/audit/send.go b/chatops-lark/pkg/audit/send.go index b34dd9cf..93f921b6 100644 --- a/chatops-lark/pkg/audit/send.go +++ b/chatops-lark/pkg/audit/send.go @@ -17,9 +17,11 @@ import ( var larkTemplateBytes string type AuditInfo struct { + Title string UserEmail string Command string Args []string + Result *string } func RecordAuditMessage(info *AuditInfo, auditWebhook string) error { diff --git a/chatops-lark/pkg/audit/send_test.go b/chatops-lark/pkg/audit/send_test.go new file mode 100644 index 00000000..89c8f609 --- /dev/null +++ b/chatops-lark/pkg/audit/send_test.go @@ -0,0 +1,22 @@ +package audit + +import ( + "testing" +) + +func TestNewLarkCardWithGoTemplate(t *testing.T) { + t.Run("success with original template", func(t *testing.T) { + result := "TiDB-X hotfix tag bumped successfully:\n• Repo: PingCAP-QE/ci\n• Commit: e2cc653b672029b78519a524847b341baf72c98f\n• Tag: v8.5.4-nextgen.202510.2" + info := &AuditInfo{ + UserEmail: "user@example.com", + Command: "/devbuild", + Args: []string{"--foo", "bar"}, + Result: &result, + } + str, err := newLarkCardWithGoTemplate(info) + t.Log(str) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + }) +} diff --git a/chatops-lark/pkg/config/config.go b/chatops-lark/pkg/config/config.go index 26c3ed40..a74d5b9b 100644 --- a/chatops-lark/pkg/config/config.go +++ b/chatops-lark/pkg/config/config.go @@ -18,14 +18,14 @@ type Config struct { } `yaml:"user_custom_attr_ids,omitempty" json:"user_custom_attr_ids,omitempty"` // Cherry pick configuration - CherryPickInvite struct { + CherryPickInvite *struct { BaseCmdConfig `yaml:",inline" json:",inline"` GithubToken string `yaml:"github_token" json:"github_token"` } `yaml:"cherry_pick_invite" json:"cherry_pick_invite"` // Ask command configuration - Ask struct { + Ask *struct { BaseCmdConfig `yaml:",inline" json:",inline"` LLM struct { @@ -43,14 +43,20 @@ type Config struct { } `yaml:"ask" json:"ask"` // DevBuild configuration - DevBuild struct { + DevBuild *struct { BaseCmdConfig `yaml:",inline" json:",inline"` ApiURL string `yaml:"api_url" json:"api_url"` } `yaml:"devbuild" json:"devbuild"` + Hotfix *struct { + BaseCmdConfig `yaml:",inline" json:",inline"` + + ApiURL string `yaml:"api_url" json:"api_url"` + } `yaml:"hotfix" json:"hotfix"` + // RepoAdmin command configuration - RepoAdmin struct { + RepoAdmin *struct { BaseCmdConfig `yaml:",inline" json:",inline"` GithubToken string `yaml:"github_token" json:"github_token"` @@ -61,7 +67,13 @@ type Config struct { } type BaseCmdConfig struct { - AuditWebhook string `yaml:"audit_webhook" json:"audit_webhook"` // if empty, disable audit. + Audit *AuditConfig `yaml:"audit,omitempty" json:"audit,omitempty"` +} + +type AuditConfig struct { + Webhook string `yaml:"webhook" json:"webhook"` // if empty, disable audit. + Title string `yaml:"title" json:"title"` // if empty, use default title + Result bool `yaml:"result" json:"result"` // if true, include command result in audit } // LoadConfig loads the configuration from the specified YAML file @@ -92,11 +104,3 @@ func (c *Config) Validate() error { } return nil } - -// SetDefaults sets default values for the configuration -func (c *Config) SetDefaults() { - // Set defaults for DevBuild - if c.DevBuild.ApiURL == "" { - c.DevBuild.ApiURL = "https://tibuild.pingcap.net/api/devbuilds" - } -} diff --git a/chatops-lark/pkg/events/handler/hotfix.go b/chatops-lark/pkg/events/handler/hotfix.go new file mode 100644 index 00000000..5564b985 --- /dev/null +++ b/chatops-lark/pkg/events/handler/hotfix.go @@ -0,0 +1,189 @@ +package handler + +import ( + "context" + "flag" + "fmt" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" + + "github.com/PingCAP-QE/ee-apps/chatops-lark/pkg/config" +) + +// ctx keys store hotfix service configuration +const hotfixCfgKey string = "hotfix.cfg" + +type hotfixRuntimeConfig struct { + APIURL string + ActorEmail string + ActorGitHub *string +} + +// construct hotfixBumpTagRequest payload according to tibuild v2 goa design +type hotfixBumpTagRequest struct { + Repo string `json:"repo"` + Author string `json:"author"` + Commit string `json:"commit,omitempty"` + // Branch is optional in design; we don't require it in command, leave empty + Branch string `json:"branch,omitempty"` +} + +type hotfixBumpTagResponse struct { + Repo string `json:"repo"` + Commit string `json:"commit"` + Tag string `json:"tag"` +} + +type hotfixAPIError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// setupCtxHotfix prepares the runtime context for hotfix-related commands. +func setupCtxHotfix(ctx context.Context, cfg config.Config, actor *CommandActor) context.Context { + runtime := hotfixRuntimeConfig{ + APIURL: cfg.Hotfix.ApiURL, + ActorEmail: actor.Email, + ActorGitHub: actor.GitHubID, + } + return context.WithValue(ctx, hotfixCfgKey, &runtime) +} + +type bumpTidbxParams struct { + repo string + commit string + help bool +} + +func parseCommandHotfixBumpTidbx(args []string) (*bumpTidbxParams, string, error) { + fs := flag.NewFlagSet("/bump-tidbx-hotfix-tag", flag.ContinueOnError) + // silence default usage output + sink := new(strings.Builder) + fs.SetOutput(sink) + + ret := &bumpTidbxParams{} + + fs.StringVar(&ret.repo, "repo", "", "Full name of GitHub repository (e.g., pingcap/tidb)") + fs.StringVar(&ret.commit, "commit", "", "Short or full git commit SHA") + fs.BoolVar(&ret.help, "help", false, "Show help") + + if err := fs.Parse(args); err != nil { + return nil, "", err + } + + if ret.help { + return ret, hotfixHelpText(), NewSkipError("Help requested") + } + + // validate required args + missing := []string{} + if ret.repo == "" { + missing = append(missing, "--repo") + } + if ret.commit == "" { + missing = append(missing, "--commit") + } + if len(missing) > 0 { + return nil, hotfixHelpText(), NewInformationError(fmt.Sprintf("Missing required argument(s): %s", strings.Join(missing, ", "))) + } + + // strict repo format validation: must be /, neither part empty, and only one slash + if strings.Count(ret.repo, "/") != 1 { + return nil, hotfixHelpText(), NewInformationError("Invalid --repo. Expected format: / (e.g., pingcap/tidb)") + } + parts := strings.Split(ret.repo, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, hotfixHelpText(), NewInformationError("Invalid --repo. Expected format: / (e.g., pingcap/tidb)") + } + + // commit sanity + if len(ret.commit) < 7 || len(ret.commit) > 40 { + // sha length can vary, but common short SHA >=7, full 40 + // continue but inform the user + return ret, "", NewInformationError("The provided --commit looks unusual; ensure it's a valid short or full SHA") + } + + return ret, "", nil +} + +func hotfixHelpText() string { + return `Usage: /bump-tidbx-hotfix-tag --repo / --commit + +Description: + Bump TiDB-X style hotfix git tag for a GitHub repository by calling TiBuild v2 API. + +Arguments: + --repo Full name of GitHub repository (e.g., pingcap/tidb) + --commit Short or full git commit SHA to tag + +Examples: + /bump-tidbx-hotfix-tag --repo pingcap/tidb --commit abc123def + +Notes: + The tag will be generated in TiDB-X style and created on the specified commit.` +} + +// runCommandHotfixBumpTidbxTag handles `/bump-tidbx-hotfix-tag` command. +func runCommandHotfixBumpTidbxTag(ctx context.Context, args []string) (string, error) { + params, msg, err := parseCommandHotfixBumpTidbx(args) + if err != nil { + // return parsed message and error type to upper layer + return msg, err + } + + runtime, ok := ctx.Value(hotfixCfgKey).(*hotfixRuntimeConfig) + if !ok || runtime == nil || runtime.APIURL == "" { + return "", fmt.Errorf("hotfix API URL is not configured") + } + + reqBody := hotfixBumpTagRequest{ + Repo: params.repo, + Commit: params.commit, + Author: preferAuthor(runtime), + } + + // POST to /bump-tag-for-tidbx + url := strings.TrimRight(runtime.APIURL, "/") + "/bump-tag-for-tidbx" + + var res hotfixBumpTagResponse + var apiErr hotfixAPIError + client := resty.New().SetTimeout(20 * time.Second) + r, err := client.R(). + SetBody(reqBody). + SetResult(&res). + SetError(&apiErr). + Post(url) + if err != nil { + log.Err(err).Msg("hotfix API request failed") + return "", fmt.Errorf("hotfix API request failed: %w", err) + } + if !r.IsSuccess() { + if apiErr.Message != "" { + return "", fmt.Errorf("hotfix API error: %s (code: %d, http: %d)", apiErr.Message, apiErr.Code, r.StatusCode()) + } + return "", fmt.Errorf("hotfix API http status: %d", r.StatusCode()) + } + + // build user-friendly message + lines := []string{ + "TiDB-X hotfix tag bumped successfully:", + fmt.Sprintf("• Repo: %s", res.Repo), + fmt.Sprintf("• Commit: %s", res.Commit), + fmt.Sprintf("• Tag: %s", res.Tag), + } + return strings.Join(lines, "\n"), nil +} + +// preferAuthor picks a string representing the author for the API. +// If GitHubID is available, prefer it; otherwise fall back to email. +func preferAuthor(rt *hotfixRuntimeConfig) string { + if rt.ActorGitHub != nil { + if s := strings.TrimSpace(*rt.ActorGitHub); s != "" { + return s + } + } + return strings.TrimSpace(rt.ActorEmail) +} diff --git a/chatops-lark/pkg/events/handler/root.go b/chatops-lark/pkg/events/handler/root.go index 8deb3d3c..f43c7a08 100644 --- a/chatops-lark/pkg/events/handler/root.go +++ b/chatops-lark/pkg/events/handler/root.go @@ -176,31 +176,46 @@ func (r *rootHandler) initialize() error { r.botOpenID = openID r.logger.Info().Str("botOpenID", r.botOpenID).Msg("Bot OpenID initialized via botinfo package") - r.commandRegistry = map[string]CommandConfig{ - "/cherry-pick-invite": { + r.commandRegistry = make(map[string]CommandConfig) + if r.Config.CherryPickInvite != nil { + r.commandRegistry["/cherry-pick-invite"] = CommandConfig{ Description: "Grant a collaborator permission to edit a cherry-pick PR", Handler: runCommandCherryPickInvite, - AuditWebhook: r.Config.CherryPickInvite.AuditWebhook, + Audit: r.Config.CherryPickInvite.Audit, SetupContext: setupCtxCherryPickInvite, - }, - "/devbuild": { + } + } + if r.Config.DevBuild != nil { + r.commandRegistry["/devbuild"] = CommandConfig{ Description: "Trigger a devbuild or check build status", Handler: runCommandDevbuild, - AuditWebhook: r.Config.DevBuild.AuditWebhook, + Audit: r.Config.DevBuild.Audit, SetupContext: setupCtxDevbuild, - }, - "/ask": { + } + } + if r.Config.Ask != nil { + r.commandRegistry["/ask"] = CommandConfig{ Description: "Ask a question with LLM", Handler: runCommandAsk, - AuditWebhook: r.Config.Ask.AuditWebhook, + Audit: r.Config.Ask.Audit, SetupContext: setupAskCtx, - }, - "/repo-admins": { + } + } + if r.Config.RepoAdmin != nil { + r.commandRegistry["/repo-admins"] = CommandConfig{ Description: "Query repository administrators", Handler: runCommandRepoAdmin, - AuditWebhook: r.Config.RepoAdmin.AuditWebhook, + Audit: r.Config.RepoAdmin.Audit, SetupContext: setupCtxRepoAdmin, - }, + } + } + if r.Config.Hotfix != nil { + r.commandRegistry["/bump-tidbx-hotfix-tag"] = CommandConfig{ + Description: "Bump TiDB-X style hotfix tag for a GitHub repo", + Handler: runCommandHotfixBumpTidbxTag, + Audit: r.Config.Hotfix.Audit, + SetupContext: setupCtxHotfix, + } } return nil @@ -247,8 +262,12 @@ func (r *rootHandler) handleCommand(ctx context.Context, command *Command) (stri return result, err } - if cmdConfig.AuditWebhook != "" { - if auditErr := r.audit(cmdConfig.AuditWebhook, command); auditErr != nil { + if cmdConfig.Audit != nil && cmdConfig.Audit.Webhook != "" { + var auditResult *string + if cmdConfig.Audit.Result { + auditResult = &result + } + if auditErr := r.audit(cmdConfig.Audit.Webhook, cmdConfig.Audit.Title, command, auditResult); auditErr != nil { cmdLogger.Warn().Err(auditErr).Msg("Failed to audit command") } } @@ -266,11 +285,13 @@ func (r *rootHandler) getCommandConfig(command *Command, cmdLogger zerolog.Logge return &cmdConfig, nil } -func (r *rootHandler) audit(auditWebhook string, command *Command) error { +func (r *rootHandler) audit(auditWebhook, title string, command *Command, result *string) error { auditInfo := &audit.AuditInfo{ + Title: title, UserEmail: command.Sender.Email, Command: command.Name, Args: command.Args, + Result: result, } return audit.RecordAuditMessage(auditInfo, auditWebhook) diff --git a/chatops-lark/pkg/events/handler/types.go b/chatops-lark/pkg/events/handler/types.go index 556f11de..f3b56c1d 100644 --- a/chatops-lark/pkg/events/handler/types.go +++ b/chatops-lark/pkg/events/handler/types.go @@ -26,7 +26,7 @@ type CommandHandler func(context.Context, []string) (string, error) type CommandConfig struct { Description string Handler CommandHandler - AuditWebhook string + Audit *config.AuditConfig SetupContext func(ctx context.Context, cfg config.Config, sender *CommandActor) context.Context }