Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/bridge/src/services/__tests__/github-projects-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 58 additions & 0 deletions apps/bridge/src/services/github-projects-fields.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
185 changes: 171 additions & 14 deletions apps/bridge/src/services/github-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = {
Expand Down Expand Up @@ -65,7 +79,7 @@ type ProjectItem = {
issueNumber: number | null;
repo: string | null;
contentId: string | null;
};
} & ExtraProjectFields;

const DEFAULT_STATUS_ALIASES: Record<string, string[]> = {
backlog: ["todo", "backlog", "new", "triage"],
Expand Down Expand Up @@ -267,6 +281,9 @@ async function loadProjectMeta(
name?: string;
options?: StatusOption[];
dataType?: string;
configuration?: {
iterations?: Array<{ id: string; title: string }>;
};
}>;
};
} | null;
Expand All @@ -283,6 +300,12 @@ async function loadProjectMeta(
id name dataType
options { id name }
}
... on ProjectV2IterationField {
id name dataType
configuration {
iterations { id title }
}
}
}
}
}
Expand All @@ -303,23 +326,36 @@ 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,
url: data.node.url,
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 ?? [],
};
}

Expand Down Expand Up @@ -367,6 +403,8 @@ async function fetchProjectItems(
name?: string;
date?: string;
text?: string;
number?: number;
title?: string;
field?: { name?: string };
}>;
};
Expand Down Expand Up @@ -408,7 +446,7 @@ async function fetchProjectItems(
pageInfo { hasNextPage endCursor }
nodes {
id
fieldValues(first: 20) {
fieldValues(first: 30) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
Expand All @@ -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 {
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading