diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index ae474b291..aaa56d25f 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -582,16 +582,17 @@ multica project get --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 --title "New title" --status in_progress multica project update --lead "Lambda" +multica project update --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 diff --git a/apps/docs/content/docs/cli/reference.zh.mdx b/apps/docs/content/docs/cli/reference.zh.mdx index 0388263a3..976e5ef7f 100644 --- a/apps/docs/content/docs/cli/reference.zh.mdx +++ b/apps/docs/content/docs/cli/reference.zh.mdx @@ -380,16 +380,17 @@ multica project get --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 --title "New title" --status in_progress multica project update --lead "Lambda" +multica project update --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 diff --git a/apps/mobile/data/schemas.ts b/apps/mobile/data/schemas.ts index 842900aef..370879d45 100644 --- a/apps/mobile/data/schemas.ts +++ b/apps/mobile/data/schemas.ts @@ -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, diff --git a/packages/core/api/schemas.test.ts b/packages/core/api/schemas.test.ts index 370b58804..d0cf00cc4 100644 --- a/packages/core/api/schemas.test.ts +++ b/packages/core/api/schemas.test.ts @@ -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(); + }); +}); diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index d63d44d90..84d8c38f5 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -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), diff --git a/packages/core/types/project.ts b/packages/core/types/project.ts index 333d09482..d47f678e9 100644 --- a/packages/core/types/project.ts +++ b/packages/core/types/project.ts @@ -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 { diff --git a/packages/views/projects/components/projects-page.test.tsx b/packages/views/projects/components/projects-page.test.tsx index d37da2f66..1530fd2ad 100644 --- a/packages/views/projects/components/projects-page.test.tsx +++ b/packages/views/projects/components/projects-page.test.tsx @@ -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, diff --git a/server/cmd/multica/cmd_project.go b/server/cmd/multica/cmd_project.go index a45d0c66e..0dbb44c3c 100644 --- a/server/cmd/multica/cmd_project.go +++ b/server/cmd/multica/cmd_project.go @@ -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 diff --git a/server/internal/handler/project.go b/server/internal/handler/project.go index 11b93915f..58b58d152 100644 --- a/server/internal/handler/project.go +++ b/server/internal/handler/project.go @@ -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, diff --git a/server/internal/handler/project_dates_test.go b/server/internal/handler/project_dates_test.go new file mode 100644 index 000000000..364f1c861 --- /dev/null +++ b/server/internal/handler/project_dates_test.go @@ -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()) + } +} diff --git a/server/internal/service/builtin_skills/multica-projects-and-resources/SKILL.md b/server/internal/service/builtin_skills/multica-projects-and-resources/SKILL.md index 850f38b04..3c5e27492 100644 --- a/server/internal/service/builtin_skills/multica-projects-and-resources/SKILL.md +++ b/server/internal/service/builtin_skills/multica-projects-and-resources/SKILL.md @@ -37,7 +37,10 @@ Common resource types: multica project list --output json multica project get --output json multica project create --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 "这个项目要在我的本地目录里跑". diff --git a/server/internal/service/builtin_skills/multica-projects-and-resources/references/projects-and-resources-source-map.md b/server/internal/service/builtin_skills/multica-projects-and-resources/references/projects-and-resources-source-map.md index 0a90af283..b488bfbdf 100644 --- a/server/internal/service/builtin_skills/multica-projects-and-resources/references/projects-and-resources-source-map.md +++ b/server/internal/service/builtin_skills/multica-projects-and-resources/references/projects-and-resources-source-map.md @@ -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. diff --git a/server/migrations/166_project_dates.down.sql b/server/migrations/166_project_dates.down.sql new file mode 100644 index 000000000..09ef69222 --- /dev/null +++ b/server/migrations/166_project_dates.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE project + DROP COLUMN IF EXISTS due_date, + DROP COLUMN IF EXISTS start_date; diff --git a/server/migrations/166_project_dates.up.sql b/server/migrations/166_project_dates.up.sql new file mode 100644 index 000000000..678628ed1 --- /dev/null +++ b/server/migrations/166_project_dates.up.sql @@ -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; diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 34d6a6d7b..5aed9919c 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -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 { diff --git a/server/pkg/db/generated/project.sql.go b/server/pkg/db/generated/project.sql.go index 1a7cdfcec..2d86726f9 100644 --- a/server/pkg/db/generated/project.sql.go +++ b/server/pkg/db/generated/project.sql.go @@ -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 } diff --git a/server/pkg/db/queries/project.sql b/server/pkg/db/queries/project.sql index 711594706..3a887139c 100644 --- a/server/pkg/db/queries/project.sql +++ b/server/pkg/db/queries/project.sql @@ -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 *;