Compare commits

...

6 Commits

Author SHA1 Message Date
e2177521e0 feat: switch cli indexing to code-first identifiers 2026-04-03 16:25:11 +00:00
84150df4d5 fix: align cli routes with backend routers 2026-04-03 13:58:15 +00:00
b287b1ff17 fix: align health endpoint with backend 2026-04-03 13:45:36 +00:00
zhi
0280f2c327 TEST-CLI-CAL-001 TEST-CLI-PR-001 add CLI integration tests 2026-04-01 12:03:24 +00:00
zhi
0fe62ed430 CLI-CAL-001/002/003/004/005/006/007/008/009/010: Add calendar command group and CRUD plan commands
- Add hf calendar command group to command surface and router
- Implement schedule/show/edit/cancel/date-list commands
- Implement plan-schedule/plan-list/plan-edit/plan-cancel commands
- Add leaf help for all calendar commands
- Align CLI with backend calendar routes and response envelopes
- Support virtual slot ids for edit/cancel
- Validate with go build and go test ./...
2026-04-01 07:02:36 +00:00
zhi
97af3d3177 CLI-PR-001/002/003/004: Rename propose->proposal, add essential commands, improve accept, restrict story
- Rename 'propose' group to 'proposal' in surface, leaf help, and routing
- Keep 'hf propose' as backward-compatible alias via groupAliases
- Add essential subcommand group: list, create, update, delete
- Accept command now shows generated story tasks in output
- Accept command supports --json output
- Task create blocks story/* types with helpful error message
- All help text updated to use 'proposal' terminology
2026-04-01 06:56:10 +00:00
13 changed files with 2620 additions and 75 deletions

View File

@@ -187,8 +187,11 @@ func handleGroup(group help.Group, args []string) {
case "support":
handleSupportCommand(sub.Name, remaining)
return
case "propose":
handleProposeCommand(sub.Name, remaining)
case "proposal", "propose":
handleProposalCommand(sub.Name, remaining)
return
case "calendar":
handleCalendarCommand(sub.Name, remaining)
return
case "comment":
handleCommentCommand(sub.Name, remaining)
@@ -309,7 +312,16 @@ func isHelpLikePath(args []string) bool {
return isLeafHelpFlagOnly(args[len(args)-1:])
}
// groupAliases maps legacy command names to their current group names.
var groupAliases = map[string]string{
"propose": "proposal",
}
func findGroup(name string) (help.Group, bool) {
// Resolve alias first
if alias, ok := groupAliases[name]; ok {
name = alias
}
for _, group := range help.CommandSurface() {
if group.Name == name {
return group, true
@@ -691,7 +703,46 @@ func handleSupportCommand(subCmd string, args []string) {
}
}
func handleProposeCommand(subCmd string, args []string) {
func handleCalendarCommand(subCmd string, args []string) {
tokenFlag := ""
var filtered []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--token":
if i+1 < len(args) {
i++
tokenFlag = args[i]
}
default:
filtered = append(filtered, args[i])
}
}
switch subCmd {
case "schedule":
commands.RunCalendarSchedule(filtered, tokenFlag)
case "show":
commands.RunCalendarShow(filtered, tokenFlag)
case "edit":
commands.RunCalendarEdit(filtered, tokenFlag)
case "cancel":
commands.RunCalendarCancel(filtered, tokenFlag)
case "date-list":
commands.RunCalendarDateList(filtered, tokenFlag)
case "plan-schedule":
commands.RunCalendarPlanSchedule(filtered, tokenFlag)
case "plan-list":
commands.RunCalendarPlanList(filtered, tokenFlag)
case "plan-edit":
commands.RunCalendarPlanEdit(filtered, tokenFlag)
case "plan-cancel":
commands.RunCalendarPlanCancel(filtered, tokenFlag)
default:
output.Errorf("hf calendar %s is not implemented yet", subCmd)
}
}
func handleProposalCommand(subCmd string, args []string) {
tokenFlag := ""
var filtered []string
for i := 0; i < len(args); i++ {
@@ -711,33 +762,80 @@ func handleProposeCommand(subCmd string, args []string) {
commands.RunProposeList(filtered, tokenFlag)
case "get":
if len(filtered) < 1 {
output.Error("usage: hf propose get <propose-code>")
output.Error("usage: hf proposal get <proposal-code>")
}
commands.RunProposeGet(filtered[0], tokenFlag)
case "create":
commands.RunProposeCreate(filtered, tokenFlag)
case "update":
if len(filtered) < 1 {
output.Error("usage: hf propose update <propose-code> [--title ...] [--desc ...]")
output.Error("usage: hf proposal update <proposal-code> [--title ...] [--desc ...]")
}
commands.RunProposeUpdate(filtered[0], filtered[1:], tokenFlag)
case "accept":
if len(filtered) < 1 {
output.Error("usage: hf propose accept <propose-code> --milestone <milestone-code>")
output.Error("usage: hf proposal accept <proposal-code> --milestone <milestone-code>")
}
commands.RunProposeAccept(filtered[0], filtered[1:], tokenFlag)
case "reject":
if len(filtered) < 1 {
output.Error("usage: hf propose reject <propose-code> [--reason <reason>]")
output.Error("usage: hf proposal reject <proposal-code> [--reason <reason>]")
}
commands.RunProposeReject(filtered[0], filtered[1:], tokenFlag)
case "reopen":
if len(filtered) < 1 {
output.Error("usage: hf propose reopen <propose-code>")
output.Error("usage: hf proposal reopen <proposal-code>")
}
commands.RunProposeReopen(filtered[0], tokenFlag)
case "essential":
handleProposalEssentialCommand(filtered, tokenFlag)
default:
output.Errorf("hf propose %s is not implemented yet", subCmd)
output.Errorf("hf proposal %s is not implemented yet", subCmd)
}
}
func handleProposalEssentialCommand(args []string, tokenFlag string) {
essentialCommands := []help.Command{
{Name: "list", Description: "List essentials for a proposal", Permitted: true},
{Name: "create", Description: "Create an essential", Permitted: true},
{Name: "update", Description: "Update an essential", Permitted: true},
{Name: "delete", Description: "Delete an essential", Permitted: true},
}
if len(args) == 0 || isHelpFlagOnly(args) {
fmt.Print(help.RenderGroupHelp("proposal essential", essentialCommands))
return
}
subCmd := args[0]
remaining := args[1:]
if isLeafHelpFlagOnly(remaining) {
if text, ok := help.RenderLeafHelp("proposal/essential", subCmd); ok {
fmt.Print(text)
return
}
fmt.Printf("hf proposal essential %s\n", subCmd)
return
}
switch subCmd {
case "list":
commands.RunEssentialList(remaining, tokenFlag)
case "create":
commands.RunEssentialCreate(remaining, tokenFlag)
case "update":
if len(remaining) < 1 {
output.Error("usage: hf proposal essential update <essential-code> [--title ...] [--type ...] [--desc ...]")
}
commands.RunEssentialUpdate(remaining[0], remaining[1:], tokenFlag)
case "delete":
if len(remaining) < 1 {
output.Error("usage: hf proposal essential delete <essential-code> --proposal <proposal-code>")
}
commands.RunEssentialDeleteFull(remaining[0], remaining[1:], tokenFlag)
default:
output.Errorf("hf proposal essential %s is not implemented yet", subCmd)
}
}

View File

@@ -93,7 +93,7 @@ func (c *Client) Delete(path string) ([]byte, error) {
// Health checks the API health endpoint and returns the response.
func (c *Client) Health() (map[string]interface{}, error) {
data, err := c.Get("/api/health/")
data, err := c.Get("/health")
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,660 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
)
// --- Slot Commands ---
// RunCalendarSchedule implements `hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>]`.
func RunCalendarSchedule(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 3 {
output.Error("usage: hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]")
}
slotType := args[0]
scheduledAt := args[1]
estimatedDuration := args[2]
date, jobCode, priority := "", "", ""
remaining := args[3:]
for i := 0; i < len(remaining); i++ {
switch remaining[i] {
case "--date":
if i+1 >= len(remaining) {
output.Error("--date requires a value")
}
i++
date = remaining[i]
case "--job":
if i+1 >= len(remaining) {
output.Error("--job requires a value")
}
i++
jobCode = remaining[i]
case "--priority":
if i+1 >= len(remaining) {
output.Error("--priority requires a value")
}
i++
priority = remaining[i]
default:
output.Errorf("unknown flag: %s", remaining[i])
}
}
payload := map[string]interface{}{
"slot_type": slotType,
"scheduled_at": scheduledAt,
"estimated_duration": estimatedDuration,
}
if date != "" {
payload["date"] = date
}
if priority != "" {
payload["priority"] = priority
}
if jobCode != "" {
payload["event_type"] = "job"
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/calendar/slots", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to schedule slot: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
// Check for warnings
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err == nil {
if ws, ok := resp["warnings"]; ok {
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
fmt.Println("⚠️ Workload warnings:")
for _, w := range warnings {
if wm, ok := w.(map[string]interface{}); ok {
if msg, ok := wm["message"].(string); ok {
fmt.Printf(" - %s\n", msg)
}
}
}
}
}
}
fmt.Printf("slot scheduled: %s at %s (%s min)\n", slotType, scheduledAt, estimatedDuration)
}
// RunCalendarShow implements `hf calendar show [--date <yyyy-mm-dd>]`.
func RunCalendarShow(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
date := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--date":
if i+1 >= len(args) {
output.Error("--date requires a value")
}
i++
date = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/calendar/day"
if date != "" {
path += "?date=" + date
}
data, err := c.Get(path)
if err != nil {
output.Errorf("failed to show calendar: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp struct {
Slots []map[string]interface{} `json:"slots"`
}
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse calendar: %v", err)
}
slots := resp.Slots
if len(slots) == 0 {
if date != "" {
fmt.Printf("No slots for %s\n", date)
} else {
fmt.Println("No slots for today")
}
return
}
headers := []string{"ID", "TIME", "TYPE", "DURATION", "PRIORITY", "STATUS", "EVENT"}
var rows [][]string
for _, s := range slots {
slotID := fmt.Sprintf("%v", s["slot_id"])
scheduled := fmt.Sprintf("%v", s["scheduled_at"])
slotType := fmt.Sprintf("%v", s["slot_type"])
dur := fmt.Sprintf("%v min", s["estimated_duration"])
pri := fmt.Sprintf("%v", s["priority"])
status := fmt.Sprintf("%v", s["status"])
event := ""
if et, ok := s["event_type"]; ok && et != nil {
event = fmt.Sprintf("%v", et)
}
if ed, ok := s["event_data"]; ok && ed != nil {
if edm, ok := ed.(map[string]interface{}); ok {
if code, ok := edm["code"]; ok {
event += " " + fmt.Sprintf("%v", code)
}
if ev, ok := edm["event"]; ok {
event += " " + fmt.Sprintf("%v", ev)
}
}
}
if isVirt, ok := s["is_virtual"]; ok && isVirt == true {
slotID += " (plan)"
}
rows = append(rows, []string{slotID, scheduled, slotType, dur, pri, status, strings.TrimSpace(event)})
}
output.PrintTable(headers, rows)
}
// RunCalendarEdit implements `hf calendar edit [--date <yyyy-mm-dd>] <slot-id> [flags]`.
func RunCalendarEdit(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--scheduled-at <HH:mm>] [--job <code>]")
}
slotID := args[0]
date, slotType, duration, scheduledAt, jobCode := "", "", "", "", ""
for i := 1; i < len(args); i++ {
switch args[i] {
case "--date":
if i+1 >= len(args) {
output.Error("--date requires a value")
}
i++
date = args[i]
case "--slot-type":
if i+1 >= len(args) {
output.Error("--slot-type requires a value")
}
i++
slotType = args[i]
case "--estimated-duration":
if i+1 >= len(args) {
output.Error("--estimated-duration requires a value")
}
i++
duration = args[i]
case "--scheduled-at":
if i+1 >= len(args) {
output.Error("--scheduled-at requires a value")
}
i++
scheduledAt = args[i]
case "--job":
if i+1 >= len(args) {
output.Error("--job requires a value")
}
i++
jobCode = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
payload := make(map[string]interface{})
if slotType != "" {
payload["slot_type"] = slotType
}
if duration != "" {
payload["estimated_duration"] = duration
}
if scheduledAt != "" {
payload["scheduled_at"] = scheduledAt
}
if jobCode != "" {
payload["event_type"] = "job"
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/calendar/slots/" + slotID
if strings.HasPrefix(slotID, "plan-") {
path = "/calendar/slots/virtual/" + slotID
}
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
data, err := c.Patch(path, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to edit slot: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err == nil {
if ws, ok := resp["warnings"]; ok {
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
fmt.Println("⚠️ Workload warnings:")
for _, w := range warnings {
if wm, ok := w.(map[string]interface{}); ok {
if msg, ok := wm["message"].(string); ok {
fmt.Printf(" - %s\n", msg)
}
}
}
}
}
}
fmt.Printf("slot edited: %s\n", slotID)
}
// RunCalendarCancel implements `hf calendar cancel [--date <yyyy-mm-dd>] <slot-id>`.
func RunCalendarCancel(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]")
}
slotID := args[0]
date := ""
for i := 1; i < len(args); i++ {
switch args[i] {
case "--date":
if i+1 >= len(args) {
output.Error("--date requires a value")
}
i++
date = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/calendar/slots/" + slotID + "/cancel"
if strings.HasPrefix(slotID, "plan-") {
path = "/calendar/slots/virtual/" + slotID + "/cancel"
}
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
_, err = c.Post(path, nil)
if err != nil {
output.Errorf("failed to cancel slot: %v", err)
}
fmt.Printf("slot cancelled: %s\n", slotID)
}
// RunCalendarDateList implements `hf calendar date-list`.
func RunCalendarDateList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/calendar/dates")
if err != nil {
output.Errorf("failed to list dates: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp struct {
Dates []string `json:"dates"`
}
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse dates: %v", err)
}
dates := resp.Dates
if len(dates) == 0 {
fmt.Println("No future dates with materialized slots")
return
}
for _, d := range dates {
fmt.Println(d)
}
}
// --- Plan Commands ---
// RunCalendarPlanSchedule implements `hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]`.
func RunCalendarPlanSchedule(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 2 {
output.Error("usage: hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]")
}
slotType := args[0]
duration := args[1]
atTime, onDay, onWeek, onMonth := "", "", "", ""
for i := 2; i < len(args); i++ {
switch args[i] {
case "--at":
if i+1 >= len(args) {
output.Error("--at requires a value")
}
i++
atTime = args[i]
case "--on-day":
if i+1 >= len(args) {
output.Error("--on-day requires a value")
}
i++
onDay = args[i]
case "--on-week":
if i+1 >= len(args) {
output.Error("--on-week requires a value")
}
i++
onWeek = args[i]
case "--on-month":
if i+1 >= len(args) {
output.Error("--on-month requires a value")
}
i++
onMonth = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if atTime == "" {
output.Error("--at is required")
}
payload := map[string]interface{}{
"slot_type": slotType,
"estimated_duration": duration,
"at_time": atTime,
}
if onDay != "" {
payload["on_day"] = onDay
}
if onWeek != "" {
payload["on_week"] = onWeek
}
if onMonth != "" {
payload["on_month"] = onMonth
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/calendar/plans", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create plan: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("plan created: %s at %s (%s min)\n", slotType, atTime, duration)
}
// RunCalendarPlanList implements `hf calendar plan-list`.
func RunCalendarPlanList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/calendar/plans")
if err != nil {
output.Errorf("failed to list plans: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var resp struct {
Plans []map[string]interface{} `json:"plans"`
}
if err := json.Unmarshal(data, &resp); err != nil {
output.Errorf("cannot parse plans: %v", err)
}
plans := resp.Plans
if len(plans) == 0 {
fmt.Println("No schedule plans")
return
}
headers := []string{"ID", "TYPE", "AT", "ON DAY", "ON WEEK", "ON MONTH", "DURATION", "ACTIVE"}
var rows [][]string
for _, p := range plans {
id := fmt.Sprintf("%v", p["id"])
slotType := fmt.Sprintf("%v", p["slot_type"])
at := fmt.Sprintf("%v", p["at_time"])
onDay := ""
if d, ok := p["on_day"]; ok && d != nil {
onDay = fmt.Sprintf("%v", d)
}
onWeek := ""
if w, ok := p["on_week"]; ok && w != nil {
onWeek = fmt.Sprintf("%v", w)
}
onMonth := ""
if m, ok := p["on_month"]; ok && m != nil {
onMonth = fmt.Sprintf("%v", m)
}
dur := fmt.Sprintf("%v min", p["estimated_duration"])
active := "yes"
if a, ok := p["is_active"]; ok && a == false {
active = "no"
}
rows = append(rows, []string{id, slotType, at, onDay, onWeek, onMonth, dur, active})
}
output.PrintTable(headers, rows)
}
// RunCalendarPlanEdit implements `hf calendar plan-edit <plan-id> [flags]`.
func RunCalendarPlanEdit(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]")
}
planID := args[0]
payload := make(map[string]interface{})
for i := 1; i < len(args); i++ {
switch args[i] {
case "--at":
if i+1 >= len(args) {
output.Error("--at requires a value")
}
i++
payload["at_time"] = args[i]
case "--on-day":
if i+1 >= len(args) {
output.Error("--on-day requires a value")
}
i++
payload["on_day"] = args[i]
case "--on-week":
if i+1 >= len(args) {
output.Error("--on-week requires a value")
}
i++
payload["on_week"] = args[i]
case "--on-month":
if i+1 >= len(args) {
output.Error("--on-month requires a value")
}
i++
payload["on_month"] = args[i]
case "--slot-type":
if i+1 >= len(args) {
output.Error("--slot-type requires a value")
}
i++
payload["slot_type"] = args[i]
case "--estimated-duration":
if i+1 >= len(args) {
output.Error("--estimated-duration requires a value")
}
i++
payload["estimated_duration"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if len(payload) == 0 {
output.Error("nothing to edit — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Patch("/calendar/plans/"+planID, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to edit plan: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("plan edited: %s\n", planID)
}
// RunCalendarPlanCancel implements `hf calendar plan-cancel <plan-id>`.
func RunCalendarPlanCancel(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
if len(args) < 1 {
output.Error("usage: hf calendar plan-cancel <plan-id>")
}
planID := args[0]
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/calendar/plans/"+planID+"/cancel", nil)
if err != nil {
output.Errorf("failed to cancel plan: %v", err)
}
fmt.Printf("plan cancelled: %s\n", planID)
}

View File

@@ -0,0 +1,826 @@
package commands
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func writeTestConfig(t *testing.T, dir, baseURL string) {
config := map[string]string{
"base-url": baseURL,
}
data, err := json.Marshal(config)
if err != nil {
t.Fatalf("failed to marshal config: %v", err)
}
cfgPath := filepath.Join(dir, ".hf-config.json")
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
}
func buildCLI(t *testing.T, cliPath string) {
srcDir := filepath.Join("..", "..")
cmd := exec.Command("go", "build", "-o", cliPath, filepath.Join(srcDir, "cmd", "hf"))
if out, err := cmd.CombinedOutput(); err != nil {
t.Skipf("cannot build CLI: %v (out: %s)", err, string(out))
}
}
func runCLI(t *testing.T, dir, cliPath string, args ...string) (string, error) {
cmd := exec.Command(cliPath, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "HF_TEST_MODE=1")
out, err := cmd.CombinedOutput()
return string(out), err
}
// --- Tests: argument parsing / usage errors ---
func TestCalendarSchedule_MissingArgs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// --token must come after subcommand: hf calendar schedule --token <tok>
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake")
if err == nil {
t.Fatalf("expected non-zero exit for missing args; got out=%s", out)
}
if !strings.Contains(out, "usage:") && !strings.Contains(out, "slot-type") {
t.Errorf("expected usage message with slot-type; got: %s", out)
}
}
func TestCalendarSchedule_UnknownFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--bad-flag")
if err == nil {
t.Fatalf("expected error for unknown flag")
}
if !strings.Contains(out, "unknown flag") {
t.Errorf("expected 'unknown flag' in output; got: %s", out)
}
}
func TestCalendarShow_UnknownFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"slots": []interface{}{}})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--bad-flag")
if err == nil {
t.Fatalf("expected error for unknown flag")
}
if !strings.Contains(out, "unknown flag") {
t.Errorf("expected 'unknown flag'; got: %s", out)
}
}
func TestCalendarEdit_MissingSlotID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing slot-id")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
func TestCalendarCancel_MissingSlotID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing slot-id")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
func TestCalendarPlanSchedule_MissingAt(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30")
if err == nil {
t.Fatalf("expected error for missing --at")
}
if !strings.Contains(out, "--at") {
t.Errorf("expected --at error; got: %s", out)
}
}
func TestCalendarPlanEdit_NothingToEdit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1")
if err == nil {
t.Fatalf("expected error for nothing to edit")
}
if !strings.Contains(out, "nothing to edit") {
t.Errorf("expected 'nothing to edit' error; got: %s", out)
}
}
func TestCalendarPlanCancel_MissingPlanID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing plan-id")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
// --- Tests: JSON output ---
func TestCalendarSchedule_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/calendar/slots" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slot_id": 42,
"slot_type": "Work",
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "schedule", "--token", "fake", "Work", "09:00", "30")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
if resp["slot_id"] != float64(42) {
t.Errorf("expected slot_id=42; got: %v", resp["slot_id"])
}
}
func TestCalendarShow_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendar/day" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slots": []interface{}{
map[string]interface{}{
"slot_id": 1,
"slot_type": "Work",
"scheduled_at": "09:00",
"estimated_duration": 30,
"priority": 50,
"status": "NotStarted",
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "show", "--token", "fake", "--date", "2026-04-01")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
slots, ok := resp["slots"].([]interface{})
if !ok || len(slots) == 0 {
t.Fatalf("expected slots array in JSON; got: %v", resp)
}
}
func TestCalendarDateList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendar/dates" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"dates": []string{"2026-04-01", "2026-04-02"},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "date-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
dates, ok := resp["dates"].([]interface{})
if !ok || len(dates) != 2 {
t.Errorf("expected 2 dates; got: %v", dates)
}
}
func TestCalendarPlanList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/calendar/plans" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": []interface{}{
map[string]interface{}{
"id": 1,
"slot_type": "Work",
"at_time": "09:00",
"estimated_duration": 30,
"is_active": true,
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "plan-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
plans, ok := resp["plans"].([]interface{})
if !ok || len(plans) == 0 {
t.Fatalf("expected plans array; got: %v", resp)
}
}
// --- Tests: human-readable output ---
func TestCalendarShow_HumanOutput_WithSlots(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slots": []interface{}{
map[string]interface{}{
"slot_id": 1,
"slot_type": "Work",
"scheduled_at": "09:00",
"estimated_duration": 30,
"priority": 50,
"status": "NotStarted",
},
map[string]interface{}{
"slot_id": "plan-1-2026-04-01",
"slot_type": "OnCall",
"scheduled_at": "14:00",
"estimated_duration": 60,
"priority": 40,
"status": "NotStarted",
"is_virtual": true,
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--date", "2026-04-01")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
// Virtual slot should be marked as plan
if !strings.Contains(out, "plan") {
t.Errorf("expected human output to mark virtual slot as plan; got: %s", out)
}
}
func TestCalendarShow_HumanOutput_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slots": []interface{}{},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "No slots") {
t.Errorf("expected 'No slots' for empty; got: %s", out)
}
}
func TestCalendarDateList_HumanOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"dates": []string{"2026-04-01", "2026-04-02"},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
for _, date := range []string{"2026-04-01", "2026-04-02"} {
if !strings.Contains(out, date) {
t.Errorf("expected date %s in output; got: %s", date, out)
}
}
}
func TestCalendarDateList_HumanOutput_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"dates": []string{},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "No future dates") {
t.Errorf("expected 'No future dates'; got: %s", out)
}
}
func TestCalendarPlanList_HumanOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": []interface{}{
map[string]interface{}{
"id": 1,
"slot_type": "Work",
"at_time": "09:00",
"on_day": "Mon",
"estimated_duration": 30,
"is_active": true,
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "09:00") || !strings.Contains(out, "Work") {
t.Errorf("expected plan data in output; got: %s", out)
}
}
func TestCalendarPlanList_HumanOutput_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": []interface{}{},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "No schedule plans") {
t.Errorf("expected 'No schedule plans'; got: %s", out)
}
}
// --- Tests: error output ---
func TestCalendarShow_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
w.Write([]byte(`{"detail":"internal error"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake")
if err == nil {
t.Fatalf("expected error for 500 response")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarEdit_SlotNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"detail":"slot not found"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "999", "--slot-type", "Work")
if err == nil {
t.Fatalf("expected error for 404")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarCancel_SlotNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"detail":"slot not found"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "999")
if err == nil {
t.Fatalf("expected error for 404")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarPlanSchedule_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
w.Write([]byte(`{"detail":"db error"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00")
if err == nil {
t.Fatalf("expected error for 500")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
func TestCalendarPlanCancel_PlanNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"detail":"plan not found"}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "999")
if err == nil {
t.Fatalf("expected error for 404")
}
if !strings.Contains(out, "failed") && !strings.Contains(out, "error") {
t.Errorf("expected error message; got: %s", out)
}
}
// --- Tests: workload warnings ---
func TestCalendarSchedule_WorkloadWarning(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slot_id": 1,
"warnings": []interface{}{
map[string]interface{}{
"type": "workload",
"message": "Daily minimum work workload (30 min) not met: current 0 min",
},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "⚠") && !strings.Contains(out, "warning") {
t.Errorf("expected workload warning in output; got: %s", out)
}
}
// --- Tests: virtual slot routing ---
func TestCalendarEdit_VirtualSlot_RoutesCorrectly(t *testing.T) {
var editedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
editedPath = r.URL.Path
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"slot_id": 10,
"slot_type": "Work",
"scheduled_at": "10:00",
"estimated_duration": 30,
"status": "NotStarted",
"priority": 50,
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "plan-1-2026-04-01", "--scheduled-at", "10:00")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(editedPath, "/calendar/slots/virtual/") {
t.Errorf("expected virtual slot path /calendar/slots/virtual/...; got: %s", editedPath)
}
}
func TestCalendarCancel_VirtualSlot_RoutesCorrectly(t *testing.T) {
var cancelledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cancelledPath = r.URL.Path
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
_, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "plan-1-2026-04-01")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(cancelledPath, "/calendar/slots/virtual/") {
t.Errorf("expected virtual slot cancel path /calendar/slots/virtual/...; got: %s", cancelledPath)
}
}
// --- Tests: successful operations ---
func TestCalendarSchedule_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"slot_id": 5})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--job", "TASK-1")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "slot scheduled") {
t.Errorf("expected 'slot scheduled' success message; got: %s", out)
}
}
func TestCalendarPlanSchedule_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00", "--on-day", "Mon")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "plan created") {
t.Errorf("expected 'plan created' success message; got: %s", out)
}
}
func TestCalendarPlanEdit_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "at_time": "10:00"})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1", "--at", "10:00")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "plan edited") {
t.Errorf("expected 'plan edited' success message; got: %s", out)
}
}
func TestCalendarPlanCancel_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "1")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "plan cancelled") {
t.Errorf("expected 'plan cancelled' success message; got: %s", out)
}
}
func TestCalendarCancel_SuccessOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "1")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "slot cancelled") {
t.Errorf("expected 'slot cancelled' success message; got: %s", out)
}
}

View File

@@ -0,0 +1,274 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
)
type essentialResponse struct {
ID int `json:"id"`
EssentialCode string `json:"essential_code"`
Type string `json:"type"`
Title string `json:"title"`
Description *string `json:"description"`
CreatedByID *int `json:"created_by_id"`
CreatedAt string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
}
// RunEssentialList implements `hf proposal essential list --proposal <proposal-code>`.
func RunEssentialList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
proposalCode := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--proposal":
if i+1 >= len(args) {
output.Error("--proposal requires a value")
}
i++
proposalCode = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if proposalCode == "" {
output.Error("usage: hf proposal essential list --proposal <proposal-code>")
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get(proposalPath(c, proposalCode) + "/essentials")
if err != nil {
output.Errorf("failed to list essentials: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var essentials []essentialResponse
if err := json.Unmarshal(data, &essentials); err != nil {
output.Errorf("cannot parse essential list: %v", err)
}
headers := []string{"CODE", "TYPE", "TITLE", "CREATED"}
var rows [][]string
for _, e := range essentials {
title := e.Title
if len(title) > 40 {
title = title[:37] + "..."
}
rows = append(rows, []string{e.EssentialCode, e.Type, title, e.CreatedAt})
}
output.PrintTable(headers, rows)
}
// RunEssentialCreate implements `hf proposal essential create --proposal <proposal-code> --title <title> --type <type> [--desc <desc>]`.
func RunEssentialCreate(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
proposalCode, title, essType, desc := "", "", "", ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--proposal":
if i+1 >= len(args) {
output.Error("--proposal requires a value")
}
i++
proposalCode = args[i]
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
title = args[i]
case "--type":
if i+1 >= len(args) {
output.Error("--type requires a value")
}
i++
essType = args[i]
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
desc = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if proposalCode == "" || title == "" || essType == "" {
output.Error("usage: hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]")
}
// Validate type
switch essType {
case "feature", "improvement", "refactor":
// valid
default:
output.Errorf("invalid essential type %q — must be one of: feature, improvement, refactor", essType)
}
payload := map[string]interface{}{
"title": title,
"type": essType,
}
if desc != "" {
payload["description"] = desc
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post(proposalPath(c, proposalCode)+"/essentials", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create essential: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
var e essentialResponse
if err := json.Unmarshal(data, &e); err != nil {
fmt.Printf("essential created: %s\n", title)
return
}
fmt.Printf("essential created: %s (code: %s)\n", e.Title, e.EssentialCode)
}
// RunEssentialUpdate implements `hf proposal essential update <essential-code> [--title ...] [--type ...] [--desc ...]`.
func RunEssentialUpdate(essentialCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
proposalCode := ""
payload := make(map[string]interface{})
for i := 0; i < len(args); i++ {
switch args[i] {
case "--proposal":
if i+1 >= len(args) {
output.Error("--proposal requires a value")
}
i++
proposalCode = args[i]
case "--title":
if i+1 >= len(args) {
output.Error("--title requires a value")
}
i++
payload["title"] = args[i]
case "--type":
if i+1 >= len(args) {
output.Error("--type requires a value")
}
i++
essType := args[i]
switch essType {
case "feature", "improvement", "refactor":
payload["type"] = essType
default:
output.Errorf("invalid essential type %q — must be one of: feature, improvement, refactor", essType)
}
case "--desc":
if i+1 >= len(args) {
output.Error("--desc requires a value")
}
i++
payload["description"] = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if proposalCode == "" {
output.Error("usage: hf proposal essential update <essential-code> --proposal <proposal-code> [--title ...] [--type ...] [--desc ...]")
}
if len(payload) == 0 {
output.Error("nothing to update — provide at least one flag")
}
body, err := json.Marshal(payload)
if err != nil {
output.Errorf("cannot marshal payload: %v", err)
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch(proposalPath(c, proposalCode)+"/essentials/"+essentialCode, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update essential: %v", err)
}
fmt.Printf("essential updated: %s\n", essentialCode)
}
// RunEssentialDeleteFull implements `hf proposal essential delete <essential-code> --proposal <code>`.
func RunEssentialDeleteFull(essentialCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
proposalCode := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--proposal":
if i+1 >= len(args) {
output.Error("--proposal requires a value")
}
i++
proposalCode = args[i]
default:
output.Errorf("unknown flag: %s", args[i])
}
}
if proposalCode == "" {
output.Error("usage: hf proposal essential delete <essential-code> --proposal <proposal-code>")
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete(proposalPath(c, proposalCode) + "/essentials/" + essentialCode)
if err != nil {
output.Errorf("failed to delete essential: %v", err)
}
fmt.Printf("essential deleted: %s\n", essentialCode)
}

View File

@@ -36,7 +36,7 @@ func RunMeetingList(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
query = appendQuery(query, "project_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")

View File

@@ -44,7 +44,7 @@ func RunMilestoneList(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
query = appendQuery(query, "project_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")

View File

@@ -12,8 +12,10 @@ import (
// monitorOverviewResponse matches the backend monitor overview schema.
type monitorOverviewResponse struct {
TotalServers int `json:"total_servers"`
OnlineServers int `json:"online_servers"`
Tasks interface{} `json:"tasks"`
Providers interface{} `json:"providers"`
Servers []monitorServerResponse `json:"servers"`
GeneratedAt string `json:"generated_at"`
}
// monitorServerResponse matches the backend monitor server schema.
@@ -28,8 +30,32 @@ type monitorServerResponse struct {
// monitorAPIKeyResponse matches the backend monitor API key schema.
type monitorAPIKeyResponse struct {
Identifier string `json:"identifier"`
APIKey string `json:"api_key"`
ServerID int `json:"server_id"`
APIKey string `json:"api_key"`
Message string `json:"message"`
}
func monitorServerList(c *client.Client) []monitorServerResponse {
data, err := c.Get("/monitor/admin/servers")
if err != nil {
output.Errorf("failed to list monitor servers: %v", err)
}
var servers []monitorServerResponse
if err := json.Unmarshal(data, &servers); err != nil {
output.Errorf("cannot parse server list: %v", err)
}
return servers
}
func resolveMonitorServerID(c *client.Client, identifier string) int {
servers := monitorServerList(c)
for _, s := range servers {
if s.Identifier == identifier {
return s.ID
}
}
output.Errorf("monitor server not found: %s", identifier)
return 0
}
// RunMonitorOverview implements `hf monitor overview`.
@@ -40,7 +66,7 @@ func RunMonitorOverview(tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/monitor/overview")
data, err := c.Get("/monitor/public/overview")
if err != nil {
output.Errorf("failed to get monitor overview: %v", err)
}
@@ -59,9 +85,16 @@ func RunMonitorOverview(tokenFlag string) {
output.Errorf("cannot parse monitor overview: %v", err)
}
online := 0
for _, s := range o.Servers {
if s.Status == "online" {
online++
}
}
output.PrintKeyValue(
"total-servers", fmt.Sprintf("%d", o.TotalServers),
"online-servers", fmt.Sprintf("%d", o.OnlineServers),
"total-servers", fmt.Sprintf("%d", len(o.Servers)),
"online-servers", fmt.Sprintf("%d", online),
"generated-at", o.GeneratedAt,
)
}
@@ -73,7 +106,7 @@ func RunMonitorServerList(tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/monitor/servers")
data, err := c.Get("/monitor/admin/servers")
if err != nil {
output.Errorf("failed to list monitor servers: %v", err)
}
@@ -116,39 +149,37 @@ func RunMonitorServerGet(identifier, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/monitor/servers/" + identifier)
if err != nil {
output.Errorf("failed to get server: %v", err)
servers := monitorServerList(c)
var found *monitorServerResponse
for i := range servers {
if servers[i].Identifier == identifier {
found = &servers[i]
break
}
}
if found == nil {
output.Errorf("failed to get server: not found: %s", identifier)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
output.PrintJSON(found)
return
}
var s monitorServerResponse
if err := json.Unmarshal(data, &s); err != nil {
output.Errorf("cannot parse server: %v", err)
}
name := ""
if s.DisplayName != nil {
name = *s.DisplayName
if found.DisplayName != nil {
name = *found.DisplayName
}
lastSeen := ""
if s.LastSeen != nil {
lastSeen = *s.LastSeen
if found.LastSeen != nil {
lastSeen = *found.LastSeen
}
output.PrintKeyValue(
"identifier", s.Identifier,
"identifier", found.Identifier,
"name", name,
"status", s.Status,
"status", found.Status,
"last-seen", lastSeen,
"created", s.CreatedAt,
"created", found.CreatedAt,
)
}
@@ -197,7 +228,7 @@ func RunMonitorServerCreate(args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/monitor/servers", bytes.NewReader(body))
data, err := c.Post("/monitor/admin/servers", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create server: %v", err)
}
@@ -223,7 +254,8 @@ func RunMonitorServerDelete(identifier, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/monitor/servers/" + identifier)
serverID := resolveMonitorServerID(c, identifier)
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d", serverID))
if err != nil {
output.Errorf("failed to delete server: %v", err)
}
@@ -238,7 +270,8 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/monitor/servers/"+identifier+"/api-key", nil)
serverID := resolveMonitorServerID(c, identifier)
data, err := c.Post(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID), nil)
if err != nil {
output.Errorf("failed to generate API key: %v", err)
}
@@ -258,8 +291,9 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) {
return
}
output.PrintKeyValue(
"identifier", k.Identifier,
"server-id", fmt.Sprintf("%d", k.ServerID),
"api-key", k.APIKey,
"message", k.Message,
)
}
@@ -271,7 +305,8 @@ func RunMonitorAPIKeyRevoke(identifier, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/monitor/servers/" + identifier + "/api-key")
serverID := resolveMonitorServerID(c, identifier)
_, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID))
if err != nil {
output.Errorf("failed to revoke API key: %v", err)
}

View File

@@ -0,0 +1,531 @@
package commands
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func runCLIProposal(t *testing.T, dir, cliPath string, args ...string) (string, error) {
cmd := exec.Command(cliPath, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "HF_TEST_MODE=1")
out, err := cmd.CombinedOutput()
return string(out), err
}
// --- Essential subcommand tests ---
func TestEssentialList_MissingProposal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "list", "--token", "fake")
if err == nil {
t.Fatalf("expected error for missing --proposal")
}
if !strings.Contains(out, "--proposal") {
t.Errorf("expected --proposal error; got: %s", out)
}
}
func TestEssentialCreate_MissingRequired(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// Missing --proposal, --title, --type
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", "--proposal", "PRJ-001")
if err == nil {
t.Fatalf("expected error for missing required args")
}
if !strings.Contains(out, "usage:") {
t.Errorf("expected usage message; got: %s", out)
}
}
func TestEssentialCreate_InvalidType(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
"--proposal", "PRJ-001", "--title", "Test", "--type", "invalid-type")
if err == nil {
t.Fatalf("expected error for invalid type")
}
if !strings.Contains(out, "invalid essential type") {
t.Errorf("expected invalid type error; got: %s", out)
}
}
func TestEssentialCreate_UnknownFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
"--proposal", "PRJ-001", "--title", "Test", "--type", "feature", "--unknown")
if err == nil {
t.Fatalf("expected error for unknown flag")
}
if !strings.Contains(out, "unknown flag") {
t.Errorf("expected unknown flag error; got: %s", out)
}
}
func TestEssentialUpdate_NothingToUpdate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake",
"ESS-001", "--proposal", "PRJ-001")
if err == nil {
t.Fatalf("expected error for nothing to update")
}
if !strings.Contains(out, "nothing to update") {
t.Errorf("expected 'nothing to update' error; got: %s", out)
}
}
func TestEssentialUpdate_MissingProposal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", "ESS-001")
if err == nil {
t.Fatalf("expected error for missing --proposal")
}
if !strings.Contains(out, "--proposal") {
t.Errorf("expected --proposal error; got: %s", out)
}
}
func TestEssentialDelete_MissingProposal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake", "ESS-001")
if err == nil {
t.Fatalf("expected error for missing --proposal")
}
if !strings.Contains(out, "--proposal") {
t.Errorf("expected --proposal error; got: %s", out)
}
}
// --- Essential list/create JSON output ---
func TestEssentialList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{
map[string]interface{}{
"id": 1,
"essential_code": "ESS-001",
"proposal_id": 1,
"type": "feature",
"title": "Add login",
"created_at": "2026-03-01",
},
})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "essential", "list", "--token", "fake", "--proposal", "PRJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
}
func TestEssentialCreate_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"essential_code": "ESS-001",
"title": "Add login",
"type": "feature",
})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake",
"--proposal", "PRJ-001", "--title", "Add login", "--type", "feature")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "essential created") {
t.Errorf("expected 'essential created' success message; got: %s", out)
}
}
func TestEssentialUpdate_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "PATCH" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
w.WriteHeader(200)
w.Write([]byte(`{}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake",
"ESS-001", "--proposal", "PRJ-001", "--title", "Updated title")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "essential updated") {
t.Errorf("expected 'essential updated' success message; got: %s", out)
}
}
func TestEssentialDelete_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "DELETE" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001":
w.WriteHeader(200)
w.Write([]byte(`{}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake",
"ESS-001", "--proposal", "PRJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "essential deleted") {
t.Errorf("expected 'essential deleted' success message; got: %s", out)
}
}
// --- Proposal Accept tests ---
func TestProposalAccept_MissingMilestone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake", "PRJ-001")
if err == nil {
t.Fatalf("expected error for missing --milestone")
}
if !strings.Contains(out, "--milestone") {
t.Errorf("expected --milestone error; got: %s", out)
}
}
func TestProposalAccept_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/projects":
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}})
case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001":
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"})
case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/accept":
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if body["milestone_code"] != "MS-001" {
t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"])
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": "PRJ-001",
"status": "Accepted",
"tasks": []interface{}{},
})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake",
"PRJ-001", "--milestone", "MS-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "proposal accepted") {
t.Errorf("expected proposal accepted success message; got: %s", out)
}
}
func TestProposalAccept_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": "PRJ-001",
"status": "Accepted",
"tasks": []interface{}{
map[string]interface{}{"code": "TASK-1", "type": "story/feature"},
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "accept", "--token", "fake",
"PRJ-001", "--milestone", "MS-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
if resp["status"] != "Accepted" {
t.Errorf("expected status=Accepted; got: %v", resp["status"])
}
}
// --- Story restricted tests ---
func TestTaskCreate_StoryRestricted(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// Try to create a story/feature directly
out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake",
"--type", "story/feature", "--title", "My story", "--project", "PRJ-001")
if err == nil {
t.Fatalf("expected error for restricted story creation")
}
if !strings.Contains(out, "restricted") && !strings.Contains(out, "proposal accept") {
t.Errorf("expected restricted error mentioning proposal accept; got: %s", out)
}
}
func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("server should not be called for restricted story type")
w.WriteHeader(200)
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
// Just "story" without slash should also be blocked
out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake",
"--type", "story", "--title", "My story", "--project", "PRJ-001")
if err == nil {
t.Fatalf("expected error for restricted story type")
}
if !strings.Contains(out, "restricted") {
t.Errorf("expected restricted error; got: %s", out)
}
}
// --- Proposal list tests ---
func TestProposalList_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{
map[string]interface{}{
"id": 1,
"code": "PRJ-001",
"title": "My Proposal",
"status": "Open",
"project_code": "PROJ-001",
"created_by": "alice",
"created_at": "2026-03-01",
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake", "--project", "PROJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
if !strings.Contains(out, "PRJ-001") {
t.Errorf("expected proposal code in output; got: %s", out)
}
}
func TestProposalList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode([]interface{}{
map[string]interface{}{
"id": 1,
"code": "PRJ-001",
"title": "My Proposal",
"status": "Open",
},
})
}))
defer server.Close()
tmpDir := t.TempDir()
writeTestConfig(t, tmpDir, server.URL)
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake", "--project", "PROJ-001")
if err != nil {
t.Fatalf("unexpected error: %v; out=%s", err, out)
}
var resp interface{}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("output is not valid JSON: %s", out)
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
@@ -23,11 +24,44 @@ type proposeResponse struct {
CreatedAt string `json:"created_at"`
}
type projectLookup struct {
ID int `json:"id"`
ProjectCode string `json:"project_code"`
}
func resolveProposalProject(c *client.Client, proposalCode string) string {
data, err := c.Get("/projects")
if err != nil {
return ""
}
var projects []projectLookup
if err := json.Unmarshal(data, &projects); err != nil {
return ""
}
for _, p := range projects {
if p.ProjectCode == "" {
continue
}
if _, err := c.Get("/projects/" + p.ProjectCode + "/proposals/" + proposalCode); err == nil {
return p.ProjectCode
}
}
return ""
}
func proposalPath(c *client.Client, proposalCode string) string {
if project := resolveProposalProject(c, proposalCode); project != "" {
return "/projects/" + project + "/proposals/" + proposalCode
}
return "/proposes/" + proposalCode
}
// RunProposeList implements `hf propose list --project <project-code>`.
func RunProposeList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
query := ""
project := ""
query := url.Values{}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--project":
@@ -35,32 +69,39 @@ func RunProposeList(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
project = args[i]
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
}
i++
query = appendQuery(query, "status", args[i])
query.Set("status", args[i])
case "--order-by":
if i+1 >= len(args) {
output.Error("--order-by requires a value")
}
i++
query = appendQuery(query, "order_by", args[i])
query.Set("order_by", args[i])
default:
output.Errorf("unknown flag: %s", args[i])
}
}
legacyPath := false
if project == "" {
legacyPath = true
}
cfg, err := config.Load()
if err != nil {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
path := "/proposes"
if query != "" {
path += "?" + query
path := "/projects/" + project + "/proposals"
if legacyPath {
path = "/proposes"
}
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
data, err := c.Get(path)
if err != nil {
@@ -105,7 +146,7 @@ func RunProposeGet(proposeCode, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Get("/proposes/" + proposeCode)
data, err := c.Get(proposalPath(c, proposeCode))
if err != nil {
output.Errorf("failed to get proposal: %v", err)
}
@@ -178,9 +219,8 @@ func RunProposeCreate(args []string, tokenFlag string) {
}
payload := map[string]interface{}{
"project_code": project,
"title": title,
"description": desc,
"title": title,
"description": desc,
}
body, err := json.Marshal(payload)
@@ -193,7 +233,7 @@ func RunProposeCreate(args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
data, err := c.Post("/proposes", bytes.NewReader(body))
data, err := c.Post("/projects/"+project+"/proposals", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create proposal: %v", err)
}
@@ -253,7 +293,7 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/proposes/"+proposeCode, bytes.NewReader(body))
_, err = c.Patch(proposalPath(c, proposeCode), bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update proposal: %v", err)
}
@@ -261,7 +301,22 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) {
fmt.Printf("proposal updated: %s\n", proposeCode)
}
// RunProposeAccept implements `hf propose accept <propose-code> --milestone <milestone-code>`.
// acceptResponse holds the accept result including generated tasks.
type acceptResponse struct {
ProposalCode string `json:"proposal_code"`
Status string `json:"status"`
GeneratedTasks []generatedTask `json:"generated_tasks"`
}
type generatedTask struct {
TaskID int `json:"task_id"`
TaskCode *string `json:"task_code"`
Title string `json:"title"`
TaskType string `json:"task_type"`
TaskSubtype *string `json:"task_subtype"`
}
// RunProposeAccept implements `hf proposal accept <proposal-code> --milestone <milestone-code>`.
func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
token := ResolveToken(tokenFlag)
@@ -280,7 +335,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
}
if milestone == "" {
output.Error("usage: hf propose accept <propose-code> --milestone <milestone-code>")
output.Error("usage: hf proposal accept <proposal-code> --milestone <milestone-code>")
}
payload := map[string]interface{}{
@@ -296,12 +351,38 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body))
data, err := c.Post(proposalPath(c, proposeCode)+"/accept", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to accept proposal: %v", err)
}
if output.JSONMode {
var raw json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
output.Errorf("invalid JSON response: %v", err)
}
output.PrintJSON(raw)
return
}
fmt.Printf("proposal accepted: %s\n", proposeCode)
// Try to parse and display generated tasks
var resp acceptResponse
if err := json.Unmarshal(data, &resp); err == nil && len(resp.GeneratedTasks) > 0 {
fmt.Printf("\nGenerated %d story task(s):\n", len(resp.GeneratedTasks))
for _, gt := range resp.GeneratedTasks {
code := "(no task_code)"
if gt.TaskCode != nil {
code = *gt.TaskCode
}
subtype := ""
if gt.TaskSubtype != nil {
subtype = "/" + *gt.TaskSubtype
}
fmt.Printf(" %s %s%s %s\n", code, gt.TaskType, subtype, gt.Title)
}
}
}
// RunProposeReject implements `hf propose reject <propose-code>`.
@@ -339,7 +420,7 @@ func RunProposeReject(proposeCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/proposes/"+proposeCode+"/reject", body)
_, err = c.Post(proposalPath(c, proposeCode)+"/reject", body)
if err != nil {
output.Errorf("failed to reject proposal: %v", err)
}
@@ -356,7 +437,7 @@ func RunProposeReopen(proposeCode, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
_, err = c.Post("/proposes/"+proposeCode+"/reopen", nil)
_, err = c.Post(proposalPath(c, proposeCode)+"/reopen", nil)
if err != nil {
output.Errorf("failed to reopen proposal: %v", err)
}

View File

@@ -38,13 +38,13 @@ func RunTaskList(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
query = appendQuery(query, "project_code", args[i])
case "--milestone":
if i+1 >= len(args) {
output.Error("--milestone requires a value")
}
i++
query = appendQuery(query, "milestone", args[i])
query = appendQuery(query, "milestone_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")
@@ -228,6 +228,11 @@ func RunTaskCreate(args []string, tokenFlag string) {
output.Error("usage: hf task create --project <project-code> --title <title>")
}
// story/* types are restricted — must be created via `hf proposal accept`
if taskType == "story" || (len(taskType) > 6 && taskType[:6] == "story/") {
output.Error("story tasks are restricted and cannot be created directly.\nUse 'hf proposal accept <proposal-code> --milestone <milestone-code>' to generate story tasks from a proposal.")
}
payload := map[string]interface{}{
"project_code": project,
"title": title,
@@ -421,7 +426,7 @@ func RunTaskSearch(args []string, tokenFlag string) {
output.Error("--project requires a value")
}
i++
query = appendQuery(query, "project", args[i])
query = appendQuery(query, "project_code", args[i])
case "--status":
if i+1 >= len(args) {
output.Error("--status requires a value")

View File

@@ -158,13 +158,27 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
"support/take": {Summary: "Assign a support ticket to the current user", Usage: []string{"hf support take <support-code>"}, Flags: authFlagHelp()},
"support/transition": {Summary: "Transition a support ticket to a new status", Usage: []string{"hf support transition <support-code> <status>"}, Flags: authFlagHelp()},
"support/delete": {Summary: "Delete a support ticket", Usage: []string{"hf support delete <support-code>"}, Flags: authFlagHelp()},
"propose/list": {Summary: "List proposals", Usage: []string{"hf propose list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
"propose/get": {Summary: "Show a proposal by code", Usage: []string{"hf propose get <propose-code>"}, Flags: authFlagHelp()},
"propose/create": {Summary: "Create a proposal", Usage: []string{"hf propose create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()},
"propose/update": {Summary: "Update a proposal", Usage: []string{"hf propose update <propose-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()},
"propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept <propose-code> --milestone <milestone-code>"}, Flags: authFlagHelp()},
"propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject <propose-code> [--reason <reason>]"}, Flags: authFlagHelp()},
"propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen <propose-code>"}, Flags: authFlagHelp()},
"proposal/list": {Summary: "List proposals", Usage: []string{"hf proposal list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()},
"proposal/get": {Summary: "Show a proposal by code", Usage: []string{"hf proposal get <proposal-code>"}, Flags: authFlagHelp()},
"proposal/create": {Summary: "Create a proposal", Usage: []string{"hf proposal create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()},
"proposal/update": {Summary: "Update a proposal", Usage: []string{"hf proposal update <proposal-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()},
"proposal/accept": {Summary: "Accept a proposal and generate story tasks", Usage: []string{"hf proposal accept <proposal-code> --milestone <milestone-code>"}, Flags: authFlagHelp(), Notes: []string{"Accept generates story/* tasks from all essentials under the proposal."}},
"proposal/reject": {Summary: "Reject a proposal", Usage: []string{"hf proposal reject <proposal-code> [--reason <reason>]"}, Flags: authFlagHelp()},
"proposal/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf proposal reopen <proposal-code>"}, Flags: authFlagHelp()},
"proposal/essential": {Summary: "Manage proposal essentials", Usage: []string{"hf proposal essential list --proposal <proposal-code>", "hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]", "hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]", "hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()},
"proposal/essential/list": {Summary: "List essentials for a proposal", Usage: []string{"hf proposal essential list --proposal <proposal-code>"}, Flags: authFlagHelp()},
"proposal/essential/create": {Summary: "Create an essential", Usage: []string{"hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]"}, Flags: authFlagHelp()},
"proposal/essential/update": {Summary: "Update an essential", Usage: []string{"hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]"}, Flags: authFlagHelp()},
"proposal/essential/delete": {Summary: "Delete an essential", Usage: []string{"hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()},
"calendar/schedule": {Summary: "Create a one-off calendar slot", Usage: []string{"hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]"}, Flags: authFlagHelp()},
"calendar/show": {Summary: "Show slots for a day", Usage: []string{"hf calendar show [--date <yyyy-mm-dd>]", "hf calendar show --json [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
"calendar/edit": {Summary: "Edit a calendar slot", Usage: []string{"hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--job <code>] [--scheduled-at <HH:mm>]"}, Flags: authFlagHelp(), Notes: []string{"For virtual plan slots, pass the virtual id like plan-12-2026-04-01."}},
"calendar/cancel": {Summary: "Cancel a calendar slot", Usage: []string{"hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},
"calendar/date-list": {Summary: "List future dates with materialized slots", Usage: []string{"hf calendar date-list", "hf calendar date-list --json"}, Flags: authFlagHelp()},
"calendar/plan-schedule": {Summary: "Create a recurring schedule plan", Usage: []string{"hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]"}, Flags: authFlagHelp()},
"calendar/plan-list": {Summary: "List recurring schedule plans", Usage: []string{"hf calendar plan-list", "hf calendar plan-list --json"}, Flags: authFlagHelp()},
"calendar/plan-edit": {Summary: "Edit a recurring schedule plan", Usage: []string{"hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]"}, Flags: authFlagHelp()},
"calendar/plan-cancel": {Summary: "Cancel a recurring schedule plan", Usage: []string{"hf calendar plan-cancel <plan-id>"}, Flags: authFlagHelp()},
"comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task <task-code> --content <text>"}, Flags: authFlagHelp()},
"comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task <task-code>"}, Flags: authFlagHelp()},
"worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()},

View File

@@ -2,6 +2,7 @@ package help
import (
"encoding/json"
"os"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
@@ -126,16 +127,32 @@ func CommandSurface() []Group {
},
},
{
Name: "propose",
Name: "proposal",
Description: "Manage proposals",
SubCommands: []Command{
{Name: "list", Description: "List proposals", Permitted: has(perms, "project.read")},
{Name: "get", Description: "Show a proposal by code", Permitted: has(perms, "project.read")},
{Name: "create", Description: "Create a proposal", Permitted: has(perms, "task.create")},
{Name: "update", Description: "Update a proposal", Permitted: has(perms, "task.write")},
{Name: "accept", Description: "Accept a proposal", Permitted: has(perms, "propose.accept")},
{Name: "accept", Description: "Accept a proposal and generate story tasks", Permitted: has(perms, "propose.accept")},
{Name: "reject", Description: "Reject a proposal", Permitted: has(perms, "propose.reject")},
{Name: "reopen", Description: "Reopen a proposal", Permitted: has(perms, "propose.reopen")},
{Name: "essential", Description: "Manage proposal essentials", Permitted: has(perms, "task.create")},
},
},
{
Name: "calendar",
Description: "Manage calendar slots and plans",
SubCommands: []Command{
{Name: "schedule", Description: "Create a one-off slot", Permitted: has(perms, "task.create")},
{Name: "show", Description: "Show slots for a day", Permitted: has(perms, "task.read")},
{Name: "edit", Description: "Edit a slot", Permitted: has(perms, "task.write")},
{Name: "cancel", Description: "Cancel a slot", Permitted: has(perms, "task.write")},
{Name: "date-list", Description: "List dates with materialized slots", Permitted: has(perms, "task.read")},
{Name: "plan-schedule", Description: "Create a recurring plan", Permitted: has(perms, "task.create")},
{Name: "plan-list", Description: "List plans", Permitted: has(perms, "task.read")},
{Name: "plan-edit", Description: "Edit a plan", Permitted: has(perms, "task.write")},
{Name: "plan-cancel", Description: "Cancel a plan", Permitted: has(perms, "task.write")},
},
},
{
@@ -207,6 +224,10 @@ func loadPermissionState(token string) permissionState {
}
func has(state permissionState, perm string) bool {
// Test/development bypass: HF_TEST_MODE=1 grants all permissions
if os.Getenv("HF_TEST_MODE") == "1" {
return true
}
if !state.Known {
return false
}