gitlink-cli/cmd/interactive/executor.go

399 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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