All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 31s
Confirmation preview enumerates every side-effect (plan-level label creation, assignee diff, bucket move, checklist truncation) so nothing happens that wasn't shown on the card. Explicit-but-missing labels trigger a 3-button choice (register / drop / cancel) since creating them mutates the Plan's categoryDescriptions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { Client } from "@microsoft/microsoft-graph-client";
|
|
import { PlanContext, Priority, Progress, RecentTaskContext } from "../llm/types";
|
|
|
|
/**
|
|
* Thin wrapper around the Graph Planner endpoints we actually use.
|
|
*
|
|
* IMPORTANT: Planner write endpoints require the resource's @odata.etag in
|
|
* an If-Match header. We always re-fetch immediately before PATCH/DELETE
|
|
* to grab a fresh ETag.
|
|
*/
|
|
export class PlannerClient {
|
|
constructor(private readonly graph: Client) {}
|
|
|
|
/** All plans the signed-in user can see, with buckets/members/labels pre-fetched.
|
|
* Plans whose title contains "deprecated" (case-insensitive) are filtered out
|
|
* so old/archived plans don't pollute the LLM context.
|
|
*/
|
|
async listPlansWithBuckets(): Promise<PlanContext[]> {
|
|
const [plansResp, me] = await Promise.all([
|
|
this.graph.api("/me/planner/plans").get(),
|
|
this.graph.api("/me").select(["id"]).get(),
|
|
]);
|
|
|
|
const meId: string = me.id;
|
|
const plans: Array<{ id: string; title: string; owner?: string }> = (plansResp.value ?? [])
|
|
.filter((p: { title?: string }) => !/deprecated/i.test(p.title ?? ""));
|
|
|
|
const enriched = await Promise.all(
|
|
plans.map(async (p) => {
|
|
// Buckets + plan-details + group-members in parallel.
|
|
const [bucketsResp, planDetails, groupMembers] = await Promise.all([
|
|
this.graph.api(`/planner/plans/${p.id}/buckets`).get(),
|
|
this.graph
|
|
.api(`/planner/plans/${p.id}/details`)
|
|
.get()
|
|
.catch(() => ({ categoryDescriptions: {} })),
|
|
this.fetchPlanMembers(p.id, p.owner),
|
|
]);
|
|
|
|
const buckets: Array<{ id: string; name: string }> = bucketsResp.value ?? [];
|
|
const cd = (planDetails.categoryDescriptions ?? {}) as Record<string, string | null>;
|
|
const labels = Object.entries(cd)
|
|
.map(([key, name]) => ({ key, name }))
|
|
.filter((x): x is { key: string; name: string } => typeof x.name === "string" && x.name.length > 0)
|
|
.map((x) => ({ slot: slotFromKey(x.key), name: x.name }))
|
|
.filter((l) => l.slot > 0);
|
|
|
|
return {
|
|
planId: p.id,
|
|
planTitle: p.title,
|
|
buckets: buckets.map((b) => ({ bucketId: b.id, bucketTitle: b.name })),
|
|
members: groupMembers.map((m) => ({
|
|
userId: m.id,
|
|
displayName: m.displayName,
|
|
...(m.id === meId ? { isMe: true as const } : {}),
|
|
})),
|
|
labels,
|
|
} satisfies PlanContext;
|
|
}),
|
|
);
|
|
|
|
return enriched;
|
|
}
|
|
|
|
/** Resolve owning M365 group's members. Falls back gracefully if owner missing. */
|
|
private async fetchPlanMembers(
|
|
planId: string,
|
|
ownerGroupId?: string,
|
|
): Promise<Array<{ id: string; displayName: string }>> {
|
|
let groupId = ownerGroupId;
|
|
if (!groupId) {
|
|
const plan = await this.graph
|
|
.api(`/planner/plans/${planId}`)
|
|
.select(["id", "owner"])
|
|
.get()
|
|
.catch(() => undefined);
|
|
groupId = plan?.owner;
|
|
}
|
|
if (!groupId) return [];
|
|
|
|
const resp = await this.graph
|
|
.api(`/groups/${groupId}/members`)
|
|
.select(["id", "displayName"])
|
|
.get()
|
|
.catch(() => ({ value: [] }));
|
|
|
|
type DirObj = { id?: string; displayName?: string; "@odata.type"?: string };
|
|
const users: Array<{ id: string; displayName: string }> = (resp.value ?? [])
|
|
.filter((m: DirObj) => !m["@odata.type"] || m["@odata.type"] === "#microsoft.graph.user")
|
|
.filter((m: DirObj): m is { id: string; displayName: string } =>
|
|
typeof m.id === "string" && typeof m.displayName === "string" && m.displayName.length > 0,
|
|
);
|
|
return users;
|
|
}
|
|
|
|
/** Recent (non-completed) tasks across the user's plans, capped. */
|
|
async listRecentTasks(limit = 20): Promise<RecentTaskContext[]> {
|
|
const resp = await this.graph.api("/me/planner/tasks").get();
|
|
type RawTask = {
|
|
id: string;
|
|
title: string;
|
|
planId: string;
|
|
bucketId: string;
|
|
percentComplete: number;
|
|
priority?: number;
|
|
assignments?: Record<string, unknown>;
|
|
createdDateTime: string;
|
|
};
|
|
const tasks: RawTask[] = resp.value ?? [];
|
|
|
|
return tasks
|
|
.filter((t) => t.percentComplete < 100)
|
|
.sort((a, b) => (a.createdDateTime < b.createdDateTime ? 1 : -1))
|
|
.slice(0, limit)
|
|
.map<RecentTaskContext>((t) => ({
|
|
taskId: t.id,
|
|
title: t.title,
|
|
planId: t.planId,
|
|
bucketId: t.bucketId,
|
|
percentComplete: t.percentComplete,
|
|
priority: priorityFromInt(t.priority),
|
|
assigneeIds: t.assignments ? Object.keys(t.assignments) : undefined,
|
|
}));
|
|
}
|
|
|
|
async createTask(args: {
|
|
planId: string;
|
|
bucketId: string;
|
|
title: string;
|
|
progress?: Progress;
|
|
priority?: Priority;
|
|
startDate?: string;
|
|
dueDate?: string;
|
|
description?: string;
|
|
assigneeUserIds?: string[];
|
|
/** Plan-label slots (1..25) to apply. */
|
|
categorySlots?: number[];
|
|
checklistItems?: string[];
|
|
}): Promise<{ taskId: string; checklistOverflow: number }> {
|
|
const body: Record<string, unknown> = {
|
|
planId: args.planId,
|
|
bucketId: args.bucketId,
|
|
title: args.title,
|
|
percentComplete: progressToPercent(args.progress) ?? 0,
|
|
};
|
|
if (args.startDate) body.startDateTime = toIsoStartOfDay(args.startDate);
|
|
if (args.dueDate) body.dueDateTime = toIsoEod(args.dueDate);
|
|
const priorityInt = priorityToInt(args.priority);
|
|
if (priorityInt !== undefined) body.priority = priorityInt;
|
|
if (args.assigneeUserIds && args.assigneeUserIds.length) {
|
|
body.assignments = buildAssignmentsForCreate(args.assigneeUserIds);
|
|
}
|
|
if (args.categorySlots && args.categorySlots.length) {
|
|
body.appliedCategories = buildAppliedCategories(args.categorySlots);
|
|
}
|
|
|
|
const created = await this.graph.api("/planner/tasks").post(body);
|
|
const taskId: string = created.id;
|
|
|
|
// Combine description + checklist into a single details PATCH.
|
|
const checklistItems = (args.checklistItems ?? []).slice(0, 20);
|
|
const checklistOverflow = Math.max(0, (args.checklistItems?.length ?? 0) - 20);
|
|
if (args.description || checklistItems.length > 0) {
|
|
const detailsPatch: Record<string, unknown> = {};
|
|
if (args.description) detailsPatch.description = args.description;
|
|
if (checklistItems.length > 0) detailsPatch.checklist = buildChecklist(checklistItems);
|
|
|
|
const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get();
|
|
const etag: string = details["@odata.etag"];
|
|
await this.graph
|
|
.api(`/planner/tasks/${taskId}/details`)
|
|
.header("If-Match", etag)
|
|
.header("Prefer", "return=representation")
|
|
.patch(detailsPatch);
|
|
}
|
|
|
|
return { taskId, checklistOverflow };
|
|
}
|
|
|
|
async updateTask(args: {
|
|
taskId: string;
|
|
newBucketId?: string;
|
|
progress?: Progress;
|
|
percentComplete?: number;
|
|
priority?: Priority;
|
|
startDate?: string;
|
|
dueDate?: string;
|
|
newTitle?: string;
|
|
/** When provided, this is the full new set of assignees — old ones are removed. */
|
|
assigneeUserIds?: string[];
|
|
currentAssigneeIds?: string[];
|
|
/** Plan-label slots (1..25) to apply (full set after this update). */
|
|
categorySlots?: number[];
|
|
currentCategorySlots?: number[];
|
|
}): Promise<void> {
|
|
const current = await this.graph.api(`/planner/tasks/${args.taskId}`).get();
|
|
const etag: string = current["@odata.etag"];
|
|
|
|
const patch: Record<string, unknown> = {};
|
|
const pct = args.percentComplete ?? progressToPercent(args.progress);
|
|
if (pct !== undefined) patch.percentComplete = pct;
|
|
if (args.newTitle) patch.title = args.newTitle;
|
|
if (args.startDate) patch.startDateTime = toIsoStartOfDay(args.startDate);
|
|
if (args.dueDate) patch.dueDateTime = toIsoEod(args.dueDate);
|
|
if (args.newBucketId) patch.bucketId = args.newBucketId;
|
|
const priorityInt = priorityToInt(args.priority);
|
|
if (priorityInt !== undefined) patch.priority = priorityInt;
|
|
|
|
if (args.assigneeUserIds) {
|
|
patch.assignments = buildAssignmentsForReplace(
|
|
args.currentAssigneeIds ?? [],
|
|
args.assigneeUserIds,
|
|
);
|
|
}
|
|
|
|
if (args.categorySlots) {
|
|
patch.appliedCategories = buildAppliedCategoriesDiff(
|
|
args.currentCategorySlots ?? [],
|
|
args.categorySlots,
|
|
);
|
|
}
|
|
|
|
if (Object.keys(patch).length === 0) return;
|
|
|
|
await this.graph
|
|
.api(`/planner/tasks/${args.taskId}`)
|
|
.header("If-Match", etag)
|
|
.patch(patch);
|
|
}
|
|
|
|
/** Append items to a task's existing checklist on its /details resource.
|
|
* Returns how many items overflowed (Planner caps at 20). */
|
|
async appendChecklist(taskId: string, items: string[]): Promise<{ overflow: number }> {
|
|
if (items.length === 0) return { overflow: 0 };
|
|
const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get();
|
|
const etag: string = details["@odata.etag"];
|
|
const existing = (details.checklist ?? {}) as Record<string, unknown>;
|
|
const remainingCapacity = Math.max(0, 20 - Object.keys(existing).length);
|
|
const toAdd = items.slice(0, remainingCapacity);
|
|
const overflow = items.length - toAdd.length;
|
|
if (toAdd.length === 0) return { overflow };
|
|
|
|
const additions = buildChecklist(toAdd);
|
|
await this.graph
|
|
.api(`/planner/tasks/${taskId}/details`)
|
|
.header("If-Match", etag)
|
|
.patch({ checklist: additions });
|
|
return { overflow };
|
|
}
|
|
|
|
async appendTaskNote(taskId: string, note: string): Promise<void> {
|
|
const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get();
|
|
const etag: string = details["@odata.etag"];
|
|
const previous: string = details.description ?? "";
|
|
const stamp = new Date().toISOString().slice(0, 16).replace("T", " ");
|
|
const combined = previous
|
|
? `${previous}\n[${stamp}] ${note}`
|
|
: `[${stamp}] ${note}`;
|
|
await this.graph
|
|
.api(`/planner/tasks/${taskId}/details`)
|
|
.header("If-Match", etag)
|
|
.patch({ description: combined });
|
|
}
|
|
|
|
/** Ensure a plan-level label exists with the given name. Returns its slot
|
|
* (1..25). Picks the lowest empty slot for new labels. Throws if 25 slots
|
|
* are all taken. */
|
|
async ensurePlanLabel(planId: string, name: string): Promise<number> {
|
|
const details = await this.graph.api(`/planner/plans/${planId}/details`).get();
|
|
const etag: string = details["@odata.etag"];
|
|
const cd = (details.categoryDescriptions ?? {}) as Record<string, string | null>;
|
|
|
|
for (let slot = 1; slot <= 25; slot++) {
|
|
const key = `category${slot}`;
|
|
if (cd[key] && cd[key].trim().toLowerCase() === name.trim().toLowerCase()) {
|
|
return slot;
|
|
}
|
|
}
|
|
let emptySlot: number | null = null;
|
|
for (let slot = 1; slot <= 25; slot++) {
|
|
const key = `category${slot}`;
|
|
if (!cd[key]) {
|
|
emptySlot = slot;
|
|
break;
|
|
}
|
|
}
|
|
if (emptySlot === null) {
|
|
throw new Error(`Plan 의 라벨 슬롯이 25개 모두 차있어 '${name}' 을 등록할 수 없어요.`);
|
|
}
|
|
await this.graph
|
|
.api(`/planner/plans/${planId}/details`)
|
|
.header("If-Match", etag)
|
|
.patch({ categoryDescriptions: { [`category${emptySlot}`]: name } });
|
|
return emptySlot;
|
|
}
|
|
}
|
|
|
|
function progressToPercent(p?: Progress): number | undefined {
|
|
switch (p) {
|
|
case "notStarted":
|
|
return 0;
|
|
case "inProgress":
|
|
return 50;
|
|
case "completed":
|
|
return 100;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function priorityToInt(p?: Priority): number | undefined {
|
|
switch (p) {
|
|
case "urgent":
|
|
return 1;
|
|
case "important":
|
|
return 3;
|
|
case "medium":
|
|
return 5;
|
|
case "low":
|
|
return 9;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function priorityFromInt(n?: number): Priority | undefined {
|
|
if (n === undefined) return undefined;
|
|
if (n <= 1) return "urgent";
|
|
if (n <= 3) return "important";
|
|
if (n <= 5) return "medium";
|
|
return "low";
|
|
}
|
|
|
|
function buildAssignmentsForCreate(userIds: string[]): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
for (const id of userIds) {
|
|
out[id] = {
|
|
"@odata.type": "#microsoft.graph.plannerAssignment",
|
|
orderHint: " !",
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Build an assignments PATCH that removes old assignees (set to null) and
|
|
* adds the new ones, preserving any user who is in both sets. */
|
|
function buildAssignmentsForReplace(
|
|
currentIds: string[],
|
|
newIds: string[],
|
|
): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
const current = new Set(currentIds);
|
|
const target = new Set(newIds);
|
|
for (const id of current) {
|
|
if (!target.has(id)) out[id] = null;
|
|
}
|
|
for (const id of target) {
|
|
if (!current.has(id)) {
|
|
out[id] = {
|
|
"@odata.type": "#microsoft.graph.plannerAssignment",
|
|
orderHint: " !",
|
|
};
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildAppliedCategories(slots: number[]): Record<string, boolean> {
|
|
const out: Record<string, boolean> = {};
|
|
for (const s of slots) {
|
|
if (s >= 1 && s <= 25) out[`category${s}`] = true;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Diff appliedCategories so we explicitly set removed slots to false. */
|
|
function buildAppliedCategoriesDiff(
|
|
currentSlots: number[],
|
|
newSlots: number[],
|
|
): Record<string, boolean> {
|
|
const out: Record<string, boolean> = {};
|
|
const target = new Set(newSlots);
|
|
for (const s of currentSlots) {
|
|
if (!target.has(s)) out[`category${s}`] = false;
|
|
}
|
|
for (const s of newSlots) {
|
|
if (s >= 1 && s <= 25) out[`category${s}`] = true;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildChecklist(items: string[]): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
for (const title of items) {
|
|
out[randomUUID()] = {
|
|
"@odata.type": "#microsoft.graph.plannerChecklistItem",
|
|
title,
|
|
isChecked: false,
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function slotFromKey(key: string): number {
|
|
const m = /^category(\d+)$/.exec(key);
|
|
return m ? Number(m[1]) : 0;
|
|
}
|
|
|
|
function toIsoEod(yyyyMmDd: string): string {
|
|
return `${yyyyMmDd}T23:59:59Z`;
|
|
}
|
|
|
|
function toIsoStartOfDay(yyyyMmDd: string): string {
|
|
return `${yyyyMmDd}T00:00:00Z`;
|
|
}
|