geekbot-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +517 -0
  3. package/package.json +50 -0
  4. package/scripts/postinstall.mjs +27 -0
  5. package/skills/geekbot/SKILL.md +281 -0
  6. package/skills/geekbot/check-cli.sh +36 -0
  7. package/skills/geekbot/cli-commands.md +382 -0
  8. package/skills/geekbot/error-recovery.md +95 -0
  9. package/skills/geekbot/manager-workflows.md +408 -0
  10. package/skills/geekbot/reporter-workflows.md +275 -0
  11. package/skills/geekbot/standup-templates.json +244 -0
  12. package/src/auth/keychain.ts +38 -0
  13. package/src/auth/resolver.ts +44 -0
  14. package/src/cli/commands/auth.ts +56 -0
  15. package/src/cli/commands/me.ts +34 -0
  16. package/src/cli/commands/poll.ts +91 -0
  17. package/src/cli/commands/report.ts +66 -0
  18. package/src/cli/commands/standup.ts +234 -0
  19. package/src/cli/commands/team.ts +40 -0
  20. package/src/cli/globals.ts +31 -0
  21. package/src/cli/index.ts +94 -0
  22. package/src/errors/cli-error.ts +16 -0
  23. package/src/errors/error-handler.ts +63 -0
  24. package/src/errors/exit-codes.ts +14 -0
  25. package/src/errors/not-found-helper.ts +86 -0
  26. package/src/handlers/auth-handlers.ts +152 -0
  27. package/src/handlers/me-handlers.ts +27 -0
  28. package/src/handlers/poll-handlers.ts +187 -0
  29. package/src/handlers/report-handlers.ts +87 -0
  30. package/src/handlers/standup-handlers.ts +534 -0
  31. package/src/handlers/team-handlers.ts +38 -0
  32. package/src/http/authenticated-client.ts +17 -0
  33. package/src/http/client.ts +138 -0
  34. package/src/http/errors.ts +134 -0
  35. package/src/output/envelope.ts +50 -0
  36. package/src/output/formatter.ts +12 -0
  37. package/src/schemas/common.ts +13 -0
  38. package/src/schemas/poll.ts +89 -0
  39. package/src/schemas/report.ts +124 -0
  40. package/src/schemas/standup.ts +64 -0
  41. package/src/schemas/team.ts +11 -0
  42. package/src/schemas/user.ts +70 -0
  43. package/src/types.ts +30 -0
  44. package/src/utils/constants.ts +24 -0
  45. package/src/utils/input-parsers.ts +234 -0
  46. package/src/utils/receipt.ts +94 -0
  47. package/src/utils/validation.ts +128 -0
