feat: switch cli indexing to code-first identifiers

This commit is contained in:
2026-04-03 16:25:11 +00:00
parent 84150df4d5
commit e2177521e0
6 changed files with 124 additions and 73 deletions

View File

@@ -13,7 +13,6 @@ import (
type essentialResponse struct {
ID int `json:"id"`
EssentialCode string `json:"essential_code"`
ProposalID int `json:"proposal_id"`
Type string `json:"type"`
Title string `json:"title"`
Description *string `json:"description"`
@@ -49,8 +48,7 @@ func RunEssentialList(args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
project := resolveProposalProject(c, proposalCode)
data, err := c.Get("/projects/" + project + "/proposals/" + proposalCode + "/essentials")
data, err := c.Get(proposalPath(c, proposalCode) + "/essentials")
if err != nil {
output.Errorf("failed to list essentials: %v", err)
}
@@ -147,8 +145,7 @@ func RunEssentialCreate(args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
project := resolveProposalProject(c, proposalCode)
data, err := c.Post("/projects/"+project+"/proposals/"+proposalCode+"/essentials", bytes.NewReader(body))
data, err := c.Post(proposalPath(c, proposalCode)+"/essentials", bytes.NewReader(body))
if err != nil {
output.Errorf("failed to create essential: %v", err)
}
@@ -231,8 +228,7 @@ func RunEssentialUpdate(essentialCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
project := resolveProposalProject(c, proposalCode)
_, err = c.Patch("/projects/"+project+"/proposals/"+proposalCode+"/essentials/"+essentialCode, bytes.NewReader(body))
_, err = c.Patch(proposalPath(c, proposalCode)+"/essentials/"+essentialCode, bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update essential: %v", err)
}
@@ -269,8 +265,7 @@ func RunEssentialDeleteFull(essentialCode string, args []string, tokenFlag strin
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
project := resolveProposalProject(c, proposalCode)
_, err = c.Delete("/projects/" + project + "/proposals/" + proposalCode + "/essentials/" + essentialCode)
_, err = c.Delete(proposalPath(c, proposalCode) + "/essentials/" + essentialCode)
if err != nil {
output.Errorf("failed to delete essential: %v", err)
}

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

@@ -170,20 +170,29 @@ func TestEssentialDelete_MissingProposal(t *testing.T) {
func TestEssentialList_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/proposes/PRJ-001/essentials" {
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)
}
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",
},
})
}))
defer server.Close()
@@ -204,16 +213,25 @@ func TestEssentialList_JSONOutput(t *testing.T) {
func TestEssentialCreate_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("expected POST; got: %s", r.Method)
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)
}
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"essential_code": "ESS-001",
"title": "Add login",
"type": "feature",
})
}))
defer server.Close()
@@ -234,11 +252,20 @@ func TestEssentialCreate_Success(t *testing.T) {
func TestEssentialUpdate_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PATCH" {
t.Errorf("expected PATCH; got: %s", r.Method)
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)
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
@@ -259,11 +286,20 @@ func TestEssentialUpdate_Success(t *testing.T) {
func TestEssentialDelete_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Errorf("expected DELETE; got: %s", r.Method)
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)
}
w.WriteHeader(200)
w.Write([]byte(`{}`))
}))
defer server.Close()
@@ -306,20 +342,29 @@ func TestProposalAccept_MissingMilestone(t *testing.T) {
func TestProposalAccept_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/proposes/PRJ-001/accept" {
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)
}
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{}{},
})
}))
defer server.Close()
@@ -421,7 +466,7 @@ func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) {
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 != "/proposes" {
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)
@@ -444,7 +489,7 @@ func TestProposalList_Success(t *testing.T) {
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake")
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)
}
@@ -455,6 +500,9 @@ func TestProposalList_Success(t *testing.T) {
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{}{
@@ -472,7 +520,7 @@ func TestProposalList_JSONOutput(t *testing.T) {
cliPath := filepath.Join(tmpDir, "hf")
buildCLI(t, cliPath)
out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake")
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)
}

View File

@@ -32,21 +32,30 @@ type projectLookup struct {
func resolveProposalProject(c *client.Client, proposalCode string) string {
data, err := c.Get("/projects")
if err != nil {
output.Errorf("failed to list projects for proposal lookup: %v", err)
return ""
}
var projects []projectLookup
if err := json.Unmarshal(data, &projects); err != nil {
output.Errorf("cannot parse project list for proposal lookup: %v", err)
return ""
}
for _, p := range projects {
if p.ProjectCode == "" {
continue
}
if _, err := c.Get("/projects/" + p.ProjectCode + "/proposals/" + proposalCode); err == nil {
return p.ProjectCode
}
}
output.Errorf("proposal not found: %s", proposalCode)
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)
@@ -77,8 +86,9 @@ func RunProposeList(args []string, tokenFlag string) {
output.Errorf("unknown flag: %s", args[i])
}
}
legacyPath := false
if project == "" {
output.Error("usage: hf propose list --project <project-code> [--status <status>] [--order-by <field>]")
legacyPath = true
}
cfg, err := config.Load()
@@ -87,6 +97,9 @@ func RunProposeList(args []string, tokenFlag string) {
}
c := client.New(cfg.BaseURL, token)
path := "/projects/" + project + "/proposals"
if legacyPath {
path = "/proposes"
}
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
@@ -133,8 +146,7 @@ func RunProposeGet(proposeCode, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
project := resolveProposalProject(c, proposeCode)
data, err := c.Get("/projects/" + project + "/proposals/" + proposeCode)
data, err := c.Get(proposalPath(c, proposeCode))
if err != nil {
output.Errorf("failed to get proposal: %v", err)
}
@@ -281,8 +293,7 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
project := resolveProposalProject(c, proposeCode)
_, err = c.Patch("/projects/"+project+"/proposals/"+proposeCode, bytes.NewReader(body))
_, err = c.Patch(proposalPath(c, proposeCode), bytes.NewReader(body))
if err != nil {
output.Errorf("failed to update proposal: %v", err)
}
@@ -340,8 +351,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
project := resolveProposalProject(c, proposeCode)
data, err := c.Post("/projects/"+project+"/proposals/"+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)
}
@@ -362,7 +372,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) {
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 := ""
code := "(no task_code)"
if gt.TaskCode != nil {
code = *gt.TaskCode
}
@@ -410,8 +420,7 @@ func RunProposeReject(proposeCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
project := resolveProposalProject(c, proposeCode)
_, err = c.Post("/projects/"+project+"/proposals/"+proposeCode+"/reject", body)
_, err = c.Post(proposalPath(c, proposeCode)+"/reject", body)
if err != nil {
output.Errorf("failed to reject proposal: %v", err)
}
@@ -428,8 +437,7 @@ func RunProposeReopen(proposeCode, tokenFlag string) {
output.Errorf("config error: %v", err)
}
c := client.New(cfg.BaseURL, token)
project := resolveProposalProject(c, proposeCode)
_, err = c.Post("/projects/"+project+"/proposals/"+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")
@@ -426,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")