mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
feat(project): add start_date and due_date fields (MUL-4388) (#5313)
Projects become schedulable planning objects alongside their issues: add optional start_date / due_date, mirroring issue.start_date / issue.due_date. This is only the first slice of #5227 — labels, metadata, and the editable metadata UI are still out of scope. - migration 166: two nullable DATE columns on `project` (calendar days, no FK/index — matches the issue end-state after migration 112) - sqlc CreateProject / UpdateProject carry the dates; UpdateProject uses narg so an explicit null clears - handler: parse YYYY-MM-DD (400 on bad format), rawFields-presence clear on update, and the hand-scanned SearchProjects query returns the columns - CLI: `project create/update --start-date/--due-date` (empty clears on update) - frontend + mobile types/zod schemas: the two new schema fields are nullable().default(null) so a project from an older backend (frontend deploys before backend) parses to null instead of degrading the batch to the empty fallback; added a search schema drift test - projects skill / CLI docs Part of #5227 Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
J
multica-agent
parent
4ee34b9fcb
commit
bf288349f6
+3
-2
@@ -582,16 +582,17 @@ multica project get <id> --output json
|
||||
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`, `--start-date`, `--due-date`. Dates are calendar days (`YYYY-MM-DD`).
|
||||
|
||||
### Update Project
|
||||
|
||||
```bash
|
||||
multica project update <id> --title "New title" --status in_progress
|
||||
multica project update <id> --lead "Lambda"
|
||||
multica project update <id> --due-date 2026-04-15
|
||||
```
|
||||
|
||||
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
|
||||
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`, `--start-date`, `--due-date`. For the date flags, pass an empty string (e.g. `--start-date ""`) to clear the date.
|
||||
|
||||
### Change Status
|
||||
|
||||
|
||||
@@ -380,16 +380,17 @@ multica project get <id> --output json
|
||||
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`, `--start-date`, `--due-date`。日期是日历日(`YYYY-MM-DD`)。
|
||||
|
||||
### Update Project
|
||||
|
||||
```bash
|
||||
multica project update <id> --title "New title" --status in_progress
|
||||
multica project update <id> --lead "Lambda"
|
||||
multica project update <id> --due-date 2026-04-15
|
||||
```
|
||||
|
||||
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
|
||||
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`, `--start-date`, `--due-date`。日期传空字符串(如 `--start-date ""`)可清除。
|
||||
|
||||
### Change Status
|
||||
|
||||
|
||||
@@ -163,6 +163,10 @@ export const ProjectSchema = z.object({
|
||||
priority: z.string(),
|
||||
lead_type: z.string().nullable(),
|
||||
lead_id: z.string().nullable(),
|
||||
// .default(null) so a project from an older backend that omits these keys
|
||||
// parses to null instead of degrading the batch to the empty fallback.
|
||||
start_date: z.string().nullable().default(null),
|
||||
due_date: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
issue_count: z.number().default(0),
|
||||
@@ -196,6 +200,8 @@ export const EMPTY_PROJECT: Project = {
|
||||
priority: "none",
|
||||
lead_type: null,
|
||||
lead_id: null,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
issue_count: 0,
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
DuplicateIssueErrorBodySchema,
|
||||
EMPTY_CREATE_FEEDBACK_RESPONSE,
|
||||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||||
EMPTY_SEARCH_PROJECTS_RESPONSE,
|
||||
EMPTY_USER,
|
||||
InboxUnreadSummarySchema,
|
||||
IssueTriggerPreviewSchema,
|
||||
ListIssuesResponseSchema,
|
||||
SearchProjectsResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
@@ -578,3 +580,52 @@ describe("InboxUnreadSummarySchema", () => {
|
||||
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchProjectsResponseSchema date drift", () => {
|
||||
const ENDPOINT = { endpoint: "GET /api/projects/search" };
|
||||
|
||||
const baseProject = {
|
||||
id: "p-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "Launch",
|
||||
description: null,
|
||||
icon: null,
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
lead_type: null,
|
||||
lead_id: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
issue_count: 0,
|
||||
done_count: 0,
|
||||
resource_count: 0,
|
||||
match_source: "title",
|
||||
};
|
||||
|
||||
it("parses start_date / due_date when the backend returns them", () => {
|
||||
const parsed = parseWithFallback(
|
||||
{ projects: [{ ...baseProject, start_date: "2026-03-01", due_date: "2026-03-31" }], total: 1 },
|
||||
SearchProjectsResponseSchema,
|
||||
EMPTY_SEARCH_PROJECTS_RESPONSE,
|
||||
ENDPOINT,
|
||||
);
|
||||
expect(parsed.projects[0]?.start_date).toBe("2026-03-01");
|
||||
expect(parsed.projects[0]?.due_date).toBe("2026-03-31");
|
||||
});
|
||||
|
||||
// Frontend deploys before backend: an older backend omits the new keys. The
|
||||
// .default(null) must keep the whole batch parseable (→ null), not degrade
|
||||
// it to the empty fallback and blank the search results.
|
||||
it("defaults missing start_date / due_date to null without dropping results", () => {
|
||||
const parsed = parseWithFallback(
|
||||
{ projects: [baseProject], total: 1 },
|
||||
SearchProjectsResponseSchema,
|
||||
EMPTY_SEARCH_PROJECTS_RESPONSE,
|
||||
ENDPOINT,
|
||||
);
|
||||
expect(parsed).not.toBe(EMPTY_SEARCH_PROJECTS_RESPONSE);
|
||||
expect(parsed.projects).toHaveLength(1);
|
||||
expect(parsed.projects[0]?.start_date).toBeNull();
|
||||
expect(parsed.projects[0]?.due_date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,6 +376,11 @@ const ProjectSchema = z.object({
|
||||
priority: z.string(),
|
||||
lead_type: z.string().nullable(),
|
||||
lead_id: z.string().nullable(),
|
||||
// .default(null) so a project from an older backend (frontend deploys before
|
||||
// backend) that omits these keys parses to null instead of failing the whole
|
||||
// object — which would degrade a search/list batch to the empty fallback.
|
||||
start_date: z.string().nullable().default(null),
|
||||
due_date: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
issue_count: z.number().default(0),
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface Project {
|
||||
priority: ProjectPriority;
|
||||
lead_type: "member" | "agent" | null;
|
||||
lead_id: string | null;
|
||||
// Calendar days ("YYYY-MM-DD"), no time-of-day or timezone — same contract as
|
||||
// issue.start_date / issue.due_date.
|
||||
start_date: string | null;
|
||||
due_date: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
issue_count: number;
|
||||
@@ -27,6 +31,8 @@ export interface CreateProjectRequest {
|
||||
priority?: ProjectPriority;
|
||||
lead_type?: "member" | "agent";
|
||||
lead_id?: string;
|
||||
start_date?: string;
|
||||
due_date?: string;
|
||||
// Resources to attach in the same transaction as the project. Server returns
|
||||
// 4xx (and rolls back) if any one is invalid or duplicate.
|
||||
resources?: CreateProjectResourceRequest[];
|
||||
@@ -40,6 +46,9 @@ export interface UpdateProjectRequest {
|
||||
priority?: ProjectPriority;
|
||||
lead_type?: "member" | "agent" | null;
|
||||
lead_id?: string | null;
|
||||
// Omit the key to leave the date untouched; send null (or "") to clear it.
|
||||
start_date?: string | null;
|
||||
due_date?: string | null;
|
||||
}
|
||||
|
||||
export interface ListProjectsResponse {
|
||||
|
||||
@@ -186,6 +186,8 @@ const PROJECT: Project = {
|
||||
priority: "high",
|
||||
lead_type: null,
|
||||
lead_id: null,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-01T00:00:00Z",
|
||||
issue_count: 3,
|
||||
|
||||
@@ -135,6 +135,8 @@ func init() {
|
||||
projectCreateCmd.Flags().String("status", "", "Project status")
|
||||
projectCreateCmd.Flags().String("icon", "", "Project icon (emoji)")
|
||||
projectCreateCmd.Flags().String("lead", "", "Lead name (member or agent)")
|
||||
projectCreateCmd.Flags().String("start-date", "", "Start date (calendar day, YYYY-MM-DD)")
|
||||
projectCreateCmd.Flags().String("due-date", "", "Due date (calendar day, YYYY-MM-DD)")
|
||||
projectCreateCmd.Flags().StringArray("repo", nil, "Attach a github_repo resource by URL (may be repeated)")
|
||||
projectCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
@@ -178,6 +180,8 @@ func init() {
|
||||
projectUpdateCmd.Flags().String("status", "", "New status")
|
||||
projectUpdateCmd.Flags().String("icon", "", "New icon (emoji)")
|
||||
projectUpdateCmd.Flags().String("lead", "", "New lead name (member or agent)")
|
||||
projectUpdateCmd.Flags().String("start-date", "", "New start date (calendar day, YYYY-MM-DD; pass empty string to clear)")
|
||||
projectUpdateCmd.Flags().String("due-date", "", "New due date (calendar day, YYYY-MM-DD; pass empty string to clear)")
|
||||
projectUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// project delete
|
||||
@@ -332,6 +336,12 @@ func runProjectCreate(cmd *cobra.Command, _ []string) error {
|
||||
body["lead_type"] = aType
|
||||
body["lead_id"] = aID
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("start-date"); v != "" {
|
||||
body["start_date"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("due-date"); v != "" {
|
||||
body["due_date"] = v
|
||||
}
|
||||
|
||||
// Bundle resources into the create payload so the server attaches them in
|
||||
// the same transaction; this avoids leaving a half-attached project on
|
||||
@@ -417,9 +427,19 @@ func runProjectUpdate(cmd *cobra.Command, args []string) error {
|
||||
body["lead_type"] = aType
|
||||
body["lead_id"] = aID
|
||||
}
|
||||
// Changed() (not "") so an explicit --start-date "" reaches the server as a
|
||||
// clear, mirroring the issue update CLI.
|
||||
if cmd.Flags().Changed("start-date") {
|
||||
v, _ := cmd.Flags().GetString("start-date")
|
||||
body["start_date"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("due-date") {
|
||||
v, _ := cmd.Flags().GetString("due-date")
|
||||
body["due_date"] = v
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use flags like --title, --status, --description, --icon, --lead")
|
||||
return fmt.Errorf("no fields to update; use flags like --title, --status, --description, --icon, --lead, --start-date, --due-date")
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
@@ -28,10 +29,14 @@ type ProjectResponse struct {
|
||||
Priority string `json:"priority"`
|
||||
LeadType *string `json:"lead_type"`
|
||||
LeadID *string `json:"lead_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
IssueCount int64 `json:"issue_count"`
|
||||
DoneCount int64 `json:"done_count"`
|
||||
// StartDate / DueDate are calendar days ("YYYY-MM-DD"), no time-of-day or
|
||||
// timezone — same contract as issue.start_date / issue.due_date.
|
||||
StartDate *string `json:"start_date"`
|
||||
DueDate *string `json:"due_date"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
IssueCount int64 `json:"issue_count"`
|
||||
DoneCount int64 `json:"done_count"`
|
||||
// ResourceCount is a breadcrumb pointing at the sub-collection at
|
||||
// /api/projects/{id}/resources. Resources themselves stay out of this
|
||||
// payload to keep parent metadata and child collections separate; clients
|
||||
@@ -50,6 +55,8 @@ func projectToResponse(p db.Project) ProjectResponse {
|
||||
Priority: p.Priority,
|
||||
LeadType: textToPtr(p.LeadType),
|
||||
LeadID: uuidToPtr(p.LeadID),
|
||||
StartDate: dateToPtr(p.StartDate),
|
||||
DueDate: dateToPtr(p.DueDate),
|
||||
CreatedAt: timestampToString(p.CreatedAt),
|
||||
UpdatedAt: timestampToString(p.UpdatedAt),
|
||||
}
|
||||
@@ -79,6 +86,8 @@ type CreateProjectRequest struct {
|
||||
Priority string `json:"priority"`
|
||||
LeadType *string `json:"lead_type"`
|
||||
LeadID *string `json:"lead_id"`
|
||||
StartDate *string `json:"start_date"`
|
||||
DueDate *string `json:"due_date"`
|
||||
Resources []CreateProjectResourceRequestPayload `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
@@ -100,6 +109,8 @@ type UpdateProjectRequest struct {
|
||||
Priority *string `json:"priority"`
|
||||
LeadType *string `json:"lead_type"`
|
||||
LeadID *string `json:"lead_id"`
|
||||
StartDate *string `json:"start_date"`
|
||||
DueDate *string `json:"due_date"`
|
||||
}
|
||||
|
||||
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -265,6 +276,27 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// start_date / due_date are optional calendar days; an absent or empty
|
||||
// value leaves the column NULL. Mirrors CreateIssue's date handling.
|
||||
var startDate pgtype.Date
|
||||
if req.StartDate != nil && *req.StartDate != "" {
|
||||
d, err := util.ParseCalendarDate(*req.StartDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
startDate = d
|
||||
}
|
||||
var dueDate pgtype.Date
|
||||
if req.DueDate != nil && *req.DueDate != "" {
|
||||
d, err := util.ParseCalendarDate(*req.DueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
dueDate = d
|
||||
}
|
||||
|
||||
// Pre-validate every resource payload before opening a transaction so an
|
||||
// invalid ref produces a clean 400 with no DB work. For local_directory we
|
||||
// also enforce one row per daemon_id within the batch — the daemon-side
|
||||
@@ -310,6 +342,8 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
LeadType: leadType,
|
||||
LeadID: leadID,
|
||||
Priority: priority,
|
||||
StartDate: startDate,
|
||||
DueDate: dueDate,
|
||||
}
|
||||
|
||||
// Without resources, keep the simple non-tx path.
|
||||
@@ -441,6 +475,8 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
Icon: prevProject.Icon,
|
||||
LeadType: prevProject.LeadType,
|
||||
LeadID: prevProject.LeadID,
|
||||
StartDate: prevProject.StartDate,
|
||||
DueDate: prevProject.DueDate,
|
||||
}
|
||||
if req.Title != nil {
|
||||
params.Title = pgtype.Text{String: *req.Title, Valid: true}
|
||||
@@ -489,6 +525,32 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
params.LeadID = pgtype.UUID{Valid: false}
|
||||
}
|
||||
}
|
||||
// Dates follow the issue contract: a present key with an empty/null value
|
||||
// clears the date; an absent key leaves the prior value untouched.
|
||||
if _, ok := rawFields["start_date"]; ok {
|
||||
if req.StartDate != nil && *req.StartDate != "" {
|
||||
d, err := util.ParseCalendarDate(*req.StartDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
params.StartDate = d
|
||||
} else {
|
||||
params.StartDate = pgtype.Date{Valid: false} // explicit null = clear date
|
||||
}
|
||||
}
|
||||
if _, ok := rawFields["due_date"]; ok {
|
||||
if req.DueDate != nil && *req.DueDate != "" {
|
||||
d, err := util.ParseCalendarDate(*req.DueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
params.DueDate = d
|
||||
} else {
|
||||
params.DueDate = pgtype.Date{Valid: false} // explicit null = clear date
|
||||
}
|
||||
}
|
||||
project, err := h.Queries.UpdateProject(r.Context(), params)
|
||||
if err != nil {
|
||||
h.writeProjectWriteError(w, r, err, "update")
|
||||
@@ -653,6 +715,7 @@ func buildProjectSearchQuery(phrase string, terms []string, includeClosed bool)
|
||||
|
||||
query := fmt.Sprintf(`SELECT p.id, p.workspace_id, p.title, p.description, p.icon,
|
||||
p.status, p.priority, p.lead_type, p.lead_id,
|
||||
p.start_date, p.due_date,
|
||||
p.created_at, p.updated_at,
|
||||
COUNT(*) OVER() AS total_count,
|
||||
%s AS match_source
|
||||
@@ -730,6 +793,8 @@ func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) {
|
||||
&row.project.Priority,
|
||||
&row.project.LeadType,
|
||||
&row.project.LeadID,
|
||||
&row.project.StartDate,
|
||||
&row.project.DueDate,
|
||||
&row.project.CreatedAt,
|
||||
&row.project.UpdatedAt,
|
||||
&row.totalCount,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// decodeProject decodes a ProjectResponse from a recorder, failing the test on
|
||||
// a non-expected status or a decode error.
|
||||
func decodeProject(t *testing.T, w *httptest.ResponseRecorder, wantStatus int) ProjectResponse {
|
||||
t.Helper()
|
||||
if w.Code != wantStatus {
|
||||
t.Fatalf("expected status %d, got %d: %s", wantStatus, w.Code, w.Body.String())
|
||||
}
|
||||
var p ProjectResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&p); err != nil {
|
||||
t.Fatalf("decode ProjectResponse: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Project start_date / due_date follow the same calendar-day contract as the
|
||||
// issue dates: create echoes them, GET persists them, an update with the key
|
||||
// present but empty clears the date while an absent key leaves it untouched.
|
||||
func TestProjectStartDueDateLifecycle(t *testing.T) {
|
||||
// Create with both dates set.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.CreateProject(w, newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "dated project",
|
||||
"start_date": "2026-03-01",
|
||||
"due_date": "2026-03-31",
|
||||
}))
|
||||
created := decodeProject(t, w, http.StatusCreated)
|
||||
t.Cleanup(func() {
|
||||
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, created.ID)
|
||||
})
|
||||
if created.StartDate == nil || *created.StartDate != "2026-03-01" {
|
||||
t.Fatalf("create start_date = %v, want 2026-03-01", created.StartDate)
|
||||
}
|
||||
if created.DueDate == nil || *created.DueDate != "2026-03-31" {
|
||||
t.Fatalf("create due_date = %v, want 2026-03-31", created.DueDate)
|
||||
}
|
||||
|
||||
// GET persists both dates.
|
||||
w = httptest.NewRecorder()
|
||||
getReq := withURLParam(newRequest("GET", "/api/projects/"+created.ID, nil), "id", created.ID)
|
||||
testHandler.GetProject(w, getReq)
|
||||
got := decodeProject(t, w, http.StatusOK)
|
||||
if got.StartDate == nil || *got.StartDate != "2026-03-01" || got.DueDate == nil || *got.DueDate != "2026-03-31" {
|
||||
t.Fatalf("get dates = (%v, %v), want (2026-03-01, 2026-03-31)", got.StartDate, got.DueDate)
|
||||
}
|
||||
|
||||
// Update due_date only; start_date is absent from the body and must persist.
|
||||
w = httptest.NewRecorder()
|
||||
putReq := withURLParam(newRequest("PUT", "/api/projects/"+created.ID, map[string]any{
|
||||
"due_date": "2026-04-15",
|
||||
}), "id", created.ID)
|
||||
testHandler.UpdateProject(w, putReq)
|
||||
updated := decodeProject(t, w, http.StatusOK)
|
||||
if updated.DueDate == nil || *updated.DueDate != "2026-04-15" {
|
||||
t.Fatalf("update due_date = %v, want 2026-04-15", updated.DueDate)
|
||||
}
|
||||
if updated.StartDate == nil || *updated.StartDate != "2026-03-01" {
|
||||
t.Fatalf("start_date must be untouched by an absent key, got %v", updated.StartDate)
|
||||
}
|
||||
|
||||
// Clearing: an explicit empty string nulls start_date, due_date untouched.
|
||||
w = httptest.NewRecorder()
|
||||
clearReq := withURLParam(newRequest("PUT", "/api/projects/"+created.ID, map[string]any{
|
||||
"start_date": "",
|
||||
}), "id", created.ID)
|
||||
testHandler.UpdateProject(w, clearReq)
|
||||
cleared := decodeProject(t, w, http.StatusOK)
|
||||
if cleared.StartDate != nil {
|
||||
t.Fatalf("start_date should be cleared, got %v", cleared.StartDate)
|
||||
}
|
||||
if cleared.DueDate == nil || *cleared.DueDate != "2026-04-15" {
|
||||
t.Fatalf("due_date must survive clearing start_date, got %v", cleared.DueDate)
|
||||
}
|
||||
}
|
||||
|
||||
// SearchProjects hand-scans an explicit column list (not SELECT *), so a
|
||||
// column/scan misalignment after adding start_date / due_date would surface
|
||||
// here as a scan error or shifted values. Exercise the real query + scan.
|
||||
func TestSearchProjectsCarriesDates(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.CreateProject(w, newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "zzsearchdated project",
|
||||
"start_date": "2026-05-01",
|
||||
"due_date": "2026-05-31",
|
||||
}))
|
||||
created := decodeProject(t, w, http.StatusCreated)
|
||||
t.Cleanup(func() {
|
||||
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.SearchProjects(w, newRequest("GET", "/api/projects/search?q=zzsearchdated", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search status = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Projects []SearchProjectResponse `json:"projects"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode search: %v", err)
|
||||
}
|
||||
var found *SearchProjectResponse
|
||||
for i := range resp.Projects {
|
||||
if resp.Projects[i].ID == created.ID {
|
||||
found = &resp.Projects[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("created project not in search results: %s", w.Body.String())
|
||||
}
|
||||
if found.StartDate == nil || *found.StartDate != "2026-05-01" || found.DueDate == nil || *found.DueDate != "2026-05-31" {
|
||||
t.Fatalf("search dates = (%v, %v), want (2026-05-01, 2026-05-31)", found.StartDate, found.DueDate)
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed calendar day is a 400 on both create and update, never a 500.
|
||||
func TestProjectInvalidDateReturns400(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.CreateProject(w, newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "bad date project",
|
||||
"start_date": "03/01/2026",
|
||||
}))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid create start_date, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Seed a valid project, then reject a malformed update due_date.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.CreateProject(w, newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "bad update date project",
|
||||
}))
|
||||
project := decodeProject(t, w, http.StatusCreated)
|
||||
t.Cleanup(func() {
|
||||
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, project.ID)
|
||||
})
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
putReq := withURLParam(newRequest("PUT", "/api/projects/"+project.ID, map[string]any{
|
||||
"due_date": "not-a-date",
|
||||
}), "id", project.ID)
|
||||
testHandler.UpdateProject(w, putReq)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid update due_date, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,10 @@ Common resource types:
|
||||
multica project list --output json
|
||||
multica project get <project-id> --output json
|
||||
multica project create --title "<title>" --repo <github-url> --output json
|
||||
multica project create --title "<title>" --start-date 2026-03-01 --due-date 2026-03-31 --output json
|
||||
multica project update <project-id> --title "<title>" --output json
|
||||
multica project update <project-id> --due-date 2026-04-15 --output json
|
||||
multica project update <project-id> --start-date "" --output json # clear the start date
|
||||
multica project status <project-id> in_progress --output json
|
||||
multica project resource list <project-id> --output json
|
||||
multica project resource add <project-id> --type github_repo --url <github-url> --output json
|
||||
@@ -50,6 +53,8 @@ multica project resource remove <project-id> <resource-id> --output json
|
||||
|
||||
For `github_repo`, non-JSON `--ref` sets `resource_ref.ref`, the default checkout branch/tag/SHA for future tasks in that project. JSON `--ref '<json>'` remains the escape hatch for full payloads or resource types not covered by shortcuts.
|
||||
|
||||
`--start-date` / `--due-date` are optional calendar days (`YYYY-MM-DD`, like issue dates). On `project update`, pass an empty string (`--start-date ""`) to clear a date; an unset flag leaves it untouched.
|
||||
|
||||
## When to add a resource
|
||||
|
||||
Add/update a project resource when the user asks for durable project context: "把这个 GitHub repo 绑到项目上", "以后都用这个 repo", "agent 总是拿不到这个项目的仓库", or "这个项目要在我的本地目录里跑".
|
||||
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
- `server/cmd/multica/cmd_project.go` registers project `list`, `get`, `create`, `update`, `delete`, and `status`.
|
||||
- The same file registers `project resource list/add/update/remove`.
|
||||
- `project create --repo` attaches `github_repo` resources during project creation.
|
||||
- `project create` / `project update` accept `--start-date` / `--due-date` (calendar days, `YYYY-MM-DD`), mapping to the project `start_date` / `due_date` columns (migration `166_project_dates`); an empty `--start-date ""`/`--due-date ""` on update clears the date, mirroring the issue date flags in `cmd_issue.go`.
|
||||
- `project resource add` supports shortcuts for `github_repo` (`--url`, non-JSON `--ref` for checkout ref, `--default-branch-hint`) and `local_directory` (`--local-path`, `--daemon-id`, `--ref-label`), or generic JSON `--ref '<json>'`.
|
||||
- `project resource update` merges shortcut edits with existing `resource_ref` so a partial edit does not clobber required fields; non-JSON `--ref` updates `github_repo.resource_ref.ref`.
|
||||
- `server/cmd/server/router.go` exposes `/api/projects` plus `/api/projects/{projectId}/resources` routes.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE project
|
||||
DROP COLUMN IF EXISTS due_date,
|
||||
DROP COLUMN IF EXISTS start_date;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Project start_date / due_date make a Project a schedulable planning object
|
||||
-- alongside its issues: a planned start plus a deadline. These mirror
|
||||
-- issue.start_date / issue.due_date after migration 112 — calendar days stored
|
||||
-- as DATE, carrying no time-of-day or timezone, so "Mar 1" means Mar 1 for
|
||||
-- every viewer regardless of their local offset (see GH #3618 / MUL-2925).
|
||||
-- Backs MUL-4388 / GH #5227. Nullable, no default: adding a nullable column is
|
||||
-- a metadata-only change with no table rewrite.
|
||||
ALTER TABLE project
|
||||
ADD COLUMN start_date DATE,
|
||||
ADD COLUMN due_date DATE;
|
||||
@@ -718,6 +718,8 @@ type Project struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Priority string `json:"priority"`
|
||||
StartDate pgtype.Date `json:"start_date"`
|
||||
DueDate pgtype.Date `json:"due_date"`
|
||||
}
|
||||
|
||||
type ProjectResource struct {
|
||||
|
||||
@@ -26,10 +26,10 @@ func (q *Queries) CountIssuesByProject(ctx context.Context, projectID pgtype.UUI
|
||||
const createProject = `-- name: CreateProject :one
|
||||
INSERT INTO project (
|
||||
workspace_id, title, description, icon, status,
|
||||
lead_type, lead_id, priority
|
||||
lead_type, lead_id, priority, start_date, due_date
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
) RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||
) RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date
|
||||
`
|
||||
|
||||
type CreateProjectParams struct {
|
||||
@@ -41,6 +41,8 @@ type CreateProjectParams struct {
|
||||
LeadType pgtype.Text `json:"lead_type"`
|
||||
LeadID pgtype.UUID `json:"lead_id"`
|
||||
Priority string `json:"priority"`
|
||||
StartDate pgtype.Date `json:"start_date"`
|
||||
DueDate pgtype.Date `json:"due_date"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
|
||||
@@ -53,6 +55,8 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
|
||||
arg.LeadType,
|
||||
arg.LeadID,
|
||||
arg.Priority,
|
||||
arg.StartDate,
|
||||
arg.DueDate,
|
||||
)
|
||||
var i Project
|
||||
err := row.Scan(
|
||||
@@ -67,6 +71,8 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Priority,
|
||||
&i.StartDate,
|
||||
&i.DueDate,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -87,7 +93,7 @@ func (q *Queries) DeleteProject(ctx context.Context, arg DeleteProjectParams) er
|
||||
}
|
||||
|
||||
const getProject = `-- name: GetProject :one
|
||||
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority FROM project
|
||||
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date FROM project
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -106,12 +112,14 @@ func (q *Queries) GetProject(ctx context.Context, id pgtype.UUID) (Project, erro
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Priority,
|
||||
&i.StartDate,
|
||||
&i.DueDate,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getProjectInWorkspace = `-- name: GetProjectInWorkspace :one
|
||||
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority FROM project
|
||||
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date FROM project
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
@@ -135,6 +143,8 @@ func (q *Queries) GetProjectInWorkspace(ctx context.Context, arg GetProjectInWor
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Priority,
|
||||
&i.StartDate,
|
||||
&i.DueDate,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -175,7 +185,7 @@ func (q *Queries) GetProjectIssueStats(ctx context.Context, projectIds []pgtype.
|
||||
}
|
||||
|
||||
const listProjects = `-- name: ListProjects :many
|
||||
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority FROM project
|
||||
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date FROM project
|
||||
WHERE workspace_id = $1
|
||||
AND ($2::text IS NULL OR status = $2)
|
||||
AND ($3::text IS NULL OR priority = $3)
|
||||
@@ -209,6 +219,8 @@ func (q *Queries) ListProjects(ctx context.Context, arg ListProjectsParams) ([]P
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Priority,
|
||||
&i.StartDate,
|
||||
&i.DueDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -229,9 +241,11 @@ UPDATE project SET
|
||||
priority = COALESCE($6, priority),
|
||||
lead_type = $7,
|
||||
lead_id = $8,
|
||||
start_date = $9,
|
||||
due_date = $10,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority
|
||||
RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date
|
||||
`
|
||||
|
||||
type UpdateProjectParams struct {
|
||||
@@ -243,6 +257,8 @@ type UpdateProjectParams struct {
|
||||
Priority pgtype.Text `json:"priority"`
|
||||
LeadType pgtype.Text `json:"lead_type"`
|
||||
LeadID pgtype.UUID `json:"lead_id"`
|
||||
StartDate pgtype.Date `json:"start_date"`
|
||||
DueDate pgtype.Date `json:"due_date"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) {
|
||||
@@ -255,6 +271,8 @@ func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (P
|
||||
arg.Priority,
|
||||
arg.LeadType,
|
||||
arg.LeadID,
|
||||
arg.StartDate,
|
||||
arg.DueDate,
|
||||
)
|
||||
var i Project
|
||||
err := row.Scan(
|
||||
@@ -269,6 +287,8 @@ func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (P
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Priority,
|
||||
&i.StartDate,
|
||||
&i.DueDate,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ WHERE id = $1 AND workspace_id = $2;
|
||||
-- name: CreateProject :one
|
||||
INSERT INTO project (
|
||||
workspace_id, title, description, icon, status,
|
||||
lead_type, lead_id, priority
|
||||
lead_type, lead_id, priority, start_date, due_date
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateProject :one
|
||||
@@ -30,6 +30,8 @@ UPDATE project SET
|
||||
priority = COALESCE(sqlc.narg('priority'), priority),
|
||||
lead_type = sqlc.narg('lead_type'),
|
||||
lead_id = sqlc.narg('lead_id'),
|
||||
start_date = sqlc.narg('start_date'),
|
||||
due_date = sqlc.narg('due_date'),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
Reference in New Issue
Block a user