@@ -0,0 +1,138 @@
1
+ import { CliError } from "../errors/cli-error.ts";
2
+ import { ExitCode } from "../errors/exit-codes.ts";
3
+ import { API_BASE_URL, APP_VERSION } from "../utils/constants.ts";
4
+ import { getBackoffMs, isRetryable, mapHttpError, parseErrorBody } from "./errors.ts";
5
+
6
+ const MAX_RETRIES = 3;
7
+ const INITIAL_BACKOFF_MS = 1000;
8
+
9
+ export interface HttpClient {
10
+ get<T>(path: string, params?: Record<string, string>): Promise<T>;
11
+ post<T>(path: string, body: unknown): Promise<T>;
12
+ patch<T>(path: string, body: unknown): Promise<T>;
13
+ put<T>(path: string, body: unknown): Promise<T>;
14
+ delete(path: string): Promise<null>;
15
+ }
16
+
17
+ export function createHttpClient(
18
+ apiKey: string,
19
+ options?: { debug?: boolean },
20
+ fetchImpl?: typeof globalThis.fetch,
21
+ ): HttpClient {
22
+ const debug = options?.debug ?? false;
23
+ const _fetch = fetchImpl ?? globalThis.fetch;
24
+
25
+ async function request<T>(
26
+ method: string,
27
+ path: string,
28
+ body?: unknown,
29
+ params?: Record<string, string>,
30
+ ): Promise<T> {
31
+ // Strip trailing slashes to avoid 301 redirects (API quirk)
32
+ const cleanPath = path.replace(/\/+$/, "");
33
+
34
+ let url = `${API_BASE_URL}${cleanPath}`;
35
+ if (params) {
36
+ const searchParams = new URLSearchParams();
37
+ for (const [key, value] of Object.entries(params)) {
38
+ if (value !== undefined && value !== "") {
39
+ searchParams.set(key, value);
40
+ }
41
+ }
42
+ const qs = searchParams.toString();
43
+ if (qs) {
44
+ url = `${url}?${qs}`;
45
+ }
46
+ }
47
+
48
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
49
+ try {
50
+ if (debug && attempt > 0) {
51
+ process.stderr.write(`[debug] Retry attempt ${attempt} for ${method} ${cleanPath}\n`);
52
+ }
53
+
54
+ const response = await _fetch(url, {
55
+ method,
56
+ headers: {
57
+ // Geekbot API expects raw token, NOT "Bearer <token>"
58
+ // WARNING: Authorization contains the raw API key.
59
+ // Never log request/response headers in debug mode.
60
+ Authorization: apiKey,
61
+ "Content-Type": "application/json",
62
+ "User-Agent": `geekbot-skill-cli/${APP_VERSION}`,
63
+ },
64
+ body: body !== undefined ? JSON.stringify(body) : undefined,
65
+ });
66
+
67
+ if (response.ok) {
68
+ // Handle 204 No Content (e.g., DELETE responses)
69
+ if (response.status === 204) {
70
+ return null as T;
71
+ }
72
+ // Runtime type validation is handled by Zod schema parsing at the call site
73
+ return (await response.json()) as T;
74
+ }
75
+
76
+ // Non-OK response -- parse error and decide retry vs throw
77
+ const errorMessage = await parseErrorBody(response);
78
+
79
+ if (debug) {
80
+ process.stderr.write(
81
+ `[debug] HTTP ${response.status} from ${method} ${cleanPath}: ${errorMessage}\n`,
82
+ );
83
+ }
84
+
85
+ if (isRetryable(response.status) && attempt < MAX_RETRIES) {
86
+ const backoff = getBackoffMs(response, attempt, INITIAL_BACKOFF_MS);
87
+ if (debug) {
88
+ process.stderr.write(`[debug] Retrying in ${backoff}ms\n`);
89
+ }
90
+ await Bun.sleep(backoff);
91
+ continue;
92
+ }
93
+
94
+ throw mapHttpError(response.status, errorMessage, cleanPath);
95
+ } catch (error) {
96
+ // Re-throw CliErrors (already mapped)
97
+ if (error instanceof CliError) throw error;
98
+
99
+ // Network-level errors (DNS failure, connection refused, timeout)
100
+ if (attempt < MAX_RETRIES) {
101
+ const backoff = INITIAL_BACKOFF_MS * 2 ** attempt;
102
+ if (debug) {
103
+ process.stderr.write(
104
+ `[debug] Network error (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${error instanceof Error ? error.message : String(error)}\n`,
105
+ );
106
+ }
107
+ await Bun.sleep(backoff);
108
+ continue;
109
+ }
110
+
111
+ throw new CliError(
112
+ `Network error: ${error instanceof Error ? error.message : String(error)}`,
113
+ "network_error",
114
+ ExitCode.NETWORK,
115
+ true,
116
+ "Check your internet connection and try again.",
117
+ );
118
+ }
119
+ }
120
+ // Unreachable: loop always returns or throws, but satisfies TS return check
121
+ throw new CliError(
122
+ "Request failed after all retries",
123
+ "network_error",
124
+ ExitCode.NETWORK,
125
+ true,
126
+ "Check your internet connection and try again.",
127
+ );
128
+ }
129
+
130
+ return {
131
+ get: <T>(path: string, params?: Record<string, string>) =>
132
+ request<T>("GET", path, undefined, params),
133
+ post: <T>(path: string, body: unknown) => request<T>("POST", path, body),
134
+ patch: <T>(path: string, body: unknown) => request<T>("PATCH", path, body),
135
+ put: <T>(path: string, body: unknown) => request<T>("PUT", path, body),
136
+ delete: (path: string) => request<null>("DELETE", path),
137
+ };
138
+ }
@@ -0,0 +1,134 @@
1
+ import { CliError } from "../errors/cli-error.ts";
2
+ import { ExitCode } from "../errors/exit-codes.ts";
3
+
4
+ /**
5
+ * Parse error body from API response.
6
+ * Handles two formats:
7
+ * 1. Object: { "error": "message" }
8
+ * 2. Bare JSON string: "Template not found"
9
+ */
10
+ export async function parseErrorBody(response: Response): Promise<string> {
11
+ const text = await response.text();
12
+ try {
13
+ const json = JSON.parse(text);
14
+ if (typeof json === "object" && json !== null && typeof json.error === "string") {
15
+ return json.error;
16
+ }
17
+ if (typeof json === "string") {
18
+ return json;
19
+ }
20
+ return text;
21
+ } catch {
22
+ return text;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Map HTTP status code + error body to a CliError with correct exit code.
28
+ */
29
+ export function mapHttpError(status: number, message: string, path: string): CliError {
30
+ switch (status) {
31
+ case 400:
32
+ return new CliError(
33
+ message || "Bad request",
34
+ "validation_error",
35
+ ExitCode.VALIDATION,
36
+ false,
37
+ "Check the request parameters and try again.",
38
+ { path, status },
39
+ );
40
+ case 401:
41
+ return new CliError(
42
+ message || "Unauthorized",
43
+ "unauthorized",
44
+ ExitCode.AUTH,
45
+ false,
46
+ "Check your API key (GEEKBOT_API_KEY or --api-key). This error can also mean insufficient permissions for the resource.",
47
+ { path, status },
48
+ );
49
+ case 403:
50
+ return new CliError(
51
+ message || "Forbidden",
52
+ "forbidden",
53
+ ExitCode.FORBIDDEN,
54
+ false,
55
+ "You don't have permission for this operation.",
56
+ { path, status },
57
+ );
58
+ case 404:
59
+ return new CliError(
60
+ message || "Not found",
61
+ "not_found",
62
+ ExitCode.NOT_FOUND,
63
+ false,
64
+ undefined, // suggestion populated by buildNotFoundSuggestion when called from command handlers
65
+ { path, status },
66
+ );
67
+ case 422:
68
+ return new CliError(
69
+ message || "Unprocessable entity",
70
+ "unprocessable",
71
+ ExitCode.VALIDATION,
72
+ false,
73
+ "The request was understood but could not be processed. Check field values.",
74
+ { path, status },
75
+ );
76
+ case 429:
77
+ return new CliError(
78
+ message || "Rate limited",
79
+ "rate_limited",
80
+ ExitCode.API_ERROR,
81
+ true,
82
+ "Rate limited. Wait a moment and try again.",
83
+ { path, status },
84
+ );
85
+ default:
86
+ if (status >= 500) {
87
+ return new CliError(
88
+ message || "Server error",
89
+ "server_error",
90
+ ExitCode.API_ERROR,
91
+ true,
92
+ "Geekbot API server error. Try again later.",
93
+ { path, status },
94
+ );
95
+ }
96
+ return new CliError(
97
+ message || `HTTP ${status}`,
98
+ "api_error",
99
+ ExitCode.API_ERROR,
100
+ false,
101
+ undefined,
102
+ { path, status },
103
+ );
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Check if an HTTP status code is retryable.
109
+ * Only 429 (rate limit) and 5xx (server errors) are retried.
110
+ */
111
+ export function isRetryable(status: number): boolean {
112
+ return status === 429 || status >= 500;
113
+ }
114
+
115
+ /** Maximum backoff delay in milliseconds (60 seconds). */
116
+ export const MAX_BACKOFF_MS = 60_000;
117
+
118
+ /**
119
+ * Calculate backoff delay for a retry attempt.
120
+ * Respects Retry-After header on 429 responses.
121
+ * All values are capped at MAX_BACKOFF_MS to prevent unbounded waits.
122
+ */
123
+ export function getBackoffMs(response: Response, attempt: number, initialMs = 1000): number {
124
+ if (response.status === 429) {
125
+ const retryAfter = response.headers.get("Retry-After");
126
+ if (retryAfter) {
127
+ const seconds = Number.parseInt(retryAfter, 10);
128
+ if (!Number.isNaN(seconds) && seconds > 0) {
129
+ return Math.min(seconds * 1000, MAX_BACKOFF_MS);
130
+ }
131
+ }
132
+ }
133
+ return Math.min(initialMs * 2 ** attempt, MAX_BACKOFF_MS);
134
+ }
@@ -0,0 +1,50 @@
1
+ import type { ErrorObject, FailureEnvelope, SuccessEnvelope } from "../types.ts";
2
+
3
+ /**
4
+ * Create a success envelope for a single item.
5
+ * Shape: { ok: true, data: T, error: null, metadata: { timestamp, ...extra } }
6
+ */
7
+ export function success<T>(data: T, meta?: Record<string, unknown>): SuccessEnvelope<T> {
8
+ return {
9
+ ok: true,
10
+ data,
11
+ error: null,
12
+ metadata: {
13
+ timestamp: new Date().toISOString(),
14
+ ...meta,
15
+ },
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Create a success envelope for a list of items.
21
+ * Shape: { ok: true, data: T[], error: null, metadata: { timestamp, count: N, ...extra } }
22
+ */
23
+ export function successList<T>(data: T[], meta?: Record<string, unknown>): SuccessEnvelope<T[]> {
24
+ return {
25
+ ok: true,
26
+ data,
27
+ error: null,
28
+ metadata: {
29
+ timestamp: new Date().toISOString(),
30
+ count: data.length,
31
+ ...meta,
32
+ },
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Create a failure envelope from an ErrorObject.
38
+ * Shape: { ok: false, data: null, error: ErrorObject, metadata: { timestamp, ...extra } }
39
+ */
40
+ export function failure(error: ErrorObject, meta?: Record<string, unknown>): FailureEnvelope {
41
+ return {
42
+ ok: false,
43
+ data: null,
44
+ error,
45
+ metadata: {
46
+ timestamp: new Date().toISOString(),
47
+ ...meta,
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,12 @@
1
+ import type { OutputEnvelope } from "../types.ts";
2
+
3
+ /**
4
+ * Write the output envelope to stdout as JSON.
5
+ * This is the ONLY function in the entire codebase that writes to stdout.
6
+ *
7
+ * Phase 1 always outputs JSON. Table output will be added in Phase 3 (CLI-07).
8
+ * The format parameter is accepted now for forward compatibility.
9
+ */
10
+ export function writeOutput<T>(envelope: OutputEnvelope<T>, _format: "json" = "json"): void {
11
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
12
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+
3
+ /** Unix timestamp (seconds since epoch) as returned by the API */
4
+ export const UnixTimestampSchema = z.number().int();
5
+
6
+ /** Nullable string that defaults to null */
7
+ export const NullableString = z.string().nullable();
8
+
9
+ /** Days of week abbreviations used by the API */
10
+ export const DayAbbreviation = z.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]);
11
+
12
+ /** IANA timezone string */
13
+ export const TimezoneSchema = z.string();
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+ import { FullUserSchema } from "./user.ts";
3
+
4
+ /** Poll question within a poll */
5
+ const PollQuestionSchema = z.object({
6
+ id: z.number(),
7
+ text: z.string(),
8
+ answer_type: z.string(),
9
+ answer_choices: z.array(z.string()),
10
+ add_own_options: z.boolean(),
11
+ one_option_limit: z.boolean(),
12
+ });
13
+
14
+ /** Poll recurrence settings */
15
+ const PollRecurrenceSchema = z
16
+ .object({
17
+ type: z.string(),
18
+ repeat: z.number().nullable(),
19
+ every: z.string().nullable(),
20
+ day: z.string().nullable(),
21
+ month: z.string().nullable(),
22
+ })
23
+ .nullable();
24
+
25
+ /** Poll schema matching actual Geekbot API response */
26
+ export const PollSchema = z.object({
27
+ id: z.number(),
28
+ name: z.string(),
29
+ time: z.string(),
30
+ timezone: z.string(),
31
+ questions: z.array(PollQuestionSchema),
32
+ users: z.array(FullUserSchema),
33
+ recurrence: PollRecurrenceSchema,
34
+ sync_channel_members: z.boolean(),
35
+ sync_channel: z.string().nullable(),
36
+ dm_mode: z.boolean(),
37
+ anonymous: z.boolean(),
38
+ intro: z.string(),
39
+ creator: FullUserSchema,
40
+ users_total: z.number(),
41
+ paused: z.boolean(),
42
+ });
43
+
44
+ export type Poll = z.output<typeof PollSchema>;
45
+
46
+ /** Poll list schema */
47
+ export const PollListSchema = z.array(PollSchema);
48
+
49
+ /** Poll vote answer within a result */
50
+ const PollVoteAnswerSchema = z.object({
51
+ text: z.string(),
52
+ catergory_id: z.union([z.string(), z.number()]), // API typo preserved, can be "uncategorized" or number
53
+ votes: z.number(),
54
+ percentage: z.number(),
55
+ users: z.array(FullUserSchema).optional(),
56
+ });
57
+
58
+ /** Poll vote result for a specific date */
59
+ const PollVoteResultSchema = z.object({
60
+ date: z.string().nullable(),
61
+ answers: z.array(PollVoteAnswerSchema),
62
+ });
63
+
64
+ /** Poll vote question with aggregated results */
65
+ const PollVoteQuestionSchema = z.object({
66
+ id: z.number(),
67
+ text: z.string(),
68
+ answer_type: z.string(),
69
+ categories: z.array(z.union([z.string(), z.number()])),
70
+ total_responses: z.number(),
71
+ total_responders: z.number(),
72
+ results: z.array(PollVoteResultSchema),
73
+ });
74
+
75
+ /** Poll vote instance */
76
+ const PollVoteInstanceSchema = z.object({
77
+ id: z.number(),
78
+ date: z.string().nullable(),
79
+ answer_count: z.number(),
80
+ });
81
+
82
+ /** Aggregated votes response from GET /v1/polls/{id}/votes */
83
+ export const PollVotesResponseSchema = z.object({
84
+ total_results: z.number(),
85
+ questions: z.array(PollVoteQuestionSchema),
86
+ instances: z.array(PollVoteInstanceSchema),
87
+ });
88
+
89
+ export type PollVotesResponse = z.output<typeof PollVotesResponseSchema>;
@@ -0,0 +1,124 @@
1
+ import { z } from "zod";
2
+ import { CompactUserSchema } from "./user.ts";
3
+
4
+ /** Answer within a report (shared fields between GET and POST responses) */
5
+ const ReportAnswerSchema = z.object({
6
+ id: z.number(),
7
+ question: z.string().optional().default(""),
8
+ question_id: z.number().optional(),
9
+ answer: z.string(),
10
+ answer_type: z.string().optional().default("text"),
11
+ images: z
12
+ .array(
13
+ z.object({
14
+ title: z.string(),
15
+ image_url: z.string(),
16
+ }),
17
+ )
18
+ .optional()
19
+ .default([]),
20
+ color: z.string().optional().default(""),
21
+ });
22
+
23
+ export type ReportAnswer = z.output<typeof ReportAnswerSchema>;
24
+
25
+ /**
26
+ * Timeline report format (GET /v1/reports).
27
+ * API returns: id, slack_ts, standup_id, timestamp, channel, is_anonymous,
28
+ * broadcast_thread, is_confidential, member (CompactUser), questions (answers array)
29
+ */
30
+ const TimelineReportRawSchema = z.object({
31
+ id: z.number(),
32
+ standup_id: z.number(),
33
+ timestamp: z.number(),
34
+ slack_ts: z.string().nullable().optional(),
35
+ channel: z.string().optional().default(""),
36
+ questions: z.array(ReportAnswerSchema),
37
+ member: CompactUserSchema,
38
+ is_anonymous: z.boolean().optional().default(false),
39
+ broadcast_thread: z.boolean().optional().default(false),
40
+ is_confidential: z.boolean().optional().default(false),
41
+ standup_name: z.string().optional().default(""),
42
+ });
43
+
44
+ /**
45
+ * Submitted report format (POST /v1/reports).
46
+ * API returns: id, slack_ts, standup_id, timestamp, started_at, done_at,
47
+ * broadcasted_at, channel, member (CompactUser with role), answers (array)
48
+ */
49
+ const SubmittedReportRawSchema = z.object({
50
+ id: z.number(),
51
+ standup_id: z.number(),
52
+ timestamp: z.number(),
53
+ slack_ts: z.string().nullable().optional(),
54
+ started_at: z.number().optional(),
55
+ done_at: z.number().optional(),
56
+ broadcasted_at: z.number().nullable().optional(),
57
+ channel: z.string().optional().default(""),
58
+ member: z
59
+ .object({
60
+ id: z.string(),
61
+ role: z.string().optional(),
62
+ username: z.string(),
63
+ realname: z.string().nullable(),
64
+ profileImg: z.string(),
65
+ })
66
+ .optional(),
67
+ answers: z.array(ReportAnswerSchema),
68
+ is_anonymous: z.boolean().optional().default(false),
69
+ });
70
+
71
+ /**
72
+ * Unified Report type. Both formats normalize to this shape.
73
+ * NORM-03: Two different API response formats -> one consistent type.
74
+ */
75
+ export interface Report {
76
+ id: number;
77
+ standup_id: number;
78
+ created_at: string;
79
+ questions: ReportAnswer[];
80
+ member: { id: string; username: string; realname: string | null; profile_img: string } | null;
81
+ is_anonymous: boolean;
82
+ standup_name: string;
83
+ }
84
+
85
+ /** Normalize a raw timeline report to the unified Report shape */
86
+ function normalizeTimelineReport(raw: z.output<typeof TimelineReportRawSchema>): Report {
87
+ return {
88
+ id: raw.id,
89
+ standup_id: raw.standup_id,
90
+ created_at: new Date(raw.timestamp * 1000).toISOString(),
91
+ questions: raw.questions,
92
+ member: raw.member, // Already normalized by CompactUserSchema (profileImg -> profile_img)
93
+ is_anonymous: raw.is_anonymous,
94
+ standup_name: raw.standup_name,
95
+ };
96
+ }
97
+
98
+ /** Parse a timeline report (GET) and normalize to unified Report */
99
+ export const TimelineReportSchema = TimelineReportRawSchema.transform(normalizeTimelineReport);
100
+
101
+ /** Parse a submitted report (POST) and normalize to unified Report */
102
+ export const SubmittedReportSchema = SubmittedReportRawSchema.transform(
103
+ (raw): Report => ({
104
+ id: raw.id,
105
+ standup_id: raw.standup_id,
106
+ created_at: new Date(raw.timestamp * 1000).toISOString(),
107
+ questions: raw.answers, // POST uses "answers" key, normalize to "questions"
108
+ member: raw.member
109
+ ? {
110
+ id: raw.member.id,
111
+ username: raw.member.username,
112
+ realname: raw.member.realname,
113
+ profile_img: raw.member.profileImg,
114
+ }
115
+ : null,
116
+ is_anonymous: raw.is_anonymous,
117
+ standup_name: "",
118
+ }),
119
+ );
120
+
121
+ /** Parse a list of timeline reports */
122
+ export const TimelineReportListSchema = z
123
+ .array(TimelineReportRawSchema)
124
+ .transform((items) => items.map(normalizeTimelineReport));
@@ -0,0 +1,64 @@
1
+ import { z } from "zod";
2
+ import { DayAbbreviation, NullableString, TimezoneSchema } from "./common.ts";
3
+ import { FullUserSchema } from "./user.ts";
4
+
5
+ export const QuestionSchema = z.object({
6
+ id: z.number(),
7
+ color: z.string(),
8
+ text: z.string(),
9
+ schedule: z.unknown().nullable(),
10
+ answer_type: z.string(),
11
+ answer_choices: z.array(z.string()),
12
+ hasAnswers: z.boolean(),
13
+ is_random: z.boolean(),
14
+ random_texts: z.array(z.string()),
15
+ prefilled_by: z.unknown().nullable(),
16
+ text_id: z.number().nullable(),
17
+ preconditions: z.array(z.unknown()),
18
+ label: NullableString,
19
+ flavor: z.string(),
20
+ });
21
+
22
+ export type Question = z.output<typeof QuestionSchema>;
23
+
24
+ /** Raw standup schema as returned by the API (wait_time in seconds) */
25
+ const StandupRawSchema = z.object({
26
+ id: z.number(),
27
+ name: z.string(),
28
+ channel: z.string(),
29
+ channel_id: z.string().optional(),
30
+ time: z.string(),
31
+ timezone: TimezoneSchema,
32
+ days: z.array(DayAbbreviation),
33
+ questions: z.array(QuestionSchema),
34
+ users: z.array(FullUserSchema),
35
+ wait_time: z.number(), // API returns seconds
36
+ personalised: z.boolean().optional(),
37
+ confidential: z.boolean(),
38
+ anonymous: z.boolean(),
39
+ sync_channel_members: z.boolean().optional(),
40
+ master_id: z.number().nullable().optional(),
41
+ channel_ready: z.boolean().optional(),
42
+ draft: z.boolean().optional(),
43
+ paused: z.boolean().optional(),
44
+ users_total: z.number().optional(),
45
+ webhooks: z.array(z.unknown()).optional(),
46
+ master: z.unknown().nullable().optional(),
47
+ sync_channel_ready: z.boolean().optional(),
48
+ sync_channel: z.string().nullable().optional(),
49
+ });
50
+
51
+ /** Normalize a raw standup: convert wait_time from seconds to minutes */
52
+ function normalizeStandup(raw: z.output<typeof StandupRawSchema>) {
53
+ return { ...raw, wait_time: raw.wait_time / 60 }; // NORM: seconds -> minutes (Pitfall 1)
54
+ }
55
+
56
+ /** Normalized standup -- wait_time converted from seconds to minutes */
57
+ export const StandupSchema = StandupRawSchema.transform(normalizeStandup);
58
+
59
+ export type Standup = z.output<typeof StandupSchema>;
60
+
61
+ /** Schema for an array of standups */
62
+ export const StandupListSchema = z
63
+ .array(StandupRawSchema)
64
+ .transform((items) => items.map(normalizeStandup));
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ import { FullUserSchema } from "./user.ts";
3
+
4
+ /** GET /v1/teams returns a single team object (not array) */
5
+ export const TeamResponseSchema = z.object({
6
+ id: z.number(),
7
+ name: z.string(),
8
+ users: z.array(FullUserSchema),
9
+ });
10
+
11
+ export type TeamResponse = z.output<typeof TeamResponseSchema>;