forked from Gitlink/gitlink-cli
399 lines
14 KiB
Go
399 lines
14 KiB
Go
package interactive
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||
)
|
||
|
||
// Executor runs shortcut commands and captures their stdout output.
|
||
type Executor struct{}
|
||
|
||
// Execute runs the given shortcut with the provided arguments, capturing
|
||
// anything written to os.Stdout during execution and returning it as a string.
|
||
//
|
||
// It builds the RuntimeContext via common.NewRuntimeContext so that Client,
|
||
// Format, Owner and Repo are properly initialized — a bare
|
||
// `&RuntimeContext{Args: args}` would leave Client nil and panic as soon as a
|
||
// command calls ctx.CallAPI. A deferred recover guards against panics raised
|
||
// inside the command so the REPL is never left with stdout pointing at a
|
||
// closed pipe.
|
||
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (output string, err error) {
|
||
// Guard against panics inside the command: always restore stdout even if
|
||
// s.Run panics, and surface the panic as a regular error so the REPL can
|
||
// display it instead of hanging with a corrupted stdout.
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
output = ""
|
||
err = fmt.Errorf("command panicked: %v", r)
|
||
}
|
||
}()
|
||
|
||
// Save original stdout
|
||
oldStdout := os.Stdout
|
||
|
||
// Create a pipe: writes go to w, reads come from r
|
||
r, w, perr := os.Pipe()
|
||
if perr != nil {
|
||
return "", fmt.Errorf("failed to create pipe: %w", perr)
|
||
}
|
||
|
||
// Redirect stdout to the write end of the pipe
|
||
os.Stdout = w
|
||
|
||
// Channel to signal that the goroutine has finished reading
|
||
done := make(chan struct{})
|
||
var buf bytes.Buffer
|
||
|
||
// Read from the pipe in a goroutine so that writes don't block
|
||
go func() {
|
||
io.Copy(&buf, r)
|
||
close(done)
|
||
}()
|
||
|
||
// Build a fully-initialized context (Client/Format/Owner/Repo).
|
||
ctx, cerr := common.NewRuntimeContext(args)
|
||
if cerr != nil {
|
||
// Restore stdout and drain the pipe before returning.
|
||
os.Stdout = oldStdout
|
||
w.Close()
|
||
<-done
|
||
return "", fmt.Errorf("failed to initialize runtime context: %w", cerr)
|
||
}
|
||
|
||
// Execute the shortcut's Run function. Recover protects the path so that
|
||
// a panic still leaves the deferred cleanup below runnable.
|
||
runErr := safeRun(s, ctx)
|
||
|
||
// Close the writer to signal EOF to the reader goroutine, wait for it to
|
||
// finish copying into the buffer, then restore stdout.
|
||
w.Close()
|
||
<-done
|
||
os.Stdout = oldStdout
|
||
|
||
return buf.String(), runErr
|
||
}
|
||
|
||
// safeRun invokes a shortcut's Run function and converts any panic into an
|
||
// error, so a panicking command cannot crash the executor goroutine or leave
|
||
// os.Stdout redirected at a closed pipe.
|
||
func safeRun(s *common.Shortcut, ctx *common.RuntimeContext) (err error) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
err = fmt.Errorf("command panicked: %v", r)
|
||
}
|
||
}()
|
||
return s.Run(ctx)
|
||
}
|
||
|
||
// parseDirectCommand parses a direct command string like "issue +list --state open"
|
||
// into its components: group, cmd (without +), flagStr, and whether parsing succeeded.
|
||
func parseDirectCommand(input string) (group, cmd, flagStr string, ok bool) {
|
||
input = strings.TrimSpace(input)
|
||
if input == "" {
|
||
return "", "", "", false
|
||
}
|
||
|
||
parts := strings.Fields(input)
|
||
if len(parts) < 2 {
|
||
return "", "", "", false
|
||
}
|
||
|
||
group = parts[0]
|
||
rawCmd := parts[1]
|
||
|
||
if !strings.HasPrefix(rawCmd, "+") {
|
||
return "", "", "", false
|
||
}
|
||
cmd = strings.TrimPrefix(rawCmd, "+")
|
||
|
||
if len(parts) > 2 {
|
||
flagStr = strings.Join(parts[2:], " ")
|
||
}
|
||
|
||
return group, cmd, flagStr, true
|
||
}
|
||
|
||
// parseFlagString parses a flag string like "--title hello -n 42" into a map.
|
||
// shortMap is used to expand short flag names to their long equivalents.
|
||
func parseFlagString(flagStr string, shortMap map[string]string) map[string]string {
|
||
result := make(map[string]string)
|
||
|
||
if flagStr == "" {
|
||
return result
|
||
}
|
||
|
||
parts := strings.Fields(flagStr)
|
||
|
||
for i := 0; i < len(parts); i++ {
|
||
part := parts[i]
|
||
|
||
if strings.HasPrefix(part, "--") {
|
||
// Long flag
|
||
flagPart := strings.TrimPrefix(part, "--")
|
||
|
||
if strings.Contains(flagPart, "=") {
|
||
// --flag=value format
|
||
kv := strings.SplitN(flagPart, "=", 2)
|
||
result[kv[0]] = kv[1]
|
||
} else if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
|
||
// --flag value format
|
||
result[flagPart] = parts[i+1]
|
||
i++
|
||
} else {
|
||
// --flag without value (boolean-like)
|
||
result[flagPart] = "true"
|
||
}
|
||
} else if strings.HasPrefix(part, "-") && len(part) > 1 {
|
||
// Short flag
|
||
shortName := strings.TrimPrefix(part, "-")
|
||
|
||
// Expand short name to long name if mapping exists
|
||
longName := shortName
|
||
if mapped, ok := shortMap[shortName]; ok {
|
||
longName = mapped
|
||
}
|
||
|
||
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
|
||
result[longName] = parts[i+1]
|
||
i++
|
||
} else {
|
||
result[longName] = "true"
|
||
}
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// findShortcut looks up a shortcut by group and command name in the registry.
|
||
func findShortcut(all map[string][]*common.Shortcut, group, cmd string) (*common.Shortcut, bool) {
|
||
shortcuts, ok := all[group]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
|
||
for _, s := range shortcuts {
|
||
if s.Name == cmd {
|
||
return s, true
|
||
}
|
||
}
|
||
|
||
return nil, false
|
||
}
|
||
|
||
// buildShortMap creates a mapping from short flag names to long flag names.
|
||
func buildShortMap(s *common.Shortcut) map[string]string {
|
||
m := make(map[string]string)
|
||
for _, f := range s.Flags {
|
||
if f.Short != "" {
|
||
m[f.Short] = f.Name
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// missingRequiredFlags returns the names (with -- prefix) of flags that are
|
||
// marked as required but not present in args.
|
||
func missingRequiredFlags(s *common.Shortcut, args map[string]string) []string {
|
||
var missing []string
|
||
for _, f := range s.Flags {
|
||
if f.Required {
|
||
if _, ok := args[f.Name]; !ok {
|
||
missing = append(missing, "--"+f.Name)
|
||
}
|
||
}
|
||
}
|
||
return missing
|
||
}
|
||
|
||
// commandSuggestions maps (group, command) to command-specific fix suggestions shown
|
||
// in the interactive REPL when a command fails with an API error.
|
||
var commandSuggestions = map[string]map[string]string{
|
||
"ci": {
|
||
"builds": "请确认仓库已启用 CI 引擎(gitlink ci +enable),且存在构建记录",
|
||
"runs": "请确认仓库已启用 CI 引擎,且存在运行记录",
|
||
"run-results": "请确认仓库已启用 CI 引擎,且存在运行结果",
|
||
"logs": "请确认构建编号正确且构建已完成",
|
||
"restart": "请确认构建编号正确且状态允许重新构建",
|
||
"stop": "请确认构建编号正确且构建处于运行中",
|
||
"pipelines": "请确认仓库已配置 CI 流水线模板",
|
||
"run-pipeline": "请确认流水线名称正确且仓库已启用 CI 引擎",
|
||
"pipeline-detail": "请确认流水线 ID 正确",
|
||
"delete-pipeline": "请确认流水线 ID 正确",
|
||
"disable": "仓库可能已禁用 CI,无需重复操作",
|
||
"enable": "仓库可能已启用 CI,无需重复操作",
|
||
"run-log": "请确认运行 ID 正确且运行已完成",
|
||
},
|
||
"issue": {
|
||
"list": "请确认 owner/repo 参数正确",
|
||
"create": "请确认标题和必填参数已填写",
|
||
"view": "请确认 Issue 编号正确",
|
||
"update": "请确认 Issue 编号和更新参数正确",
|
||
"close": "请确认 Issue 编号正确,且你有关闭权限",
|
||
"delete": "请确认 Issue 编号正确,此操作不可恢复",
|
||
"comment": "请确认 Issue 编号正确且评论内容不为空",
|
||
"comments": "请确认 Issue 编号正确",
|
||
"update-comment": "请确认评论 ID 正确",
|
||
"delete-comment": "请确认评论 ID 正确",
|
||
"reply-comment": "请确认评论 ID 正确",
|
||
"batch-close": "请确认 Issue 编号正确,且你有批量关闭权限",
|
||
"batch-update": "请确认 Issue 编号和更新参数正确",
|
||
"batch-destroy": "请确认 Issue 编号正确,此操作不可恢复",
|
||
},
|
||
"pr": {
|
||
"list": "请确认 owner/repo 参数正确",
|
||
"create": "请确认源分支和目标分支正确",
|
||
"view": "请确认 PR 编号正确",
|
||
"merge": "请确认 PR 已通过 CI 检查且无合并冲突",
|
||
"close": "请确认 PR 编号正确",
|
||
"reopen": "请确认 PR 编号正确且 PR 当前为关闭状态",
|
||
"update": "请确认 PR 编号和更新参数正确",
|
||
"comment": "请确认 PR 编号正确且评论内容不为空",
|
||
"files": "请确认 PR 编号正确",
|
||
"versions": "请确认 PR 编号正确",
|
||
"version-diff": "请确认 PR 编号正确",
|
||
"reviews": "请确认 PR 编号正确",
|
||
"review": "请确认 PR 编号正确且你已被列为审查者",
|
||
"comments": "请确认 PR 编号正确",
|
||
"create-comment": "请确认 PR 编号正确且评论内容不为空",
|
||
"update-comment": "请确认评论 ID 正确",
|
||
"delete-comment": "请确认评论 ID 正确",
|
||
"commits": "请确认 PR 编号正确",
|
||
"batch-close": "请确认 PR 编号正确,且你有批量关闭权限",
|
||
"batch-merge": "请确认 PR 已通过 CI 检查且无合并冲突",
|
||
},
|
||
"release": {
|
||
"list": "请确认 owner/repo 参数正确",
|
||
"create": "请确认标签名和版本号正确,仓库默认分支可能存在",
|
||
"view": "请确认 Release ID 或标签名正确",
|
||
"delete": "请确认 Release ID 正确,此操作不可恢复",
|
||
"edit": "请确认 Release ID 和更新参数正确",
|
||
"update": "请确认 Release ID 和更新参数正确",
|
||
},
|
||
"branch": {
|
||
"list": "请确认 owner/repo 参数正确",
|
||
"create": "请确认源分支名正确,默认分支名可能不匹配",
|
||
"delete": "请确认分支名正确,此操作不可恢复",
|
||
"protect": "请确认分支名正确",
|
||
"unprotect": "请确认分支名正确",
|
||
"all": "请确认 owner/repo 参数正确",
|
||
"default": "请确认仓库存在且可见",
|
||
"restore": "请确认分支名正确且分支已被删除",
|
||
"batch-delete": "请确认分支名正确,此操作不可恢复",
|
||
"batch-protect": "请确认分支名正确",
|
||
},
|
||
"repo": {
|
||
"list": "请确认用户/组织名正确",
|
||
"info": "请确认 owner/repo 参数正确",
|
||
"create": "请确认仓库名合法且不与其他仓库冲突",
|
||
"fork": "请确认源仓库存在且可见",
|
||
"delete": "请确认仓库名正确,此操作不可恢复",
|
||
"update": "请确认更新参数正确",
|
||
"about": "请确认 owner/repo 参数正确",
|
||
"menu": "请确认 owner/repo 参数正确",
|
||
"units-get": "请确认 owner/repo 参数正确",
|
||
"units-set": "请确认仓库单元设置值有效",
|
||
"edit-detail": "请确认更新参数正确",
|
||
"simple": "请确认 owner/repo 参数正确",
|
||
"code-stats": "请确认 owner/repo 参数正确",
|
||
"languages": "请确认 owner/repo 参数正确",
|
||
"contributors": "请确认 owner/repo 参数正确",
|
||
"contributors-stat": "请确认 owner/repo 参数正确",
|
||
"recommend": "该功能可能需要特定权限",
|
||
"star": "请确认 owner/repo 参数正确",
|
||
"unstar": "请确认 owner/repo 参数正确",
|
||
"watch": "请确认 owner/repo 参数正确",
|
||
"unwatch": "请确认 owner/repo 参数正确",
|
||
"stargazers": "请确认 owner/repo 参数正确",
|
||
"watchers": "请确认 owner/repo 参数正确",
|
||
"transfer": "请确认目标所有者正确,且你有仓库转让权限",
|
||
"cancel-transfer": "请确认存在待处理的转让请求",
|
||
"transfer-orgs": "请确认你的组织列表中存在目标组织",
|
||
"invite-link": "请确认 owner/repo 参数正确",
|
||
"join": "请确认邀请链接有效",
|
||
"quit": "请确认 owner/repo 参数正确",
|
||
"migrate": "请确认源仓库地址正确且可访问",
|
||
"sync-mirror": "请确认仓库为镜像仓库",
|
||
"topics": "请确认 owner/repo 参数正确",
|
||
"create-topic": "请确认标签名合法",
|
||
"delete-topic": "请确认标签名正确",
|
||
},
|
||
}
|
||
|
||
// formatInteractiveError wraps a command execution error for display in the
|
||
// interactive REPL. It hides raw HTTP/API technical details and provides
|
||
// command-specific suggestions. The original error is appended in a dim line.
|
||
func formatInteractiveError(group string, s *common.Shortcut, err error) string {
|
||
var apiErr *client.APIError
|
||
if errors.As(err, &apiErr) {
|
||
return formatAPIError(group, s, apiErr)
|
||
}
|
||
return fmt.Sprintf("✗ %s +%s 执行失败\n 原因:%v", group, s.Name, err)
|
||
}
|
||
|
||
// formatAPIError builds a user-friendly error message from an APIError.
|
||
func formatAPIError(group string, s *common.Shortcut, e *client.APIError) string {
|
||
var sb strings.Builder
|
||
|
||
sb.WriteString(fmt.Sprintf("✗ %s +%s 执行失败", group, s.Name))
|
||
|
||
// Reason — use the API message directly, it's already Chinese in most cases
|
||
reason := e.Message
|
||
if reason == "" {
|
||
reason = fmt.Sprintf("服务器返回错误码 [%v]", e.Code)
|
||
}
|
||
sb.WriteString(fmt.Sprintf("\n 原因:%s", reason))
|
||
|
||
// Suggestion — command-specific first, then generic fallback
|
||
if suggestion := lookupSuggestion(group, s.Name); suggestion != "" {
|
||
sb.WriteString(fmt.Sprintf("\n 建议:%s", suggestion))
|
||
}
|
||
|
||
// Original error in dim text
|
||
sb.WriteString(fmt.Sprintf("\n [原始: %s]", e.Error()))
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// lookupSuggestion returns a command-specific suggestion, or empty string.
|
||
func lookupSuggestion(group, cmd string) string {
|
||
if cmds, ok := commandSuggestions[group]; ok {
|
||
if suggestion, ok := cmds[cmd]; ok {
|
||
return suggestion
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// formatCommandDisplay formats a command for display, e.g. "issue +create --title hello --body world".
|
||
func formatCommandDisplay(group string, s *common.Shortcut, args map[string]string) string {
|
||
var b strings.Builder
|
||
b.WriteString(group)
|
||
b.WriteString(" +")
|
||
b.WriteString(s.Name)
|
||
|
||
// Sort flag names for deterministic output
|
||
keys := make([]string, 0, len(args))
|
||
for k := range args {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
for _, k := range keys {
|
||
b.WriteString(" --")
|
||
b.WriteString(k)
|
||
b.WriteString(" ")
|
||
b.WriteString(args[k])
|
||
}
|
||
|
||
return b.String()
|
||
}
|