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
3 changes: 2 additions & 1 deletion change-insight/lib/github/common.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package github

import (
"fmt"
"log"
"net/http"
"os"
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 0 additions & 2 deletions chatops-lark/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
19 changes: 15 additions & 4 deletions chatops-lark/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ app_secret: <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"
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions chatops-lark/pkg/audit/audit-card.yaml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@ 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
content: "EE ChatOps Bot developed by EE team"
header:
template: turquoise
title:
content: chatbot audit message
content: {{ .Title | default "chatbot audit message" }}
tag: plain_text
2 changes: 2 additions & 0 deletions chatops-lark/pkg/audit/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions chatops-lark/pkg/audit/send_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
30 changes: 17 additions & 13 deletions chatops-lark/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"`
Expand All @@ -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
Expand Down Expand Up @@ -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"
}
}
189 changes: 189 additions & 0 deletions chatops-lark/pkg/events/handler/hotfix.go
Original file line number Diff line number Diff line change
@@ -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 <org>/<repo>, neither part empty, and only one slash
if strings.Count(ret.repo, "/") != 1 {
return nil, hotfixHelpText(), NewInformationError("Invalid --repo. Expected format: <org>/<repo> (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: <org>/<repo> (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
}
Comment on lines 61 to 110
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

The new hotfix command lacks test coverage. Similar commands in this module (e.g., devbuild_trigger_test.go, repo_admin_test.go) have comprehensive unit tests. Consider adding tests for parseCommandHotfixBumpTidbx to cover validation logic, help text display, and error cases.

Copilot uses AI. Check for mistakes.

func hotfixHelpText() string {
return `Usage: /bump-tidbx-hotfix-tag --repo <org>/<repo> --commit <commit-sha>

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)
}
Loading