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:
Bohan Jiang
2026-07-13 13:22:02 +08:00
committed by GitHub
co-authored by J multica-agent
parent 4ee34b9fcb
commit bf288349f6
17 changed files with 376 additions and 18 deletions
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+6
View File
@@ -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,
+51
View File
@@ -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();
});
});
+5
View File
@@ -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),
+9
View File
@@ -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,
+21 -1
View File
@@ -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
+69 -4
View File
@@ -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 "这个项目要在我的本地目录里跑".
@@ -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;
+2
View File
@@ -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 {
+27 -7
View File
@@ -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
}
+4 -2
View File
@@ -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 *;