From 98337af57b4b5f509aa9432c544ef52bc11b9330 Mon Sep 17 00:00:00 2001 From: ReBotics AI Date: Sat, 1 Aug 2026 17:29:35 -0600 Subject: [PATCH] Sync GitHub Project iteration, estimate, text, and start date fields. Closes #277 --- .../__tests__/github-projects-fields.test.ts | 31 +++ .../src/services/github-projects-fields.ts | 58 ++++++ apps/bridge/src/services/github-projects.ts | 185 ++++++++++++++++-- .../intelligence/projects/ProjectsBoard.tsx | 108 +++++++++- docs/features/tasks.md | 4 +- vitest.config.ts | 1 + 6 files changed, 370 insertions(+), 17 deletions(-) create mode 100644 apps/bridge/src/services/__tests__/github-projects-fields.test.ts create mode 100644 apps/bridge/src/services/github-projects-fields.ts diff --git a/apps/bridge/src/services/__tests__/github-projects-fields.test.ts b/apps/bridge/src/services/__tests__/github-projects-fields.test.ts new file mode 100644 index 0000000..ed57b9d --- /dev/null +++ b/apps/bridge/src/services/__tests__/github-projects-fields.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + isDueDateFieldName, + isEstimateFieldName, + isIterationFieldName, + isStartDateFieldName, + isTextNoteFieldName, +} from "../github-projects-fields.js"; + +describe("github-projects-fields", () => { + it("keeps start date out of due/target matching", () => { + expect(isDueDateFieldName("Target Date")).toBe(true); + expect(isDueDateFieldName("Due date")).toBe(true); + expect(isDueDateFieldName("Start Date")).toBe(false); + expect(isStartDateFieldName("Start Date")).toBe(true); + expect(isStartDateFieldName("Due Date")).toBe(false); + }); + + it("matches estimate and text/note conventions", () => { + expect(isEstimateFieldName("Estimate")).toBe(true); + expect(isEstimateFieldName("Story Points")).toBe(true); + expect(isTextNoteFieldName("Notes")).toBe(true); + expect(isTextNoteFieldName("Priority")).toBe(false); + }); + + it("matches iteration / sprint names", () => { + expect(isIterationFieldName("Iteration")).toBe(true); + expect(isIterationFieldName("Sprint")).toBe(true); + expect(isIterationFieldName("Status")).toBe(false); + }); +}); diff --git a/apps/bridge/src/services/github-projects-fields.ts b/apps/bridge/src/services/github-projects-fields.ts new file mode 100644 index 0000000..acbc374 --- /dev/null +++ b/apps/bridge/src/services/github-projects-fields.ts @@ -0,0 +1,58 @@ +/** + * Name matching for GitHub Projects field parity leftovers (#277). + */ + +export function isDueDateFieldName(name: string): boolean { + const n = name.trim().toLowerCase(); + if (!n) return false; + if (isStartDateFieldName(n)) return false; + return ["target date", "due date", "due", "date", "end date"].includes(n); +} + +export function isStartDateFieldName(name: string): boolean { + const n = name.trim().toLowerCase(); + return n === "start date" || n === "start" || n === "start at"; +} + +export function isEstimateFieldName(name: string): boolean { + const n = name.trim().toLowerCase(); + return ( + n === "estimate" || + n === "story points" || + n === "points" || + n === "size" || + n === "effort" + ); +} + +export function isTextNoteFieldName(name: string): boolean { + const n = name.trim().toLowerCase(); + return ( + n === "text" || + n === "note" || + n === "notes" || + n === "comment" || + n === "summary" + ); +} + +export function isIterationFieldName(name: string): boolean { + const n = name.trim().toLowerCase(); + return n === "iteration" || n === "sprint" || n === "cycle"; +} + +export type ExtraProjectFields = { + startAt: string | null; + estimate: number | null; + textNote: string | null; + iteration: string | null; +}; + +export function emptyExtraFields(): ExtraProjectFields { + return { + startAt: null, + estimate: null, + textNote: null, + iteration: null, + }; +} diff --git a/apps/bridge/src/services/github-projects.ts b/apps/bridge/src/services/github-projects.ts index dcd5ce3..e5110c5 100644 --- a/apps/bridge/src/services/github-projects.ts +++ b/apps/bridge/src/services/github-projects.ts @@ -11,6 +11,15 @@ import { userProjectId, } from "./user-productivity.js"; import { readGithubProjectsToken, resolveGithubProjectsAccessToken } from "./github-integration.js"; +import { + emptyExtraFields, + isDueDateFieldName, + isEstimateFieldName, + isIterationFieldName, + isStartDateFieldName, + isTextNoteFieldName, + type ExtraProjectFields, +} from "./github-projects-fields.js"; export type GithubProjectSummary = { id: string; @@ -34,8 +43,13 @@ type ProjectMeta = { statusFieldId: string | null; statusOptions: StatusOption[]; dateFieldId: string | null; + startDateFieldId: string | null; priorityFieldId: string | null; priorityOptions: StatusOption[]; + estimateFieldId: string | null; + textFieldId: string | null; + iterationFieldId: string | null; + iterationOptions: Array<{ id: string; title: string }>; }; type GithubAssignee = { @@ -65,7 +79,7 @@ type ProjectItem = { issueNumber: number | null; repo: string | null; contentId: string | null; -}; +} & ExtraProjectFields; const DEFAULT_STATUS_ALIASES: Record = { backlog: ["todo", "backlog", "new", "triage"], @@ -267,6 +281,9 @@ async function loadProjectMeta( name?: string; options?: StatusOption[]; dataType?: string; + configuration?: { + iterations?: Array<{ id: string; title: string }>; + }; }>; }; } | null; @@ -283,6 +300,12 @@ async function loadProjectMeta( id name dataType options { id name } } + ... on ProjectV2IterationField { + id name dataType + configuration { + iterations { id title } + } + } } } } @@ -303,14 +326,22 @@ async function loadProjectMeta( fields.find((f) => f.name?.toLowerCase() === "status" && f.options) ?? fields.find((f) => f.options && f.options.length > 0); const dateField = - fields.find((f) => - ["target date", "due date", "due", "date"].includes( - (f.name ?? "").toLowerCase() - ) - ) ?? null; + fields.find((f) => isDueDateFieldName(f.name ?? "")) ?? null; + const startDateField = + fields.find((f) => isStartDateFieldName(f.name ?? "")) ?? null; const priorityField = fields.find((f) => (f.name ?? "").toLowerCase() === "priority" && f.options) ?? null; + const estimateField = + fields.find((f) => isEstimateFieldName(f.name ?? "")) ?? null; + const textField = + fields.find((f) => isTextNoteFieldName(f.name ?? "")) ?? null; + const iterationField = + fields.find( + (f) => + isIterationFieldName(f.name ?? "") || + (f.dataType ?? "").toUpperCase() === "ITERATION" + ) ?? null; return { id: data.node.id, title: data.node.title, @@ -318,8 +349,13 @@ async function loadProjectMeta( statusFieldId: status?.id ?? null, statusOptions: status?.options ?? [], dateFieldId: dateField?.id ?? null, + startDateFieldId: startDateField?.id ?? null, priorityFieldId: priorityField?.id ?? null, priorityOptions: priorityField?.options ?? [], + estimateFieldId: estimateField?.id ?? null, + textFieldId: textField?.id ?? null, + iterationFieldId: iterationField?.id ?? null, + iterationOptions: iterationField?.configuration?.iterations ?? [], }; } @@ -367,6 +403,8 @@ async function fetchProjectItems( name?: string; date?: string; text?: string; + number?: number; + title?: string; field?: { name?: string }; }>; }; @@ -408,7 +446,7 @@ async function fetchProjectItems( pageInfo { hasNextPage endCursor } nodes { id - fieldValues(first: 20) { + fieldValues(first: 30) { nodes { ... on ProjectV2ItemFieldSingleSelectValue { name @@ -422,6 +460,14 @@ async function fetchProjectItems( text field { ... on ProjectV2FieldCommon { name } } } + ... on ProjectV2ItemFieldNumberValue { + number + field { ... on ProjectV2FieldCommon { name } } + } + ... on ProjectV2ItemFieldIterationValue { + title + field { ... on ProjectV2FieldCommon { name } } + } } } content { @@ -461,16 +507,27 @@ async function fetchProjectItems( let statusName: string | null = null; let dueAt: string | null = null; let priorityName: string | null = null; + const extra = emptyExtraFields(); for (const fv of node.fieldValues.nodes ?? []) { - const fieldName = (fv.field?.name ?? "").toLowerCase(); - if (fieldName === "status" && fv.name) statusName = fv.name; - if ( - ["target date", "due date", "due", "date"].includes(fieldName) && - fv.date - ) { + const fieldName = fv.field?.name ?? ""; + const fieldKey = fieldName.toLowerCase(); + if (fieldKey === "status" && fv.name) statusName = fv.name; + if (isDueDateFieldName(fieldName) && fv.date) { dueAt = fv.date; } - if (fieldName === "priority" && fv.name) priorityName = fv.name; + if (isStartDateFieldName(fieldName) && fv.date) { + extra.startAt = fv.date; + } + if (fieldKey === "priority" && fv.name) priorityName = fv.name; + if (isEstimateFieldName(fieldName) && typeof fv.number === "number") { + extra.estimate = fv.number; + } + if (isTextNoteFieldName(fieldName) && typeof fv.text === "string") { + extra.textNote = fv.text; + } + if (isIterationFieldName(fieldName) && (fv.title || fv.name)) { + extra.iteration = String(fv.title || fv.name); + } } const assignees: GithubAssignee[] = content?.assignees?.nodes?.map((a) => ({ @@ -500,6 +557,7 @@ async function fetchProjectItems( issueNumber: content?.number ?? null, repo: content?.repository?.nameWithOwner ?? null, contentId: content?.id ?? null, + ...extra, }); } if (!connection.pageInfo.hasNextPage) break; @@ -993,6 +1051,10 @@ async function pullBoardFromGithub(opts: { url: item.url, assignees: item.assignees, milestone: item.milestone, + startAt: item.startAt, + estimate: item.estimate, + textNote: item.textNote, + iteration: item.iteration, lastSyncedAt: new Date().toISOString(), }; const prev = byItemId.get(item.itemId); @@ -1275,6 +1337,101 @@ export async function pushCardFieldsToGithub(opts: { ); } + const fieldGh = parseGithubContext(card.context_json) as { + startAt?: string | null; + estimate?: number | null; + textNote?: string | null; + iteration?: string | null; + }; + + if (meta.startDateFieldId && fieldGh.startAt) { + const date = String(fieldGh.startAt).slice(0, 10); + if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { + await githubGraphql( + accessToken, + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { date: $date } + }) { projectV2Item { id } } + }`, + { + projectId: card.github_project_node_id, + itemId: gh.projectItemId, + fieldId: meta.startDateFieldId, + date, + } + ); + } + } + + if (meta.estimateFieldId && typeof fieldGh.estimate === "number") { + await githubGraphql( + accessToken, + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $number: Float!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { number: $number } + }) { projectV2Item { id } } + }`, + { + projectId: card.github_project_node_id, + itemId: gh.projectItemId, + fieldId: meta.estimateFieldId, + number: fieldGh.estimate, + } + ); + } + + if (meta.textFieldId && typeof fieldGh.textNote === "string") { + await githubGraphql( + accessToken, + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { text: $text } + }) { projectV2Item { id } } + }`, + { + projectId: card.github_project_node_id, + itemId: gh.projectItemId, + fieldId: meta.textFieldId, + text: fieldGh.textNote, + } + ); + } + + if (meta.iterationFieldId && fieldGh.iteration) { + const hit = meta.iterationOptions.find( + (o) => o.title.toLowerCase() === String(fieldGh.iteration).toLowerCase() + ); + if (hit) { + await githubGraphql( + accessToken, + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $iterationId: ID!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { iterationId: $iterationId } + }) { projectV2Item { id } } + }`, + { + projectId: card.github_project_node_id, + itemId: gh.projectItemId, + fieldId: meta.iterationFieldId, + iterationId: hit.id, + } + ); + } + } + if (meta.priorityFieldId) { const optionId = priorityOptionId( Number(card.priority ?? 2), diff --git a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx index 6dc0234..22b1879 100644 --- a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx +++ b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx @@ -158,6 +158,10 @@ type GithubCardMeta = { projectItemId?: string; assignees?: GithubAssignee[]; milestone?: GithubMilestone | null; + startAt?: string | null; + estimate?: number | null; + textNote?: string | null; + iteration?: string | null; }; type GithubCreateMode = "draft" | "issue" | "none"; @@ -201,6 +205,8 @@ type CardFaceVisibility = { assignees: boolean; due: boolean; milestone: boolean; + iteration: boolean; + estimate: boolean; }; const DEFAULT_CARD_FACE: CardFaceVisibility = { @@ -209,6 +215,8 @@ const DEFAULT_CARD_FACE: CardFaceVisibility = { assignees: true, due: true, milestone: true, + iteration: true, + estimate: true, }; function cardFaceStorageKey(boardKey: string) { @@ -510,6 +518,24 @@ function SortableCard({ {dueLabel} ) : null} + {face.iteration && gh.iteration ? ( + + {gh.iteration} + + ) : null} + {face.estimate && gh.estimate != null ? ( + + {gh.estimate} + + ) : null} {subtaskProgress && subtaskProgress.total > 0 ? ( @@ -595,6 +621,10 @@ function CardEditorDialog({ const [assignedAgentId, setAssignedAgentId] = useState(""); const [assigneeLogins, setAssigneeLogins] = useState(""); const [milestoneTitleEdit, setMilestoneTitleEdit] = useState(""); + const [startAt, setStartAt] = useState(""); + const [estimate, setEstimate] = useState(""); + const [textNote, setTextNote] = useState(""); + const [iteration, setIteration] = useState(""); const isReview = card?.column_id === "review"; const ghMeta = useMemo( @@ -685,6 +715,14 @@ function CardEditorDialog({ const meta = parseGithubCardMeta(card.context_json); setAssigneeLogins((meta.assignees ?? []).map((a) => a.login).join(", ")); setMilestoneTitleEdit(meta.milestone?.title ?? ""); + setStartAt(dueInputValue(meta.startAt)); + setEstimate( + typeof meta.estimate === "number" && Number.isFinite(meta.estimate) + ? String(meta.estimate) + : "" + ); + setTextNote(typeof meta.textNote === "string" ? meta.textNote : ""); + setIteration(typeof meta.iteration === "string" ? meta.iteration : ""); setComposer(""); setNewSubtask(""); void reloadSubtasks(); @@ -816,6 +854,7 @@ function CardEditorDialog({ const persist = useCallback(async () => { if (!card) return; + const estimateNum = estimate.trim() === "" ? null : Number(estimate); const githubPatch: Record | undefined = showGithubFields ? { assignees: assigneeLogins @@ -826,6 +865,13 @@ function CardEditorDialog({ milestone: milestoneTitleEdit.trim() ? { title: milestoneTitleEdit.trim() } : null, + startAt: startAt || null, + estimate: + estimateNum != null && Number.isFinite(estimateNum) + ? estimateNum + : null, + textNote: textNote, + iteration: iteration.trim() || null, } : undefined; const patch = { @@ -861,6 +907,10 @@ function CardEditorDialog({ showGithubFields, assigneeLogins, milestoneTitleEdit, + startAt, + estimate, + textNote, + iteration, ]); const toggleTag = (tag: string) => { @@ -1054,6 +1104,59 @@ function CardEditorDialog({ {showGithubFields ? (
+
+ + setStartAt(e.target.value)} + className="h-8 text-xs" + disabled={readOnly} + /> +
+
+ + setEstimate(e.target.value)} + placeholder="Story points / estimate" + className="h-8 text-xs" + disabled={readOnly} + /> +
+
+ + setIteration(e.target.value)} + placeholder="Iteration / sprint title" + className="h-8 text-xs" + disabled={readOnly} + /> +
+
+ +