From 28b6ee181720c228cb21480c1110e5578ac814d1 Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 01:08:31 +0200 Subject: [PATCH 01/15] Rules for Cursor, file name changes --- .cursor/rules/api-client-patterns.mdc | 79 +++++++++++++++ .cursor/rules/data-validation.mdc | 124 +++++++++++++++++++++++ .cursor/rules/development-workflow.mdc | 87 ++++++++++++++++ .cursor/rules/error-handling.mdc | 81 +++++++++++++++ .cursor/rules/mcp-tool-patterns.mdc | 80 +++++++++++++++ .cursor/rules/project-structure.mdc | 32 ++++++ .cursor/rules/typescript-conventions.mdc | 41 ++++++++ package-lock.json | 7 +- 8 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 .cursor/rules/api-client-patterns.mdc create mode 100644 .cursor/rules/data-validation.mdc create mode 100644 .cursor/rules/development-workflow.mdc create mode 100644 .cursor/rules/error-handling.mdc create mode 100644 .cursor/rules/mcp-tool-patterns.mdc create mode 100644 .cursor/rules/project-structure.mdc create mode 100644 .cursor/rules/typescript-conventions.mdc diff --git a/.cursor/rules/api-client-patterns.mdc b/.cursor/rules/api-client-patterns.mdc new file mode 100644 index 0000000..26b3f4a --- /dev/null +++ b/.cursor/rules/api-client-patterns.mdc @@ -0,0 +1,79 @@ +# API Client Patterns + +## Client Structure + +The [src/api/client.ts](mdc:src/api/client.ts) implements the `MotionApiClient` class with: + +- **Axios instance** with base configuration +- **Rate limiting** using `p-queue` (configurable via environment) +- **Error handling** with proper HTTP status interpretation +- **Request queuing** to respect Motion API limits + +## HTTP Methods + +- **GET**: List and retrieve operations +- **POST**: Create operations +- **PATCH**: Update operations +- **DELETE**: Delete operations + +## Rate Limiting + +- Default: **12 requests per minute** (configurable via `MOTION_RATE_LIMIT_PER_MINUTE`) +- Uses **p-queue** with `concurrency: 1` and `intervalCap` +- All requests go through the rate-limited queue + +## Error Handling Pattern + +```typescript +if (status === 429) { + throw new Error(`Rate limit exceeded. Please try again later. ${message}`); +} else if (status === 401) { + throw new Error(`Authentication failed. Check your API key. ${message}`); +} else if (status === 404) { + throw new Error(`Resource not found. ${message}`); +} +``` + +## Request Method Pattern + +```typescript +private async request(method: string, path: string, data?: any, params?: any): Promise { + return this.queue.add(async () => { + const response = await this.axios.request({ + method, + url: path, + data, + params, + }); + return response.data; + }) as Promise; +} +``` + +## API Method Patterns + +- **List methods**: Accept optional filter parameters, return arrays +- **Get methods**: Take ID parameter, return single object +- **Create methods**: Take creation parameters, return created object +- **Update methods**: Take ID + update parameters, return updated object +- **Delete methods**: Take ID parameter, return void + +## Type Safety + +- All methods use **generic types** with Motion interfaces +- Parameters use **specific interfaces** (e.g., `MotionTaskCreateParams`) +- Return types match **Motion API response structures** + +## Configuration + +Configuration is loaded from [src/config.ts](mdc:src/config.ts): + +```typescript +{ + apiKey: string; // MOTION_API_KEY (required) + baseUrl: string; // MOTION_API_BASE_URL (optional) + rateLimitPerMinute: number; // MOTION_RATE_LIMIT_PER_MINUTE (optional) +} +``` +description: API client implementation patterns +--- diff --git a/.cursor/rules/data-validation.mdc b/.cursor/rules/data-validation.mdc new file mode 100644 index 0000000..d6a71bf --- /dev/null +++ b/.cursor/rules/data-validation.mdc @@ -0,0 +1,124 @@ +# Data Validation Patterns + +## Zod Schema Patterns + +All tool handlers use **Zod schemas** for runtime validation following these patterns: + +### Basic Types + +```typescript +const schema = z.object({ + id: z.string().min(1), // Required string + name: z.string().optional(), // Optional string + count: z.number().min(0), // Number with constraint + enabled: z.boolean(), // Boolean + items: z.array(z.string()), // Array of strings +}); +``` + +### Motion-Specific Patterns + +```typescript +// Task priorities +priority: z.enum(['ASAP', 'HIGH', 'MEDIUM', 'LOW']).optional() + +// Deadline types +deadlineType: z.enum(['HARD', 'SOFT', 'NONE']).optional() + +// Duration (string or number) +duration: z.union([z.string(), z.number()]).optional() + +// ISO date strings +dueDate: z.string().optional() + +// Array of status strings +status: z.array(z.string()).optional() +``` + +### Complex Objects + +```typescript +// Auto-scheduling configuration +autoScheduled: z.union([ + z.object({ + startDate: z.string(), + deadlineType: z.enum(['HARD', 'SOFT', 'NONE']).optional(), + schedule: z.string().optional(), + }), + z.null(), +]).optional() +``` + +## Validation Workflow + +1. **Define schema** at the top of handler function +2. **Parse input** with `schema.parse(args)` +3. **Use validated data** with full TypeScript support +4. **Let errors bubble** up to MCP error handler + +```typescript +handler: async (args: unknown) => { + const schema = z.object({ + taskId: z.string().min(1), + name: z.string().optional(), + }); + + const validated = schema.parse(args); + return await client.updateTask(validated.taskId, validated); +} +``` + +## Common Validation Patterns + +### ID Validation +```typescript +taskId: z.string().min(1) // Non-empty string +workspaceId: z.string().min(1) // Non-empty string +``` + +### Optional Parameters +```typescript +cursor: z.string().optional() // Pagination cursor +includeAllStatuses: z.boolean().optional() // Filter flag +``` + +### Filter Arrays +```typescript +labels: z.array(z.string()).optional() // Array of label names +assignees: z.array(z.string()).optional() // Array of user IDs +``` + +## Schema Reuse + +For complex schemas, consider extracting to constants: + +```typescript +const CreateTaskSchema = z.object({ + name: z.string().min(1), + workspaceId: z.string().min(1), + // ... other fields +}); + +const UpdateTaskSchema = CreateTaskSchema.partial().extend({ + taskId: z.string().min(1), +}); +``` + +## Motion Type Integration + +Validation schemas should align with Motion types from [src/types/motion.ts](mdc:src/types/motion.ts): + +- Use **same field names** as Motion interfaces +- Apply **same constraints** as API requirements +- Support **same optional/required** patterns + +## Error Messages + +Zod provides detailed error messages automatically. For custom validation: + +```typescript +z.string().min(1, "Task name cannot be empty") +z.number().min(0, "Duration must be positive") +``` +description: Data validation patterns using Zod +--- diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc new file mode 100644 index 0000000..5e89e40 --- /dev/null +++ b/.cursor/rules/development-workflow.mdc @@ -0,0 +1,87 @@ +# Development Workflow + +## Available Scripts + +From [package.json](mdc:package.json): + +- `npm run build` - Compile TypeScript to `dist/` +- `npm run dev` - Watch mode development with `tsx` +- `npm start` - Run compiled server from `dist/` +- `npm run typecheck` - Type checking without compilation +- `npm run lint` - ESLint validation +- `npm run format` - Prettier code formatting + +## Environment Setup + +Required environment variables: + +```bash +MOTION_API_KEY=your_api_key_here +MOTION_API_BASE_URL=https://api.usemotion.com/v1 # optional +MOTION_RATE_LIMIT_PER_MINUTE=12 # optional +``` + +## Development Server + +Run in development mode: + +```bash +npm run dev +``` + +This uses `tsx watch` to automatically restart on file changes. + +## Testing with MCP + +To test the MCP server: + +1. **Build the project**: `npm run build` +2. **Run the server**: `npm start` +3. **Test with MCP client** or integration + +## Adding New Tools + +1. **Create tool file** in `src/tools/` (e.g., `newFeature.ts`) +2. **Export register function**: `export function registerNewFeatureTools(client: MotionApiClient): Tool[]` +3. **Register in main server**: Add to imports and tools array in [src/index.ts](mdc:src/index.ts) +4. **Follow existing patterns** from [src/tools/task.ts](mdc:src/tools/task.ts) + +## Code Quality + +- **Type safety**: Use TypeScript strictly, no `any` types +- **Validation**: Zod schemas for all inputs +- **Error handling**: Proper error messages and types +- **Formatting**: Prettier with [.prettierrc](mdc:.prettierrc) config +- **Linting**: ESLint with [.eslintrc.json](mdc:.eslintrc.json) config + +## Debugging + +- Use `console.error()` for debugging (goes to stderr) +- Check Motion API documentation for parameter requirements +- Test rate limiting with multiple rapid requests +- Verify environment variables are loaded correctly + +## API Documentation + +Reference the Motion API documentation and [docs/MOTION_API_REFERENCE.md](mdc:docs/MOTION_API_REFERENCE.md) for: + +- **Endpoint specifications** +- **Parameter requirements** +- **Response formats** +- **Rate limiting details** + +## Common Issues + +1. **Rate limiting**: Respect the 12 requests/minute default limit +2. **Authentication**: Ensure `MOTION_API_KEY` is set correctly +3. **Validation**: Zod schemas must match Motion API requirements +4. **ESM imports**: Use `.js` extensions in import statements + +## Build Process + +- **TypeScript compilation** to `dist/` directory +- **Preserve file structure** from `src/` +- **Include type definitions** for better IDE support +- **Executable binary** at `dist/index.js` +description: Development workflow, testing, and debugging patterns +--- diff --git a/.cursor/rules/error-handling.mdc b/.cursor/rules/error-handling.mdc new file mode 100644 index 0000000..44979f9 --- /dev/null +++ b/.cursor/rules/error-handling.mdc @@ -0,0 +1,81 @@ +# Error Handling Patterns + +## MCP Error Types + +Use `McpError` from `@modelcontextprotocol/sdk/types.js` for MCP-specific errors: + +```typescript +import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; + +// Tool not found +throw new McpError(ErrorCode.MethodNotFound, `Tool ${toolName} not found`); + +// Tool execution failed +throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message}`); +``` + +## API Client Error Handling + +The API client in [src/api/client.ts](mdc:src/api/client.ts) handles common HTTP errors: + +- **429 (Rate Limited)**: "Rate limit exceeded. Please try again later." +- **401 (Unauthorized)**: "Authentication failed. Check your API key." +- **404 (Not Found)**: "Resource not found." +- **Other errors**: Generic API error with status code + +## Validation Error Handling + +Use Zod for input validation in tool handlers: + +```typescript +try { + const validated = schema.parse(args); + // ... use validated data +} catch (error) { + // Zod errors will be caught by MCP error handler + throw error; +} +``` + +## Configuration Error Handling + +Configuration errors are thrown immediately in [src/config.ts](mdc:src/config.ts): + +```typescript +if (!apiKey) { + throw new Error('MOTION_API_KEY environment variable is required'); +} +``` + +## Server Error Handling + +The main server in [src/index.ts](mdc:src/index.ts) has global error handling: + +```typescript +main().catch((error) => { + console.error('Fatal error starting Motion MCP server:', error); + process.exit(1); +}); +``` + +## Error Response Format + +Tool handlers should return structured error information when possible: + +```typescript +return { + success: false, + error: 'Specific error message', + details: additionalContext +}; +``` + +## Best Practices + +1. **Be specific**: Include relevant context in error messages +2. **Don't expose internals**: Sanitize error messages for user consumption +3. **Log appropriately**: Use `console.error` for debugging information +4. **Fail fast**: Validate inputs early and throw meaningful errors +5. **Chain errors**: Preserve original error context when re-throwing +description: Error handling patterns and conventions +--- diff --git a/.cursor/rules/mcp-tool-patterns.mdc b/.cursor/rules/mcp-tool-patterns.mdc new file mode 100644 index 0000000..2b55ee4 --- /dev/null +++ b/.cursor/rules/mcp-tool-patterns.mdc @@ -0,0 +1,80 @@ +# MCP Tool Implementation Patterns + +## Tool Structure + +Every MCP tool follows this structure from [src/types/tool.ts](mdc:src/types/tool.ts): + +```typescript +{ + name: string; // Format: 'motion_action_resource' + description: string; // Clear, actionable description + inputSchema: object; // JSON Schema for parameters + handler: async function // Implementation with validation +} +``` + +## Naming Convention + +- **Prefix**: All tools start with `motion_` +- **Pattern**: `motion__` (e.g., `motion_create_task`) +- **Actions**: `list`, `get`, `create`, `update`, `delete`, `move`, `complete` +- **Resources**: `task`, `project`, `workspace`, `user`, `comment`, etc. + +## Input Schema Best Practices + +- Use **JSON Schema format** with `type: 'object'` +- Include helpful **descriptions** for all properties +- Mark **required fields** in the `required` array +- Support **optional filtering** parameters for list operations +- Use **enum arrays** for constrained values + +## Handler Implementation + +1. **Validation**: Use Zod schema to validate input +2. **API Call**: Use the injected `MotionApiClient` instance +3. **Response**: Return structured data, not raw API responses +4. **Error Handling**: Let errors bubble up to the MCP error handler + +## Example Pattern + +```typescript +{ + name: 'motion_create_task', + description: 'Create a new task in Motion', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Task title' }, + workspaceId: { type: 'string', description: 'Workspace ID' }, + // ... other properties + }, + required: ['name', 'workspaceId'], + }, + handler: async (args: unknown) => { + const schema = z.object({ + name: z.string().min(1), + workspaceId: z.string().min(1), + // ... validation + }); + + const validated = schema.parse(args); + return await client.createTask(validated); + }, +} +``` + +## Registration Pattern + +Export a `register*Tools()` function that takes the API client and returns Tool arrays: + +```typescript +export function registerTaskTools(client: MotionApiClient): Tool[] { + return [ + // ... tool objects + ]; +} +``` + +See [src/tools/task.ts](mdc:src/tools/task.ts) for a complete example. +description: MCP tool implementation patterns and conventions +--- diff --git a/.cursor/rules/project-structure.mdc b/.cursor/rules/project-structure.mdc new file mode 100644 index 0000000..ca5d940 --- /dev/null +++ b/.cursor/rules/project-structure.mdc @@ -0,0 +1,32 @@ +# Motion MCP Project Structure + +This is a **Motion MCP (Model Context Protocol) Server** that provides AI assistants with access to Motion's task management API. + +## Core Architecture + +- **Entry Point**: [src/index.ts](mdc:src/index.ts) - Main server setup and tool registration +- **Configuration**: [src/config.ts](mdc:src/config.ts) - Environment-based configuration loading +- **API Client**: [src/api/client.ts](mdc:src/api/client.ts) - HTTP client with rate limiting and error handling +- **Types**: [src/types/](mdc:src/types/) - TypeScript interfaces for Motion API and MCP tools +- **Tools**: [src/tools/](mdc:src/tools/) - Individual MCP tool implementations + +## Key Files + +- [src/types/motion.ts](mdc:src/types/motion.ts) - Motion API data structures +- [src/types/tool.ts](mdc:src/types/tool.ts) - MCP tool interface definition +- [package.json](mdc:package.json) - Dependencies and project configuration +- [tsconfig.json](mdc:tsconfig.json) - TypeScript configuration + +## Tool Organization + +Each tool file in `src/tools/` exports a `register*Tools()` function that returns an array of `Tool` objects. All tools are registered in the main server in [src/index.ts](mdc:src/index.ts). + +## Dependencies + +- `@modelcontextprotocol/sdk` - MCP protocol implementation +- `axios` - HTTP client for Motion API +- `p-queue` - Rate limiting +- `zod` - Runtime validation +- `dotenv` - Environment variable management +alwaysApply: true +--- diff --git a/.cursor/rules/typescript-conventions.mdc b/.cursor/rules/typescript-conventions.mdc new file mode 100644 index 0000000..98d206f --- /dev/null +++ b/.cursor/rules/typescript-conventions.mdc @@ -0,0 +1,41 @@ +# TypeScript Conventions + +## Import/Export Patterns + +- Use **explicit `.js` extensions** in imports (required for ESM): `import { Tool } from '../types/tool.js'` +- Use **named exports** for functions and classes +- Use **default exports** sparingly, only for main entry points +- Import types and interfaces with regular `import` statements + +## Type Safety + +- All function parameters should be **typed with interfaces** from [src/types/motion.ts](mdc:src/types/motion.ts) +- Use **Zod schemas** for runtime validation in tool handlers +- Prefer **interface over type** for object shapes +- Use **string literal unions** for enums (e.g., `'HARD' | 'SOFT' | 'NONE'`) + +## Error Handling + +- Throw **descriptive errors** with proper error messages +- Use **McpError** from `@modelcontextprotocol/sdk/types.js` for MCP-specific errors +- Handle **AxiosError** in API client with proper status code interpretation + +## Code Organization + +- Keep tool implementations in individual files under `src/tools/` +- Export a single `register*Tools()` function per tool file +- Use consistent naming: `motion_` prefix for all tool names +- Follow the pattern in [src/tools/task.ts](mdc:src/tools/task.ts) for new tools + +## Validation Pattern + +```typescript +const schema = z.object({ + required: z.string().min(1), + optional: z.string().optional(), +}); + +const validated = schema.parse(args); +``` +globs: *.ts,*.tsx +--- diff --git a/package-lock.json b/package-lock.json index 0e9ed28..29cf036 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@mcp/motion-server", + "name": "@rf-d/motion-mcp", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@mcp/motion-server", + "name": "@rf-d/motion-mcp", "version": "0.1.0", "license": "MIT", "dependencies": { @@ -15,6 +15,9 @@ "p-queue": "^8.0.1", "zod": "^3.24.1" }, + "bin": { + "motion-mcp": "dist/index.js" + }, "devDependencies": { "@types/json-schema": "^7.0.15", "@types/node": "^22.10.2", From 1670cb7f12a6990fdb3ada9caa81f009a2fa3fd3 Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 01:13:12 +0200 Subject: [PATCH 02/15] Remove CLAUDE.md from .gitignore --- .gitignore | 1 - CLAUDE.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 896d4cf..d238f05 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ out/ # Claude .claude/ -CLAUDE.md claude.json .claude_files/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..126d376 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +This is an MCP (Model Context Protocol) server for Motion (usemotion.com) that enables AI assistants to interact with Motion's API. The server implements 32 tools across 8 categories for comprehensive task, project, and calendar management. + +## Key Commands + +### Development +```bash +npm run dev # Development mode with hot reload +npm run build # Compile TypeScript to JavaScript +npm run start # Run compiled version +``` + +### Code Quality (Run these before committing) +```bash +npm run typecheck # Check TypeScript types +npm run lint # Run ESLint +npm run format # Format with Prettier +``` + +## Architecture + +### Core Components + +1. **API Client** (`src/api/client.ts`): + - Handles all Motion API interactions + - Implements rate limiting (12 req/min for individual, 120 for team accounts) + - Uses p-queue for request queuing + - Provides comprehensive error handling + +2. **Tool Modules** (`src/tools/`): + - Each file implements a specific category of MCP tools + - All tools follow consistent pattern: input validation → API call → response formatting + - Tools use Zod schemas for runtime validation + +3. **Type System** (`src/types/`): + - `motion.ts`: Complete Motion API type definitions + - `tool.ts`: MCP tool interface definitions + - All API responses are strongly typed + +### Key Patterns + +1. **Tool Registration**: Tools are imported and registered in `src/index.ts` using a modular pattern. Each tool module exports an array of tool definitions. + +2. **Error Handling**: The API client wraps all requests with try-catch and provides detailed error messages. Rate limit errors are handled specially. + +3. **Environment Configuration**: Required env vars are validated at startup. The server won't start without a valid MOTION_API_KEY. + +4. **Rate Limiting**: Implemented using p-queue with configurable limits based on account type. + +## Motion API Integration + +- Base URL: `https://api.usemotion.com/v1` +- Authentication: X-API-Key header +- Rate limits are strictly enforced by the p-queue implementation +- All datetime values use ISO 8601 format +- API documentation reference: `docs/MOTION_API_REFERENCE.md` + +## Adding New Features + +When adding new Motion API integrations: +1. Add types to `src/types/motion.ts` +2. Create new tool in appropriate file under `src/tools/` +3. Follow existing tool patterns for consistency +4. Register the tool in `src/index.ts` +5. Update README.md with the new tool documentation + +## Testing Locally + +1. Copy `.env.example` to `.env` and add your Motion API key +2. Run `npm run build && npm run dev` +3. The server will start on stdio for MCP communication +4. Use Claude Desktop or another MCP client to connect \ No newline at end of file From c27dbc642ba624394749328e86d7fe835a344ceb Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 01:25:04 +0200 Subject: [PATCH 03/15] Add more detailedl Motion API reference --- docs/MOTION_API_REFERENCE_FULL.md | 1573 +++++++++++++++++++++++++++++ 1 file changed, 1573 insertions(+) create mode 100644 docs/MOTION_API_REFERENCE_FULL.md diff --git a/docs/MOTION_API_REFERENCE_FULL.md b/docs/MOTION_API_REFERENCE_FULL.md new file mode 100644 index 0000000..503228c --- /dev/null +++ b/docs/MOTION_API_REFERENCE_FULL.md @@ -0,0 +1,1573 @@ +# Motion API Official Reference + +> Last Updated: July 2025 +> +> This is a comprehensive reference for the Motion (usemotion.com) REST API v1. + +## Table of Contents + +1. [Overview](#overview) +2. [Authentication](#authentication) +3. [Base URL](#base-url) +4. [Rate Limiting](#rate-limiting) +5. [Request Format](#request-format) +6. [Response Format](#response-format) +7. [Error Handling](#error-handling) +8. [API Endpoints](#api-endpoints) + - [Users](#users) + - [Workspaces](#workspaces) + - [Tasks](#tasks) + - [Projects](#projects) + - [Comments](#comments) + - [Recurring Tasks](#recurring-tasks) + - [Custom Fields](#custom-fields) + - [Schedules](#schedules) + - [Statuses](#statuses) +9. [Data Types](#data-types) +10. [Pagination](#pagination) +11. [Webhooks](#webhooks) +12. [Code Examples](#code-examples) +13. [SDKs and Libraries](#sdks-and-libraries) +14. [Best Practices](#best-practices) + +## Overview + +The Motion API provides programmatic access to Motion's task management, project management, and scheduling features. It enables developers to build custom integrations and automate workflows. + +**Note**: The API is intended for advanced users. Most users should use Motion's desktop and mobile applications for everyday use. + +### Key Features + +- Full CRUD operations on tasks, projects, and comments +- Recurring task management +- Custom field support +- Workspace management +- Real-time scheduling access +- Comprehensive filtering and search capabilities + +## Authentication + +Motion uses API Key authentication for all API requests. + +### Creating an API Key + +1. Log into your Motion account at [app.usemotion.com](https://app.usemotion.com) +2. Navigate to **Settings** → **API Keys** +3. Click **Create API Key** +4. Give your key a descriptive name +5. Copy the key immediately - **it will only be shown once** + +### Using the API Key + +Include your API key in the `X-API-Key` header of all requests: + +```http +X-API-Key: your-api-key-here +``` + +### Security Best Practices + +- Store API keys securely (use environment variables) +- Never commit API keys to version control +- Rotate keys regularly +- Use separate keys for different environments +- Restrict key permissions when possible + +## Base URL + +All API requests should be made to: + +``` +https://api.usemotion.com/v1 +``` + +## Rate Limiting + +Motion implements rate limiting to ensure fair usage and system stability. + +### Rate Limits by Plan + +| Plan | Requests per Minute | Requests per Hour | +|------|-------------------|-------------------| +| Starter | 12 | 720 | +| Professional | 120 | 7,200 | +| Team | 120 | 7,200 | +| Enterprise | Custom | Custom | + +### Rate Limit Implementation + +- Uses a **sliding window** approach (rolling 60-second period) +- Limits apply per API key +- All endpoints count toward the same limit + +### Rate Limit Headers + +Every response includes rate limit information: + +```http +X-RateLimit-Limit: 120 +X-RateLimit-Remaining: 118 +X-RateLimit-Reset: 1625097600 +``` + +- `X-RateLimit-Limit`: Total requests allowed per minute +- `X-RateLimit-Remaining`: Requests remaining in current window +- `X-RateLimit-Reset`: Unix timestamp when the window resets + +### Handling Rate Limits + +When rate limited, the API returns: + +```http +HTTP/1.1 429 Too Many Requests +Content-Type: application/json +Retry-After: 30 + +{ + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded. Please retry after 30 seconds." + } +} +``` + +## Request Format + +### Headers + +Required headers for all requests: + +```http +Content-Type: application/json +X-API-Key: your-api-key-here +``` + +### Request Body + +All POST and PUT requests should send JSON-encoded bodies: + +```json +{ + "name": "Task name", + "description": "Task description", + "dueDate": "2025-07-15T10:00:00Z" +} +``` + +### Date/Time Format + +All date/time values must use ISO 8601 format: + +- Full datetime: `2025-07-15T10:00:00Z` +- Date only: `2025-07-15` +- With timezone: `2025-07-15T10:00:00-07:00` + +## Response Format + +### Success Response + +```json +{ + "data": { + "id": "task_123", + "name": "Complete project", + "status": "active", + "createdAt": "2025-07-14T10:00:00Z" + } +} +``` + +### List Response + +```json +{ + "data": [ + { + "id": "task_123", + "name": "Task 1" + }, + { + "id": "task_124", + "name": "Task 2" + } + ], + "meta": { + "hasMore": true, + "cursor": "eyJpZCI6MTIzfQ==" + } +} +``` + +## Error Handling + +### Error Response Format + +```json +{ + "error": { + "code": "validation_error", + "message": "Validation failed", + "details": { + "name": ["Name is required"], + "dueDate": ["Invalid date format"] + } + } +} +``` + +### HTTP Status Codes + +| Status Code | Description | +|------------|-------------| +| 200 | Success - Request completed successfully | +| 201 | Created - Resource created successfully | +| 204 | No Content - Request succeeded with no response body | +| 400 | Bad Request - Invalid request parameters | +| 401 | Unauthorized - Missing or invalid API key | +| 403 | Forbidden - Insufficient permissions | +| 404 | Not Found - Resource doesn't exist | +| 409 | Conflict - Resource conflict (e.g., duplicate) | +| 422 | Unprocessable Entity - Validation error | +| 429 | Too Many Requests - Rate limit exceeded | +| 500 | Internal Server Error - Server error | +| 503 | Service Unavailable - Temporary server issue | + +### Error Codes + +Common error codes returned in the `error.code` field: + +- `invalid_api_key` - API key is invalid or expired +- `insufficient_permissions` - API key lacks required permissions +- `resource_not_found` - Requested resource doesn't exist +- `validation_error` - Request validation failed +- `rate_limit_exceeded` - Rate limit exceeded +- `internal_error` - Internal server error + +## API Endpoints + +### Users + +#### Get Current User + +Returns information about the API key owner. + +```http +GET /users/me +``` + +**Response:** + +```json +{ + "data": { + "id": "user_123", + "email": "user@example.com", + "name": "John Doe", + "role": "admin", + "createdAt": "2025-01-01T00:00:00Z" + } +} +``` + +### Workspaces + +#### List Workspaces + +Get all workspaces accessible to the user. + +```http +GET /workspaces +``` + +**Query Parameters:** + +- `cursor` (string) - Pagination cursor +- `limit` (integer) - Number of results (1-100, default: 20) + +**Response:** + +```json +{ + "data": [ + { + "id": "workspace_123", + "name": "Marketing Team", + "type": "team", + "memberCount": 5, + "createdAt": "2025-01-01T00:00:00Z" + } + ], + "meta": { + "hasMore": false, + "cursor": null + } +} +``` + +### Tasks + +#### Create Task + +Create a new task. + +```http +POST /tasks +``` + +**Request Body:** + +```json +{ + "name": "Complete Q3 Report", + "description": "Prepare and submit the Q3 financial report", + "workspaceId": "workspace_123", + "projectId": "project_456", + "assigneeId": "user_789", + "status": "TODO", + "priority": "HIGH", + "dueDate": "2025-07-30T17:00:00Z", + "duration": 120, + "labels": ["urgent", "finance"], + "customFields": { + "department": "Finance", + "budget": 5000 + } +} +``` + +**Required Fields:** +- `name` (string) - Task name +- `workspaceId` (string) - Workspace ID + +**Optional Fields:** +- `description` (string) - Task description +- `projectId` (string) - Project ID +- `assigneeId` (string) - User ID to assign +- `status` (string) - Task status (TODO, IN_PROGRESS, DONE, BLOCKED) +- `priority` (string) - Priority (LOW, MEDIUM, HIGH, URGENT) +- `dueDate` (string) - Due date in ISO 8601 +- `duration` (integer) - Duration in minutes +- `labels` (array) - Array of label strings +- `customFields` (object) - Custom field values + +**Response:** + +```json +{ + "data": { + "id": "task_999", + "name": "Complete Q3 Report", + "description": "Prepare and submit the Q3 financial report", + "workspaceId": "workspace_123", + "projectId": "project_456", + "assignee": { + "id": "user_789", + "name": "Jane Smith", + "email": "jane@example.com" + }, + "status": "TODO", + "priority": "HIGH", + "dueDate": "2025-07-30T17:00:00Z", + "duration": 120, + "labels": ["urgent", "finance"], + "customFields": { + "department": "Finance", + "budget": 5000 + }, + "createdAt": "2025-07-14T10:00:00Z", + "updatedAt": "2025-07-14T10:00:00Z" + } +} +``` + +#### List Tasks + +Get all tasks with optional filtering. + +```http +GET /tasks +``` + +**Query Parameters:** + +- `workspaceId` (string) - Filter by workspace +- `projectId` (string) - Filter by project +- `assigneeId` (string) - Filter by assignee +- `status` (string) - Filter by status +- `priority` (string) - Filter by priority +- `label` (string) - Filter by label (can be used multiple times) +- `dueBefore` (string) - Tasks due before date +- `dueAfter` (string) - Tasks due after date +- `createdBefore` (string) - Tasks created before date +- `createdAfter` (string) - Tasks created after date +- `updatedBefore` (string) - Tasks updated before date +- `updatedAfter` (string) - Tasks updated after date +- `includeCompleted` (boolean) - Include completed tasks (default: false) +- `cursor` (string) - Pagination cursor +- `limit` (integer) - Number of results (1-100, default: 20) + +**Response:** + +```json +{ + "data": [ + { + "id": "task_123", + "name": "Task 1", + "status": "IN_PROGRESS", + "assignee": { + "id": "user_123", + "name": "John Doe" + }, + "dueDate": "2025-07-15T10:00:00Z", + "priority": "MEDIUM" + } + ], + "meta": { + "hasMore": true, + "cursor": "eyJpZCI6MTIzfQ==" + } +} +``` + +#### Get Task + +Get a specific task by ID. + +```http +GET /tasks/{taskId} +``` + +**Response:** + +Returns the full task object as shown in Create Task response. + +#### Update Task + +Update an existing task. + +```http +PUT /tasks/{taskId} +``` + +**Request Body:** + +Any fields from Create Task can be updated. Only include fields you want to change. + +```json +{ + "name": "Updated Task Name", + "status": "IN_PROGRESS", + "priority": "URGENT" +} +``` + +**Response:** + +Returns the updated task object. + +#### Delete Task + +Delete a task permanently. + +```http +DELETE /tasks/{taskId} +``` + +**Response:** + +```http +HTTP/1.1 204 No Content +``` + +#### Move Task + +Move a task to another workspace. + +```http +POST /tasks/{taskId}/move +``` + +**Request Body:** + +```json +{ + "workspaceId": "workspace_456" +} +``` + +**Note:** When moving tasks between workspaces, the following are reset: +- Project assignment +- Status +- Labels +- Assignee + +**Response:** + +Returns the updated task object. + +#### Unassign Task + +Remove assignee from a task. + +```http +POST /tasks/{taskId}/unassign +``` + +**Response:** + +Returns the updated task object with `assignee` set to `null`. + +### Projects + +#### Create Project + +Create a new project. + +```http +POST /projects +``` + +**Request Body:** + +```json +{ + "name": "Q3 Marketing Campaign", + "description": "Launch new product marketing campaign", + "workspaceId": "workspace_123", + "color": "#FF6B6B", + "status": "ACTIVE", + "startDate": "2025-07-01", + "endDate": "2025-09-30", + "budget": 50000, + "customFields": { + "department": "Marketing", + "manager": "Sarah Johnson" + } +} +``` + +**Required Fields:** +- `name` (string) - Project name +- `workspaceId` (string) - Workspace ID + +**Optional Fields:** +- `description` (string) - Project description +- `color` (string) - Hex color code +- `status` (string) - Project status (ACTIVE, ON_HOLD, COMPLETED, CANCELLED) +- `startDate` (string) - Start date +- `endDate` (string) - End date +- `budget` (number) - Project budget +- `customFields` (object) - Custom field values + +#### List Projects + +Get all projects with optional filtering. + +```http +GET /projects +``` + +**Query Parameters:** + +- `workspaceId` (string) - Filter by workspace +- `status` (string) - Filter by status +- `cursor` (string) - Pagination cursor +- `limit` (integer) - Number of results (1-100, default: 20) + +#### Get Project + +Get a specific project by ID. + +```http +GET /projects/{projectId} +``` + +### Comments + +#### Create Comment + +Add a comment to a task. + +```http +POST /comments +``` + +**Request Body:** + +```json +{ + "taskId": "task_123", + "content": "This looks great! Just need to update the budget section.", + "mentions": ["user_456", "user_789"] +} +``` + +**Required Fields:** +- `taskId` (string) - Task ID +- `content` (string) - Comment text + +**Optional Fields:** +- `mentions` (array) - Array of user IDs to mention + +#### List Comments + +Get comments for a task. + +```http +GET /comments +``` + +**Query Parameters:** + +- `taskId` (string) - Required - Task ID +- `cursor` (string) - Pagination cursor +- `limit` (integer) - Number of results (1-100, default: 20) + +### Recurring Tasks + +#### Create Recurring Task + +Create a task that repeats on a schedule. + +```http +POST /recurring-tasks +``` + +**Request Body:** + +```json +{ + "name": "Weekly Team Meeting", + "description": "Review progress and plan for next week", + "workspaceId": "workspace_123", + "assigneeId": "user_456", + "duration": 60, + "priority": "MEDIUM", + "recurrence": { + "frequency": "WEEKLY", + "interval": 1, + "daysOfWeek": ["MONDAY"], + "startDate": "2025-07-15", + "endDate": "2025-12-31", + "time": "10:00" + } +} +``` + +**Recurrence Options:** + +- `frequency` (string) - DAILY, WEEKLY, MONTHLY, YEARLY +- `interval` (integer) - Repeat every N periods +- `daysOfWeek` (array) - For WEEKLY: ["MONDAY", "TUESDAY", etc.] +- `dayOfMonth` (integer) - For MONTHLY: 1-31 +- `monthOfYear` (string) - For YEARLY: "JANUARY", "FEBRUARY", etc. +- `startDate` (string) - When to start recurring +- `endDate` (string) - When to stop recurring (optional) +- `time` (string) - Time of day (HH:MM format) + +#### List Recurring Tasks + +```http +GET /recurring-tasks +``` + +#### Delete Recurring Task + +```http +DELETE /recurring-tasks/{recurringTaskId} +``` + +**Query Parameters:** + +- `deleteInstances` (boolean) - Delete all created instances (default: false) + +### Custom Fields + +#### Create Custom Field + +Define a custom field for tasks or projects. + +```http +POST /custom-fields +``` + +**Request Body:** + +```json +{ + "name": "Department", + "type": "SELECT", + "entityType": "TASK", + "workspaceId": "workspace_123", + "required": false, + "options": [ + {"label": "Engineering", "value": "eng"}, + {"label": "Marketing", "value": "mkt"}, + {"label": "Sales", "value": "sales"} + ] +} +``` + +**Field Types:** + +- `TEXT` - Single line text +- `TEXTAREA` - Multi-line text +- `NUMBER` - Numeric value +- `SELECT` - Dropdown selection +- `MULTISELECT` - Multiple selection +- `DATE` - Date picker +- `CHECKBOX` - Boolean +- `URL` - URL field +- `EMAIL` - Email field + +#### List Custom Fields + +```http +GET /custom-fields +``` + +**Query Parameters:** + +- `workspaceId` (string) - Filter by workspace +- `entityType` (string) - Filter by entity type (TASK, PROJECT) + +#### Delete Custom Field + +```http +DELETE /custom-fields/{customFieldId} +``` + +### Schedules + +#### List Schedules + +Get scheduling information for users. + +```http +GET /schedules +``` + +**Query Parameters:** + +- `userId` (string) - User ID (defaults to current user) +- `startDate` (string) - Start of date range +- `endDate` (string) - End of date range +- `includeBlocked` (boolean) - Include blocked time (default: true) + +**Response:** + +```json +{ + "data": { + "userId": "user_123", + "schedule": [ + { + "date": "2025-07-15", + "blocks": [ + { + "startTime": "09:00", + "endTime": "10:00", + "type": "TASK", + "taskId": "task_456", + "taskName": "Project Review" + }, + { + "startTime": "14:00", + "endTime": "15:00", + "type": "MEETING", + "title": "Team Standup" + } + ] + } + ] + } +} +``` + +### Statuses + +#### Get Statuses + +Get available statuses for a workspace. + +```http +GET /statuses +``` + +**Query Parameters:** + +- `workspaceId` (string) - Workspace ID + +**Response:** + +```json +{ + "data": [ + { + "id": "status_1", + "name": "To Do", + "type": "TODO", + "color": "#808080", + "order": 1 + }, + { + "id": "status_2", + "name": "In Progress", + "type": "IN_PROGRESS", + "color": "#3B82F6", + "order": 2 + }, + { + "id": "status_3", + "name": "Done", + "type": "DONE", + "color": "#10B981", + "order": 3 + } + ] +} +``` + +## Data Types + +### Task Object + +```typescript +interface Task { + id: string; + name: string; + description?: string; + workspaceId: string; + projectId?: string; + assignee?: User; + status: TaskStatus; + priority: Priority; + dueDate?: string; + duration?: number; + labels: string[]; + customFields: Record; + createdAt: string; + updatedAt: string; + completedAt?: string; +} +``` + +### Project Object + +```typescript +interface Project { + id: string; + name: string; + description?: string; + workspaceId: string; + color?: string; + status: ProjectStatus; + startDate?: string; + endDate?: string; + budget?: number; + customFields: Record; + taskCount: number; + completedTaskCount: number; + createdAt: string; + updatedAt: string; +} +``` + +### User Object + +```typescript +interface User { + id: string; + email: string; + name: string; + role: UserRole; + avatarUrl?: string; + timezone?: string; + createdAt: string; +} +``` + +### Enums + +```typescript +enum TaskStatus { + TODO = "TODO", + IN_PROGRESS = "IN_PROGRESS", + DONE = "DONE", + BLOCKED = "BLOCKED" +} + +enum Priority { + LOW = "LOW", + MEDIUM = "MEDIUM", + HIGH = "HIGH", + URGENT = "URGENT" +} + +enum ProjectStatus { + ACTIVE = "ACTIVE", + ON_HOLD = "ON_HOLD", + COMPLETED = "COMPLETED", + CANCELLED = "CANCELLED" +} + +enum UserRole { + ADMIN = "ADMIN", + MEMBER = "MEMBER", + VIEWER = "VIEWER" +} +``` + +## Pagination + +Motion uses cursor-based pagination for all list endpoints. + +### Request + +```http +GET /tasks?limit=50&cursor=eyJpZCI6MTIzfQ== +``` + +### Response + +```json +{ + "data": [...], + "meta": { + "hasMore": true, + "cursor": "eyJpZCI6MTczfQ==" + } +} +``` + +### Implementation Example + +```javascript +async function getAllTasks() { + const tasks = []; + let cursor = null; + let hasMore = true; + + while (hasMore) { + const response = await fetch( + `https://api.usemotion.com/v1/tasks?limit=100${cursor ? `&cursor=${cursor}` : ''}`, + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + const data = await response.json(); + tasks.push(...data.data); + + hasMore = data.meta.hasMore; + cursor = data.meta.cursor; + } + + return tasks; +} +``` + +## Webhooks + +Motion supports webhooks for real-time event notifications. + +### Webhook Events + +- `task.created` - Task created +- `task.updated` - Task updated +- `task.deleted` - Task deleted +- `task.completed` - Task marked as done +- `project.created` - Project created +- `project.updated` - Project updated +- `project.deleted` - Project deleted +- `comment.created` - Comment added + +### Webhook Payload + +```json +{ + "id": "webhook_event_123", + "type": "task.updated", + "timestamp": "2025-07-14T10:00:00Z", + "data": { + "task": { + "id": "task_456", + "name": "Updated task", + // ... full task object + }, + "changes": { + "status": { + "from": "TODO", + "to": "IN_PROGRESS" + } + } + } +} +``` + +### Webhook Security + +Webhooks include a signature header for verification: + +```http +X-Motion-Signature: sha256=a1b2c3d4e5f6... +``` + +Verify the signature using your webhook secret: + +```javascript +const crypto = require('crypto'); + +function verifyWebhookSignature(payload, signature, secret) { + const expectedSignature = 'sha256=' + + crypto.createHmac('sha256', secret) + .update(payload) + .digest('hex'); + + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); +} +``` + +## Code Examples + +### JavaScript/Node.js + +#### Basic Setup + +```javascript +const axios = require('axios'); + +const motionApi = axios.create({ + baseURL: 'https://api.usemotion.com/v1', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + } +}); + +// Handle rate limiting with exponential backoff +motionApi.interceptors.response.use( + response => response, + async error => { + if (error.response?.status === 429) { + const retryAfter = error.response.headers['retry-after'] || 30; + await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)); + return motionApi.request(error.config); + } + return Promise.reject(error); + } +); +``` + +#### Create Task with Error Handling + +```javascript +async function createTask(taskData) { + try { + const response = await motionApi.post('/tasks', { + name: taskData.name, + description: taskData.description, + workspaceId: taskData.workspaceId, + dueDate: new Date(taskData.dueDate).toISOString(), + priority: taskData.priority || 'MEDIUM', + assigneeId: taskData.assigneeId + }); + + console.log('Task created:', response.data.data); + return response.data.data; + } catch (error) { + if (error.response) { + console.error('API Error:', error.response.data.error); + + // Handle validation errors + if (error.response.status === 422) { + console.error('Validation errors:', error.response.data.error.details); + } + } else { + console.error('Network error:', error.message); + } + throw error; + } +} +``` + +#### Batch Operations + +```javascript +async function batchCreateTasks(tasks) { + const results = { + successful: [], + failed: [] + }; + + // Use Promise.allSettled to handle partial failures + const promises = tasks.map(task => + createTask(task) + .then(result => ({ status: 'fulfilled', value: result })) + .catch(error => ({ status: 'rejected', reason: error })) + ); + + const outcomes = await Promise.allSettled(promises); + + outcomes.forEach((outcome, index) => { + if (outcome.status === 'fulfilled') { + results.successful.push(outcome.value); + } else { + results.failed.push({ + task: tasks[index], + error: outcome.reason + }); + } + }); + + return results; +} +``` + +### Python + +```python +import os +import time +import requests +from typing import Dict, List, Optional +from datetime import datetime + +class MotionAPI: + def __init__(self, api_key: str): + self.api_key = api_key + self.base_url = "https://api.usemotion.com/v1" + self.session = requests.Session() + self.session.headers.update({ + "X-API-Key": api_key, + "Content-Type": "application/json" + }) + + def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict: + """Make API request with automatic retry on rate limit.""" + url = f"{self.base_url}{endpoint}" + + for attempt in range(3): + response = self.session.request(method, url, **kwargs) + + if response.status_code == 429: + retry_after = int(response.headers.get("Retry-After", 30)) + time.sleep(retry_after) + continue + + response.raise_for_status() + return response.json() + + raise Exception("Max retries exceeded") + + def create_task(self, + name: str, + workspace_id: str, + description: Optional[str] = None, + due_date: Optional[datetime] = None, + priority: str = "MEDIUM", + assignee_id: Optional[str] = None) -> Dict: + """Create a new task.""" + data = { + "name": name, + "workspaceId": workspace_id, + "priority": priority + } + + if description: + data["description"] = description + if due_date: + data["dueDate"] = due_date.isoformat() + if assignee_id: + data["assigneeId"] = assignee_id + + return self._make_request("POST", "/tasks", json=data) + + def list_tasks(self, + workspace_id: Optional[str] = None, + status: Optional[str] = None, + limit: int = 20) -> List[Dict]: + """List tasks with optional filters.""" + params = {"limit": limit} + + if workspace_id: + params["workspaceId"] = workspace_id + if status: + params["status"] = status + + tasks = [] + cursor = None + + while True: + if cursor: + params["cursor"] = cursor + + response = self._make_request("GET", "/tasks", params=params) + tasks.extend(response["data"]) + + if not response["meta"]["hasMore"]: + break + + cursor = response["meta"]["cursor"] + + return tasks + +# Usage example +if __name__ == "__main__": + api = MotionAPI(os.environ["MOTION_API_KEY"]) + + # Create a task + task = api.create_task( + name="Review Q3 Report", + workspace_id="workspace_123", + description="Review and provide feedback on Q3 financial report", + due_date=datetime(2025, 7, 30, 17, 0), + priority="HIGH" + ) + + print(f"Created task: {task['data']['id']}") +``` + +### cURL Examples + +#### Create Task + +```bash +curl -X POST https://api.usemotion.com/v1/tasks \ + -H "X-API-Key: your-api-key-here" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Complete project documentation", + "workspaceId": "workspace_123", + "priority": "HIGH", + "dueDate": "2025-07-20T17:00:00Z" + }' +``` + +#### List Tasks with Filters + +```bash +curl -X GET "https://api.usemotion.com/v1/tasks?workspaceId=workspace_123&status=IN_PROGRESS&limit=50" \ + -H "X-API-Key: your-api-key-here" +``` + +#### Update Task Status + +```bash +curl -X PUT https://api.usemotion.com/v1/tasks/task_456 \ + -H "X-API-Key: your-api-key-here" \ + -H "Content-Type: application/json" \ + -d '{ + "status": "DONE" + }' +``` + +## SDKs and Libraries + +### Official SDKs + +Motion does not currently provide official SDKs. The REST API can be used directly with any HTTP client. + +### Community Libraries + +Several community-maintained libraries are available: + +- **Node.js/TypeScript**: Various npm packages available +- **Python**: Community packages on PyPI +- **Go**: Community implementations on GitHub + +### Integration Platforms + +Motion is integrated with several platforms: + +- **Zapier**: 5000+ app integrations +- **Make (Integromat)**: Visual automation workflows +- **Pipedream**: Code-based integrations +- **n8n**: Open-source automation + +## Best Practices + +### 1. Error Handling + +Always implement comprehensive error handling: + +```javascript +try { + const result = await apiCall(); +} catch (error) { + if (error.response) { + // API error + switch (error.response.status) { + case 400: + console.error('Bad request:', error.response.data); + break; + case 401: + console.error('Authentication failed'); + break; + case 429: + console.error('Rate limited'); + break; + default: + console.error('API error:', error.response.status); + } + } else if (error.request) { + // Network error + console.error('Network error:', error.message); + } else { + // Other error + console.error('Error:', error.message); + } +} +``` + +### 2. Rate Limit Management + +Implement a queue system for API requests: + +```javascript +const PQueue = require('p-queue'); + +// Create queue with concurrency based on your plan +const queue = new PQueue({ + concurrency: 1, + interval: 60000, // 1 minute + intervalCap: 120 // Requests per interval +}); + +// Add requests to queue +const task = await queue.add(() => createTask(taskData)); +``` + +### 3. Caching + +Cache frequently accessed data: + +```javascript +const cache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +async function getCachedProject(projectId) { + const cached = cache.get(projectId); + + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data; + } + + const project = await api.get(`/projects/${projectId}`); + cache.set(projectId, { + data: project, + timestamp: Date.now() + }); + + return project; +} +``` + +### 4. Bulk Operations + +Batch operations when possible: + +```javascript +// Instead of creating tasks one by one +for (const task of tasks) { + await createTask(task); // Slow, hits rate limits +} + +// Use concurrent requests with rate limiting +const results = await Promise.all( + tasks.map(task => + queue.add(() => createTask(task)) + ) +); +``` + +### 5. Webhook Processing + +Process webhooks asynchronously: + +```javascript +app.post('/webhooks/motion', (req, res) => { + // Verify signature + if (!verifySignature(req.body, req.headers['x-motion-signature'])) { + return res.status(401).send('Invalid signature'); + } + + // Acknowledge receipt immediately + res.status(200).send('OK'); + + // Process webhook asynchronously + processWebhookAsync(req.body); +}); +``` + +### 6. Data Validation + +Always validate data before sending to API: + +```javascript +const Joi = require('joi'); + +const taskSchema = Joi.object({ + name: Joi.string().required().max(255), + workspaceId: Joi.string().required(), + description: Joi.string().max(5000), + dueDate: Joi.date().iso().min('now'), + priority: Joi.string().valid('LOW', 'MEDIUM', 'HIGH', 'URGENT'), + assigneeId: Joi.string() +}); + +function validateTask(taskData) { + const { error, value } = taskSchema.validate(taskData); + if (error) { + throw new Error(`Validation error: ${error.details[0].message}`); + } + return value; +} +``` + +### 7. Monitoring and Logging + +Implement comprehensive logging: + +```javascript +const winston = require('winston'); + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.json(), + transports: [ + new winston.transports.File({ filename: 'motion-api.log' }) + ] +}); + +// Log all API requests +motionApi.interceptors.request.use(request => { + logger.info('API Request', { + method: request.method, + url: request.url, + timestamp: new Date().toISOString() + }); + return request; +}); + +// Log responses +motionApi.interceptors.response.use( + response => { + logger.info('API Response', { + status: response.status, + url: response.config.url, + duration: response.duration + }); + return response; + }, + error => { + logger.error('API Error', { + status: error.response?.status, + message: error.message, + url: error.config?.url + }); + return Promise.reject(error); + } +); +``` + +### 8. Security Best Practices + +1. **Environment Variables**: Never hardcode API keys + ```bash + MOTION_API_KEY=your-key-here + ``` + +2. **Secure Storage**: Use secrets management services in production + +3. **Minimal Permissions**: Request only necessary scopes + +4. **HTTPS Only**: Always use HTTPS for API communication + +5. **Input Sanitization**: Sanitize all user input before API calls + +6. **Audit Logging**: Log all API operations for security audits + +## Troubleshooting + +### Common Issues + +1. **401 Unauthorized** + - Verify API key is correct + - Check key hasn't expired + - Ensure key has required permissions + +2. **429 Rate Limited** + - Implement exponential backoff + - Check your plan's rate limits + - Use request queuing + +3. **422 Validation Error** + - Check required fields + - Verify date formats (ISO 8601) + - Validate enum values + +4. **404 Not Found** + - Verify resource IDs + - Check workspace access + - Ensure resource hasn't been deleted + +5. **500 Server Error** + - Retry with exponential backoff + - Check Motion status page + - Contact support if persistent + +### Debug Mode + +Enable detailed logging for debugging: + +```javascript +if (process.env.DEBUG) { + motionApi.interceptors.request.use(request => { + console.log('Request:', JSON.stringify(request, null, 2)); + return request; + }); + + motionApi.interceptors.response.use( + response => { + console.log('Response:', JSON.stringify(response.data, null, 2)); + return response; + }, + error => { + console.error('Error Details:', error.response?.data); + return Promise.reject(error); + } + ); +} +``` + +## Resources + +### Official Resources + +- **API Documentation**: [https://docs.usemotion.com/](https://docs.usemotion.com/) +- **Getting Started Guide**: [https://docs.usemotion.com/cookbooks/getting-started/](https://docs.usemotion.com/cookbooks/getting-started/) +- **Help Center**: [https://help.usemotion.com/](https://help.usemotion.com/) +- **Status Page**: [https://status.usemotion.com/](https://status.usemotion.com/) + +### Community Resources + +- **GitHub Topics**: Search for "motion-api" on GitHub +- **Stack Overflow**: Tag questions with "motion-api" +- **Discord/Slack Communities**: Join Motion user communities + +### Support + +- **Email**: support@usemotion.com +- **In-app Support**: Available in Motion app +- **Enterprise Support**: Contact sales for dedicated support + +--- + +*This reference is based on publicly available information and may not reflect the most recent API changes. Always refer to the official Motion API documentation for the most up-to-date information.* \ No newline at end of file From 5d69faa3510e31c20652fe541858c0f95e9aeb8b Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 11:46:41 +0200 Subject: [PATCH 04/15] Add the comprehensive Motion API reference document as part of the project restructuring. --- docs/MOTION_API_REFERENCE_FULL.md | 1573 ----------------- docs/official_api/README.md | 136 ++ docs/official_api/authentication.md | 41 + docs/official_api/comments/README.md | 100 ++ docs/official_api/comments/create-comment.md | 322 ++++ docs/official_api/comments/get-comments.md | 294 +++ docs/official_api/custom-fields/README.md | 237 +++ .../custom-fields/add-to-project.md | 536 ++++++ .../official_api/custom-fields/add-to-task.md | 485 +++++ .../custom-fields/create-custom-field.md | 483 +++++ .../custom-fields/delete-custom-field.md | 335 ++++ .../custom-fields/list-custom-fields.md | 410 +++++ .../custom-fields/remove-from-project.md | 412 +++++ .../custom-fields/remove-from-task.md | 337 ++++ docs/official_api/data-types.md | 104 ++ docs/official_api/error-handling.md | 127 ++ docs/official_api/projects/README.md | 127 ++ docs/official_api/projects/create-project.md | 340 ++++ docs/official_api/projects/get-project.md | 327 ++++ docs/official_api/projects/list-projects.md | 303 ++++ docs/official_api/recurring-tasks/README.md | 149 ++ .../recurring-tasks/create-recurring-task.md | 377 ++++ .../recurring-tasks/delete-recurring-task.md | 299 ++++ .../recurring-tasks/list-recurring-tasks.md | 358 ++++ docs/official_api/schedules/README.md | 108 ++ docs/official_api/schedules/get-schedules.md | 322 ++++ docs/official_api/statuses/README.md | 107 ++ docs/official_api/statuses/list-statuses.md | 330 ++++ docs/official_api/tasks/README.md | 128 ++ docs/official_api/tasks/create-task.md | 312 ++++ docs/official_api/tasks/delete-task.md | 221 +++ docs/official_api/tasks/get-task.md | 230 +++ docs/official_api/tasks/list-tasks.md | 334 ++++ docs/official_api/tasks/move-task.md | 292 +++ docs/official_api/tasks/unassign-task.md | 261 +++ docs/official_api/tasks/update-task.md | 312 ++++ docs/official_api/users/README.md | 89 + docs/official_api/users/get-current-user.md | 262 +++ docs/official_api/users/get-user.md | 359 ++++ docs/official_api/workspaces/README.md | 143 ++ .../workspaces/list-workspaces.md | 380 ++++ 41 files changed, 10829 insertions(+), 1573 deletions(-) delete mode 100644 docs/MOTION_API_REFERENCE_FULL.md create mode 100644 docs/official_api/README.md create mode 100644 docs/official_api/authentication.md create mode 100644 docs/official_api/comments/README.md create mode 100644 docs/official_api/comments/create-comment.md create mode 100644 docs/official_api/comments/get-comments.md create mode 100644 docs/official_api/custom-fields/README.md create mode 100644 docs/official_api/custom-fields/add-to-project.md create mode 100644 docs/official_api/custom-fields/add-to-task.md create mode 100644 docs/official_api/custom-fields/create-custom-field.md create mode 100644 docs/official_api/custom-fields/delete-custom-field.md create mode 100644 docs/official_api/custom-fields/list-custom-fields.md create mode 100644 docs/official_api/custom-fields/remove-from-project.md create mode 100644 docs/official_api/custom-fields/remove-from-task.md create mode 100644 docs/official_api/data-types.md create mode 100644 docs/official_api/error-handling.md create mode 100644 docs/official_api/projects/README.md create mode 100644 docs/official_api/projects/create-project.md create mode 100644 docs/official_api/projects/get-project.md create mode 100644 docs/official_api/projects/list-projects.md create mode 100644 docs/official_api/recurring-tasks/README.md create mode 100644 docs/official_api/recurring-tasks/create-recurring-task.md create mode 100644 docs/official_api/recurring-tasks/delete-recurring-task.md create mode 100644 docs/official_api/recurring-tasks/list-recurring-tasks.md create mode 100644 docs/official_api/schedules/README.md create mode 100644 docs/official_api/schedules/get-schedules.md create mode 100644 docs/official_api/statuses/README.md create mode 100644 docs/official_api/statuses/list-statuses.md create mode 100644 docs/official_api/tasks/README.md create mode 100644 docs/official_api/tasks/create-task.md create mode 100644 docs/official_api/tasks/delete-task.md create mode 100644 docs/official_api/tasks/get-task.md create mode 100644 docs/official_api/tasks/list-tasks.md create mode 100644 docs/official_api/tasks/move-task.md create mode 100644 docs/official_api/tasks/unassign-task.md create mode 100644 docs/official_api/tasks/update-task.md create mode 100644 docs/official_api/users/README.md create mode 100644 docs/official_api/users/get-current-user.md create mode 100644 docs/official_api/users/get-user.md create mode 100644 docs/official_api/workspaces/README.md create mode 100644 docs/official_api/workspaces/list-workspaces.md diff --git a/docs/MOTION_API_REFERENCE_FULL.md b/docs/MOTION_API_REFERENCE_FULL.md deleted file mode 100644 index 503228c..0000000 --- a/docs/MOTION_API_REFERENCE_FULL.md +++ /dev/null @@ -1,1573 +0,0 @@ -# Motion API Official Reference - -> Last Updated: July 2025 -> -> This is a comprehensive reference for the Motion (usemotion.com) REST API v1. - -## Table of Contents - -1. [Overview](#overview) -2. [Authentication](#authentication) -3. [Base URL](#base-url) -4. [Rate Limiting](#rate-limiting) -5. [Request Format](#request-format) -6. [Response Format](#response-format) -7. [Error Handling](#error-handling) -8. [API Endpoints](#api-endpoints) - - [Users](#users) - - [Workspaces](#workspaces) - - [Tasks](#tasks) - - [Projects](#projects) - - [Comments](#comments) - - [Recurring Tasks](#recurring-tasks) - - [Custom Fields](#custom-fields) - - [Schedules](#schedules) - - [Statuses](#statuses) -9. [Data Types](#data-types) -10. [Pagination](#pagination) -11. [Webhooks](#webhooks) -12. [Code Examples](#code-examples) -13. [SDKs and Libraries](#sdks-and-libraries) -14. [Best Practices](#best-practices) - -## Overview - -The Motion API provides programmatic access to Motion's task management, project management, and scheduling features. It enables developers to build custom integrations and automate workflows. - -**Note**: The API is intended for advanced users. Most users should use Motion's desktop and mobile applications for everyday use. - -### Key Features - -- Full CRUD operations on tasks, projects, and comments -- Recurring task management -- Custom field support -- Workspace management -- Real-time scheduling access -- Comprehensive filtering and search capabilities - -## Authentication - -Motion uses API Key authentication for all API requests. - -### Creating an API Key - -1. Log into your Motion account at [app.usemotion.com](https://app.usemotion.com) -2. Navigate to **Settings** → **API Keys** -3. Click **Create API Key** -4. Give your key a descriptive name -5. Copy the key immediately - **it will only be shown once** - -### Using the API Key - -Include your API key in the `X-API-Key` header of all requests: - -```http -X-API-Key: your-api-key-here -``` - -### Security Best Practices - -- Store API keys securely (use environment variables) -- Never commit API keys to version control -- Rotate keys regularly -- Use separate keys for different environments -- Restrict key permissions when possible - -## Base URL - -All API requests should be made to: - -``` -https://api.usemotion.com/v1 -``` - -## Rate Limiting - -Motion implements rate limiting to ensure fair usage and system stability. - -### Rate Limits by Plan - -| Plan | Requests per Minute | Requests per Hour | -|------|-------------------|-------------------| -| Starter | 12 | 720 | -| Professional | 120 | 7,200 | -| Team | 120 | 7,200 | -| Enterprise | Custom | Custom | - -### Rate Limit Implementation - -- Uses a **sliding window** approach (rolling 60-second period) -- Limits apply per API key -- All endpoints count toward the same limit - -### Rate Limit Headers - -Every response includes rate limit information: - -```http -X-RateLimit-Limit: 120 -X-RateLimit-Remaining: 118 -X-RateLimit-Reset: 1625097600 -``` - -- `X-RateLimit-Limit`: Total requests allowed per minute -- `X-RateLimit-Remaining`: Requests remaining in current window -- `X-RateLimit-Reset`: Unix timestamp when the window resets - -### Handling Rate Limits - -When rate limited, the API returns: - -```http -HTTP/1.1 429 Too Many Requests -Content-Type: application/json -Retry-After: 30 - -{ - "error": { - "code": "rate_limit_exceeded", - "message": "Rate limit exceeded. Please retry after 30 seconds." - } -} -``` - -## Request Format - -### Headers - -Required headers for all requests: - -```http -Content-Type: application/json -X-API-Key: your-api-key-here -``` - -### Request Body - -All POST and PUT requests should send JSON-encoded bodies: - -```json -{ - "name": "Task name", - "description": "Task description", - "dueDate": "2025-07-15T10:00:00Z" -} -``` - -### Date/Time Format - -All date/time values must use ISO 8601 format: - -- Full datetime: `2025-07-15T10:00:00Z` -- Date only: `2025-07-15` -- With timezone: `2025-07-15T10:00:00-07:00` - -## Response Format - -### Success Response - -```json -{ - "data": { - "id": "task_123", - "name": "Complete project", - "status": "active", - "createdAt": "2025-07-14T10:00:00Z" - } -} -``` - -### List Response - -```json -{ - "data": [ - { - "id": "task_123", - "name": "Task 1" - }, - { - "id": "task_124", - "name": "Task 2" - } - ], - "meta": { - "hasMore": true, - "cursor": "eyJpZCI6MTIzfQ==" - } -} -``` - -## Error Handling - -### Error Response Format - -```json -{ - "error": { - "code": "validation_error", - "message": "Validation failed", - "details": { - "name": ["Name is required"], - "dueDate": ["Invalid date format"] - } - } -} -``` - -### HTTP Status Codes - -| Status Code | Description | -|------------|-------------| -| 200 | Success - Request completed successfully | -| 201 | Created - Resource created successfully | -| 204 | No Content - Request succeeded with no response body | -| 400 | Bad Request - Invalid request parameters | -| 401 | Unauthorized - Missing or invalid API key | -| 403 | Forbidden - Insufficient permissions | -| 404 | Not Found - Resource doesn't exist | -| 409 | Conflict - Resource conflict (e.g., duplicate) | -| 422 | Unprocessable Entity - Validation error | -| 429 | Too Many Requests - Rate limit exceeded | -| 500 | Internal Server Error - Server error | -| 503 | Service Unavailable - Temporary server issue | - -### Error Codes - -Common error codes returned in the `error.code` field: - -- `invalid_api_key` - API key is invalid or expired -- `insufficient_permissions` - API key lacks required permissions -- `resource_not_found` - Requested resource doesn't exist -- `validation_error` - Request validation failed -- `rate_limit_exceeded` - Rate limit exceeded -- `internal_error` - Internal server error - -## API Endpoints - -### Users - -#### Get Current User - -Returns information about the API key owner. - -```http -GET /users/me -``` - -**Response:** - -```json -{ - "data": { - "id": "user_123", - "email": "user@example.com", - "name": "John Doe", - "role": "admin", - "createdAt": "2025-01-01T00:00:00Z" - } -} -``` - -### Workspaces - -#### List Workspaces - -Get all workspaces accessible to the user. - -```http -GET /workspaces -``` - -**Query Parameters:** - -- `cursor` (string) - Pagination cursor -- `limit` (integer) - Number of results (1-100, default: 20) - -**Response:** - -```json -{ - "data": [ - { - "id": "workspace_123", - "name": "Marketing Team", - "type": "team", - "memberCount": 5, - "createdAt": "2025-01-01T00:00:00Z" - } - ], - "meta": { - "hasMore": false, - "cursor": null - } -} -``` - -### Tasks - -#### Create Task - -Create a new task. - -```http -POST /tasks -``` - -**Request Body:** - -```json -{ - "name": "Complete Q3 Report", - "description": "Prepare and submit the Q3 financial report", - "workspaceId": "workspace_123", - "projectId": "project_456", - "assigneeId": "user_789", - "status": "TODO", - "priority": "HIGH", - "dueDate": "2025-07-30T17:00:00Z", - "duration": 120, - "labels": ["urgent", "finance"], - "customFields": { - "department": "Finance", - "budget": 5000 - } -} -``` - -**Required Fields:** -- `name` (string) - Task name -- `workspaceId` (string) - Workspace ID - -**Optional Fields:** -- `description` (string) - Task description -- `projectId` (string) - Project ID -- `assigneeId` (string) - User ID to assign -- `status` (string) - Task status (TODO, IN_PROGRESS, DONE, BLOCKED) -- `priority` (string) - Priority (LOW, MEDIUM, HIGH, URGENT) -- `dueDate` (string) - Due date in ISO 8601 -- `duration` (integer) - Duration in minutes -- `labels` (array) - Array of label strings -- `customFields` (object) - Custom field values - -**Response:** - -```json -{ - "data": { - "id": "task_999", - "name": "Complete Q3 Report", - "description": "Prepare and submit the Q3 financial report", - "workspaceId": "workspace_123", - "projectId": "project_456", - "assignee": { - "id": "user_789", - "name": "Jane Smith", - "email": "jane@example.com" - }, - "status": "TODO", - "priority": "HIGH", - "dueDate": "2025-07-30T17:00:00Z", - "duration": 120, - "labels": ["urgent", "finance"], - "customFields": { - "department": "Finance", - "budget": 5000 - }, - "createdAt": "2025-07-14T10:00:00Z", - "updatedAt": "2025-07-14T10:00:00Z" - } -} -``` - -#### List Tasks - -Get all tasks with optional filtering. - -```http -GET /tasks -``` - -**Query Parameters:** - -- `workspaceId` (string) - Filter by workspace -- `projectId` (string) - Filter by project -- `assigneeId` (string) - Filter by assignee -- `status` (string) - Filter by status -- `priority` (string) - Filter by priority -- `label` (string) - Filter by label (can be used multiple times) -- `dueBefore` (string) - Tasks due before date -- `dueAfter` (string) - Tasks due after date -- `createdBefore` (string) - Tasks created before date -- `createdAfter` (string) - Tasks created after date -- `updatedBefore` (string) - Tasks updated before date -- `updatedAfter` (string) - Tasks updated after date -- `includeCompleted` (boolean) - Include completed tasks (default: false) -- `cursor` (string) - Pagination cursor -- `limit` (integer) - Number of results (1-100, default: 20) - -**Response:** - -```json -{ - "data": [ - { - "id": "task_123", - "name": "Task 1", - "status": "IN_PROGRESS", - "assignee": { - "id": "user_123", - "name": "John Doe" - }, - "dueDate": "2025-07-15T10:00:00Z", - "priority": "MEDIUM" - } - ], - "meta": { - "hasMore": true, - "cursor": "eyJpZCI6MTIzfQ==" - } -} -``` - -#### Get Task - -Get a specific task by ID. - -```http -GET /tasks/{taskId} -``` - -**Response:** - -Returns the full task object as shown in Create Task response. - -#### Update Task - -Update an existing task. - -```http -PUT /tasks/{taskId} -``` - -**Request Body:** - -Any fields from Create Task can be updated. Only include fields you want to change. - -```json -{ - "name": "Updated Task Name", - "status": "IN_PROGRESS", - "priority": "URGENT" -} -``` - -**Response:** - -Returns the updated task object. - -#### Delete Task - -Delete a task permanently. - -```http -DELETE /tasks/{taskId} -``` - -**Response:** - -```http -HTTP/1.1 204 No Content -``` - -#### Move Task - -Move a task to another workspace. - -```http -POST /tasks/{taskId}/move -``` - -**Request Body:** - -```json -{ - "workspaceId": "workspace_456" -} -``` - -**Note:** When moving tasks between workspaces, the following are reset: -- Project assignment -- Status -- Labels -- Assignee - -**Response:** - -Returns the updated task object. - -#### Unassign Task - -Remove assignee from a task. - -```http -POST /tasks/{taskId}/unassign -``` - -**Response:** - -Returns the updated task object with `assignee` set to `null`. - -### Projects - -#### Create Project - -Create a new project. - -```http -POST /projects -``` - -**Request Body:** - -```json -{ - "name": "Q3 Marketing Campaign", - "description": "Launch new product marketing campaign", - "workspaceId": "workspace_123", - "color": "#FF6B6B", - "status": "ACTIVE", - "startDate": "2025-07-01", - "endDate": "2025-09-30", - "budget": 50000, - "customFields": { - "department": "Marketing", - "manager": "Sarah Johnson" - } -} -``` - -**Required Fields:** -- `name` (string) - Project name -- `workspaceId` (string) - Workspace ID - -**Optional Fields:** -- `description` (string) - Project description -- `color` (string) - Hex color code -- `status` (string) - Project status (ACTIVE, ON_HOLD, COMPLETED, CANCELLED) -- `startDate` (string) - Start date -- `endDate` (string) - End date -- `budget` (number) - Project budget -- `customFields` (object) - Custom field values - -#### List Projects - -Get all projects with optional filtering. - -```http -GET /projects -``` - -**Query Parameters:** - -- `workspaceId` (string) - Filter by workspace -- `status` (string) - Filter by status -- `cursor` (string) - Pagination cursor -- `limit` (integer) - Number of results (1-100, default: 20) - -#### Get Project - -Get a specific project by ID. - -```http -GET /projects/{projectId} -``` - -### Comments - -#### Create Comment - -Add a comment to a task. - -```http -POST /comments -``` - -**Request Body:** - -```json -{ - "taskId": "task_123", - "content": "This looks great! Just need to update the budget section.", - "mentions": ["user_456", "user_789"] -} -``` - -**Required Fields:** -- `taskId` (string) - Task ID -- `content` (string) - Comment text - -**Optional Fields:** -- `mentions` (array) - Array of user IDs to mention - -#### List Comments - -Get comments for a task. - -```http -GET /comments -``` - -**Query Parameters:** - -- `taskId` (string) - Required - Task ID -- `cursor` (string) - Pagination cursor -- `limit` (integer) - Number of results (1-100, default: 20) - -### Recurring Tasks - -#### Create Recurring Task - -Create a task that repeats on a schedule. - -```http -POST /recurring-tasks -``` - -**Request Body:** - -```json -{ - "name": "Weekly Team Meeting", - "description": "Review progress and plan for next week", - "workspaceId": "workspace_123", - "assigneeId": "user_456", - "duration": 60, - "priority": "MEDIUM", - "recurrence": { - "frequency": "WEEKLY", - "interval": 1, - "daysOfWeek": ["MONDAY"], - "startDate": "2025-07-15", - "endDate": "2025-12-31", - "time": "10:00" - } -} -``` - -**Recurrence Options:** - -- `frequency` (string) - DAILY, WEEKLY, MONTHLY, YEARLY -- `interval` (integer) - Repeat every N periods -- `daysOfWeek` (array) - For WEEKLY: ["MONDAY", "TUESDAY", etc.] -- `dayOfMonth` (integer) - For MONTHLY: 1-31 -- `monthOfYear` (string) - For YEARLY: "JANUARY", "FEBRUARY", etc. -- `startDate` (string) - When to start recurring -- `endDate` (string) - When to stop recurring (optional) -- `time` (string) - Time of day (HH:MM format) - -#### List Recurring Tasks - -```http -GET /recurring-tasks -``` - -#### Delete Recurring Task - -```http -DELETE /recurring-tasks/{recurringTaskId} -``` - -**Query Parameters:** - -- `deleteInstances` (boolean) - Delete all created instances (default: false) - -### Custom Fields - -#### Create Custom Field - -Define a custom field for tasks or projects. - -```http -POST /custom-fields -``` - -**Request Body:** - -```json -{ - "name": "Department", - "type": "SELECT", - "entityType": "TASK", - "workspaceId": "workspace_123", - "required": false, - "options": [ - {"label": "Engineering", "value": "eng"}, - {"label": "Marketing", "value": "mkt"}, - {"label": "Sales", "value": "sales"} - ] -} -``` - -**Field Types:** - -- `TEXT` - Single line text -- `TEXTAREA` - Multi-line text -- `NUMBER` - Numeric value -- `SELECT` - Dropdown selection -- `MULTISELECT` - Multiple selection -- `DATE` - Date picker -- `CHECKBOX` - Boolean -- `URL` - URL field -- `EMAIL` - Email field - -#### List Custom Fields - -```http -GET /custom-fields -``` - -**Query Parameters:** - -- `workspaceId` (string) - Filter by workspace -- `entityType` (string) - Filter by entity type (TASK, PROJECT) - -#### Delete Custom Field - -```http -DELETE /custom-fields/{customFieldId} -``` - -### Schedules - -#### List Schedules - -Get scheduling information for users. - -```http -GET /schedules -``` - -**Query Parameters:** - -- `userId` (string) - User ID (defaults to current user) -- `startDate` (string) - Start of date range -- `endDate` (string) - End of date range -- `includeBlocked` (boolean) - Include blocked time (default: true) - -**Response:** - -```json -{ - "data": { - "userId": "user_123", - "schedule": [ - { - "date": "2025-07-15", - "blocks": [ - { - "startTime": "09:00", - "endTime": "10:00", - "type": "TASK", - "taskId": "task_456", - "taskName": "Project Review" - }, - { - "startTime": "14:00", - "endTime": "15:00", - "type": "MEETING", - "title": "Team Standup" - } - ] - } - ] - } -} -``` - -### Statuses - -#### Get Statuses - -Get available statuses for a workspace. - -```http -GET /statuses -``` - -**Query Parameters:** - -- `workspaceId` (string) - Workspace ID - -**Response:** - -```json -{ - "data": [ - { - "id": "status_1", - "name": "To Do", - "type": "TODO", - "color": "#808080", - "order": 1 - }, - { - "id": "status_2", - "name": "In Progress", - "type": "IN_PROGRESS", - "color": "#3B82F6", - "order": 2 - }, - { - "id": "status_3", - "name": "Done", - "type": "DONE", - "color": "#10B981", - "order": 3 - } - ] -} -``` - -## Data Types - -### Task Object - -```typescript -interface Task { - id: string; - name: string; - description?: string; - workspaceId: string; - projectId?: string; - assignee?: User; - status: TaskStatus; - priority: Priority; - dueDate?: string; - duration?: number; - labels: string[]; - customFields: Record; - createdAt: string; - updatedAt: string; - completedAt?: string; -} -``` - -### Project Object - -```typescript -interface Project { - id: string; - name: string; - description?: string; - workspaceId: string; - color?: string; - status: ProjectStatus; - startDate?: string; - endDate?: string; - budget?: number; - customFields: Record; - taskCount: number; - completedTaskCount: number; - createdAt: string; - updatedAt: string; -} -``` - -### User Object - -```typescript -interface User { - id: string; - email: string; - name: string; - role: UserRole; - avatarUrl?: string; - timezone?: string; - createdAt: string; -} -``` - -### Enums - -```typescript -enum TaskStatus { - TODO = "TODO", - IN_PROGRESS = "IN_PROGRESS", - DONE = "DONE", - BLOCKED = "BLOCKED" -} - -enum Priority { - LOW = "LOW", - MEDIUM = "MEDIUM", - HIGH = "HIGH", - URGENT = "URGENT" -} - -enum ProjectStatus { - ACTIVE = "ACTIVE", - ON_HOLD = "ON_HOLD", - COMPLETED = "COMPLETED", - CANCELLED = "CANCELLED" -} - -enum UserRole { - ADMIN = "ADMIN", - MEMBER = "MEMBER", - VIEWER = "VIEWER" -} -``` - -## Pagination - -Motion uses cursor-based pagination for all list endpoints. - -### Request - -```http -GET /tasks?limit=50&cursor=eyJpZCI6MTIzfQ== -``` - -### Response - -```json -{ - "data": [...], - "meta": { - "hasMore": true, - "cursor": "eyJpZCI6MTczfQ==" - } -} -``` - -### Implementation Example - -```javascript -async function getAllTasks() { - const tasks = []; - let cursor = null; - let hasMore = true; - - while (hasMore) { - const response = await fetch( - `https://api.usemotion.com/v1/tasks?limit=100${cursor ? `&cursor=${cursor}` : ''}`, - { - headers: { - 'X-API-Key': process.env.MOTION_API_KEY - } - } - ); - - const data = await response.json(); - tasks.push(...data.data); - - hasMore = data.meta.hasMore; - cursor = data.meta.cursor; - } - - return tasks; -} -``` - -## Webhooks - -Motion supports webhooks for real-time event notifications. - -### Webhook Events - -- `task.created` - Task created -- `task.updated` - Task updated -- `task.deleted` - Task deleted -- `task.completed` - Task marked as done -- `project.created` - Project created -- `project.updated` - Project updated -- `project.deleted` - Project deleted -- `comment.created` - Comment added - -### Webhook Payload - -```json -{ - "id": "webhook_event_123", - "type": "task.updated", - "timestamp": "2025-07-14T10:00:00Z", - "data": { - "task": { - "id": "task_456", - "name": "Updated task", - // ... full task object - }, - "changes": { - "status": { - "from": "TODO", - "to": "IN_PROGRESS" - } - } - } -} -``` - -### Webhook Security - -Webhooks include a signature header for verification: - -```http -X-Motion-Signature: sha256=a1b2c3d4e5f6... -``` - -Verify the signature using your webhook secret: - -```javascript -const crypto = require('crypto'); - -function verifyWebhookSignature(payload, signature, secret) { - const expectedSignature = 'sha256=' + - crypto.createHmac('sha256', secret) - .update(payload) - .digest('hex'); - - return crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(expectedSignature) - ); -} -``` - -## Code Examples - -### JavaScript/Node.js - -#### Basic Setup - -```javascript -const axios = require('axios'); - -const motionApi = axios.create({ - baseURL: 'https://api.usemotion.com/v1', - headers: { - 'X-API-Key': process.env.MOTION_API_KEY, - 'Content-Type': 'application/json' - } -}); - -// Handle rate limiting with exponential backoff -motionApi.interceptors.response.use( - response => response, - async error => { - if (error.response?.status === 429) { - const retryAfter = error.response.headers['retry-after'] || 30; - await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)); - return motionApi.request(error.config); - } - return Promise.reject(error); - } -); -``` - -#### Create Task with Error Handling - -```javascript -async function createTask(taskData) { - try { - const response = await motionApi.post('/tasks', { - name: taskData.name, - description: taskData.description, - workspaceId: taskData.workspaceId, - dueDate: new Date(taskData.dueDate).toISOString(), - priority: taskData.priority || 'MEDIUM', - assigneeId: taskData.assigneeId - }); - - console.log('Task created:', response.data.data); - return response.data.data; - } catch (error) { - if (error.response) { - console.error('API Error:', error.response.data.error); - - // Handle validation errors - if (error.response.status === 422) { - console.error('Validation errors:', error.response.data.error.details); - } - } else { - console.error('Network error:', error.message); - } - throw error; - } -} -``` - -#### Batch Operations - -```javascript -async function batchCreateTasks(tasks) { - const results = { - successful: [], - failed: [] - }; - - // Use Promise.allSettled to handle partial failures - const promises = tasks.map(task => - createTask(task) - .then(result => ({ status: 'fulfilled', value: result })) - .catch(error => ({ status: 'rejected', reason: error })) - ); - - const outcomes = await Promise.allSettled(promises); - - outcomes.forEach((outcome, index) => { - if (outcome.status === 'fulfilled') { - results.successful.push(outcome.value); - } else { - results.failed.push({ - task: tasks[index], - error: outcome.reason - }); - } - }); - - return results; -} -``` - -### Python - -```python -import os -import time -import requests -from typing import Dict, List, Optional -from datetime import datetime - -class MotionAPI: - def __init__(self, api_key: str): - self.api_key = api_key - self.base_url = "https://api.usemotion.com/v1" - self.session = requests.Session() - self.session.headers.update({ - "X-API-Key": api_key, - "Content-Type": "application/json" - }) - - def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict: - """Make API request with automatic retry on rate limit.""" - url = f"{self.base_url}{endpoint}" - - for attempt in range(3): - response = self.session.request(method, url, **kwargs) - - if response.status_code == 429: - retry_after = int(response.headers.get("Retry-After", 30)) - time.sleep(retry_after) - continue - - response.raise_for_status() - return response.json() - - raise Exception("Max retries exceeded") - - def create_task(self, - name: str, - workspace_id: str, - description: Optional[str] = None, - due_date: Optional[datetime] = None, - priority: str = "MEDIUM", - assignee_id: Optional[str] = None) -> Dict: - """Create a new task.""" - data = { - "name": name, - "workspaceId": workspace_id, - "priority": priority - } - - if description: - data["description"] = description - if due_date: - data["dueDate"] = due_date.isoformat() - if assignee_id: - data["assigneeId"] = assignee_id - - return self._make_request("POST", "/tasks", json=data) - - def list_tasks(self, - workspace_id: Optional[str] = None, - status: Optional[str] = None, - limit: int = 20) -> List[Dict]: - """List tasks with optional filters.""" - params = {"limit": limit} - - if workspace_id: - params["workspaceId"] = workspace_id - if status: - params["status"] = status - - tasks = [] - cursor = None - - while True: - if cursor: - params["cursor"] = cursor - - response = self._make_request("GET", "/tasks", params=params) - tasks.extend(response["data"]) - - if not response["meta"]["hasMore"]: - break - - cursor = response["meta"]["cursor"] - - return tasks - -# Usage example -if __name__ == "__main__": - api = MotionAPI(os.environ["MOTION_API_KEY"]) - - # Create a task - task = api.create_task( - name="Review Q3 Report", - workspace_id="workspace_123", - description="Review and provide feedback on Q3 financial report", - due_date=datetime(2025, 7, 30, 17, 0), - priority="HIGH" - ) - - print(f"Created task: {task['data']['id']}") -``` - -### cURL Examples - -#### Create Task - -```bash -curl -X POST https://api.usemotion.com/v1/tasks \ - -H "X-API-Key: your-api-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Complete project documentation", - "workspaceId": "workspace_123", - "priority": "HIGH", - "dueDate": "2025-07-20T17:00:00Z" - }' -``` - -#### List Tasks with Filters - -```bash -curl -X GET "https://api.usemotion.com/v1/tasks?workspaceId=workspace_123&status=IN_PROGRESS&limit=50" \ - -H "X-API-Key: your-api-key-here" -``` - -#### Update Task Status - -```bash -curl -X PUT https://api.usemotion.com/v1/tasks/task_456 \ - -H "X-API-Key: your-api-key-here" \ - -H "Content-Type: application/json" \ - -d '{ - "status": "DONE" - }' -``` - -## SDKs and Libraries - -### Official SDKs - -Motion does not currently provide official SDKs. The REST API can be used directly with any HTTP client. - -### Community Libraries - -Several community-maintained libraries are available: - -- **Node.js/TypeScript**: Various npm packages available -- **Python**: Community packages on PyPI -- **Go**: Community implementations on GitHub - -### Integration Platforms - -Motion is integrated with several platforms: - -- **Zapier**: 5000+ app integrations -- **Make (Integromat)**: Visual automation workflows -- **Pipedream**: Code-based integrations -- **n8n**: Open-source automation - -## Best Practices - -### 1. Error Handling - -Always implement comprehensive error handling: - -```javascript -try { - const result = await apiCall(); -} catch (error) { - if (error.response) { - // API error - switch (error.response.status) { - case 400: - console.error('Bad request:', error.response.data); - break; - case 401: - console.error('Authentication failed'); - break; - case 429: - console.error('Rate limited'); - break; - default: - console.error('API error:', error.response.status); - } - } else if (error.request) { - // Network error - console.error('Network error:', error.message); - } else { - // Other error - console.error('Error:', error.message); - } -} -``` - -### 2. Rate Limit Management - -Implement a queue system for API requests: - -```javascript -const PQueue = require('p-queue'); - -// Create queue with concurrency based on your plan -const queue = new PQueue({ - concurrency: 1, - interval: 60000, // 1 minute - intervalCap: 120 // Requests per interval -}); - -// Add requests to queue -const task = await queue.add(() => createTask(taskData)); -``` - -### 3. Caching - -Cache frequently accessed data: - -```javascript -const cache = new Map(); -const CACHE_TTL = 5 * 60 * 1000; // 5 minutes - -async function getCachedProject(projectId) { - const cached = cache.get(projectId); - - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - return cached.data; - } - - const project = await api.get(`/projects/${projectId}`); - cache.set(projectId, { - data: project, - timestamp: Date.now() - }); - - return project; -} -``` - -### 4. Bulk Operations - -Batch operations when possible: - -```javascript -// Instead of creating tasks one by one -for (const task of tasks) { - await createTask(task); // Slow, hits rate limits -} - -// Use concurrent requests with rate limiting -const results = await Promise.all( - tasks.map(task => - queue.add(() => createTask(task)) - ) -); -``` - -### 5. Webhook Processing - -Process webhooks asynchronously: - -```javascript -app.post('/webhooks/motion', (req, res) => { - // Verify signature - if (!verifySignature(req.body, req.headers['x-motion-signature'])) { - return res.status(401).send('Invalid signature'); - } - - // Acknowledge receipt immediately - res.status(200).send('OK'); - - // Process webhook asynchronously - processWebhookAsync(req.body); -}); -``` - -### 6. Data Validation - -Always validate data before sending to API: - -```javascript -const Joi = require('joi'); - -const taskSchema = Joi.object({ - name: Joi.string().required().max(255), - workspaceId: Joi.string().required(), - description: Joi.string().max(5000), - dueDate: Joi.date().iso().min('now'), - priority: Joi.string().valid('LOW', 'MEDIUM', 'HIGH', 'URGENT'), - assigneeId: Joi.string() -}); - -function validateTask(taskData) { - const { error, value } = taskSchema.validate(taskData); - if (error) { - throw new Error(`Validation error: ${error.details[0].message}`); - } - return value; -} -``` - -### 7. Monitoring and Logging - -Implement comprehensive logging: - -```javascript -const winston = require('winston'); - -const logger = winston.createLogger({ - level: 'info', - format: winston.format.json(), - transports: [ - new winston.transports.File({ filename: 'motion-api.log' }) - ] -}); - -// Log all API requests -motionApi.interceptors.request.use(request => { - logger.info('API Request', { - method: request.method, - url: request.url, - timestamp: new Date().toISOString() - }); - return request; -}); - -// Log responses -motionApi.interceptors.response.use( - response => { - logger.info('API Response', { - status: response.status, - url: response.config.url, - duration: response.duration - }); - return response; - }, - error => { - logger.error('API Error', { - status: error.response?.status, - message: error.message, - url: error.config?.url - }); - return Promise.reject(error); - } -); -``` - -### 8. Security Best Practices - -1. **Environment Variables**: Never hardcode API keys - ```bash - MOTION_API_KEY=your-key-here - ``` - -2. **Secure Storage**: Use secrets management services in production - -3. **Minimal Permissions**: Request only necessary scopes - -4. **HTTPS Only**: Always use HTTPS for API communication - -5. **Input Sanitization**: Sanitize all user input before API calls - -6. **Audit Logging**: Log all API operations for security audits - -## Troubleshooting - -### Common Issues - -1. **401 Unauthorized** - - Verify API key is correct - - Check key hasn't expired - - Ensure key has required permissions - -2. **429 Rate Limited** - - Implement exponential backoff - - Check your plan's rate limits - - Use request queuing - -3. **422 Validation Error** - - Check required fields - - Verify date formats (ISO 8601) - - Validate enum values - -4. **404 Not Found** - - Verify resource IDs - - Check workspace access - - Ensure resource hasn't been deleted - -5. **500 Server Error** - - Retry with exponential backoff - - Check Motion status page - - Contact support if persistent - -### Debug Mode - -Enable detailed logging for debugging: - -```javascript -if (process.env.DEBUG) { - motionApi.interceptors.request.use(request => { - console.log('Request:', JSON.stringify(request, null, 2)); - return request; - }); - - motionApi.interceptors.response.use( - response => { - console.log('Response:', JSON.stringify(response.data, null, 2)); - return response; - }, - error => { - console.error('Error Details:', error.response?.data); - return Promise.reject(error); - } - ); -} -``` - -## Resources - -### Official Resources - -- **API Documentation**: [https://docs.usemotion.com/](https://docs.usemotion.com/) -- **Getting Started Guide**: [https://docs.usemotion.com/cookbooks/getting-started/](https://docs.usemotion.com/cookbooks/getting-started/) -- **Help Center**: [https://help.usemotion.com/](https://help.usemotion.com/) -- **Status Page**: [https://status.usemotion.com/](https://status.usemotion.com/) - -### Community Resources - -- **GitHub Topics**: Search for "motion-api" on GitHub -- **Stack Overflow**: Tag questions with "motion-api" -- **Discord/Slack Communities**: Join Motion user communities - -### Support - -- **Email**: support@usemotion.com -- **In-app Support**: Available in Motion app -- **Enterprise Support**: Contact sales for dedicated support - ---- - -*This reference is based on publicly available information and may not reflect the most recent API changes. Always refer to the official Motion API documentation for the most up-to-date information.* \ No newline at end of file diff --git a/docs/official_api/README.md b/docs/official_api/README.md new file mode 100644 index 0000000..5bffd47 --- /dev/null +++ b/docs/official_api/README.md @@ -0,0 +1,136 @@ +# Motion API Documentation + +This is the official Motion API documentation, organized by resource type and API methods. + +## Base URL + +``` +https://api.usemotion.com/v1 +``` + +For beta endpoints (custom fields): +``` +https://api.usemotion.com/beta +``` + +## Authentication + +All API requests require authentication using an API key in the request header: + +``` +X-API-Key: YOUR_API_KEY +``` + +## Rate Limits + +- Individual accounts: 12 requests per minute +- Team accounts: 120 requests per minute + +## API Resources + +### Core Resources + +- **[Tasks](./tasks/)** - Core task management functionality + - [Overview](./tasks/README.md) + - [Get Task](./tasks/get-task.md) + - [List Tasks](./tasks/list-tasks.md) + - [Create Task](./tasks/create-task.md) + - [Update Task](./tasks/update-task.md) + - [Delete Task](./tasks/delete-task.md) + - [Move Task](./tasks/move-task.md) + - [Unassign Task](./tasks/unassign-task.md) + +- **[Projects](./projects/)** - Project management + - [Overview](./projects/README.md) + - [Get Project](./projects/get-project.md) + - [List Projects](./projects/list-projects.md) + - [Create Project](./projects/create-project.md) + +- **[Recurring Tasks](./recurring-tasks/)** - Recurring task templates + - [Overview](./recurring-tasks/README.md) + - [Get Recurring Task](./recurring-tasks/get-recurring-task.md) + - [Create Recurring Task](./recurring-tasks/create-recurring-task.md) + - [Delete Recurring Task](./recurring-tasks/delete-recurring-task.md) + +- **[Comments](./comments/)** - Task comments + - [Overview](./comments/README.md) + - [Get Comments](./comments/get-comments.md) + - [Create Comment](./comments/create-comment.md) + +### User & Workspace Resources + +- **[Users](./users/)** - User information + - [Overview](./users/README.md) + - [Get User](./users/get-user.md) + - [Get Current User](./users/get-current-user.md) + +- **[Workspaces](./workspaces/)** - Workspace management + - [Overview](./workspaces/README.md) + - [List Workspaces](./workspaces/list-workspaces.md) + +- **[Schedules](./schedules/)** - Work hour configurations + - [Overview](./schedules/README.md) + - [Get Schedules](./schedules/get-schedules.md) + +- **[Statuses](./statuses/)** - Available task/project statuses + - [Overview](./statuses/README.md) + - [List Statuses](./statuses/list-statuses.md) + +### Beta Features + +- **[Custom Fields](./custom-fields/)** (Beta) - Custom field management + - [Overview](./custom-fields/README.md) + - [List Custom Fields](./custom-fields/list-custom-fields.md) + - [Create Custom Field](./custom-fields/create-custom-field.md) + - [Delete Custom Field](./custom-fields/delete-custom-field.md) + - [Add to Task](./custom-fields/add-to-task.md) + - [Remove from Task](./custom-fields/remove-from-task.md) + - [Add to Project](./custom-fields/add-to-project.md) + - [Remove from Project](./custom-fields/remove-from-project.md) + +## Common Patterns + +### Pagination + +List endpoints support cursor-based pagination: + +```json +{ + "meta": { + "nextCursor": "cursor_string", + "pageSize": 25 + }, + "tasks": [...] +} +``` + +### Date Format + +All datetime values use ISO 8601 format: +``` +2024-01-15T09:00:00Z +``` + +### Response Format + +Successful responses return the requested resource(s). Error responses include: + +```json +{ + "error": { + "message": "Error description", + "code": "ERROR_CODE" + } +} +``` + +## Getting Started + +1. Obtain an API key from your Motion account settings +2. Choose the appropriate endpoint for your use case +3. Make authenticated requests using your API key +4. Handle rate limits and pagination appropriately + +## Support + +For API support, please contact Motion support. The API is intended for advanced users who need programmatic access to Motion's functionality. \ No newline at end of file diff --git a/docs/official_api/authentication.md b/docs/official_api/authentication.md new file mode 100644 index 0000000..5c7bac5 --- /dev/null +++ b/docs/official_api/authentication.md @@ -0,0 +1,41 @@ +# Authentication + +Motion API uses API key authentication for all requests. + +## Obtaining an API Key + +1. Log in to your Motion account +2. Navigate to Account Settings +3. Find the API section +4. Generate or copy your API key + +## Using the API Key + +Include your API key in the request header: + +```http +X-API-Key: YOUR_API_KEY_HERE +``` + +## Example Request + +```bash +curl -X GET https://api.usemotion.com/v1/users/me \ + -H "X-API-Key: YOUR_API_KEY_HERE" +``` + +## Security Best Practices + +- **Never expose your API key** in client-side code or public repositories +- **Rotate your API key** regularly +- **Use environment variables** to store your API key +- **Restrict API key access** to only necessary team members + +## Rate Limits + +Your API key is subject to rate limits based on your account type: + +- **Individual accounts**: 12 requests per minute +- **Team accounts**: 120 requests per minute + +Exceeding rate limits will result in `429 Too Many Requests` responses. \ No newline at end of file diff --git a/docs/official_api/comments/README.md b/docs/official_api/comments/README.md new file mode 100644 index 0000000..4dc0248 --- /dev/null +++ b/docs/official_api/comments/README.md @@ -0,0 +1,100 @@ +# Comments API + +The Comments API allows you to add and retrieve comments on tasks, enabling collaboration and communication within Motion. + +## Overview + +Comments provide a way to: +- Add context and updates to tasks +- Collaborate with team members +- Track task progress and decisions +- Create an audit trail of task-related discussions + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/comments` | [Get all comments for a task](./get-comments.md) | +| POST | `/v1/comments` | [Create a new comment on a task](./create-comment.md) | + +## Comment Object + +```javascript +{ + id: string, // Unique comment identifier + taskId: string, // Associated task ID + content: string, // HTML content (converted from Markdown) + createdAt: datetime, // ISO 8601 creation timestamp + creator: { + id: string, // User ID + name: string, // User name + email: string // User email + } +} +``` + +## Content Format + +- **Input**: Comments are created using GitHub Flavored Markdown +- **Output**: Comments are returned as HTML + +### Supported Markdown Features + +- **Bold**: `**text**` or `__text__` +- **Italic**: `*text*` or `_text_` +- **Links**: `[text](url)` +- **Code**: `` `inline code` `` or code blocks +- **Lists**: Ordered and unordered +- **Headers**: `# H1`, `## H2`, etc. +- **Quotes**: `> quoted text` + +## Common Use Cases + +### 1. Task Updates + +```javascript +POST /v1/comments +{ + "taskId": "task_123", + "content": "**Update**: Completed the initial research phase. Moving to implementation." +} +``` + +### 2. Questions and Clarifications + +```javascript +POST /v1/comments +{ + "taskId": "task_456", + "content": "@jane Could you clarify the requirements for the third deliverable?" +} +``` + +### 3. Progress Tracking + +```javascript +POST /v1/comments +{ + "taskId": "task_789", + "content": "## Progress Update\n- [x] Design mockups\n- [x] API implementation\n- [ ] Frontend integration\n- [ ] Testing" +} +``` + +## Best Practices + +1. **Use Markdown**: Take advantage of formatting to make comments more readable +2. **Be Concise**: Keep comments focused and relevant to the task +3. **Regular Updates**: Add comments for significant progress or changes +4. **Tag Team Members**: Use @mentions to notify specific users +5. **Include Context**: Reference relevant information or decisions + +## Rate Limits + +Comments API follows the same rate limits as other Motion APIs: +- Individual accounts: 12 requests per minute +- Team accounts: 120 requests per minute + +## Related Resources + +- [Tasks API](../tasks/) - Manage tasks that comments are attached to +- [Users API](../users/) - Get information about comment creators \ No newline at end of file diff --git a/docs/official_api/comments/create-comment.md b/docs/official_api/comments/create-comment.md new file mode 100644 index 0000000..5608013 --- /dev/null +++ b/docs/official_api/comments/create-comment.md @@ -0,0 +1,322 @@ +# Create Comment + +Add a new comment to a task. + +## Endpoint + +``` +POST /v1/comments +``` + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +Content-Type: application/json +``` + +### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| taskId | string | Yes | The task on which to place a comment | +| content | string | Yes | GitHub Flavored Markdown representing the comment | + +### Example Request + +```bash +curl -X POST https://api.usemotion.com/v1/comments \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "taskId": "task_123", + "content": "**Status Update**: Completed the initial design phase.\n\nNext steps:\n- Review with stakeholders\n- Implement feedback\n- Finalize designs" + }' +``` + +## Response + +### Success Response (200 OK) + +Returns the created comment with HTML-formatted content: + +```json +{ + "id": "comment_new_001", + "taskId": "task_123", + "content": "

Status Update: Completed the initial design phase.

\n

Next steps:

\n
    \n
  • Review with stakeholders
  • \n
  • Implement feedback
  • \n
  • Finalize designs
  • \n
", + "createdAt": "2024-12-15T16:30:00Z", + "creator": { + "id": "user_456", + "name": "John Doe", + "email": "john@example.com" + } +} +``` + +### Error Responses + +#### 400 Bad Request + +Missing required fields: + +```json +{ + "error": { + "message": "Missing required field: content", + "code": "MISSING_REQUIRED_FIELD" + } +} +``` + +#### 404 Not Found + +Task doesn't exist: + +```json +{ + "error": { + "message": "Task not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +## Markdown Examples + +### 1. Simple Text Comment + +```json +{ + "taskId": "task_123", + "content": "This looks good to me. Approved!" +} +``` + +### 2. Formatted Status Update + +```json +{ + "taskId": "task_123", + "content": "## Progress Update\n\n**Completed:**\n- ✅ Database schema design\n- ✅ API endpoints implementation\n\n**In Progress:**\n- 🔄 Frontend integration\n\n**Blocked:**\n- ❌ Waiting for design assets" +} +``` + +### 3. Code Review Comment + +```json +{ + "taskId": "task_123", + "content": "Found an issue in the implementation:\n\n```javascript\n// Current implementation\nconst result = data.filter(item => item.status = 'active');\n\n// Should be (note the comparison operator)\nconst result = data.filter(item => item.status === 'active');\n```\n\nThis bug causes all items to be marked as active." +} +``` + +### 4. Mention Team Members + +```json +{ + "taskId": "task_123", + "content": "@jane @bob Please review the updated requirements in the attached document.\n\n**Key changes:**\n1. Extended deadline to end of month\n2. Added mobile support requirement\n3. Simplified authentication flow" +} +``` + +### 5. Links and References + +```json +{ + "taskId": "task_123", + "content": "Related resources:\n- [Design mockups](https://figma.com/file/abc123)\n- [API documentation](https://docs.example.com/api)\n- [Previous discussion](#comment_789)\n\nPlease review before our meeting tomorrow." +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function createComment(taskId, content) { + const response = await fetch( + 'https://api.usemotion.com/v1/comments', + { + method: 'POST', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + taskId, + content + }) + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to create comment: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage examples +// Simple comment +const comment1 = await createComment( + 'task_123', + 'Task completed successfully!' +); + +// Formatted comment +const comment2 = await createComment( + 'task_456', + `## Review Complete + +**Findings:** +- Code quality: ✅ Excellent +- Test coverage: ⚠️ Needs improvement (currently 75%) +- Documentation: ✅ Complete + +**Recommendation:** Approve with minor changes.` +); +``` + +### Python + +```python +import requests +import json +import os + +def create_comment(task_id, content): + url = "https://api.usemotion.com/v1/comments" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY"), + "Content-Type": "application/json" + } + + data = { + "taskId": task_id, + "content": content + } + + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + + return response.json() + +# Usage +# Simple comment +comment1 = create_comment( + "task_123", + "Task completed successfully!" +) + +# Formatted comment with markdown +comment2 = create_comment( + "task_456", + """## Review Complete + +**Findings:** +- Code quality: ✅ Excellent +- Test coverage: ⚠️ Needs improvement (currently 75%) +- Documentation: ✅ Complete + +**Recommendation:** Approve with minor changes.""" +) + +print(f"Created comment: {comment2['id']}") +``` + +### Comment Templates + +```javascript +// Create reusable comment templates +const commentTemplates = { + statusUpdate: (status, details) => ` +## Status Update + +**Current Status:** ${status} + +${details} + +_Updated at ${new Date().toLocaleString()}_ + `, + + codeReview: (findings, recommendation) => ` +## Code Review + +**Findings:** +${findings.map(f => `- ${f}`).join('\n')} + +**Recommendation:** ${recommendation} + `, + + blocked: (reason, needsFrom) => ` +⚠️ **BLOCKED** + +**Reason:** ${reason} + +**Needs from:** ${needsFrom} + +Please unblock ASAP to maintain timeline. + ` +}; + +// Use templates +await createComment( + 'task_123', + commentTemplates.statusUpdate( + 'In Progress', + 'Completed backend implementation. Starting frontend work.' + ) +); + +await createComment( + 'task_456', + commentTemplates.blocked( + 'Waiting for API credentials', + '@jane from DevOps team' + ) +); +``` + +## Markdown Support + +### Supported Elements + +- **Headers**: `# H1`, `## H2`, `### H3`, etc. +- **Bold**: `**bold**` or `__bold__` +- **Italic**: `*italic*` or `_italic_` +- **Links**: `[text](url)` +- **Images**: `![alt text](url)` +- **Lists**: `- item` or `1. item` +- **Code**: `` `inline` `` or ` ```block``` ` +- **Quotes**: `> quoted text` +- **Tables**: Using pipe syntax +- **Task Lists**: `- [ ] unchecked` or `- [x] checked` +- **Line Breaks**: Two spaces at end of line or `\n\n` + +### Unsupported Elements + +- HTML tags (will be escaped) +- JavaScript or other scripts +- Embedded media (except images) + +## Best Practices + +1. **Use Markdown**: Format comments for better readability +2. **Be Concise**: Keep comments focused and relevant +3. **Add Context**: Include why, not just what +4. **Use Templates**: Create consistent comment formats +5. **Tag People**: Use @mentions for visibility +6. **Include Links**: Reference related resources +7. **Update Status**: Keep task status current through comments + +## Notes + +- Comments cannot be edited after creation +- Comments cannot be deleted via API +- The creator is automatically set to the API key owner +- Content is converted from Markdown to HTML +- Maximum content length depends on your plan \ No newline at end of file diff --git a/docs/official_api/comments/get-comments.md b/docs/official_api/comments/get-comments.md new file mode 100644 index 0000000..457f56b --- /dev/null +++ b/docs/official_api/comments/get-comments.md @@ -0,0 +1,294 @@ +# Get Comments + +Retrieve all comments for a specific task. + +## Endpoint + +``` +GET /v1/comments +``` + +## Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| taskId | string | Yes | The task for which all comments should be returned | +| cursor | string | No | Pagination cursor from previous response | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X GET "https://api.usemotion.com/v1/comments?taskId=task_123" \ + -H "X-API-Key: YOUR_API_KEY" +``` + +### With Pagination + +```bash +curl -X GET "https://api.usemotion.com/v1/comments?taskId=task_123&cursor=eyJza2lwIjoyNX0=" \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +```json +{ + "meta": { + "nextCursor": "eyJza2lwIjoyNX0=", + "pageSize": 25 + }, + "comments": [ + { + "id": "comment_001", + "taskId": "task_123", + "content": "

Status Update: Completed the initial design phase.

", + "createdAt": "2024-12-15T10:30:00Z", + "creator": { + "id": "user_456", + "name": "John Doe", + "email": "john@example.com" + } + }, + { + "id": "comment_002", + "taskId": "task_123", + "content": "

Great progress! Let's review in tomorrow's standup.

", + "createdAt": "2024-12-15T11:00:00Z", + "creator": { + "id": "user_789", + "name": "Jane Smith", + "email": "jane@example.com" + } + }, + { + "id": "comment_003", + "taskId": "task_123", + "content": "

Adding the following requirements:

  • Mobile responsive design
  • Dark mode support
", + "createdAt": "2024-12-15T14:00:00Z", + "creator": { + "id": "user_456", + "name": "John Doe", + "email": "john@example.com" + } + } + ] +} +``` + +### Empty Result + +```json +{ + "meta": { + "nextCursor": null, + "pageSize": 0 + }, + "comments": [] +} +``` + +### Error Responses + +#### 400 Bad Request + +Missing required taskId parameter: + +```json +{ + "error": { + "message": "Missing required parameter: taskId", + "code": "MISSING_REQUIRED_FIELD" + } +} +``` + +#### 404 Not Found + +Task doesn't exist: + +```json +{ + "error": { + "message": "Task not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +## Pagination + +Comments are returned in chronological order (oldest first) with cursor-based pagination: + +```javascript +async function getAllComments(taskId) { + const allComments = []; + let cursor = null; + + do { + const params = new URLSearchParams({ taskId }); + if (cursor) params.append('cursor', cursor); + + const response = await fetch( + `https://api.usemotion.com/v1/comments?${params}`, + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + const data = await response.json(); + allComments.push(...data.comments); + cursor = data.meta.nextCursor; + + } while (cursor); + + return allComments; +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function getTaskComments(taskId, cursor = null) { + const params = new URLSearchParams({ taskId }); + if (cursor) params.append('cursor', cursor); + + const response = await fetch( + `https://api.usemotion.com/v1/comments?${params}`, + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to get comments: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + const result = await getTaskComments('task_123'); + console.log(`Found ${result.comments.length} comments`); + + result.comments.forEach(comment => { + console.log(`${comment.creator.name}: ${comment.content}`); + }); +} catch (error) { + console.error('Error:', error); +} +``` + +### Python + +```python +import requests +import os +from urllib.parse import urlencode + +def get_task_comments(task_id, cursor=None): + url = "https://api.usemotion.com/v1/comments" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + params = {"taskId": task_id} + if cursor: + params["cursor"] = cursor + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + return response.json() + +# Usage +try: + result = get_task_comments("task_123") + print(f"Found {len(result['comments'])} comments") + + for comment in result['comments']: + print(f"{comment['creator']['name']}: {comment['content']}") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Display Comments with Formatting + +```javascript +function displayComments(comments) { + comments.forEach(comment => { + const date = new Date(comment.createdAt); + const formattedDate = date.toLocaleString(); + + console.log(` +=================================== +${comment.creator.name} - ${formattedDate} +----------------------------------- +${stripHtml(comment.content)} +=================================== + `); + }); +} + +function stripHtml(html) { + // Simple HTML stripping for console display + return html.replace(/<[^>]*>/g, ''); +} + +// Usage +const result = await getTaskComments('task_123'); +displayComments(result.comments); +``` + +### Get Recent Comments + +```javascript +async function getRecentComments(taskId, hoursAgo = 24) { + const allComments = await getAllComments(taskId); + const cutoffTime = new Date(); + cutoffTime.setHours(cutoffTime.getHours() - hoursAgo); + + return allComments.filter(comment => + new Date(comment.createdAt) > cutoffTime + ); +} + +// Get comments from last 24 hours +const recentComments = await getRecentComments('task_123', 24); +console.log(`${recentComments.length} comments in the last 24 hours`); +``` + +## Response Notes + +1. **Content Format**: Comments are returned as HTML, not Markdown +2. **Order**: Comments are returned in chronological order (oldest first) +3. **Creator Info**: Each comment includes full creator information +4. **Task Association**: All comments are associated with a single task +5. **No Editing**: Comments cannot be edited after creation +6. **No Deletion**: The API doesn't support comment deletion + +## Best Practices + +1. **Cache Results**: Comments don't change, so consider caching +2. **Paginate Large Sets**: Use pagination for tasks with many comments +3. **Parse HTML**: Use an HTML parser if you need plain text +4. **Time Filtering**: Filter comments client-side by creation time +5. **Display Formatting**: Preserve HTML formatting when displaying to users \ No newline at end of file diff --git a/docs/official_api/custom-fields/README.md b/docs/official_api/custom-fields/README.md new file mode 100644 index 0000000..167b145 --- /dev/null +++ b/docs/official_api/custom-fields/README.md @@ -0,0 +1,237 @@ +# Custom Fields API (Beta) + +The Custom Fields API allows you to create and manage custom data fields for tasks and projects in Motion. + +## Overview + +Custom fields enable: +- Adding business-specific data to tasks and projects +- Creating structured data collection +- Building custom workflows +- Tracking additional metadata + +**Note**: This API is in beta status. The base URL for all custom fields endpoints is: +``` +https://api.usemotion.com/beta +``` + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/beta/workspaces/{workspaceId}/custom-fields` | [List custom fields](./list-custom-fields.md) | +| POST | `/beta/workspaces/{workspaceId}/custom-fields` | [Create custom field](./create-custom-field.md) | +| DELETE | `/beta/workspaces/{workspaceId}/custom-fields/{id}` | [Delete custom field](./delete-custom-field.md) | +| POST | `/beta/custom-field-values/task/{taskId}` | [Add custom field to task](./add-to-task.md) | +| DELETE | `/beta/custom-field-values/task/{taskId}/custom-fields/{valueId}` | [Remove from task](./remove-from-task.md) | +| POST | `/beta/custom-field-values/project/{projectId}` | [Add custom field to project](./add-to-project.md) | +| DELETE | `/beta/custom-field-values/project/{projectId}/custom-fields/{valueId}` | [Remove from project](./remove-from-project.md) | + +## Custom Field Types + +Motion supports 12 custom field types: + +### Text Fields +- **text** - Single line text input +- **url** - URL validation +- **email** - Email validation +- **phone** - Phone number + +### Numeric & Date +- **number** - Numeric values with optional formatting +- **date** - Date picker (ISO 8601 format) + +### Selection Fields +- **select** - Single selection from options +- **multiSelect** - Multiple selections +- **checkbox** - Boolean toggle + +### Relationship Fields +- **person** - Single user selection +- **multiPerson** - Multiple user selection +- **relatedTo** - Link to another task + +## Custom Field Value Structure + +Custom field values follow a discriminated union pattern: + +```javascript +// Text field +{ + type: "text", + value: "Example text" +} + +// Number field +{ + type: "number", + value: 12345 +} + +// Select field +{ + type: "select", + value: "option-id" +} + +// Person field +{ + type: "person", + value: { + id: "user_123", + name: "John Doe", + email: "john@example.com" + } +} + +// Multi-select field +{ + type: "multiSelect", + value: ["option-1", "option-2"] +} +``` + +## Common Use Cases + +### 1. Project Budget Tracking + +```javascript +// Create budget field +POST /beta/workspaces/{workspaceId}/custom-fields +{ + "name": "Budget", + "type": "number", + "metadata": { + "format": "formatted" + } +} + +// Add to project +POST /beta/custom-field-values/project/{projectId} +{ + "customFieldInstanceId": "field_123", + "value": { + "type": "number", + "value": 50000 + } +} +``` + +### 2. Task Priority Matrix + +```javascript +// Create impact field +POST /beta/workspaces/{workspaceId}/custom-fields +{ + "name": "Business Impact", + "type": "select", + "metadata": { + "options": [ + { "id": "high", "value": "High Impact", "color": "red" }, + { "id": "medium", "value": "Medium Impact", "color": "yellow" }, + { "id": "low", "value": "Low Impact", "color": "green" } + ] + } +} +``` + +### 3. Client Information + +```javascript +// Create client contact field +POST /beta/workspaces/{workspaceId}/custom-fields +{ + "name": "Client Contact", + "type": "person" +} + +// Create client company field +POST /beta/workspaces/{workspaceId}/custom-fields +{ + "name": "Client Company", + "type": "text" +} +``` + +## Working with Custom Fields + +### Field Creation Process + +1. Create the field definition in a workspace +2. Field becomes available for all tasks/projects in that workspace +3. Add field values to specific tasks or projects +4. Update values as needed + +### Value Management + +- Values are stored separately from field definitions +- Each task/project can have different values +- Values can be null/empty +- Values are accessed by field name in API responses + +### Field Metadata + +Some field types support additional configuration: + +```javascript +// Number field with formatting +{ + "type": "number", + "metadata": { + "format": "plain" | "formatted" | "percent" + } +} + +// Select field with options +{ + "type": "select", + "metadata": { + "options": [ + { + "id": "unique-id", + "value": "Display Name", + "color": "blue" + } + ] + } +} + +// Checkbox with toggle style +{ + "type": "checkbox", + "metadata": { + "toggle": true + } +} +``` + +## Best Practices + +1. **Plan Field Structure**: Design your custom fields schema before implementation +2. **Use Appropriate Types**: Choose the right field type for your data +3. **Consistent Naming**: Use clear, consistent field names +4. **Validate Input**: Ensure values match the field type requirements +5. **Handle Missing Fields**: Tasks/projects may not have all custom fields + +## Limitations + +- Custom fields are workspace-specific +- Field names must be unique within a workspace +- Some field types have specific validation requirements +- Beta API may have changes in future versions +- Field deletion removes all associated values + +## Error Handling + +Common errors when working with custom fields: +- Invalid field type +- Duplicate field names +- Type mismatch in values +- Missing required metadata +- Workspace access issues + +## Related Resources + +- [Tasks API](../tasks/) - Tasks can have custom field values +- [Projects API](../projects/) - Projects can have custom field values +- [Workspaces API](../workspaces/) - Custom fields belong to workspaces \ No newline at end of file diff --git a/docs/official_api/custom-fields/add-to-project.md b/docs/official_api/custom-fields/add-to-project.md new file mode 100644 index 0000000..24aa8d5 --- /dev/null +++ b/docs/official_api/custom-fields/add-to-project.md @@ -0,0 +1,536 @@ +# Add Custom Field to Project + +Add or update a custom field value on a project. + +## Endpoint + +``` +POST /beta/custom-field-values/project/{projectId} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| projectId | string | Yes | The ID of the project | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +Content-Type: application/json +``` + +### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| customFieldInstanceId | string | Yes | The ID of the custom field | +| value | object | Yes | The value object with type and value | + +### Example Request + +```bash +curl -X POST https://api.usemotion.com/beta/custom-field-values/project/project_123 \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "customFieldInstanceId": "field_456", + "value": { + "type": "number", + "value": 1000000 + } + }' +``` + +## Response + +### Success Response (200 OK) + +Returns confirmation of the added value: + +```json +{ + "projectId": "project_123", + "customFieldId": "field_456", + "value": { + "type": "number", + "value": 1000000 + } +} +``` + +### Error Responses + +#### 400 Bad Request + +Invalid value for field type: + +```json +{ + "error": { + "message": "Invalid value type. Expected 'number' but got 'text'", + "code": "INVALID_PARAMETER" + } +} +``` + +#### 404 Not Found + +Project or custom field not found: + +```json +{ + "error": { + "message": "Project not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +## Project-Specific Field Examples + +### Project Budget + +```json +{ + "customFieldInstanceId": "field_budget", + "value": { + "type": "number", + "value": 500000.00 + } +} +``` + +### Project Status + +```json +{ + "customFieldInstanceId": "field_status", + "value": { + "type": "select", + "value": "in-progress" // Option ID + } +} +``` + +### Project Manager + +```json +{ + "customFieldInstanceId": "field_manager", + "value": { + "type": "person", + "value": { + "id": "user_123", + "name": "Sarah Johnson", + "email": "sarah@example.com" + } + } +} +``` + +### Project Timeline + +```json +{ + "customFieldInstanceId": "field_start_date", + "value": { + "type": "date", + "value": "2024-01-15" + } +} +``` + +### Project Categories + +```json +{ + "customFieldInstanceId": "field_categories", + "value": { + "type": "multiSelect", + "value": ["web-development", "mobile", "api"] + } +} +``` + +### Client Information + +```json +{ + "customFieldInstanceId": "field_client_name", + "value": { + "type": "text", + "value": "Acme Corporation" + } +} +``` + +### Project Team + +```json +{ + "customFieldInstanceId": "field_team_members", + "value": { + "type": "multiPerson", + "value": [ + { + "id": "user_123", + "name": "Jane Smith", + "email": "jane@example.com" + }, + { + "id": "user_456", + "name": "Bob Johnson", + "email": "bob@example.com" + }, + { + "id": "user_789", + "name": "Alice Brown", + "email": "alice@example.com" + } + ] + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function addCustomFieldToProject(projectId, fieldId, value) { + const response = await fetch( + `https://api.usemotion.com/beta/custom-field-values/project/${projectId}`, + { + method: 'POST', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + customFieldInstanceId: fieldId, + value: value + }) + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to add custom field: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage examples +// Add budget to project +await addCustomFieldToProject('project_123', 'field_budget', { + type: 'number', + value: 250000 +}); + +// Add project phase +await addCustomFieldToProject('project_123', 'field_phase', { + type: 'select', + value: 'development' +}); + +// Add project team +await addCustomFieldToProject('project_123', 'field_team', { + type: 'multiPerson', + value: [ + { id: 'user_1', name: 'Developer 1', email: 'dev1@example.com' }, + { id: 'user_2', name: 'Developer 2', email: 'dev2@example.com' } + ] +}); +``` + +### Python + +```python +import requests +import json +import os + +def add_custom_field_to_project(project_id, field_id, value): + url = f"https://api.usemotion.com/beta/custom-field-values/project/{project_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY"), + "Content-Type": "application/json" + } + + data = { + "customFieldInstanceId": field_id, + "value": value + } + + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + + return response.json() + +# Usage +# Add project deadline +result = add_custom_field_to_project("project_123", "field_deadline", { + "type": "date", + "value": "2024-12-31" +}) + +print(f"Added custom field to project {result['projectId']}") +``` + +### Project Setup Helper + +```javascript +async function setupProjectCustomFields(projectId, projectData) { + const fields = [ + { + fieldId: 'field_budget', + value: { type: 'number', value: projectData.budget } + }, + { + fieldId: 'field_start_date', + value: { type: 'date', value: projectData.startDate } + }, + { + fieldId: 'field_end_date', + value: { type: 'date', value: projectData.endDate } + }, + { + fieldId: 'field_client', + value: { type: 'text', value: projectData.clientName } + }, + { + fieldId: 'field_status', + value: { type: 'select', value: projectData.status || 'planning' } + }, + { + fieldId: 'field_manager', + value: { type: 'person', value: projectData.manager } + } + ]; + + const results = []; + + for (const { fieldId, value } of fields) { + try { + const result = await addCustomFieldToProject(projectId, fieldId, value); + results.push({ success: true, fieldId }); + console.log(`✓ Added ${fieldId}`); + } catch (error) { + results.push({ success: false, fieldId, error: error.message }); + console.error(`✗ Failed ${fieldId}: ${error.message}`); + } + } + + return results; +} + +// Set up a new project +const projectSetup = await setupProjectCustomFields('project_new', { + budget: 100000, + startDate: '2024-01-15', + endDate: '2024-06-30', + clientName: 'Acme Corp', + status: 'active', + manager: { + id: 'user_pm', + name: 'Project Manager', + email: 'pm@example.com' + } +}); +``` + +### Financial Tracking + +```javascript +async function updateProjectFinancials(projectId, financials) { + const updates = []; + + // Update budget + if (financials.budget !== undefined) { + updates.push( + addCustomFieldToProject(projectId, 'field_budget', { + type: 'number', + value: financials.budget + }) + ); + } + + // Update spent amount + if (financials.spent !== undefined) { + updates.push( + addCustomFieldToProject(projectId, 'field_spent', { + type: 'number', + value: financials.spent + }) + ); + } + + // Update percentage complete + if (financials.percentComplete !== undefined) { + updates.push( + addCustomFieldToProject(projectId, 'field_complete', { + type: 'number', + value: financials.percentComplete + }) + ); + } + + // Update billing status + if (financials.billable !== undefined) { + updates.push( + addCustomFieldToProject(projectId, 'field_billable', { + type: 'checkbox', + value: financials.billable + }) + ); + } + + await Promise.all(updates); + console.log('Project financials updated'); +} + +// Update project financial data +await updateProjectFinancials('project_123', { + budget: 150000, + spent: 75000, + percentComplete: 50, + billable: true +}); +``` + +### Project Metadata Builder + +```javascript +class ProjectFieldBuilder { + constructor(projectId) { + this.projectId = projectId; + this.fields = []; + } + + addBudget(amount) { + this.fields.push({ + fieldId: 'field_budget', + value: { type: 'number', value: amount } + }); + return this; + } + + addTimeline(startDate, endDate) { + this.fields.push( + { + fieldId: 'field_start_date', + value: { type: 'date', value: startDate } + }, + { + fieldId: 'field_end_date', + value: { type: 'date', value: endDate } + } + ); + return this; + } + + addClient(clientName, clientEmail) { + this.fields.push( + { + fieldId: 'field_client_name', + value: { type: 'text', value: clientName } + }, + { + fieldId: 'field_client_email', + value: { type: 'email', value: clientEmail } + } + ); + return this; + } + + addTags(tags) { + this.fields.push({ + fieldId: 'field_tags', + value: { type: 'multiSelect', value: tags } + }); + return this; + } + + async apply() { + const results = []; + for (const { fieldId, value } of this.fields) { + results.push( + await addCustomFieldToProject(this.projectId, fieldId, value) + ); + } + return results; + } +} + +// Use builder pattern +const builder = new ProjectFieldBuilder('project_456') + .addBudget(200000) + .addTimeline('2024-02-01', '2024-08-31') + .addClient('BigCorp Inc', 'contact@bigcorp.com') + .addTags(['enterprise', 'priority', 'q2-2024']); + +await builder.apply(); +``` + +## Use Cases + +### 1. Project Portfolio Management + +Track key metrics across all projects: + +```javascript +async function updateProjectMetrics(projectId, metrics) { + await addCustomFieldToProject(projectId, 'field_roi', { + type: 'number', + value: metrics.roi + }); + + await addCustomFieldToProject(projectId, 'field_risk_level', { + type: 'select', + value: metrics.riskLevel // 'low', 'medium', 'high' + }); + + await addCustomFieldToProject(projectId, 'field_health_score', { + type: 'number', + value: metrics.healthScore // 0-100 + }); +} +``` + +### 2. Client Project Tracking + +Maintain client-specific information: + +```javascript +async function setupClientProject(projectId, clientData) { + await addCustomFieldToProject(projectId, 'field_client_company', { + type: 'text', + value: clientData.company + }); + + await addCustomFieldToProject(projectId, 'field_contract_value', { + type: 'number', + value: clientData.contractValue + }); + + await addCustomFieldToProject(projectId, 'field_contract_url', { + type: 'url', + value: clientData.contractUrl + }); +} +``` + +## Notes + +- Same endpoint structure as task custom fields +- Project must exist in a workspace with the custom field +- Values are validated against field type definitions +- Updates overwrite existing values +- Use the delete endpoint to remove values \ No newline at end of file diff --git a/docs/official_api/custom-fields/add-to-task.md b/docs/official_api/custom-fields/add-to-task.md new file mode 100644 index 0000000..e8426bc --- /dev/null +++ b/docs/official_api/custom-fields/add-to-task.md @@ -0,0 +1,485 @@ +# Add Custom Field to Task + +Add or update a custom field value on a task. + +## Endpoint + +``` +POST /beta/custom-field-values/task/{taskId} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| taskId | string | Yes | The ID of the task | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +Content-Type: application/json +``` + +### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| customFieldInstanceId | string | Yes | The ID of the custom field | +| value | object | Yes | The value object with type and value | + +### Value Object Structure + +The value object must include: +- `type` - The field type (must match the custom field definition) +- `value` - The actual value (format depends on type) + +### Example Request + +```bash +curl -X POST https://api.usemotion.com/beta/custom-field-values/task/task_123 \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "customFieldInstanceId": "field_456", + "value": { + "type": "number", + "value": 50000 + } + }' +``` + +## Response + +### Success Response (200 OK) + +Returns confirmation of the added value: + +```json +{ + "taskId": "task_123", + "customFieldId": "field_456", + "value": { + "type": "number", + "value": 50000 + } +} +``` + +### Error Responses + +#### 400 Bad Request + +Invalid value for field type: + +```json +{ + "error": { + "message": "Invalid value type. Expected 'number' but got 'text'", + "code": "INVALID_PARAMETER" + } +} +``` + +#### 404 Not Found + +Task or custom field not found: + +```json +{ + "error": { + "message": "Task not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +## Value Examples by Field Type + +### Text Field + +```json +{ + "customFieldInstanceId": "field_text", + "value": { + "type": "text", + "value": "Project Alpha Documentation" + } +} +``` + +### Number Field + +```json +{ + "customFieldInstanceId": "field_number", + "value": { + "type": "number", + "value": 125000.50 + } +} +``` + +### Date Field + +```json +{ + "customFieldInstanceId": "field_date", + "value": { + "type": "date", + "value": "2024-12-31" + } +} +``` + +### Select Field + +```json +{ + "customFieldInstanceId": "field_select", + "value": { + "type": "select", + "value": "high-priority" // Option ID + } +} +``` + +### Multi-Select Field + +```json +{ + "customFieldInstanceId": "field_multiselect", + "value": { + "type": "multiSelect", + "value": ["frontend", "backend", "api"] // Array of option IDs + } +} +``` + +### Person Field + +```json +{ + "customFieldInstanceId": "field_person", + "value": { + "type": "person", + "value": { + "id": "user_789", + "name": "John Doe", + "email": "john@example.com" + } + } +} +``` + +### Multi-Person Field + +```json +{ + "customFieldInstanceId": "field_multiperson", + "value": { + "type": "multiPerson", + "value": [ + { + "id": "user_123", + "name": "Jane Smith", + "email": "jane@example.com" + }, + { + "id": "user_456", + "name": "Bob Johnson", + "email": "bob@example.com" + } + ] + } +} +``` + +### Checkbox Field + +```json +{ + "customFieldInstanceId": "field_checkbox", + "value": { + "type": "checkbox", + "value": true + } +} +``` + +### URL Field + +```json +{ + "customFieldInstanceId": "field_url", + "value": { + "type": "url", + "value": "https://docs.example.com/project" + } +} +``` + +### Email Field + +```json +{ + "customFieldInstanceId": "field_email", + "value": { + "type": "email", + "value": "client@example.com" + } +} +``` + +### Phone Field + +```json +{ + "customFieldInstanceId": "field_phone", + "value": { + "type": "phone", + "value": "+1-555-123-4567" + } +} +``` + +### Related Task Field + +```json +{ + "customFieldInstanceId": "field_related", + "value": { + "type": "relatedTo", + "value": "task_999" // Related task ID + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function addCustomFieldToTask(taskId, fieldId, value) { + const response = await fetch( + `https://api.usemotion.com/beta/custom-field-values/task/${taskId}`, + { + method: 'POST', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + customFieldInstanceId: fieldId, + value: value + }) + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to add custom field: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage examples +// Add budget to task +await addCustomFieldToTask('task_123', 'field_budget', { + type: 'number', + value: 75000 +}); + +// Add priority +await addCustomFieldToTask('task_123', 'field_priority', { + type: 'select', + value: 'high' +}); + +// Add multiple tags +await addCustomFieldToTask('task_123', 'field_tags', { + type: 'multiSelect', + value: ['urgent', 'client-request', 'frontend'] +}); +``` + +### Python + +```python +import requests +import json +import os + +def add_custom_field_to_task(task_id, field_id, value): + url = f"https://api.usemotion.com/beta/custom-field-values/task/{task_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY"), + "Content-Type": "application/json" + } + + data = { + "customFieldInstanceId": field_id, + "value": value + } + + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + + return response.json() + +# Usage +# Add due date custom field +result = add_custom_field_to_task("task_123", "field_due_date", { + "type": "date", + "value": "2024-12-31" +}) + +print(f"Added custom field to task {result['taskId']}") +``` + +### Add Multiple Custom Fields + +```javascript +async function addMultipleCustomFields(taskId, fields) { + const results = []; + + for (const { fieldId, value } of fields) { + try { + const result = await addCustomFieldToTask(taskId, fieldId, value); + results.push({ success: true, fieldId, result }); + } catch (error) { + results.push({ success: false, fieldId, error: error.message }); + } + } + + return results; +} + +// Add multiple fields to a task +const fieldsToAdd = [ + { + fieldId: 'field_budget', + value: { type: 'number', value: 100000 } + }, + { + fieldId: 'field_status', + value: { type: 'select', value: 'in-progress' } + }, + { + fieldId: 'field_manager', + value: { + type: 'person', + value: { id: 'user_123', name: 'Jane Doe', email: 'jane@example.com' } + } + } +]; + +const results = await addMultipleCustomFields('task_456', fieldsToAdd); +console.log(`Added ${results.filter(r => r.success).length} fields successfully`); +``` + +### Field Value Builder + +```javascript +class CustomFieldValueBuilder { + static text(value) { + return { type: 'text', value }; + } + + static number(value) { + return { type: 'number', value }; + } + + static date(value) { + return { type: 'date', value }; + } + + static select(optionId) { + return { type: 'select', value: optionId }; + } + + static multiSelect(optionIds) { + return { type: 'multiSelect', value: optionIds }; + } + + static person(user) { + return { type: 'person', value: user }; + } + + static multiPerson(users) { + return { type: 'multiPerson', value: users }; + } + + static checkbox(checked) { + return { type: 'checkbox', value: checked }; + } + + static url(url) { + return { type: 'url', value: url }; + } + + static email(email) { + return { type: 'email', value: email }; + } + + static phone(phoneNumber) { + return { type: 'phone', value: phoneNumber }; + } + + static relatedTask(taskId) { + return { type: 'relatedTo', value: taskId }; + } +} + +// Use builder for cleaner code +await addCustomFieldToTask( + 'task_789', + 'field_budget', + CustomFieldValueBuilder.number(50000) +); + +await addCustomFieldToTask( + 'task_789', + 'field_tags', + CustomFieldValueBuilder.multiSelect(['urgent', 'backend']) +); +``` + +### Update Existing Field Value + +To update an existing custom field value, use the same endpoint: + +```javascript +// Initial value +await addCustomFieldToTask('task_123', 'field_budget', { + type: 'number', + value: 50000 +}); + +// Update value +await addCustomFieldToTask('task_123', 'field_budget', { + type: 'number', + value: 75000 // New value +}); +``` + +## Important Notes + +1. **Type Matching**: The value type must match the field definition +2. **Field Validation**: Values are validated based on field type +3. **Updating Values**: Use the same endpoint to update existing values +4. **Null Values**: To clear a field, you need to use the delete endpoint +5. **Field Access**: Custom field must exist in the task's workspace + +## Best Practices + +1. **Validate Field Type**: Ensure value type matches field definition +2. **Check Field Exists**: Verify custom field exists before adding +3. **Handle Errors**: Gracefully handle type mismatches and validation errors +4. **Batch Operations**: Add multiple fields in sequence when needed +5. **User Validation**: Verify user IDs exist before adding person fields \ No newline at end of file diff --git a/docs/official_api/custom-fields/create-custom-field.md b/docs/official_api/custom-fields/create-custom-field.md new file mode 100644 index 0000000..35214b9 --- /dev/null +++ b/docs/official_api/custom-fields/create-custom-field.md @@ -0,0 +1,483 @@ +# Create Custom Field + +Create a new custom field definition in a workspace. + +## Endpoint + +``` +POST /beta/workspaces/{workspaceId}/custom-fields +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| workspaceId | string | Yes | The workspace ID where the field will be created | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +Content-Type: application/json +``` + +### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| type | string | Yes | Field type (see supported types below) | +| name | string | Yes | Display name for the field | +| metadata | object | No | Type-specific configuration | + +### Supported Field Types + +- `text` - Single line text +- `url` - URL with validation +- `date` - Date picker +- `person` - Single user selection +- `multiPerson` - Multiple user selection +- `phone` - Phone number +- `select` - Single selection from options +- `multiSelect` - Multiple selections +- `number` - Numeric value +- `email` - Email with validation +- `checkbox` - Boolean toggle +- `relatedTo` - Link to another task + +### Example Request + +```bash +curl -X POST https://api.usemotion.com/beta/workspaces/workspace_123/custom-fields \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Project Budget", + "type": "number", + "metadata": { + "format": "formatted" + } + }' +``` + +## Response + +### Success Response (201 Created) + +Returns the created custom field: + +```json +{ + "id": "field_new_123", + "name": "Project Budget", + "type": "number", + "metadata": { + "format": "formatted" + } +} +``` + +### Error Responses + +#### 400 Bad Request + +Invalid field configuration: + +```json +{ + "error": { + "message": "Invalid field type: invalid_type", + "code": "INVALID_PARAMETER" + } +} +``` + +#### 409 Conflict + +Duplicate field name: + +```json +{ + "error": { + "message": "A custom field with name 'Budget' already exists", + "code": "RESOURCE_ALREADY_EXISTS" + } +} +``` + +## Field Type Examples + +### 1. Text Field + +```json +{ + "name": "Client Name", + "type": "text" +} +``` + +### 2. Number Field with Formatting + +```json +{ + "name": "Budget", + "type": "number", + "metadata": { + "format": "formatted" // Options: "plain", "formatted", "percent" + } +} +``` + +### 3. Select Field with Options + +```json +{ + "name": "Priority", + "type": "select", + "metadata": { + "options": [ + { + "id": "critical", + "value": "Critical", + "color": "red" + }, + { + "id": "high", + "value": "High", + "color": "orange" + }, + { + "id": "medium", + "value": "Medium", + "color": "yellow" + }, + { + "id": "low", + "value": "Low", + "color": "green" + } + ] + } +} +``` + +### 4. Multi-Select Field + +```json +{ + "name": "Tags", + "type": "multiSelect", + "metadata": { + "options": [ + { + "id": "frontend", + "value": "Frontend", + "color": "blue" + }, + { + "id": "backend", + "value": "Backend", + "color": "purple" + }, + { + "id": "database", + "value": "Database", + "color": "teal" + }, + { + "id": "api", + "value": "API", + "color": "indigo" + } + ] + } +} +``` + +### 5. Date Field + +```json +{ + "name": "Contract End Date", + "type": "date" +} +``` + +### 6. Person Field + +```json +{ + "name": "Project Manager", + "type": "person" +} +``` + +### 7. Multi-Person Field + +```json +{ + "name": "Stakeholders", + "type": "multiPerson" +} +``` + +### 8. Checkbox with Toggle Style + +```json +{ + "name": "Is Billable", + "type": "checkbox", + "metadata": { + "toggle": true + } +} +``` + +### 9. URL Field + +```json +{ + "name": "Documentation Link", + "type": "url" +} +``` + +### 10. Email Field + +```json +{ + "name": "Client Email", + "type": "email" +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function createCustomField(workspaceId, fieldConfig) { + const response = await fetch( + `https://api.usemotion.com/beta/workspaces/${workspaceId}/custom-fields`, + { + method: 'POST', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(fieldConfig) + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to create custom field: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage examples +// Create a budget field +const budgetField = await createCustomField('workspace_123', { + name: 'Budget', + type: 'number', + metadata: { + format: 'formatted' + } +}); + +// Create a priority select field +const priorityField = await createCustomField('workspace_123', { + name: 'Priority Level', + type: 'select', + metadata: { + options: [ + { id: 'p1', value: 'P1 - Critical', color: 'red' }, + { id: 'p2', value: 'P2 - High', color: 'orange' }, + { id: 'p3', value: 'P3 - Medium', color: 'yellow' }, + { id: 'p4', value: 'P4 - Low', color: 'green' } + ] + } +}); + +console.log(`Created fields: ${budgetField.name}, ${priorityField.name}`); +``` + +### Python + +```python +import requests +import json +import os + +def create_custom_field(workspace_id, field_config): + url = f"https://api.usemotion.com/beta/workspaces/{workspace_id}/custom-fields" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY"), + "Content-Type": "application/json" + } + + response = requests.post(url, headers=headers, json=field_config) + response.raise_for_status() + + return response.json() + +# Usage +# Create a department select field +department_field = create_custom_field("workspace_123", { + "name": "Department", + "type": "select", + "metadata": { + "options": [ + {"id": "eng", "value": "Engineering", "color": "blue"}, + {"id": "sales", "value": "Sales", "color": "green"}, + {"id": "marketing", "value": "Marketing", "color": "purple"}, + {"id": "support", "value": "Support", "color": "orange"} + ] + } +}) + +print(f"Created field: {department_field['name']} ({department_field['id']})") +``` + +### Create Multiple Fields + +```javascript +async function createProjectFields(workspaceId) { + const fields = [ + { + name: 'Project Code', + type: 'text' + }, + { + name: 'Budget', + type: 'number', + metadata: { format: 'formatted' } + }, + { + name: 'Start Date', + type: 'date' + }, + { + name: 'End Date', + type: 'date' + }, + { + name: 'Project Manager', + type: 'person' + }, + { + name: 'Status', + type: 'select', + metadata: { + options: [ + { id: 'planning', value: 'Planning', color: 'gray' }, + { id: 'active', value: 'Active', color: 'blue' }, + { id: 'on-hold', value: 'On Hold', color: 'yellow' }, + { id: 'completed', value: 'Completed', color: 'green' } + ] + } + } + ]; + + const created = []; + + for (const field of fields) { + try { + const result = await createCustomField(workspaceId, field); + created.push(result); + console.log(`✓ Created field: ${result.name}`); + } catch (error) { + console.error(`✗ Failed to create ${field.name}: ${error.message}`); + } + } + + return created; +} + +// Create standard project fields +const projectFields = await createProjectFields('workspace_123'); +console.log(`Created ${projectFields.length} custom fields`); +``` + +### Field Builder Helper + +```javascript +class CustomFieldBuilder { + constructor(name, type) { + this.field = { name, type }; + } + + withFormat(format) { + if (!this.field.metadata) this.field.metadata = {}; + this.field.metadata.format = format; + return this; + } + + withOptions(options) { + if (!this.field.metadata) this.field.metadata = {}; + this.field.metadata.options = options; + return this; + } + + withToggle(isToggle = true) { + if (!this.field.metadata) this.field.metadata = {}; + this.field.metadata.toggle = isToggle; + return this; + } + + build() { + return this.field; + } +} + +// Use builder pattern +const field = new CustomFieldBuilder('Revenue', 'number') + .withFormat('formatted') + .build(); + +const createdField = await createCustomField('workspace_123', field); +``` + +## Metadata Reference + +### Number Field Formats + +- `plain` - No formatting (e.g., 50000) +- `formatted` - Thousand separators (e.g., 50,000) +- `percent` - Percentage display (e.g., 50%) + +### Select/MultiSelect Options + +Each option requires: +- `id` - Unique identifier (string) +- `value` - Display text (string) +- `color` - Color name (string) + +Available colors: +- `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `indigo`, `purple`, `pink`, `gray` + +### Checkbox Toggle + +- `toggle: true` - Display as toggle switch +- `toggle: false` or omitted - Display as checkbox + +## Best Practices + +1. **Unique Names**: Ensure field names are unique within the workspace +2. **Meaningful IDs**: Use descriptive IDs for select options +3. **Color Coding**: Use consistent color schemes for option meanings +4. **Validation**: The API validates field types and metadata +5. **Error Handling**: Handle duplicate name errors gracefully + +## Notes + +- Field names must be unique within a workspace +- Once created, field types cannot be changed +- Field IDs are generated automatically +- Metadata requirements vary by field type +- Creating a field doesn't add it to existing tasks/projects \ No newline at end of file diff --git a/docs/official_api/custom-fields/delete-custom-field.md b/docs/official_api/custom-fields/delete-custom-field.md new file mode 100644 index 0000000..619b4d2 --- /dev/null +++ b/docs/official_api/custom-fields/delete-custom-field.md @@ -0,0 +1,335 @@ +# Delete Custom Field + +Delete a custom field definition from a workspace. This removes the field and all associated values. + +## Endpoint + +``` +DELETE /beta/workspaces/{workspaceId}/custom-fields/{id} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| workspaceId | string | Yes | The workspace containing the custom field | +| id | string | Yes | The ID of the custom field to delete | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X DELETE https://api.usemotion.com/beta/workspaces/workspace_123/custom-fields/field_456 \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (204 No Content) + +The custom field is successfully deleted. No response body is returned. + +### Error Responses + +#### 404 Not Found + +Custom field or workspace doesn't exist: + +```json +{ + "error": { + "message": "Custom field not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +#### 403 Forbidden + +Insufficient permissions: + +```json +{ + "error": { + "message": "You do not have permission to delete custom fields", + "code": "INSUFFICIENT_PERMISSIONS" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function deleteCustomField(workspaceId, fieldId) { + const response = await fetch( + `https://api.usemotion.com/beta/workspaces/${workspaceId}/custom-fields/${fieldId}`, + { + method: 'DELETE', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Custom field not found'); + } + const error = await response.json(); + throw new Error(`Failed to delete custom field: ${error.error.message}`); + } + + // Success - no content returned + return true; +} + +// Usage +try { + await deleteCustomField('workspace_123', 'field_456'); + console.log('Custom field deleted successfully'); +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Python + +```python +import requests +import os + +def delete_custom_field(workspace_id, field_id): + url = f"https://api.usemotion.com/beta/workspaces/{workspace_id}/custom-fields/{field_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.delete(url, headers=headers) + + if response.status_code == 204: + return True + elif response.status_code == 404: + raise Exception("Custom field not found") + else: + response.raise_for_status() + +# Usage +try: + delete_custom_field("workspace_123", "field_456") + print("Custom field deleted successfully") +except Exception as e: + print(f"Error: {e}") +``` + +### Delete Field with Confirmation + +```javascript +async function deleteFieldWithConfirmation(workspaceId, fieldId) { + // First, get the field details + const fields = await listCustomFields(workspaceId); + const field = fields.find(f => f.id === fieldId); + + if (!field) { + throw new Error('Field not found'); + } + + console.log(`About to delete field: ${field.name} (${field.type})`); + console.log('Warning: This will remove all associated values from tasks and projects'); + + // Delete the field + await deleteCustomField(workspaceId, fieldId); + + return { + deleted: true, + fieldName: field.name, + fieldType: field.type + }; +} + +// Usage +try { + const result = await deleteFieldWithConfirmation('workspace_123', 'field_789'); + console.log(`Deleted field: ${result.fieldName}`); +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Bulk Delete Custom Fields + +```javascript +async function deleteMultipleFields(workspaceId, fieldIds) { + const results = []; + + for (const fieldId of fieldIds) { + try { + await deleteCustomField(workspaceId, fieldId); + results.push({ + fieldId, + success: true + }); + } catch (error) { + results.push({ + fieldId, + success: false, + error: error.message + }); + } + } + + return results; +} + +// Delete multiple fields +const fieldsToDelete = ['field_123', 'field_456', 'field_789']; +const deleteResults = await deleteMultipleFields('workspace_123', fieldsToDelete); + +const successful = deleteResults.filter(r => r.success).length; +console.log(`Deleted ${successful} out of ${deleteResults.length} fields`); + +// Show any errors +deleteResults.filter(r => !r.success).forEach(result => { + console.error(`Failed to delete ${result.fieldId}: ${result.error}`); +}); +``` + +### Clean Up Unused Fields + +```javascript +async function cleanupUnusedFields(workspaceId) { + // This is a conceptual example - you'd need to implement + // logic to determine which fields are unused + + const fields = await listCustomFields(workspaceId); + const tasks = await listTasks({ workspaceId }); + const projects = await listProjects(workspaceId); + + // Find fields not used in any task or project + const unusedFields = fields.filter(field => { + const fieldUsedInTasks = tasks.some(task => + task.customFieldValues && field.name in task.customFieldValues + ); + const fieldUsedInProjects = projects.some(project => + project.customFieldValues && field.name in project.customFieldValues + ); + + return !fieldUsedInTasks && !fieldUsedInProjects; + }); + + console.log(`Found ${unusedFields.length} unused fields`); + + // Delete unused fields + for (const field of unusedFields) { + try { + await deleteCustomField(workspaceId, field.id); + console.log(`Deleted unused field: ${field.name}`); + } catch (error) { + console.error(`Failed to delete ${field.name}: ${error.message}`); + } + } +} +``` + +## Important Considerations + +### Data Loss Warning + +Deleting a custom field: +1. **Permanently removes** the field definition +2. **Deletes all values** associated with the field across all tasks and projects +3. **Cannot be undone** - there is no recovery option +4. **Immediate effect** - deletion happens instantly + +### Before Deleting + +Consider these alternatives: +- Archive the data by exporting tasks/projects with custom field values +- Rename the field if it's being repurposed +- Keep the field but stop using it for new items + +### Impact Analysis + +```javascript +async function analyzeFieldDeletionImpact(workspaceId, fieldId) { + const fields = await listCustomFields(workspaceId); + const field = fields.find(f => f.id === fieldId); + + if (!field) { + throw new Error('Field not found'); + } + + // Get all tasks and projects to count usage + const tasks = await listTasks({ workspaceId }); + const projects = await listProjects(workspaceId); + + let affectedTasks = 0; + let affectedProjects = 0; + + tasks.forEach(task => { + if (task.customFieldValues && field.name in task.customFieldValues) { + affectedTasks++; + } + }); + + projects.forEach(project => { + if (project.customFieldValues && field.name in project.customFieldValues) { + affectedProjects++; + } + }); + + return { + fieldName: field.name, + fieldType: field.type, + affectedTasks, + affectedProjects, + totalAffected: affectedTasks + affectedProjects + }; +} + +// Check impact before deletion +const impact = await analyzeFieldDeletionImpact('workspace_123', 'field_456'); +console.log(`Deleting "${impact.fieldName}" will affect:`); +console.log(`- ${impact.affectedTasks} tasks`); +console.log(`- ${impact.affectedProjects} projects`); +console.log(`Total: ${impact.totalAffected} items will lose this field`); +``` + +## Best Practices + +1. **Backup Data**: Export custom field values before deletion +2. **Verify Impact**: Check how many items use the field +3. **Communicate**: Inform team members before deleting shared fields +4. **Test First**: Try in a test workspace if possible +5. **Document**: Keep records of deleted fields and reasons + +## Notes + +- Deletion is permanent and immediate +- All associated values are lost +- No cascade warnings are provided +- Field IDs cannot be reused after deletion +- Consider archiving data before deletion \ No newline at end of file diff --git a/docs/official_api/custom-fields/list-custom-fields.md b/docs/official_api/custom-fields/list-custom-fields.md new file mode 100644 index 0000000..8d2f88e --- /dev/null +++ b/docs/official_api/custom-fields/list-custom-fields.md @@ -0,0 +1,410 @@ +# List Custom Fields + +Get all custom fields defined in a workspace. + +## Endpoint + +``` +GET /beta/workspaces/{workspaceId}/custom-fields +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| workspaceId | string | Yes | The workspace ID to get custom fields for | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X GET https://api.usemotion.com/beta/workspaces/workspace_123/custom-fields \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +Returns an array of custom field definitions: + +```json +[ + { + "id": "field_001", + "name": "Budget", + "type": "number", + "metadata": { + "format": "formatted" + } + }, + { + "id": "field_002", + "name": "Client Name", + "type": "text" + }, + { + "id": "field_003", + "name": "Priority Level", + "type": "select", + "metadata": { + "options": [ + { + "id": "critical", + "value": "Critical", + "color": "red" + }, + { + "id": "high", + "value": "High", + "color": "orange" + }, + { + "id": "medium", + "value": "Medium", + "color": "yellow" + }, + { + "id": "low", + "value": "Low", + "color": "green" + } + ] + } + }, + { + "id": "field_004", + "name": "Due Date", + "type": "date" + }, + { + "id": "field_005", + "name": "Stakeholders", + "type": "multiPerson" + }, + { + "id": "field_006", + "name": "Is Billable", + "type": "checkbox", + "metadata": { + "toggle": true + } + }, + { + "id": "field_007", + "name": "Project URL", + "type": "url" + }, + { + "id": "field_008", + "name": "Categories", + "type": "multiSelect", + "metadata": { + "options": [ + { + "id": "frontend", + "value": "Frontend", + "color": "blue" + }, + { + "id": "backend", + "value": "Backend", + "color": "purple" + }, + { + "id": "design", + "value": "Design", + "color": "pink" + } + ] + } + } +] +``` + +### Empty Response + +If no custom fields are defined: + +```json +[] +``` + +### Error Responses + +#### 404 Not Found + +Invalid workspace ID: + +```json +{ + "error": { + "message": "Workspace not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function listCustomFields(workspaceId) { + const response = await fetch( + `https://api.usemotion.com/beta/workspaces/${workspaceId}/custom-fields`, + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to list custom fields: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + const customFields = await listCustomFields('workspace_123'); + + console.log(`Found ${customFields.length} custom fields:`); + customFields.forEach(field => { + console.log(`- ${field.name} (${field.type})`); + + // Show additional metadata + if (field.metadata?.options) { + console.log(` Options: ${field.metadata.options.map(o => o.value).join(', ')}`); + } + if (field.metadata?.format) { + console.log(` Format: ${field.metadata.format}`); + } + }); +} catch (error) { + console.error('Error:', error); +} +``` + +### Python + +```python +import requests +import os + +def list_custom_fields(workspace_id): + url = f"https://api.usemotion.com/beta/workspaces/{workspace_id}/custom-fields" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + return response.json() + +# Usage +try: + custom_fields = list_custom_fields("workspace_123") + + print(f"Found {len(custom_fields)} custom fields:") + for field in custom_fields: + print(f"- {field['name']} ({field['type']})") + + # Show metadata if present + if 'metadata' in field: + if 'options' in field['metadata']: + options = [opt['value'] for opt in field['metadata']['options']] + print(f" Options: {', '.join(options)}") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Group Fields by Type + +```javascript +function groupFieldsByType(customFields) { + const grouped = {}; + + customFields.forEach(field => { + if (!grouped[field.type]) { + grouped[field.type] = []; + } + grouped[field.type].push(field); + }); + + return grouped; +} + +// Group custom fields by type +const fields = await listCustomFields('workspace_123'); +const grouped = groupFieldsByType(fields); + +console.log('Custom fields by type:'); +Object.entries(grouped).forEach(([type, fields]) => { + console.log(`\n${type}:`); + fields.forEach(field => { + console.log(` - ${field.name} (${field.id})`); + }); +}); +``` + +### Find Field by Name + +```javascript +async function findCustomFieldByName(workspaceId, fieldName) { + const fields = await listCustomFields(workspaceId); + return fields.find(field => field.name === fieldName); +} + +// Find specific field +const budgetField = await findCustomFieldByName('workspace_123', 'Budget'); +if (budgetField) { + console.log(`Found field: ${budgetField.name} (${budgetField.id})`); +} else { + console.log('Field not found'); +} +``` + +### Get Field Configuration + +```javascript +function getFieldConfiguration(field) { + const config = { + id: field.id, + name: field.name, + type: field.type, + hasOptions: false, + optionCount: 0, + format: null, + isToggle: false + }; + + if (field.metadata) { + if (field.metadata.options) { + config.hasOptions = true; + config.optionCount = field.metadata.options.length; + } + if (field.metadata.format) { + config.format = field.metadata.format; + } + if (field.metadata.toggle) { + config.isToggle = true; + } + } + + return config; +} + +// Analyze field configurations +const fields = await listCustomFields('workspace_123'); +const configurations = fields.map(getFieldConfiguration); + +configurations.forEach(config => { + console.log(`${config.name}:`); + console.log(` Type: ${config.type}`); + if (config.hasOptions) { + console.log(` Options: ${config.optionCount}`); + } + if (config.format) { + console.log(` Format: ${config.format}`); + } +}); +``` + +### Validate Field Exists + +```javascript +async function validateFieldExists(workspaceId, fieldId) { + const fields = await listCustomFields(workspaceId); + const field = fields.find(f => f.id === fieldId); + + if (!field) { + throw new Error(`Custom field ${fieldId} not found in workspace`); + } + + return field; +} + +// Validate before using field +try { + const field = await validateFieldExists('workspace_123', 'field_001'); + console.log(`Valid field: ${field.name} (${field.type})`); +} catch (error) { + console.error(error.message); +} +``` + +## Response Field Details + +### Custom Field Object + +- **id**: Unique identifier for the custom field +- **name**: Display name of the field +- **type**: Field type (text, number, select, etc.) +- **metadata**: Optional configuration for the field type + +### Metadata Structure + +Different field types support different metadata: + +#### Select/MultiSelect Fields +```javascript +{ + "options": [ + { + "id": "unique-option-id", + "value": "Display Name", + "color": "color-name" + } + ] +} +``` + +#### Number Fields +```javascript +{ + "format": "plain" | "formatted" | "percent" +} +``` + +#### Checkbox Fields +```javascript +{ + "toggle": true | false +} +``` + +## Notes + +- Custom fields are workspace-specific +- Field names must be unique within a workspace +- The beta API endpoint may change in future versions +- Empty metadata objects are not included in the response +- Field order in the response is not guaranteed \ No newline at end of file diff --git a/docs/official_api/custom-fields/remove-from-project.md b/docs/official_api/custom-fields/remove-from-project.md new file mode 100644 index 0000000..3d0f64b --- /dev/null +++ b/docs/official_api/custom-fields/remove-from-project.md @@ -0,0 +1,412 @@ +# Remove Custom Field from Project + +Remove a custom field value from a project. + +## Endpoint + +``` +DELETE /beta/custom-field-values/project/{projectId}/custom-fields/{valueId} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| projectId | string | Yes | The ID of the project | +| valueId | string | Yes | The ID of the custom field value to remove | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X DELETE https://api.usemotion.com/beta/custom-field-values/project/project_123/custom-fields/field_456 \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (204 No Content) + +The custom field value is successfully removed from the project. No response body is returned. + +### Error Responses + +#### 404 Not Found + +Project or custom field value not found: + +```json +{ + "error": { + "message": "Custom field value not found on project", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function removeCustomFieldFromProject(projectId, fieldId) { + const response = await fetch( + `https://api.usemotion.com/beta/custom-field-values/project/${projectId}/custom-fields/${fieldId}`, + { + method: 'DELETE', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Custom field value not found on project'); + } + const error = await response.json(); + throw new Error(`Failed to remove custom field: ${error.error.message}`); + } + + // Success - no content returned + return true; +} + +// Usage +try { + await removeCustomFieldFromProject('project_123', 'field_456'); + console.log('Custom field removed from project successfully'); +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Python + +```python +import requests +import os + +def remove_custom_field_from_project(project_id, field_id): + url = f"https://api.usemotion.com/beta/custom-field-values/project/{project_id}/custom-fields/{field_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.delete(url, headers=headers) + + if response.status_code == 204: + return True + elif response.status_code == 404: + raise Exception("Custom field value not found on project") + else: + response.raise_for_status() + +# Usage +try: + remove_custom_field_from_project("project_123", "field_456") + print("Custom field removed from project successfully") +except Exception as e: + print(f"Error: {e}") +``` + +### Remove Multiple Fields from Project + +```javascript +async function removeMultipleFieldsFromProject(projectId, fieldIds) { + const results = []; + + for (const fieldId of fieldIds) { + try { + await removeCustomFieldFromProject(projectId, fieldId); + results.push({ + fieldId, + success: true + }); + } catch (error) { + results.push({ + fieldId, + success: false, + error: error.message + }); + } + } + + return results; +} + +// Remove multiple fields +const fieldsToRemove = ['field_budget', 'field_timeline', 'field_client']; +const results = await removeMultipleFieldsFromProject('project_123', fieldsToRemove); + +const successful = results.filter(r => r.success).length; +console.log(`Removed ${successful} out of ${results.length} fields from project`); +``` + +### Clear Project Custom Fields + +```javascript +async function clearProjectCustomFields(projectId) { + // Get project with custom fields + const project = await getProject(projectId); + + if (!project.customFieldValues || Object.keys(project.customFieldValues).length === 0) { + console.log('No custom fields to remove from project'); + return []; + } + + // Get workspace custom fields to map names to IDs + const customFields = await listCustomFields(project.workspaceId); + const fieldMap = new Map(customFields.map(f => [f.name, f.id])); + + // Remove each custom field + const results = []; + for (const fieldName of Object.keys(project.customFieldValues)) { + const fieldId = fieldMap.get(fieldName); + if (fieldId) { + try { + await removeCustomFieldFromProject(projectId, fieldId); + results.push({ fieldName, success: true }); + console.log(`✓ Removed ${fieldName}`); + } catch (error) { + results.push({ fieldName, success: false, error: error.message }); + console.error(`✗ Failed to remove ${fieldName}`); + } + } + } + + return results; +} + +// Clear all custom fields from a project +const cleared = await clearProjectCustomFields('project_123'); +console.log(`Cleared ${cleared.filter(r => r.success).length} fields from project`); +``` + +### Project Closure Cleanup + +```javascript +async function closeProject(projectId) { + // Update project status + await updateProject(projectId, { + status: 'Completed' + }); + + // Remove time-sensitive fields + const fieldsToRemove = [ + 'field_deadline', + 'field_current_sprint', + 'field_active_tasks', + 'field_burn_rate' + ]; + + for (const fieldId of fieldsToRemove) { + try { + await removeCustomFieldFromProject(projectId, fieldId); + console.log(`Removed ${fieldId} from closed project`); + } catch (error) { + // Field might not exist, which is okay + if (!error.message.includes('not found')) { + console.error(`Error removing ${fieldId}: ${error.message}`); + } + } + } + + console.log('Project closed and cleaned up'); +} + +// Close and clean up a project +await closeProject('project_123'); +``` + +### Archive Project Data + +```javascript +async function archiveProjectCustomFields(projectId) { + // Get current project data + const project = await getProject(projectId); + + if (!project.customFieldValues) { + console.log('No custom fields to archive'); + return null; + } + + // Create archive record + const archive = { + projectId: project.id, + projectName: project.name, + archivedAt: new Date().toISOString(), + customFieldValues: { ...project.customFieldValues } + }; + + // Save archive (implement your storage method) + await saveToArchive(archive); + + // Get field IDs + const customFields = await listCustomFields(project.workspaceId); + const fieldMap = new Map(customFields.map(f => [f.name, f.id])); + + // Remove fields after archiving + for (const fieldName of Object.keys(project.customFieldValues)) { + const fieldId = fieldMap.get(fieldName); + if (fieldId) { + await removeCustomFieldFromProject(projectId, fieldId); + } + } + + console.log('Project custom fields archived and removed'); + return archive; +} +``` + +### Conditional Field Removal + +```javascript +async function removeProjectFieldsByCondition(projectId, condition) { + const project = await getProject(projectId); + + if (!project.customFieldValues) { + return []; + } + + const customFields = await listCustomFields(project.workspaceId); + const fieldMap = new Map(customFields.map(f => [f.name, f.id])); + const removed = []; + + for (const [fieldName, fieldValue] of Object.entries(project.customFieldValues)) { + if (condition(fieldName, fieldValue)) { + const fieldId = fieldMap.get(fieldName); + if (fieldId) { + try { + await removeCustomFieldFromProject(projectId, fieldId); + removed.push(fieldName); + } catch (error) { + console.error(`Failed to remove ${fieldName}: ${error.message}`); + } + } + } + } + + return removed; +} + +// Remove all empty text fields +const removedFields = await removeProjectFieldsByCondition( + 'project_123', + (name, value) => value.type === 'text' && (!value.value || value.value.trim() === '') +); + +console.log(`Removed ${removedFields.length} empty fields`); + +// Remove all zero-value number fields +const removedNumbers = await removeProjectFieldsByCondition( + 'project_123', + (name, value) => value.type === 'number' && value.value === 0 +); +``` + +## Use Cases + +### 1. Project Phase Transitions + +Remove phase-specific fields when moving between phases: + +```javascript +async function transitionProjectPhase(projectId, fromPhase, toPhase) { + const phaseFields = { + planning: ['field_estimated_budget', 'field_proposal_url'], + development: ['field_sprint_number', 'field_velocity'], + testing: ['field_bug_count', 'field_test_coverage'], + deployment: ['field_deployment_date', 'field_rollback_plan'] + }; + + // Remove old phase fields + const oldFields = phaseFields[fromPhase] || []; + for (const fieldId of oldFields) { + await removeCustomFieldFromProject(projectId, fieldId); + } + + console.log(`Transitioned project from ${fromPhase} to ${toPhase}`); +} +``` + +### 2. Budget Reset + +Clear financial fields for new fiscal period: + +```javascript +async function resetProjectBudget(projectId) { + const financialFields = [ + 'field_spent_amount', + 'field_remaining_budget', + 'field_burn_rate', + 'field_cost_overrun' + ]; + + for (const fieldId of financialFields) { + try { + await removeCustomFieldFromProject(projectId, fieldId); + } catch (error) { + // Ignore if field doesn't exist + } + } + + console.log('Project financial fields reset'); +} +``` + +### 3. Template Cleanup + +Remove template-specific fields after project creation: + +```javascript +async function cleanupTemplateFields(projectId) { + const templateFields = [ + 'field_template_name', + 'field_template_version', + 'field_copied_from' + ]; + + const results = await removeMultipleFieldsFromProject(projectId, templateFields); + console.log('Template fields cleaned up'); + return results; +} +``` + +## Important Notes + +1. **Field ID Required**: You need the custom field ID, not the name +2. **Immediate Deletion**: Removal happens instantly and cannot be undone +3. **No Value Returned**: The deleted value is not returned +4. **Silent Success**: 204 response has no body +5. **Project Remains**: Only the field value is removed + +## Best Practices + +1. **Archive Before Removal**: Consider saving field values before deletion +2. **Verify Field Exists**: Check if field is present before attempting removal +3. **Handle 404 Gracefully**: Field might already be removed +4. **Batch Operations**: Group multiple removals for efficiency +5. **Document Removals**: Keep audit trail of field deletions \ No newline at end of file diff --git a/docs/official_api/custom-fields/remove-from-task.md b/docs/official_api/custom-fields/remove-from-task.md new file mode 100644 index 0000000..573770c --- /dev/null +++ b/docs/official_api/custom-fields/remove-from-task.md @@ -0,0 +1,337 @@ +# Remove Custom Field from Task + +Remove a custom field value from a task. + +## Endpoint + +``` +DELETE /beta/custom-field-values/task/{taskId}/custom-fields/{valueId} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| taskId | string | Yes | The ID of the task | +| valueId | string | Yes | The ID of the custom field value to remove | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X DELETE https://api.usemotion.com/beta/custom-field-values/task/task_123/custom-fields/field_456 \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (204 No Content) + +The custom field value is successfully removed from the task. No response body is returned. + +### Error Responses + +#### 404 Not Found + +Task or custom field value not found: + +```json +{ + "error": { + "message": "Custom field value not found on task", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function removeCustomFieldFromTask(taskId, fieldId) { + const response = await fetch( + `https://api.usemotion.com/beta/custom-field-values/task/${taskId}/custom-fields/${fieldId}`, + { + method: 'DELETE', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Custom field value not found on task'); + } + const error = await response.json(); + throw new Error(`Failed to remove custom field: ${error.error.message}`); + } + + // Success - no content returned + return true; +} + +// Usage +try { + await removeCustomFieldFromTask('task_123', 'field_456'); + console.log('Custom field removed from task successfully'); +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Python + +```python +import requests +import os + +def remove_custom_field_from_task(task_id, field_id): + url = f"https://api.usemotion.com/beta/custom-field-values/task/{task_id}/custom-fields/{field_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.delete(url, headers=headers) + + if response.status_code == 204: + return True + elif response.status_code == 404: + raise Exception("Custom field value not found on task") + else: + response.raise_for_status() + +# Usage +try: + remove_custom_field_from_task("task_123", "field_456") + print("Custom field removed from task successfully") +except Exception as e: + print(f"Error: {e}") +``` + +### Remove Multiple Fields + +```javascript +async function removeMultipleFieldsFromTask(taskId, fieldIds) { + const results = []; + + for (const fieldId of fieldIds) { + try { + await removeCustomFieldFromTask(taskId, fieldId); + results.push({ + fieldId, + success: true + }); + } catch (error) { + results.push({ + fieldId, + success: false, + error: error.message + }); + } + } + + return results; +} + +// Remove multiple fields from a task +const fieldsToRemove = ['field_123', 'field_456', 'field_789']; +const results = await removeMultipleFieldsFromTask('task_001', fieldsToRemove); + +const successful = results.filter(r => r.success).length; +console.log(`Removed ${successful} out of ${results.length} fields`); +``` + +### Clear All Custom Fields + +```javascript +async function clearAllCustomFields(taskId) { + // First, get the task to see current custom fields + const task = await getTask(taskId); + + if (!task.customFieldValues || Object.keys(task.customFieldValues).length === 0) { + console.log('No custom fields to remove'); + return []; + } + + // Get workspace custom fields to map names to IDs + const customFields = await listCustomFields(task.workspace.id); + const fieldMap = new Map(customFields.map(f => [f.name, f.id])); + + // Remove each custom field + const results = []; + for (const fieldName of Object.keys(task.customFieldValues)) { + const fieldId = fieldMap.get(fieldName); + if (fieldId) { + try { + await removeCustomFieldFromTask(taskId, fieldId); + results.push({ fieldName, success: true }); + } catch (error) { + results.push({ fieldName, success: false, error: error.message }); + } + } + } + + return results; +} + +// Clear all custom fields from a task +const cleared = await clearAllCustomFields('task_123'); +console.log(`Cleared ${cleared.filter(r => r.success).length} custom fields`); +``` + +### Conditional Field Removal + +```javascript +async function removeFieldIfValue(taskId, fieldId, condition) { + // Get current task data + const task = await getTask(taskId); + + // Find the field value + const customFields = await listCustomFields(task.workspace.id); + const field = customFields.find(f => f.id === fieldId); + + if (!field || !task.customFieldValues[field.name]) { + console.log('Field not found on task'); + return false; + } + + const currentValue = task.customFieldValues[field.name]; + + // Check condition + if (condition(currentValue)) { + await removeCustomFieldFromTask(taskId, fieldId); + console.log(`Removed field ${field.name} based on condition`); + return true; + } + + console.log(`Field ${field.name} kept - condition not met`); + return false; +} + +// Remove budget field if it's zero +await removeFieldIfValue('task_123', 'field_budget', + value => value.type === 'number' && value.value === 0 +); + +// Remove date field if it's in the past +await removeFieldIfValue('task_123', 'field_deadline', + value => value.type === 'date' && new Date(value.value) < new Date() +); +``` + +### Replace Field Value + +To effectively "update" a field, remove and re-add: + +```javascript +async function replaceCustomFieldValue(taskId, fieldId, newValue) { + try { + // Remove existing value + await removeCustomFieldFromTask(taskId, fieldId); + } catch (error) { + // Field might not exist on task, which is okay + if (!error.message.includes('not found')) { + throw error; + } + } + + // Add new value + await addCustomFieldToTask(taskId, fieldId, newValue); + console.log('Field value replaced successfully'); +} + +// Replace a budget value +await replaceCustomFieldValue('task_123', 'field_budget', { + type: 'number', + value: 100000 +}); +``` + +## Use Cases + +### 1. Clear Completed Task Fields + +Remove certain fields when task is completed: + +```javascript +async function cleanupCompletedTask(taskId) { + const task = await getTask(taskId); + + if (task.completed) { + // Remove time-sensitive fields + await removeCustomFieldFromTask(taskId, 'field_deadline'); + await removeCustomFieldFromTask(taskId, 'field_reminder_date'); + console.log('Cleaned up completed task fields'); + } +} +``` + +### 2. Reset Task Fields + +Clear fields when task status changes: + +```javascript +async function resetTaskFields(taskId, newStatus) { + if (newStatus === 'TODO') { + // Clear progress-related fields + await removeCustomFieldFromTask(taskId, 'field_completion_percentage'); + await removeCustomFieldFromTask(taskId, 'field_time_spent'); + } +} +``` + +### 3. Remove Invalid Data + +Clean up fields with invalid or outdated data: + +```javascript +async function removeInvalidUrls(taskId) { + const task = await getTask(taskId); + const urlField = task.customFieldValues?.['Project URL']; + + if (urlField && !isValidUrl(urlField.value)) { + await removeCustomFieldFromTask(taskId, 'field_url'); + console.log('Removed invalid URL field'); + } +} +``` + +## Important Notes + +1. **Field ID Required**: You need the custom field ID, not the name +2. **No Undo**: Removal is immediate and cannot be undone +3. **No Value Returned**: The actual value is not returned on deletion +4. **Silent Success**: 204 response has no body +5. **Task Still Exists**: Only the field value is removed, not the task + +## Best Practices + +1. **Verify Before Removal**: Check if field exists on task first +2. **Handle 404 Gracefully**: Field might already be removed +3. **Log Removals**: Keep audit trail of field removals +4. **Batch Carefully**: Remove multiple fields in sequence +5. **Consider Null Values**: Sometimes setting null is better than removing \ No newline at end of file diff --git a/docs/official_api/data-types.md b/docs/official_api/data-types.md new file mode 100644 index 0000000..18c6885 --- /dev/null +++ b/docs/official_api/data-types.md @@ -0,0 +1,104 @@ +# Common Data Types + +This document describes the common data types used throughout the Motion API. + +## Date and Time + +All datetime values use ISO 8601 format: + +``` +2024-01-15T09:00:00Z +2024-01-15T09:00:00.000Z +2024-01-15T09:00:00+00:00 +``` + +## Task Priority + +Tasks can have one of the following priority levels: + +- `ASAP` - As soon as possible (highest priority) +- `HIGH` - High priority +- `MEDIUM` - Medium priority +- `LOW` - Low priority + +## Task Duration + +Task duration can be specified as: + +- **Number**: Duration in minutes (e.g., `30`, `60`, `120`) +- **"NONE"**: No duration +- **"REMINDER"**: Reminder only (no duration) + +## Task Status + +Available statuses vary by workspace configuration. Common statuses include: + +- `TODO` +- `IN_PROGRESS` +- `DONE` +- `CANCELLED` + +Use the [List Statuses](./statuses/list-statuses.md) endpoint to get available statuses for your workspace. + +## Labels + +Labels are strings that can be attached to tasks for categorization. Examples: + +- `"bug"` +- `"feature"` +- `"urgent"` +- `"client-work"` + +## IDs + +All resource IDs are strings in UUID format: + +``` +"550e8400-e29b-41d4-a716-446655440000" +``` + +## Pagination Cursor + +Pagination cursors are opaque strings used for navigating through paginated results: + +``` +"eyJza2lwIjoyNX0=" +``` + +## Common Response Fields + +### Timestamps +- `createdTime` - When the resource was created +- `updatedTime` - When the resource was last updated +- `completedTime` - When a task was completed (if applicable) + +### Relationships +- `workspace` - Object containing workspace details +- `project` - Object containing project details (if applicable) +- `creator` - User object who created the resource +- `assignees` - Array of user objects assigned to the task + +### Task-Specific Fields +- `scheduledStart` - When Motion scheduled the task to start +- `scheduledEnd` - When Motion scheduled the task to end +- `schedulingIssue` - Boolean indicating if there's a scheduling problem + +## Custom Field Values + +Custom field values follow a discriminated union pattern: + +```typescript +type CustomFieldValue = + | { type: "text"; value: string } + | { type: "number"; value: number } + | { type: "url"; value: string } + | { type: "date"; value: string } // ISO 8601 date + | { type: "email"; value: string } + | { type: "phone"; value: string } + | { type: "checkbox"; value: boolean } + | { type: "select"; value: string } + | { type: "multiSelect"; value: string[] } + | { type: "person"; value: string } // User ID + | { type: "multiPerson"; value: string[] } // Array of User IDs + | { type: "relatedTo"; value: string } // Task ID +``` \ No newline at end of file diff --git a/docs/official_api/error-handling.md b/docs/official_api/error-handling.md new file mode 100644 index 0000000..a3748c7 --- /dev/null +++ b/docs/official_api/error-handling.md @@ -0,0 +1,127 @@ +# Error Handling + +The Motion API uses standard HTTP status codes to indicate the success or failure of requests. + +## HTTP Status Codes + +### Success Codes + +- **200 OK** - Request succeeded +- **201 Created** - Resource created successfully +- **204 No Content** - Request succeeded with no response body (e.g., DELETE) + +### Client Error Codes + +- **400 Bad Request** - Invalid request parameters or body +- **401 Unauthorized** - Missing or invalid API key +- **403 Forbidden** - Valid API key but insufficient permissions +- **404 Not Found** - Requested resource doesn't exist +- **422 Unprocessable Entity** - Request validation failed +- **429 Too Many Requests** - Rate limit exceeded + +### Server Error Codes + +- **500 Internal Server Error** - Server encountered an error +- **503 Service Unavailable** - Service temporarily unavailable + +## Error Response Format + +Error responses include a JSON body with error details: + +```json +{ + "error": { + "message": "Human-readable error description", + "code": "ERROR_CODE" + } +} +``` + +## Common Error Codes + +### Authentication Errors +- `INVALID_API_KEY` - API key is invalid or expired +- `MISSING_API_KEY` - X-API-Key header not provided + +### Validation Errors +- `INVALID_PARAMETER` - Request parameter is invalid +- `MISSING_REQUIRED_FIELD` - Required field not provided +- `INVALID_DATE_FORMAT` - Date not in ISO 8601 format + +### Resource Errors +- `RESOURCE_NOT_FOUND` - Requested resource doesn't exist +- `RESOURCE_ALREADY_EXISTS` - Attempting to create duplicate resource + +### Rate Limiting +- `RATE_LIMIT_EXCEEDED` - Too many requests in time window + +## Handling Errors + +### Example Error Handling (JavaScript) + +```javascript +async function makeAPIRequest(endpoint, options) { + try { + const response = await fetch( + `https://api.usemotion.com/v1${endpoint}`, + { + ...options, + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json', + ...options.headers + } + } + ); + + if (!response.ok) { + const error = await response.json(); + + switch (response.status) { + case 401: + throw new Error('Invalid API key'); + case 429: + throw new Error('Rate limit exceeded. Please retry later.'); + case 404: + throw new Error(`Resource not found: ${error.error.message}`); + default: + throw new Error(error.error.message || 'API request failed'); + } + } + + return await response.json(); + } catch (error) { + console.error('API Error:', error); + throw error; + } +} +``` + +### Rate Limit Handling + +When you receive a 429 response, implement exponential backoff: + +```javascript +async function withRetry(fn, maxRetries = 3) { + for (let i = 0; i < maxRetries; i++) { + try { + return await fn(); + } catch (error) { + if (error.message.includes('Rate limit') && i < maxRetries - 1) { + const delay = Math.pow(2, i) * 1000; // Exponential backoff + await new Promise(resolve => setTimeout(resolve, delay)); + } else { + throw error; + } + } + } +} +``` + +## Best Practices + +1. **Always check response status** before processing the response body +2. **Log errors** for debugging but don't expose sensitive information +3. **Implement retry logic** for transient errors (429, 503) +4. **Validate inputs** before making API requests to reduce errors +5. **Handle specific error codes** to provide better user experience \ No newline at end of file diff --git a/docs/official_api/projects/README.md b/docs/official_api/projects/README.md new file mode 100644 index 0000000..add79f5 --- /dev/null +++ b/docs/official_api/projects/README.md @@ -0,0 +1,127 @@ +# Projects API + +The Projects API provides functionality for managing projects in Motion, allowing you to organize tasks and track progress across initiatives. + +## Overview + +Projects in Motion help you: +- Group related tasks together +- Track progress on larger initiatives +- Manage project status and lifecycle +- Organize work by team or objective +- Apply custom fields for project-specific data + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/projects` | [List all projects](./list-projects.md) | +| GET | `/v1/projects/{id}` | [Get a specific project](./get-project.md) | +| POST | `/v1/projects` | [Create a new project](./create-project.md) | + +## Project Object + +```javascript +{ + id: string, // Unique project identifier + name: string, // Project name + description: string, // HTML description + workspaceId: string, // Parent workspace ID + status: { + name: string, // Status name + isDefaultStatus: boolean, // Whether this is the default + isResolvedStatus: boolean // Whether this indicates completion + }, + createdTime: datetime, // ISO 8601 creation timestamp + updatedTime: datetime, // ISO 8601 last update timestamp + customFieldValues: { // Custom field values + "fieldName": { + type: string, + value: any + } + } +} +``` + +## Common Use Cases + +### 1. Create a Project for a Sprint + +```javascript +POST /v1/projects +{ + "name": "Sprint 24 - User Authentication", + "workspaceId": "workspace_engineering", + "description": "Implement user authentication and authorization features", + "status": "In Progress" +} +``` + +### 2. Create a Marketing Campaign Project + +```javascript +POST /v1/projects +{ + "name": "Q1 2024 Product Launch", + "workspaceId": "workspace_marketing", + "description": "Launch campaign for new product line including social media, email, and content marketing" +} +``` + +### 3. Create a Client Project + +```javascript +POST /v1/projects +{ + "name": "Acme Corp Website Redesign", + "workspaceId": "workspace_clients", + "description": "Complete website redesign and development for Acme Corporation" +} +``` + +## Working with Projects + +### Project Lifecycle + +1. **Creation**: Projects start with a name and workspace +2. **Planning**: Add description and initial tasks +3. **Execution**: Tasks are worked on and completed +4. **Tracking**: Monitor progress through task completion +5. **Completion**: Mark project status as resolved + +### Organizing Tasks + +Tasks can be associated with projects to: +- Group related work items +- Track progress at project level +- Filter and report by project +- Manage team assignments + +### Status Management + +Projects use workspace-defined statuses. Common patterns: +- **Default statuses**: "Not Started", "Planning" +- **Active statuses**: "In Progress", "On Hold" +- **Resolved statuses**: "Completed", "Cancelled" + +## Best Practices + +1. **Naming Conventions**: Use clear, descriptive project names +2. **Description Details**: Include objectives, timelines, and key information +3. **Status Updates**: Keep project status current as work progresses +4. **Task Organization**: Group all related tasks under appropriate projects +5. **Custom Fields**: Use custom fields for project-specific metadata + +## Limitations + +- Projects cannot be moved between workspaces +- Project deletion is permanent and cannot be undone +- Projects must belong to a workspace (no standalone projects) +- Status options are limited to workspace-defined statuses + +## Related Resources + +- [Tasks API](../tasks/) - Create and manage tasks within projects +- [Workspaces API](../workspaces/) - Manage workspaces that contain projects +- [Custom Fields API](../custom-fields/) - Add custom data to projects +- [Statuses API](../statuses/) - View available project statuses \ No newline at end of file diff --git a/docs/official_api/projects/create-project.md b/docs/official_api/projects/create-project.md new file mode 100644 index 0000000..65fe0e0 --- /dev/null +++ b/docs/official_api/projects/create-project.md @@ -0,0 +1,340 @@ +# Create Project + +Create a new project in Motion. + +## Endpoint + +``` +POST /v1/projects +``` + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +Content-Type: application/json +``` + +### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| name | string | Yes | Project name | +| workspaceId | string | Yes | ID of the workspace where the project will be created | +| description | string | No | Project description (supports HTML/Markdown) | +| status | string | No | Initial project status (must be valid for the workspace) | + +### Example Request + +```bash +curl -X POST https://api.usemotion.com/v1/projects \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Q2 Product Launch", + "workspaceId": "workspace_123", + "description": "Launch campaign for our new product line including marketing, sales enablement, and customer success initiatives", + "status": "Planning" + }' +``` + +## Response + +### Success Response (201 Created) + +Returns the created project object: + +```json +{ + "id": "project_new_456", + "name": "Q2 Product Launch", + "description": "

Launch campaign for our new product line including marketing, sales enablement, and customer success initiatives

", + "workspaceId": "workspace_123", + "status": { + "name": "Planning", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + "createdTime": "2024-12-15T17:00:00Z", + "updatedTime": "2024-12-15T17:00:00Z", + "customFieldValues": {} +} +``` + +### Error Responses + +#### 400 Bad Request + +Missing required fields or invalid data: + +```json +{ + "error": { + "message": "Missing required field: name", + "code": "MISSING_REQUIRED_FIELD" + } +} +``` + +#### 404 Not Found + +Invalid workspace ID: + +```json +{ + "error": { + "message": "Workspace not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 422 Unprocessable Entity + +Invalid status for workspace: + +```json +{ + "error": { + "message": "Invalid status: 'InvalidStatus' is not a valid status for this workspace", + "code": "INVALID_PARAMETER" + } +} +``` + +## Project Creation Examples + +### 1. Simple Project + +```json +{ + "name": "Website Redesign", + "workspaceId": "workspace_123" +} +``` + +### 2. Project with Description + +```json +{ + "name": "Customer Feedback Analysis", + "workspaceId": "workspace_456", + "description": "Analyze Q4 customer feedback to identify improvement areas and prioritize feature requests" +} +``` + +### 3. Project with Initial Status + +```json +{ + "name": "API Integration Phase 2", + "workspaceId": "workspace_789", + "description": "Implement additional third-party API integrations", + "status": "In Progress" +} +``` + +### 4. Marketing Campaign Project + +```json +{ + "name": "Black Friday 2024 Campaign", + "workspaceId": "workspace_marketing", + "description": "## Campaign Overview\n\n**Objectives:**\n- Increase sales by 40%\n- Acquire 10,000 new customers\n- Boost brand awareness\n\n**Channels:**\n- Email marketing\n- Social media\n- Paid advertising\n- Influencer partnerships" +} +``` + +### 5. Development Sprint Project + +```json +{ + "name": "Sprint 15 - Authentication Improvements", + "workspaceId": "workspace_engineering", + "description": "### Sprint Goals\n1. Implement OAuth 2.0\n2. Add two-factor authentication\n3. Improve password reset flow\n4. Security audit and fixes", + "status": "Planning" +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function createProject(projectData) { + const response = await fetch( + 'https://api.usemotion.com/v1/projects', + { + method: 'POST', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(projectData) + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to create project: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + const newProject = await createProject({ + name: 'Q2 Marketing Campaign', + workspaceId: 'workspace_123', + description: 'Marketing initiatives for Q2 2024', + status: 'Planning' + }); + + console.log(`Created project: ${newProject.name} (${newProject.id})`); +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Python + +```python +import requests +import json +import os + +def create_project(project_data): + url = "https://api.usemotion.com/v1/projects" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY"), + "Content-Type": "application/json" + } + + response = requests.post(url, headers=headers, json=project_data) + response.raise_for_status() + + return response.json() + +# Usage +try: + new_project = create_project({ + "name": "Q2 Marketing Campaign", + "workspaceId": "workspace_123", + "description": "Marketing initiatives for Q2 2024", + "status": "Planning" + }) + + print(f"Created project: {new_project['name']} ({new_project['id']})") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Create Project with Validation + +```javascript +async function createProjectWithValidation(name, workspaceId, options = {}) { + // Validate required fields + if (!name || !name.trim()) { + throw new Error('Project name is required'); + } + + if (!workspaceId) { + throw new Error('Workspace ID is required'); + } + + // Build project data + const projectData = { + name: name.trim(), + workspaceId + }; + + // Add optional fields + if (options.description) { + projectData.description = options.description; + } + + if (options.status) { + projectData.status = options.status; + } + + return await createProject(projectData); +} + +// Usage +const project = await createProjectWithValidation( + 'New Feature Development', + 'workspace_123', + { + description: 'Develop and launch new product features', + status: 'In Progress' + } +); +``` + +### Batch Create Projects + +```javascript +async function createMultipleProjects(projectsData) { + const results = []; + + for (const data of projectsData) { + try { + const project = await createProject(data); + results.push({ + success: true, + project + }); + } catch (error) { + results.push({ + success: false, + error: error.message, + data + }); + } + } + + return results; +} + +// Create multiple projects +const projectsToCreate = [ + { + name: 'Q1 Initiatives', + workspaceId: 'workspace_123' + }, + { + name: 'Q2 Initiatives', + workspaceId: 'workspace_123' + }, + { + name: 'Q3 Initiatives', + workspaceId: 'workspace_123' + } +]; + +const results = await createMultipleProjects(projectsToCreate); +const successful = results.filter(r => r.success).length; +console.log(`Created ${successful} out of ${results.length} projects`); +``` + +## Important Notes + +1. **Workspace Required**: Every project must belong to a workspace +2. **Name Uniqueness**: Project names don't need to be unique within a workspace +3. **Status Validation**: If provided, status must exist in the workspace +4. **Default Status**: If no status is provided, the workspace's default status is used +5. **Description Format**: Description supports Markdown which is converted to HTML +6. **Custom Fields**: Cannot be set during creation - use the custom fields API after creation +7. **No Project Hierarchy**: Projects cannot be nested or have parent projects + +## Best Practices + +1. **Descriptive Names**: Use clear, descriptive project names +2. **Add Context**: Include detailed descriptions for better understanding +3. **Status Strategy**: Set appropriate initial status based on project phase +4. **Validation**: Validate inputs before making API calls +5. **Error Handling**: Handle specific error cases (invalid workspace, status, etc.) +6. **Workspace Verification**: Ensure workspace exists and you have access before creating \ No newline at end of file diff --git a/docs/official_api/projects/get-project.md b/docs/official_api/projects/get-project.md new file mode 100644 index 0000000..f6293f6 --- /dev/null +++ b/docs/official_api/projects/get-project.md @@ -0,0 +1,327 @@ +# Get Project + +Retrieve detailed information about a specific project. + +## Endpoint + +``` +GET /v1/projects/{id} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| id | string | Yes | The ID of the project to retrieve | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X GET https://api.usemotion.com/v1/projects/project_123 \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +Returns the complete project object: + +```json +{ + "id": "project_123", + "name": "Q1 Marketing Campaign", + "description": "

Marketing campaign for Q1 2024 focusing on:

  • Social media outreach
  • Email marketing
  • Content creation
", + "workspaceId": "workspace_456", + "status": { + "name": "In Progress", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + "createdTime": "2024-01-01T09:00:00Z", + "updatedTime": "2024-12-15T16:45:00Z", + "customFieldValues": { + "Budget": { + "type": "number", + "value": 50000 + }, + "Department": { + "type": "select", + "value": "Marketing" + }, + "Project Manager": { + "type": "person", + "value": { + "id": "user_789", + "name": "Jane Smith", + "email": "jane@example.com" + } + }, + "Target Completion": { + "type": "date", + "value": "2024-03-31" + } + } +} +``` + +### Error Responses + +#### 404 Not Found + +Project doesn't exist: + +```json +{ + "error": { + "message": "Project not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +#### 403 Forbidden + +No access to the project: + +```json +{ + "error": { + "message": "You do not have access to this project", + "code": "INSUFFICIENT_PERMISSIONS" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function getProject(projectId) { + const response = await fetch( + `https://api.usemotion.com/v1/projects/${projectId}`, + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to get project: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + const project = await getProject('project_123'); + console.log(`Project: ${project.name}`); + console.log(`Status: ${project.status.name}`); + console.log(`Workspace: ${project.workspaceId}`); + + // Check if completed + if (project.status.isResolvedStatus) { + console.log('Project is completed!'); + } +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Python + +```python +import requests +import os + +def get_project(project_id): + url = f"https://api.usemotion.com/v1/projects/{project_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + return response.json() + +# Usage +try: + project = get_project("project_123") + print(f"Project: {project['name']}") + print(f"Status: {project['status']['name']}") + + # Check custom fields + if 'Budget' in project.get('customFieldValues', {}): + budget = project['customFieldValues']['Budget']['value'] + print(f"Budget: ${budget:,.2f}") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Get Project with Error Handling + +```javascript +async function getProjectSafely(projectId) { + try { + const project = await getProject(projectId); + return { success: true, project }; + } catch (error) { + if (error.message.includes('not found')) { + return { success: false, error: 'Project does not exist' }; + } else if (error.message.includes('Invalid API key')) { + return { success: false, error: 'Authentication failed' }; + } else { + return { success: false, error: error.message }; + } + } +} + +// Usage +const result = await getProjectSafely('project_123'); +if (result.success) { + console.log('Project found:', result.project.name); +} else { + console.log('Error:', result.error); +} +``` + +### Display Project Summary + +```javascript +function displayProjectSummary(project) { + console.log('='.repeat(50)); + console.log(`Project: ${project.name}`); + console.log('='.repeat(50)); + console.log(`ID: ${project.id}`); + console.log(`Status: ${project.status.name}`); + console.log(`Created: ${new Date(project.createdTime).toLocaleDateString()}`); + console.log(`Updated: ${new Date(project.updatedTime).toLocaleDateString()}`); + + // Display description (strip HTML) + const description = project.description + .replace(/<[^>]*>/g, '') + .trim(); + if (description) { + console.log(`\nDescription:\n${description}`); + } + + // Display custom fields + const customFields = project.customFieldValues || {}; + if (Object.keys(customFields).length > 0) { + console.log('\nCustom Fields:'); + Object.entries(customFields).forEach(([name, field]) => { + let value = field.value; + if (field.type === 'person' && value) { + value = value.name; + } else if (field.type === 'number' && value) { + value = value.toLocaleString(); + } + console.log(` ${name}: ${value}`); + }); + } +} + +// Usage +const project = await getProject('project_123'); +displayProjectSummary(project); +``` + +### Check Project Budget + +```javascript +async function getProjectBudgetInfo(projectId) { + const project = await getProject(projectId); + + const budgetField = project.customFieldValues?.Budget; + if (!budgetField || budgetField.type !== 'number') { + return { + hasBudget: false, + project: project.name + }; + } + + return { + hasBudget: true, + project: project.name, + budget: budgetField.value, + status: project.status.name, + isActive: !project.status.isResolvedStatus + }; +} + +// Usage +const budgetInfo = await getProjectBudgetInfo('project_123'); +if (budgetInfo.hasBudget) { + console.log(`${budgetInfo.project}: $${budgetInfo.budget.toLocaleString()}`); + if (budgetInfo.isActive) { + console.log('Project is currently active'); + } +} else { + console.log(`${budgetInfo.project} has no budget assigned`); +} +``` + +## Project Details + +### Core Fields + +- **id**: Unique project identifier +- **name**: Project name +- **description**: HTML-formatted description +- **workspaceId**: ID of the containing workspace + +### Status Information + +The status object provides important metadata: +- **name**: Human-readable status name +- **isDefaultStatus**: True if this is the default for new projects +- **isResolvedStatus**: True if this indicates project completion + +### Timestamps + +- **createdTime**: When the project was created (ISO 8601) +- **updatedTime**: Last modification time (ISO 8601) + +### Custom Fields + +Custom fields are returned in the `customFieldValues` object with: +- Field name as the key +- Value object containing `type` and `value` +- Types can include: text, number, date, select, person, etc. + +## Best Practices + +1. **Error Handling**: Always handle 404 errors for invalid project IDs +2. **Status Checking**: Use `isResolvedStatus` to determine if project is complete +3. **Custom Fields**: Check field type before processing values +4. **Caching**: Cache project data if accessed frequently +5. **Description Parsing**: Project descriptions are HTML, parse accordingly \ No newline at end of file diff --git a/docs/official_api/projects/list-projects.md b/docs/official_api/projects/list-projects.md new file mode 100644 index 0000000..abda4f1 --- /dev/null +++ b/docs/official_api/projects/list-projects.md @@ -0,0 +1,303 @@ +# List Projects + +Retrieve a list of all projects, optionally filtered by workspace. + +## Endpoint + +``` +GET /v1/projects +``` + +## Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| workspaceId | string | No | Filter projects by workspace ID | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +# Get all projects +curl -X GET "https://api.usemotion.com/v1/projects" \ + -H "X-API-Key: YOUR_API_KEY" + +# Get projects for specific workspace +curl -X GET "https://api.usemotion.com/v1/projects?workspaceId=workspace_123" \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +Returns an array of project objects: + +```json +[ + { + "id": "project_001", + "name": "Q1 Marketing Campaign", + "description": "

Marketing initiatives for Q1 2024

", + "workspaceId": "workspace_marketing", + "status": { + "name": "In Progress", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + "createdTime": "2024-01-01T09:00:00Z", + "updatedTime": "2024-12-15T14:30:00Z", + "customFieldValues": { + "Budget": { + "type": "number", + "value": 50000 + }, + "Priority": { + "type": "select", + "value": "High" + } + } + }, + { + "id": "project_002", + "name": "Website Redesign", + "description": "

Complete overhaul of company website

", + "workspaceId": "workspace_engineering", + "status": { + "name": "Planning", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + "createdTime": "2024-01-15T10:00:00Z", + "updatedTime": "2024-01-20T16:00:00Z", + "customFieldValues": {} + }, + { + "id": "project_003", + "name": "Customer Onboarding Improvement", + "description": "

Streamline the customer onboarding process

", + "workspaceId": "workspace_product", + "status": { + "name": "Completed", + "isDefaultStatus": false, + "isResolvedStatus": true + }, + "createdTime": "2023-11-01T08:00:00Z", + "updatedTime": "2024-01-10T17:00:00Z", + "customFieldValues": { + "Quarter": { + "type": "text", + "value": "Q4 2023" + } + } + } +] +``` + +### Empty Result + +```json +[] +``` + +### Error Responses + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +#### 404 Not Found + +Invalid workspace ID: + +```json +{ + "error": { + "message": "Workspace not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function listProjects(workspaceId = null) { + const url = new URL('https://api.usemotion.com/v1/projects'); + if (workspaceId) { + url.searchParams.append('workspaceId', workspaceId); + } + + const response = await fetch(url, { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to list projects: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +// Get all projects +const allProjects = await listProjects(); +console.log(`Found ${allProjects.length} projects`); + +// Get projects for specific workspace +const workspaceProjects = await listProjects('workspace_123'); +workspaceProjects.forEach(project => { + console.log(`${project.name} - ${project.status.name}`); +}); +``` + +### Python + +```python +import requests +import os + +def list_projects(workspace_id=None): + url = "https://api.usemotion.com/v1/projects" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + params = {} + if workspace_id: + params["workspaceId"] = workspace_id + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + return response.json() + +# Usage +# Get all projects +all_projects = list_projects() +print(f"Found {len(all_projects)} projects") + +# Get projects for specific workspace +workspace_projects = list_projects("workspace_123") +for project in workspace_projects: + print(f"{project['name']} - {project['status']['name']}") +``` + +### Filter Active Projects + +```javascript +async function getActiveProjects(workspaceId = null) { + const projects = await listProjects(workspaceId); + + return projects.filter(project => + !project.status.isResolvedStatus + ); +} + +// Get only active (non-completed) projects +const activeProjects = await getActiveProjects(); +console.log(`${activeProjects.length} active projects`); +``` + +### Group Projects by Status + +```javascript +async function groupProjectsByStatus() { + const projects = await listProjects(); + + const grouped = projects.reduce((acc, project) => { + const status = project.status.name; + if (!acc[status]) { + acc[status] = []; + } + acc[status].push(project); + return acc; + }, {}); + + return grouped; +} + +// Usage +const projectsByStatus = await groupProjectsByStatus(); +Object.entries(projectsByStatus).forEach(([status, projects]) => { + console.log(`${status}: ${projects.length} projects`); +}); +``` + +### Projects with Custom Fields + +```javascript +async function getProjectsWithBudget() { + const projects = await listProjects(); + + return projects.filter(project => + project.customFieldValues?.Budget?.value > 0 + ).map(project => ({ + name: project.name, + budget: project.customFieldValues.Budget.value, + status: project.status.name + })); +} + +// Get projects with budget information +const budgetedProjects = await getProjectsWithBudget(); +budgetedProjects.forEach(project => { + console.log(`${project.name}: $${project.budget.toLocaleString()}`); +}); +``` + +## Response Details + +### Project Object Fields + +- **id**: Unique identifier for the project +- **name**: Project name (required) +- **description**: HTML-formatted project description +- **workspaceId**: ID of the containing workspace +- **status**: Current project status with metadata +- **createdTime**: When the project was created +- **updatedTime**: Last modification timestamp +- **customFieldValues**: Key-value pairs of custom field data + +### Status Object + +- **name**: Display name of the status +- **isDefaultStatus**: Whether new projects start with this status +- **isResolvedStatus**: Whether this status indicates project completion + +## Best Practices + +1. **Cache Results**: Project lists don't change frequently, consider caching +2. **Filter by Workspace**: Use workspace filtering to reduce response size +3. **Check Status**: Use status flags to identify active vs completed projects +4. **Handle Empty Arrays**: Always handle the case of no projects +5. **Monitor Custom Fields**: Projects may have varying custom fields + +## Notes + +- Projects are returned in no guaranteed order +- No pagination is currently supported for project lists +- All projects accessible to the API key are returned +- Workspace filtering is done server-side for efficiency +- Custom field values may be null or missing if not set \ No newline at end of file diff --git a/docs/official_api/recurring-tasks/README.md b/docs/official_api/recurring-tasks/README.md new file mode 100644 index 0000000..c707eff --- /dev/null +++ b/docs/official_api/recurring-tasks/README.md @@ -0,0 +1,149 @@ +# Recurring Tasks API + +The Recurring Tasks API allows you to create and manage task templates that automatically generate tasks on a recurring schedule. + +## Overview + +Recurring tasks are perfect for: +- Regular meetings (daily standups, weekly 1-on-1s) +- Routine maintenance tasks +- Periodic reports and reviews +- Scheduled check-ins and follow-ups +- Repeating project milestones + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/recurring-tasks` | [List recurring tasks](./list-recurring-tasks.md) | +| POST | `/v1/recurring-tasks` | [Create a recurring task](./create-recurring-task.md) | +| DELETE | `/v1/recurring-tasks/{id}` | [Delete a recurring task](./delete-recurring-task.md) | + +## Recurring Task Object + +```javascript +{ + id: string, // Unique identifier + name: string, // Task template name + frequency: string, // Recurrence pattern + creator: { // Creator information + id: string, + name: string, + email: string + }, + assignee: { // Assigned user + id: string, + name: string, + email: string + }, + project: { // Associated project (if any) + id: string, + name: string, + description: string, + workspaceId: string, + status: object, + customFieldValues: object + }, + workspace: { // Workspace details + id: string, + name: string, + teamId: string, + type: string, + labels: array, + statuses: array + }, + status: object, // Task status + priority: string, // Task priority + labels: array, // Task labels + description: string, // Task description + duration: string | number, // Task duration + deadlineType: string, // Deadline type + startingOn: string, // Start date + idealTime: string, // Preferred time + schedule: string // Schedule name +} +``` + +## Frequency Options + +Recurring tasks support various frequency patterns: +- Daily +- Weekly (specific days) +- Monthly (specific dates) +- Custom patterns + +## Common Use Cases + +### 1. Daily Standup Meeting + +```javascript +POST /v1/recurring-tasks +{ + "name": "Daily Standup", + "frequency": "DAILY", + "workspaceId": "workspace_123", + "assigneeId": "user_456", + "duration": 15, + "idealTime": "09:00", + "priority": "HIGH" +} +``` + +### 2. Weekly Report + +```javascript +POST /v1/recurring-tasks +{ + "name": "Weekly Status Report", + "frequency": "WEEKLY_FRIDAY", + "workspaceId": "workspace_123", + "assigneeId": "user_789", + "duration": 60, + "deadlineType": "SOFT", + "description": "Compile and send weekly status report to leadership" +} +``` + +### 3. Monthly Review + +```javascript +POST /v1/recurring-tasks +{ + "name": "Monthly Performance Review", + "frequency": "MONTHLY_LAST", + "workspaceId": "workspace_123", + "assigneeId": "user_manager", + "duration": 120, + "priority": "HIGH", + "projectId": "project_reviews" +} +``` + +## Task Generation + +When a recurring task is created: +1. Motion generates individual task instances based on the frequency +2. Each instance is scheduled according to the specified parameters +3. Tasks are auto-scheduled if the assignee has auto-scheduling enabled +4. New instances are created as previous ones are completed + +## Best Practices + +1. **Clear Naming**: Use descriptive names that indicate the recurring nature +2. **Appropriate Duration**: Set realistic time estimates for recurring work +3. **Deadline Types**: Use SOFT deadlines for flexible tasks, HARD for critical ones +4. **Ideal Times**: Specify preferred times for time-sensitive recurring tasks +5. **Project Association**: Group related recurring tasks under projects + +## Important Notes + +- Deleting a recurring task doesn't delete already-created task instances +- Changes to recurring tasks only affect future instances +- Each workspace can have multiple recurring tasks +- Recurring tasks follow the same scheduling rules as regular tasks + +## Related Resources + +- [Tasks API](../tasks/) - Manage individual task instances +- [Projects API](../projects/) - Organize recurring tasks in projects +- [Schedules API](../schedules/) - Configure work schedules \ No newline at end of file diff --git a/docs/official_api/recurring-tasks/create-recurring-task.md b/docs/official_api/recurring-tasks/create-recurring-task.md new file mode 100644 index 0000000..f878e5e --- /dev/null +++ b/docs/official_api/recurring-tasks/create-recurring-task.md @@ -0,0 +1,377 @@ +# Create Recurring Task + +Create a new recurring task template that automatically generates tasks on a schedule. + +## Endpoint + +``` +POST /v1/recurring-tasks +``` + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +Content-Type: application/json +``` + +### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| frequency | string | Yes | Task recurrence pattern | +| name | string | Yes | Task name | +| workspaceId | string | Yes | Workspace ID | +| assigneeId | string | Yes | User ID to assign tasks to | +| deadlineType | string | No | "HARD" or "SOFT" (default: "SOFT") | +| duration | integer \| string | No | Minutes > 0 or "REMINDER" | +| startingOn | string | No | ISO 8601 date when to start | +| idealTime | string | No | Preferred time (HH:mm format) | +| schedule | string | No | Schedule name (default: "Work Hours") | +| description | string | No | Task description | +| priority | string | No | "HIGH" or "MEDIUM" (default: "MEDIUM") | +| projectId | string | No | Project ID to associate with | +| labels | array | No | Array of label names | + +### Frequency Options + +Common frequency patterns: +- `DAILY` - Every day +- `WEEKLY_MONDAY` through `WEEKLY_SUNDAY` - Weekly on specific day +- `MONTHLY_1` through `MONTHLY_31` - Monthly on specific date +- `MONTHLY_LAST` - Last day of month + +### Example Request + +```bash +curl -X POST https://api.usemotion.com/v1/recurring-tasks \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Daily Standup Meeting", + "frequency": "DAILY", + "workspaceId": "workspace_123", + "assigneeId": "user_456", + "duration": 15, + "priority": "HIGH", + "deadlineType": "SOFT", + "idealTime": "09:00", + "description": "Daily team sync to discuss progress and blockers", + "labels": ["meeting", "standup"], + "projectId": "project_meetings" + }' +``` + +## Response + +### Success Response (201 Created) + +Returns the created recurring task object: + +```json +{ + "id": "recurring_new_123", + "name": "Daily Standup Meeting", + "frequency": "DAILY", + "creator": { + "id": "user_current", + "name": "Current User", + "email": "current@example.com" + }, + "assignee": { + "id": "user_456", + "name": "Jane Smith", + "email": "jane@example.com" + }, + "project": { + "id": "project_meetings", + "name": "Team Meetings", + "workspaceId": "workspace_123" + }, + "workspace": { + "id": "workspace_123", + "name": "Engineering Team", + "teamId": "team_456", + "type": "team" + }, + "status": { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + "priority": "HIGH", + "labels": [ + { "name": "meeting" }, + { "name": "standup" } + ], + "description": "Daily team sync to discuss progress and blockers", + "duration": 15, + "deadlineType": "SOFT", + "startingOn": "2024-12-16", + "idealTime": "09:00", + "schedule": "Work Hours" +} +``` + +### Error Responses + +#### 400 Bad Request + +Missing required fields: + +```json +{ + "error": { + "message": "Missing required field: assigneeId", + "code": "MISSING_REQUIRED_FIELD" + } +} +``` + +#### 422 Unprocessable Entity + +Invalid frequency or parameters: + +```json +{ + "error": { + "message": "Invalid frequency: INVALID_FREQUENCY", + "code": "INVALID_PARAMETER" + } +} +``` + +## Recurring Task Examples + +### 1. Daily Standup + +```json +{ + "name": "Daily Standup", + "frequency": "DAILY", + "workspaceId": "workspace_123", + "assigneeId": "user_456", + "duration": 15, + "priority": "HIGH", + "idealTime": "09:00", + "description": "Quick sync on daily progress" +} +``` + +### 2. Weekly Report (Every Friday) + +```json +{ + "name": "Weekly Status Report", + "frequency": "WEEKLY_FRIDAY", + "workspaceId": "workspace_123", + "assigneeId": "user_789", + "duration": 60, + "deadlineType": "HARD", + "idealTime": "16:00", + "description": "Compile and send weekly status update to leadership" +} +``` + +### 3. Monthly Review (First of Month) + +```json +{ + "name": "Monthly Performance Review", + "frequency": "MONTHLY_1", + "workspaceId": "workspace_123", + "assigneeId": "user_manager", + "duration": 120, + "priority": "HIGH", + "projectId": "project_reviews", + "startingOn": "2024-01-01" +} +``` + +### 4. Bi-Weekly 1-on-1 (Every Other Monday) + +```json +{ + "name": "1-on-1 with Manager", + "frequency": "WEEKLY_MONDAY", + "workspaceId": "workspace_123", + "assigneeId": "user_456", + "duration": 30, + "idealTime": "14:00", + "description": "Bi-weekly check-in with manager", + "labels": ["1on1", "meeting"] +} +``` + +### 5. End of Month Tasks + +```json +{ + "name": "Month-End Financial Close", + "frequency": "MONTHLY_LAST", + "workspaceId": "workspace_finance", + "assigneeId": "user_accountant", + "duration": 240, + "deadlineType": "HARD", + "priority": "HIGH", + "description": "Complete month-end financial closing procedures" +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function createRecurringTask(taskData) { + const response = await fetch( + 'https://api.usemotion.com/v1/recurring-tasks', + { + method: 'POST', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(taskData) + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to create recurring task: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + const recurringTask = await createRecurringTask({ + name: 'Weekly Team Meeting', + frequency: 'WEEKLY_MONDAY', + workspaceId: 'workspace_123', + assigneeId: 'user_456', + duration: 60, + priority: 'HIGH', + idealTime: '10:00', + description: 'Weekly team sync and planning session' + }); + + console.log(`Created recurring task: ${recurringTask.name} (${recurringTask.id})`); +} catch (error) { + console.error('Error:', error); +} +``` + +### Python + +```python +import requests +import json +import os + +def create_recurring_task(task_data): + url = "https://api.usemotion.com/v1/recurring-tasks" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY"), + "Content-Type": "application/json" + } + + response = requests.post(url, headers=headers, json=task_data) + response.raise_for_status() + + return response.json() + +# Usage +try: + recurring_task = create_recurring_task({ + "name": "Weekly Team Meeting", + "frequency": "WEEKLY_MONDAY", + "workspaceId": "workspace_123", + "assigneeId": "user_456", + "duration": 60, + "priority": "HIGH", + "idealTime": "10:00", + "description": "Weekly team sync and planning session" + }) + + print(f"Created recurring task: {recurring_task['name']} ({recurring_task['id']})") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Create Multiple Recurring Tasks + +```javascript +async function setupTeamRecurringTasks(workspaceId, teamLeadId) { + const recurringTasks = [ + { + name: 'Daily Standup', + frequency: 'DAILY', + duration: 15, + idealTime: '09:00', + priority: 'HIGH' + }, + { + name: 'Weekly Planning', + frequency: 'WEEKLY_MONDAY', + duration: 60, + idealTime: '10:00', + priority: 'HIGH' + }, + { + name: 'Weekly Retrospective', + frequency: 'WEEKLY_FRIDAY', + duration: 45, + idealTime: '16:00', + priority: 'MEDIUM' + } + ]; + + const results = []; + + for (const task of recurringTasks) { + try { + const created = await createRecurringTask({ + ...task, + workspaceId, + assigneeId: teamLeadId + }); + results.push({ success: true, task: created }); + } catch (error) { + results.push({ success: false, task: task.name, error: error.message }); + } + } + + return results; +} + +// Set up standard team recurring tasks +const results = await setupTeamRecurringTasks('workspace_123', 'user_456'); +const successful = results.filter(r => r.success).length; +console.log(`Created ${successful} out of ${results.length} recurring tasks`); +``` + +## Important Notes + +1. **Frequency Required**: You must specify a valid frequency pattern +2. **Assignee Required**: Every recurring task must have an assignee +3. **Auto-Scheduling**: Tasks are auto-scheduled if the assignee has it enabled +4. **Start Date**: If not specified, tasks start generating immediately +5. **Time Preferences**: idealTime is a preference, not a guarantee +6. **Duration**: Must be positive integer or "REMINDER" +7. **Labels**: New labels are created automatically if they don't exist +8. **Project Association**: Optional but helpful for organization +9. **Schedule Limitation**: Only "Work Hours" schedule is supported + +## Best Practices + +1. **Clear Names**: Use descriptive names indicating recurrence +2. **Appropriate Duration**: Set realistic time estimates +3. **Ideal Times**: Specify preferred times for predictable scheduling +4. **Priority Setting**: Use HIGH for critical recurring tasks +5. **Description**: Include instructions or context for recurring work +6. **Project Grouping**: Group related recurring tasks in projects \ No newline at end of file diff --git a/docs/official_api/recurring-tasks/delete-recurring-task.md b/docs/official_api/recurring-tasks/delete-recurring-task.md new file mode 100644 index 0000000..36b709e --- /dev/null +++ b/docs/official_api/recurring-tasks/delete-recurring-task.md @@ -0,0 +1,299 @@ +# Delete Recurring Task + +Delete a recurring task template. This stops future task generation but doesn't affect already-created task instances. + +## Endpoint + +``` +DELETE /v1/recurring-tasks/{id} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| id | integer | Yes | The ID of the recurring task to delete | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X DELETE https://api.usemotion.com/v1/recurring-tasks/123 \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (204 No Content) + +The recurring task is successfully deleted. No response body is returned. + +### Error Responses + +#### 404 Not Found + +Recurring task doesn't exist: + +```json +{ + "error": { + "message": "Recurring task not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +#### 403 Forbidden + +Insufficient permissions: + +```json +{ + "error": { + "message": "You do not have permission to delete this recurring task", + "code": "INSUFFICIENT_PERMISSIONS" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function deleteRecurringTask(recurringTaskId) { + const response = await fetch( + `https://api.usemotion.com/v1/recurring-tasks/${recurringTaskId}`, + { + method: 'DELETE', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Recurring task not found'); + } + const error = await response.json(); + throw new Error(`Failed to delete recurring task: ${error.error.message}`); + } + + // Success - no content returned + return true; +} + +// Usage +try { + await deleteRecurringTask(123); + console.log('Recurring task deleted successfully'); +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Python + +```python +import requests +import os + +def delete_recurring_task(recurring_task_id): + url = f"https://api.usemotion.com/v1/recurring-tasks/{recurring_task_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.delete(url, headers=headers) + + if response.status_code == 204: + return True + elif response.status_code == 404: + raise Exception("Recurring task not found") + else: + response.raise_for_status() + +# Usage +try: + delete_recurring_task(123) + print("Recurring task deleted successfully") +except Exception as e: + print(f"Error: {e}") +``` + +### Delete with Confirmation + +```javascript +async function deleteRecurringTaskWithConfirmation(workspaceId, recurringTaskId) { + // First, get the recurring task details + const result = await listRecurringTasks(workspaceId); + const task = result.recurringTasks.find(t => t.id === recurringTaskId.toString()); + + if (!task) { + throw new Error('Recurring task not found'); + } + + console.log(`About to delete recurring task: ${task.name} (${task.frequency})`); + + // Delete the recurring task + await deleteRecurringTask(recurringTaskId); + + return { + deleted: true, + taskName: task.name, + frequency: task.frequency, + assignee: task.assignee.name + }; +} + +// Usage +try { + const result = await deleteRecurringTaskWithConfirmation('workspace_123', 456); + console.log(`Deleted: ${result.taskName} (${result.frequency})`); +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Bulk Delete Recurring Tasks + +```javascript +async function deleteMultipleRecurringTasks(recurringTaskIds) { + const results = []; + + for (const id of recurringTaskIds) { + try { + await deleteRecurringTask(id); + results.push({ + id, + success: true + }); + } catch (error) { + results.push({ + id, + success: false, + error: error.message + }); + } + } + + return results; +} + +// Delete multiple recurring tasks +const idsToDelete = [123, 456, 789]; +const deleteResults = await deleteMultipleRecurringTasks(idsToDelete); + +const successful = deleteResults.filter(r => r.success).length; +console.log(`Deleted ${successful} out of ${deleteResults.length} recurring tasks`); + +// Show any errors +deleteResults.filter(r => !r.success).forEach(result => { + console.error(`Failed to delete ${result.id}: ${result.error}`); +}); +``` + +### Clean Up Recurring Tasks by Pattern + +```javascript +async function cleanupRecurringTasksByName(workspaceId, namePattern) { + // Get all recurring tasks + const result = await listRecurringTasks(workspaceId); + + // Filter tasks matching the pattern + const tasksToDelete = result.recurringTasks.filter(task => + task.name.toLowerCase().includes(namePattern.toLowerCase()) + ); + + if (tasksToDelete.length === 0) { + console.log('No matching recurring tasks found'); + return []; + } + + console.log(`Found ${tasksToDelete.length} recurring tasks to delete:`); + tasksToDelete.forEach(task => { + console.log(` - ${task.name} (${task.frequency})`); + }); + + // Delete the tasks + const results = []; + for (const task of tasksToDelete) { + try { + await deleteRecurringTask(parseInt(task.id)); + results.push({ + name: task.name, + success: true + }); + } catch (error) { + results.push({ + name: task.name, + success: false, + error: error.message + }); + } + } + + return results; +} + +// Clean up all test recurring tasks +const cleanupResults = await cleanupRecurringTasksByName('workspace_123', 'test'); +``` + +## Important Notes + +1. **No Undo**: Deletion is permanent and cannot be undone +2. **Existing Tasks Remain**: Already-generated task instances are not deleted +3. **Future Tasks Stop**: No new tasks will be generated after deletion +4. **ID Type**: The ID parameter must be an integer, not a string +5. **Permissions**: You must have appropriate permissions to delete the recurring task +6. **No Cascade**: Deleting doesn't affect the project or workspace + +## What Happens When Deleted + +When you delete a recurring task: + +1. **Template Removed**: The recurring task template is permanently deleted +2. **Generation Stops**: No new task instances will be created +3. **Existing Tasks**: Previously generated tasks remain unchanged +4. **History Lost**: The recurring task configuration cannot be recovered +5. **Immediate Effect**: Deletion takes effect immediately + +## Best Practices + +1. **Verify Before Deleting**: Check the recurring task details before deletion +2. **Document Deletion**: Keep records of deleted recurring tasks +3. **Consider Alternatives**: Instead of deleting, consider modifying the task +4. **Check Dependencies**: Ensure no workflows depend on the recurring task +5. **Communicate Changes**: Notify affected team members before deletion + +## Alternative Approaches + +Instead of deleting, consider: +- Modifying the assignee to a placeholder user +- Changing the frequency to reduce task generation +- Moving to a different workspace +- Updating the task to be a reminder only \ No newline at end of file diff --git a/docs/official_api/recurring-tasks/list-recurring-tasks.md b/docs/official_api/recurring-tasks/list-recurring-tasks.md new file mode 100644 index 0000000..bf33803 --- /dev/null +++ b/docs/official_api/recurring-tasks/list-recurring-tasks.md @@ -0,0 +1,358 @@ +# List Recurring Tasks + +Retrieve a list of recurring tasks for a specific workspace. + +## Endpoint + +``` +GET /v1/recurring-tasks +``` + +## Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| workspaceId | string | Yes | The workspace ID to filter recurring tasks | +| cursor | string | No | Pagination cursor from previous response | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +# Get recurring tasks for a workspace +curl -X GET "https://api.usemotion.com/v1/recurring-tasks?workspaceId=workspace_123" \ + -H "X-API-Key: YOUR_API_KEY" + +# With pagination +curl -X GET "https://api.usemotion.com/v1/recurring-tasks?workspaceId=workspace_123&cursor=eyJza2lwIjoyNX0=" \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +```json +{ + "meta": { + "nextCursor": "eyJza2lwIjoyNX0=", + "pageSize": 25 + }, + "recurringTasks": [ + { + "id": "recurring_001", + "name": "Daily Standup", + "frequency": "DAILY", + "creator": { + "id": "user_123", + "name": "John Doe", + "email": "john@example.com" + }, + "assignee": { + "id": "user_456", + "name": "Jane Smith", + "email": "jane@example.com" + }, + "project": { + "id": "project_789", + "name": "Team Meetings", + "description": "All recurring team meetings", + "workspaceId": "workspace_123", + "status": { + "name": "Active", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + "customFieldValues": {} + }, + "workspace": { + "id": "workspace_123", + "name": "Engineering Team", + "teamId": "team_456", + "type": "team", + "labels": [ + { "name": "meeting" }, + { "name": "daily" } + ], + "statuses": [ + { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + { + "name": "DONE", + "isDefaultStatus": false, + "isResolvedStatus": true + } + ] + }, + "status": { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + "priority": "HIGH", + "labels": [ + { "name": "meeting" }, + { "name": "standup" } + ], + "description": "Daily team sync to discuss progress and blockers", + "duration": 15, + "deadlineType": "SOFT", + "startingOn": "2024-01-01", + "idealTime": "09:00", + "schedule": "Work Hours" + }, + { + "id": "recurring_002", + "name": "Weekly Report", + "frequency": "WEEKLY_FRIDAY", + "creator": { + "id": "user_789", + "name": "Manager Bob", + "email": "bob@example.com" + }, + "assignee": { + "id": "user_456", + "name": "Jane Smith", + "email": "jane@example.com" + }, + "workspace": { + "id": "workspace_123", + "name": "Engineering Team", + "teamId": "team_456", + "type": "team" + }, + "status": { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + "priority": "MEDIUM", + "labels": [ + { "name": "report" }, + { "name": "weekly" } + ], + "description": "Compile and send weekly status report", + "duration": 60, + "deadlineType": "HARD", + "idealTime": "16:00", + "schedule": "Work Hours" + } + ] +} +``` + +### Empty Result + +```json +{ + "meta": { + "nextCursor": null, + "pageSize": 0 + }, + "recurringTasks": [] +} +``` + +### Error Responses + +#### 400 Bad Request + +Missing required workspaceId: + +```json +{ + "error": { + "message": "Missing required parameter: workspaceId", + "code": "MISSING_REQUIRED_FIELD" + } +} +``` + +#### 404 Not Found + +Invalid workspace: + +```json +{ + "error": { + "message": "Workspace not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function listRecurringTasks(workspaceId, cursor = null) { + const params = new URLSearchParams({ workspaceId }); + if (cursor) params.append('cursor', cursor); + + const response = await fetch( + `https://api.usemotion.com/v1/recurring-tasks?${params}`, + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to list recurring tasks: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + const result = await listRecurringTasks('workspace_123'); + console.log(`Found ${result.recurringTasks.length} recurring tasks`); + + result.recurringTasks.forEach(task => { + console.log(`${task.name} - ${task.frequency} - Assigned to: ${task.assignee.name}`); + }); +} catch (error) { + console.error('Error:', error); +} +``` + +### Python + +```python +import requests +import os + +def list_recurring_tasks(workspace_id, cursor=None): + url = "https://api.usemotion.com/v1/recurring-tasks" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + params = {"workspaceId": workspace_id} + if cursor: + params["cursor"] = cursor + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + return response.json() + +# Usage +try: + result = list_recurring_tasks("workspace_123") + print(f"Found {len(result['recurringTasks'])} recurring tasks") + + for task in result['recurringTasks']: + print(f"{task['name']} - {task['frequency']} - Assigned to: {task['assignee']['name']}") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Get All Recurring Tasks with Pagination + +```javascript +async function getAllRecurringTasks(workspaceId) { + const allTasks = []; + let cursor = null; + + do { + const result = await listRecurringTasks(workspaceId, cursor); + allTasks.push(...result.recurringTasks); + cursor = result.meta.nextCursor; + } while (cursor); + + return allTasks; +} + +// Get all recurring tasks +const allRecurringTasks = await getAllRecurringTasks('workspace_123'); +console.log(`Total recurring tasks: ${allRecurringTasks.length}`); +``` + +### Filter by Frequency + +```javascript +function filterByFrequency(recurringTasks, frequency) { + return recurringTasks.filter(task => + task.frequency === frequency + ); +} + +// Get all recurring tasks and filter +const result = await listRecurringTasks('workspace_123'); +const dailyTasks = filterByFrequency(result.recurringTasks, 'DAILY'); +const weeklyTasks = filterByFrequency(result.recurringTasks, 'WEEKLY_FRIDAY'); + +console.log(`Daily tasks: ${dailyTasks.length}`); +console.log(`Weekly Friday tasks: ${weeklyTasks.length}`); +``` + +### Group by Assignee + +```javascript +function groupByAssignee(recurringTasks) { + return recurringTasks.reduce((groups, task) => { + const assigneeName = task.assignee.name; + if (!groups[assigneeName]) { + groups[assigneeName] = []; + } + groups[assigneeName].push(task); + return groups; + }, {}); +} + +// Group recurring tasks by assignee +const result = await listRecurringTasks('workspace_123'); +const tasksByAssignee = groupByAssignee(result.recurringTasks); + +Object.entries(tasksByAssignee).forEach(([assignee, tasks]) => { + console.log(`${assignee}: ${tasks.length} recurring tasks`); + tasks.forEach(task => { + console.log(` - ${task.name} (${task.frequency})`); + }); +}); +``` + +## Response Fields + +### Recurring Task Object + +- **id**: Unique identifier for the recurring task +- **name**: Task template name +- **frequency**: Recurrence pattern (DAILY, WEEKLY_*, MONTHLY_*, etc.) +- **creator**: User who created the recurring task +- **assignee**: User assigned to the recurring tasks +- **project**: Associated project (optional) +- **workspace**: Workspace containing the recurring task +- **status**: Default status for generated tasks +- **priority**: Task priority (HIGH, MEDIUM) +- **labels**: Array of labels for generated tasks +- **description**: Task description +- **duration**: Time duration in minutes or "REMINDER" +- **deadlineType**: "HARD" or "SOFT" +- **startingOn**: Date when recurring tasks start +- **idealTime**: Preferred time of day (HH:mm format) +- **schedule**: Schedule name (usually "Work Hours") + +## Notes + +- **Workspace Filter Required**: You must specify a workspaceId +- **Pagination**: Use cursor-based pagination for large result sets +- **Project Association**: Not all recurring tasks have associated projects +- **Frequency Patterns**: Common patterns include DAILY, WEEKLY_MONDAY through WEEKLY_SUNDAY, MONTHLY_1 through MONTHLY_31, MONTHLY_LAST +- **Generated Tasks**: This endpoint returns templates, not the generated task instances \ No newline at end of file diff --git a/docs/official_api/schedules/README.md b/docs/official_api/schedules/README.md new file mode 100644 index 0000000..4a5bbb6 --- /dev/null +++ b/docs/official_api/schedules/README.md @@ -0,0 +1,108 @@ +# Schedules API + +The Schedules API provides access to work hour configurations that Motion uses for task scheduling. + +## Overview + +Schedules define: +- Working hours for each day of the week +- Time zones for scheduling +- Availability windows for task scheduling +- Default work patterns + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/schedules` | [Get all schedules](./get-schedules.md) | + +## Schedule Object + +```javascript +{ + name: string, // Schedule name (e.g., "Work Hours") + isDefaultTimezone: boolean, // Whether using default timezone + timezone: string, // Timezone identifier (e.g., "America/New_York") + schedule: { + monday: [ + { start: "HH:MM", end: "HH:MM" } + ], + tuesday: [ + { start: "HH:MM", end: "HH:MM" } + ], + wednesday: [ + { start: "HH:MM", end: "HH:MM" } + ], + thursday: [ + { start: "HH:MM", end: "HH:MM" } + ], + friday: [ + { start: "HH:MM", end: "HH:MM" } + ], + saturday: [ + { start: "HH:MM", end: "HH:MM" } + ], + sunday: [ + { start: "HH:MM", end: "HH:MM" } + ] + } +} +``` + +## Common Use Cases + +### Standard Work Week + +Most users have a standard Monday-Friday schedule: +```javascript +{ + name: "Work Hours", + timezone: "America/New_York", + schedule: { + monday: [{ start: "09:00", end: "17:00" }], + tuesday: [{ start: "09:00", end: "17:00" }], + wednesday: [{ start: "09:00", end: "17:00" }], + thursday: [{ start: "09:00", end: "17:00" }], + friday: [{ start: "09:00", end: "17:00" }], + saturday: [], + sunday: [] + } +} +``` + +### Split Schedule + +Some users have split schedules with breaks: +```javascript +{ + name: "Work Hours", + timezone: "Europe/London", + schedule: { + monday: [ + { start: "09:00", end: "12:00" }, + { start: "13:00", end: "17:00" } + ], + // ... other days + } +} +``` + +## How Motion Uses Schedules + +1. **Task Scheduling**: Motion only schedules tasks during defined work hours +2. **Auto-Scheduling**: The AI respects schedule boundaries when placing tasks +3. **Time Zone Handling**: All scheduling respects the schedule's timezone +4. **Buffer Time**: Motion may include buffer time around schedule boundaries + +## Important Notes + +- Schedules are read-only via the API +- Each user typically has one primary schedule +- "Work Hours" is the default schedule name +- Schedule modifications must be done through the Motion app +- Time formats use 24-hour notation (HH:MM) + +## Related Resources + +- [Tasks API](../tasks/) - Tasks are scheduled within these hours +- [Recurring Tasks API](../recurring-tasks/) - Recurring tasks respect schedules \ No newline at end of file diff --git a/docs/official_api/schedules/get-schedules.md b/docs/official_api/schedules/get-schedules.md new file mode 100644 index 0000000..ce5e66a --- /dev/null +++ b/docs/official_api/schedules/get-schedules.md @@ -0,0 +1,322 @@ +# Get Schedules + +Retrieve all available work schedules for the current user. + +## Endpoint + +``` +GET /v1/schedules +``` + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X GET https://api.usemotion.com/v1/schedules \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +Returns an array of schedule objects: + +```json +[ + { + "name": "Work Hours", + "isDefaultTimezone": false, + "timezone": "America/New_York", + "schedule": { + "monday": [ + { "start": "09:00", "end": "17:00" } + ], + "tuesday": [ + { "start": "09:00", "end": "17:00" } + ], + "wednesday": [ + { "start": "09:00", "end": "17:00" } + ], + "thursday": [ + { "start": "09:00", "end": "17:00" } + ], + "friday": [ + { "start": "09:00", "end": "17:00" } + ], + "saturday": [], + "sunday": [] + } + }, + { + "name": "Weekend Schedule", + "isDefaultTimezone": false, + "timezone": "America/New_York", + "schedule": { + "monday": [], + "tuesday": [], + "wednesday": [], + "thursday": [], + "friday": [], + "saturday": [ + { "start": "10:00", "end": "14:00" } + ], + "sunday": [ + { "start": "10:00", "end": "14:00" } + ] + } + } +] +``` + +### Split Schedule Example + +```json +[ + { + "name": "Work Hours", + "isDefaultTimezone": false, + "timezone": "Europe/London", + "schedule": { + "monday": [ + { "start": "09:00", "end": "12:30" }, + { "start": "13:30", "end": "18:00" } + ], + "tuesday": [ + { "start": "09:00", "end": "12:30" }, + { "start": "13:30", "end": "18:00" } + ], + "wednesday": [ + { "start": "09:00", "end": "12:30" }, + { "start": "13:30", "end": "18:00" } + ], + "thursday": [ + { "start": "09:00", "end": "12:30" }, + { "start": "13:30", "end": "18:00" } + ], + "friday": [ + { "start": "09:00", "end": "12:30" }, + { "start": "13:30", "end: "17:00" } + ], + "saturday": [], + "sunday": [] + } + } +] +``` + +### Error Responses + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function getSchedules() { + const response = await fetch( + 'https://api.usemotion.com/v1/schedules', + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to get schedules: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + const schedules = await getSchedules(); + + schedules.forEach(schedule => { + console.log(`\nSchedule: ${schedule.name}`); + console.log(`Timezone: ${schedule.timezone}`); + + Object.entries(schedule.schedule).forEach(([day, hours]) => { + if (hours.length > 0) { + const times = hours.map(h => `${h.start}-${h.end}`).join(', '); + console.log(` ${day}: ${times}`); + } + }); + }); +} catch (error) { + console.error('Error:', error); +} +``` + +### Python + +```python +import requests +import os + +def get_schedules(): + url = "https://api.usemotion.com/v1/schedules" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + return response.json() + +# Usage +try: + schedules = get_schedules() + + for schedule in schedules: + print(f"\nSchedule: {schedule['name']}") + print(f"Timezone: {schedule['timezone']}") + + for day, hours in schedule['schedule'].items(): + if hours: + times = ', '.join([f"{h['start']}-{h['end']}" for h in hours]) + print(f" {day}: {times}") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Calculate Total Work Hours + +```javascript +function calculateWeeklyHours(schedule) { + let totalMinutes = 0; + + Object.values(schedule.schedule).forEach(dayHours => { + dayHours.forEach(period => { + const start = period.start.split(':').map(Number); + const end = period.end.split(':').map(Number); + + const startMinutes = start[0] * 60 + start[1]; + const endMinutes = end[0] * 60 + end[1]; + + totalMinutes += endMinutes - startMinutes; + }); + }); + + return totalMinutes / 60; +} + +// Calculate total work hours per week +const schedules = await getSchedules(); +const workSchedule = schedules.find(s => s.name === 'Work Hours'); + +if (workSchedule) { + const weeklyHours = calculateWeeklyHours(workSchedule); + console.log(`Total work hours per week: ${weeklyHours}`); +} +``` + +### Get Schedule for Specific Day + +```javascript +function getScheduleForDay(schedule, dayName) { + const day = dayName.toLowerCase(); + const hours = schedule.schedule[day] || []; + + if (hours.length === 0) { + return { isWorkDay: false, hours: [] }; + } + + return { + isWorkDay: true, + hours: hours, + totalHours: hours.reduce((total, period) => { + const [startH, startM] = period.start.split(':').map(Number); + const [endH, endM] = period.end.split(':').map(Number); + return total + (endH - startH) + (endM - startM) / 60; + }, 0) + }; +} + +// Check if today is a work day +const schedules = await getSchedules(); +const workSchedule = schedules.find(s => s.name === 'Work Hours'); +const today = new Date().toLocaleDateString('en-US', { weekday: 'long' }); + +const todaySchedule = getScheduleForDay(workSchedule, today); +if (todaySchedule.isWorkDay) { + console.log(`Today's work hours: ${todaySchedule.hours.map(h => `${h.start}-${h.end}`).join(', ')}`); + console.log(`Total hours: ${todaySchedule.totalHours}`); +} else { + console.log('No work scheduled for today'); +} +``` + +### Check Current Availability + +```javascript +function isCurrentlyWorking(schedule) { + const now = new Date(); + const dayName = now.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase(); + const currentTime = now.toTimeString().slice(0, 5); // HH:MM format + + const daySchedule = schedule.schedule[dayName] || []; + + return daySchedule.some(period => { + return currentTime >= period.start && currentTime <= period.end; + }); +} + +// Check if currently in work hours +const schedules = await getSchedules(); +const workSchedule = schedules.find(s => s.name === 'Work Hours'); + +if (workSchedule) { + const working = isCurrentlyWorking(workSchedule); + console.log(working ? 'Currently in work hours' : 'Outside work hours'); +} +``` + +## Response Fields + +### Schedule Object + +- **name**: Display name of the schedule (e.g., "Work Hours") +- **isDefaultTimezone**: Whether using system default timezone +- **timezone**: IANA timezone identifier (e.g., "America/New_York") +- **schedule**: Object with days as keys + +### Day Schedule + +Each day (monday through sunday) contains an array of time periods: +- **start**: Start time in 24-hour format (HH:MM) +- **end**: End time in 24-hour format (HH:MM) + +Empty array indicates no work scheduled for that day. + +## Notes + +- Schedules are read-only via API +- Multiple schedules may be returned if user has different schedules configured +- Time periods within a day should not overlap +- All times are in the schedule's specified timezone +- The primary schedule is typically named "Work Hours" \ No newline at end of file diff --git a/docs/official_api/statuses/README.md b/docs/official_api/statuses/README.md new file mode 100644 index 0000000..9fe4f18 --- /dev/null +++ b/docs/official_api/statuses/README.md @@ -0,0 +1,107 @@ +# Statuses API + +The Statuses API provides access to available task and project statuses within workspaces. + +## Overview + +Statuses in Motion: +- Define the workflow states for tasks and projects +- Can be customized per workspace +- Include metadata about default and completion states +- Control task visibility and scheduling behavior + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/statuses` | [List available statuses](./list-statuses.md) | + +## Status Object + +```javascript +{ + name: string, // Status display name + isDefaultStatus: boolean, // Whether this is the default for new items + isResolvedStatus: boolean // Whether this indicates completion +} +``` + +## Common Status Types + +### Task Statuses + +Typical task workflow statuses: +- **TODO** - Default status for new tasks +- **In Progress** - Task being worked on +- **Blocked** - Task cannot proceed +- **Done** - Task completed (resolved) +- **Cancelled** - Task cancelled (resolved) + +### Project Statuses + +Common project lifecycle statuses: +- **Planning** - Project in planning phase +- **Active** - Project being executed +- **On Hold** - Project temporarily paused +- **Completed** - Project finished (resolved) +- **Archived** - Project archived (resolved) + +## Status Properties + +### Default Status + +- One status per workspace is marked as default +- New tasks/projects automatically get this status +- Usually "TODO" or "Planning" + +### Resolved Status + +- Indicates completion or termination +- Tasks with resolved status: + - Don't appear in active task lists + - Are excluded from scheduling + - Show as completed in reports + +## Using Statuses + +### Task Creation + +When creating tasks, specify a valid status: +```javascript +{ + "name": "New Task", + "status": "In Progress", + "workspaceId": "workspace_123" +} +``` + +### Status Transitions + +Update task status to move through workflow: +```javascript +// Move task to completed +PATCH /v1/tasks/{id} +{ + "status": "Done" +} +``` + +## Best Practices + +1. **Use Workspace Statuses**: Only use statuses available in the target workspace +2. **Check Status Type**: Use `isResolvedStatus` to identify completion statuses +3. **Default Handling**: Omit status to use workspace default +4. **Workflow Design**: Create statuses that match your team's workflow + +## Important Notes + +- Statuses are defined at the workspace level +- Cannot create or modify statuses via API +- Status names must match exactly (case-sensitive) +- Different workspaces may have different statuses + +## Related Resources + +- [Tasks API](../tasks/) - Apply statuses to tasks +- [Projects API](../projects/) - Apply statuses to projects +- [Workspaces API](../workspaces/) - Statuses are workspace-specific \ No newline at end of file diff --git a/docs/official_api/statuses/list-statuses.md b/docs/official_api/statuses/list-statuses.md new file mode 100644 index 0000000..a77c11a --- /dev/null +++ b/docs/official_api/statuses/list-statuses.md @@ -0,0 +1,330 @@ +# List Statuses + +Get all available statuses, optionally filtered by workspace. + +## Endpoint + +``` +GET /v1/statuses +``` + +## Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| workspaceId | string | No | Filter statuses by specific workspace | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +# Get all statuses across all workspaces +curl -X GET https://api.usemotion.com/v1/statuses \ + -H "X-API-Key: YOUR_API_KEY" + +# Get statuses for specific workspace +curl -X GET "https://api.usemotion.com/v1/statuses?workspaceId=workspace_123" \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +Returns an array of status objects: + +```json +[ + { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + { + "name": "In Progress", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + { + "name": "Blocked", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + { + "name": "Done", + "isDefaultStatus": false, + "isResolvedStatus": true + }, + { + "name": "Cancelled", + "isDefaultStatus": false, + "isResolvedStatus": true + } +] +``` + +### Project Statuses Example + +```json +[ + { + "name": "Planning", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + { + "name": "Active", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + { + "name": "On Hold", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + { + "name": "Completed", + "isDefaultStatus": false, + "isResolvedStatus": true + }, + { + "name": "Archived", + "isDefaultStatus": false, + "isResolvedStatus": true + } +] +``` + +### Error Responses + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +#### 404 Not Found + +Invalid workspace ID: + +```json +{ + "error": { + "message": "Workspace not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function getStatuses(workspaceId = null) { + const url = new URL('https://api.usemotion.com/v1/statuses'); + if (workspaceId) { + url.searchParams.append('workspaceId', workspaceId); + } + + const response = await fetch(url, { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to get statuses: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + // Get all statuses + const allStatuses = await getStatuses(); + console.log(`Found ${allStatuses.length} statuses`); + + // Get statuses for specific workspace + const workspaceStatuses = await getStatuses('workspace_123'); + + // Group by type + const defaultStatus = workspaceStatuses.find(s => s.isDefaultStatus); + const resolvedStatuses = workspaceStatuses.filter(s => s.isResolvedStatus); + const activeStatuses = workspaceStatuses.filter(s => !s.isResolvedStatus); + + console.log(`Default: ${defaultStatus?.name}`); + console.log(`Active statuses: ${activeStatuses.map(s => s.name).join(', ')}`); + console.log(`Resolved statuses: ${resolvedStatuses.map(s => s.name).join(', ')}`); + +} catch (error) { + console.error('Error:', error); +} +``` + +### Python + +```python +import requests +import os + +def get_statuses(workspace_id=None): + url = "https://api.usemotion.com/v1/statuses" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + params = {} + if workspace_id: + params["workspaceId"] = workspace_id + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + return response.json() + +# Usage +try: + # Get all statuses + all_statuses = get_statuses() + print(f"Found {len(all_statuses)} statuses") + + # Get statuses for specific workspace + workspace_statuses = get_statuses("workspace_123") + + # Categorize statuses + default_status = next((s for s in workspace_statuses if s.get('isDefaultStatus')), None) + resolved_statuses = [s for s in workspace_statuses if s.get('isResolvedStatus')] + active_statuses = [s for s in workspace_statuses if not s.get('isResolvedStatus')] + + if default_status: + print(f"Default: {default_status['name']}") + print(f"Active: {', '.join(s['name'] for s in active_statuses)}") + print(f"Resolved: {', '.join(s['name'] for s in resolved_statuses)}") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Status Validation Helper + +```javascript +async function validateStatus(workspaceId, statusName) { + const statuses = await getStatuses(workspaceId); + const validStatus = statuses.find(s => s.name === statusName); + + if (!validStatus) { + const availableStatuses = statuses.map(s => s.name).join(', '); + throw new Error( + `Invalid status '${statusName}'. Available statuses: ${availableStatuses}` + ); + } + + return validStatus; +} + +// Validate before creating/updating task +try { + const status = await validateStatus('workspace_123', 'In Progress'); + console.log(`Valid status: ${status.name}`); + + if (status.isResolvedStatus) { + console.log('Warning: This is a resolved status'); + } +} catch (error) { + console.error(error.message); +} +``` + +### Get Status Categories + +```javascript +function categorizeStatuses(statuses) { + return { + default: statuses.find(s => s.isDefaultStatus), + active: statuses.filter(s => !s.isResolvedStatus), + resolved: statuses.filter(s => s.isResolvedStatus), + all: statuses + }; +} + +// Categorize workspace statuses +const statuses = await getStatuses('workspace_123'); +const categories = categorizeStatuses(statuses); + +console.log('Status Categories:'); +console.log(`- Default: ${categories.default?.name || 'None'}`); +console.log(`- Active (${categories.active.length}): ${categories.active.map(s => s.name).join(', ')}`); +console.log(`- Resolved (${categories.resolved.length}): ${categories.resolved.map(s => s.name).join(', ')}`); +``` + +### Status Transition Validator + +```javascript +function getValidTransitions(currentStatus, allStatuses) { + const current = allStatuses.find(s => s.name === currentStatus); + + if (!current) { + throw new Error(`Unknown status: ${currentStatus}`); + } + + // Define transition rules + if (current.isResolvedStatus) { + // Can't transition from resolved status + return []; + } + + // Can transition to any other status + return allStatuses.filter(s => s.name !== currentStatus); +} + +// Check valid transitions +const statuses = await getStatuses('workspace_123'); +const validTransitions = getValidTransitions('TODO', statuses); + +console.log('Valid transitions from TODO:'); +validTransitions.forEach(status => { + const type = status.isResolvedStatus ? '(resolved)' : '(active)'; + console.log(` - ${status.name} ${type}`); +}); +``` + +## Response Fields + +### Status Object + +- **name**: Display name of the status (case-sensitive) +- **isDefaultStatus**: Boolean indicating if this is the default status for new items +- **isResolvedStatus**: Boolean indicating if this status represents completion + +## Important Notes + +1. **Case Sensitivity**: Status names are case-sensitive when used in API calls +2. **Workspace Specific**: Different workspaces may have different available statuses +3. **No Modification**: Statuses cannot be created or modified via API +4. **Single Default**: Only one status per workspace can be the default +5. **Multiple Resolved**: Multiple statuses can be marked as resolved + +## Best Practices + +1. **Cache Status Lists**: Statuses don't change frequently, so cache the results +2. **Validate Before Use**: Always validate status names before using in create/update operations +3. **Handle Missing Workspace**: When no workspace filter is provided, statuses from all accessible workspaces are returned +4. **Check Status Type**: Use `isResolvedStatus` to determine if a status indicates task completion +5. **Provide User Feedback**: Show available statuses to users when status is invalid \ No newline at end of file diff --git a/docs/official_api/tasks/README.md b/docs/official_api/tasks/README.md new file mode 100644 index 0000000..87731d6 --- /dev/null +++ b/docs/official_api/tasks/README.md @@ -0,0 +1,128 @@ +# Tasks API + +The Tasks API provides comprehensive functionality for managing tasks in Motion, including creating, updating, scheduling, and organizing tasks. + +## Overview + +Tasks are the core unit of work in Motion. They can be: +- Scheduled automatically by Motion's AI +- Assigned to users +- Organized into projects +- Tagged with labels +- Customized with custom fields + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/tasks/{id}` | [Get a single task](./get-task.md) | +| GET | `/v1/tasks` | [List tasks with filtering](./list-tasks.md) | +| POST | `/v1/tasks` | [Create a new task](./create-task.md) | +| PATCH | `/v1/tasks/{id}` | [Update an existing task](./update-task.md) | +| DELETE | `/v1/tasks/{id}` | [Delete a task](./delete-task.md) | +| PATCH | `/v1/tasks/{id}/move` | [Move task to different workspace](./move-task.md) | +| DELETE | `/v1/tasks/{id}/assignee` | [Unassign a task](./unassign-task.md) | + +## Task Object + +```javascript +{ + // Identifiers + id: string, + name: string, + description: string, // HTML content + + // Timing + duration: string | number, // "NONE", "REMINDER", or minutes + dueDate: datetime, + deadlineType: "HARD" | "SOFT" | "NONE", + startOn: string, // YYYY-MM-DD format + + // Status + completed: boolean, + completedTime: datetime, + status: { + name: string, + isDefaultStatus: boolean, + isResolvedStatus: boolean + }, + + // Scheduling + scheduledStart: datetime, + scheduledEnd: datetime, + schedulingIssue: boolean, + chunks: array, // Scheduled time blocks + + // Organization + priority: "ASAP" | "HIGH" | "MEDIUM" | "LOW", + labels: array<{ name: string }>, + + // Relationships + workspace: object, + project: object, + creator: object, + assignees: array, + + // Metadata + createdTime: datetime, + updatedTime: datetime, + lastInteractedTime: datetime, + customFieldValues: record +} +``` + +## Common Use Cases + +### 1. Creating a Simple Task + +```javascript +POST /v1/tasks +{ + "name": "Review Q4 budget", + "workspaceId": "workspace_123", + "dueDate": "2024-12-31T17:00:00Z", + "duration": 60 +} +``` + +### 2. Creating an Auto-Scheduled Task + +```javascript +POST /v1/tasks +{ + "name": "Prepare presentation", + "workspaceId": "workspace_123", + "dueDate": "2024-12-20T17:00:00Z", + "duration": 120, + "autoScheduled": { + "startDate": "2024-12-15T09:00:00Z", + "deadlineType": "SOFT", + "schedule": "Work Hours" + } +} +``` + +### 3. Filtering Tasks + +```javascript +GET /v1/tasks?assigneeId=user_123&status=TODO,IN_PROGRESS&projectId=project_456 +``` + +## Best Practices + +1. **Use Auto-Scheduling**: Let Motion's AI schedule tasks optimally by providing `autoScheduled` parameters +2. **Set Appropriate Durations**: Provide realistic time estimates for better scheduling +3. **Use Labels**: Organize tasks with labels for easy filtering +4. **Handle Pagination**: Use cursor-based pagination for large task lists +5. **Check Scheduling Issues**: Monitor the `schedulingIssue` flag to identify scheduling conflicts + +## Rate Limits + +- Individual accounts: 12 requests per minute +- Team accounts: 120 requests per minute + +## Related Resources + +- [Projects API](../projects/) - Organize tasks into projects +- [Recurring Tasks API](../recurring-tasks/) - Create task templates +- [Custom Fields API](../custom-fields/) - Add custom data to tasks \ No newline at end of file diff --git a/docs/official_api/tasks/create-task.md b/docs/official_api/tasks/create-task.md new file mode 100644 index 0000000..23d09e5 --- /dev/null +++ b/docs/official_api/tasks/create-task.md @@ -0,0 +1,312 @@ +# Create Task + +Create a new task in Motion. + +## Endpoint + +``` +POST /v1/tasks +``` + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +Content-Type: application/json +``` + +### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| name | string | Yes | Task title | +| workspaceId | string | Yes | ID of the workspace | +| dueDate | datetime | Conditional | ISO 8601 due date (required for scheduled tasks) | +| duration | string \| number | No | "NONE", "REMINDER", or minutes (default: "NONE") | +| status | string | No | Task status name | +| autoScheduled | object \| null | No | Auto-scheduling configuration | +| projectId | string | No | ID of the project | +| description | string | No | Task description in GitHub Flavored Markdown | +| priority | string | No | "ASAP", "HIGH", "MEDIUM", or "LOW" | +| labels | array | No | Array of label names | +| assigneeId | string | No | ID of the assignee | + +#### Auto-Scheduling Object + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| startDate | datetime | Yes | Earliest date to schedule | +| deadlineType | string | Yes | "HARD", "SOFT", or "NONE" | +| schedule | string | No | Schedule name (default: "Work Hours") | + +### Example Request + +```bash +curl -X POST https://api.usemotion.com/v1/tasks \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Review Q4 budget report", + "workspaceId": "workspace_123", + "dueDate": "2024-12-31T17:00:00Z", + "duration": 60, + "priority": "HIGH", + "status": "TODO", + "description": "Review and approve the Q4 budget report before the board meeting", + "labels": ["finance", "q4-planning"], + "assigneeId": "user_456", + "projectId": "project_789", + "autoScheduled": { + "startDate": "2024-12-15T09:00:00Z", + "deadlineType": "HARD", + "schedule": "Work Hours" + } + }' +``` + +## Response + +### Success Response (201 Created) + +Returns the created task object: + +```json +{ + "id": "task_new_123", + "name": "Review Q4 budget report", + "description": "

Review and approve the Q4 budget report before the board meeting

", + "duration": 60, + "dueDate": "2024-12-31T17:00:00Z", + "deadlineType": "HARD", + "completed": false, + "status": { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + "priority": "HIGH", + "labels": [ + { "name": "finance" }, + { "name": "q4-planning" } + ], + "scheduledStart": "2024-12-30T14:00:00Z", + "scheduledEnd": "2024-12-30T15:00:00Z", + "schedulingIssue": false, + "workspace": { + "id": "workspace_123", + "name": "My Workspace", + "teamId": "team_456", + "type": "team" + }, + "project": { + "id": "project_789", + "name": "Q4 Planning", + "workspaceId": "workspace_123" + }, + "assignees": [ + { + "id": "user_456", + "name": "John Doe", + "email": "john@example.com" + } + ], + "creator": { + "id": "user_123", + "name": "Jane Smith", + "email": "jane@example.com" + }, + "createdTime": "2024-12-15T10:00:00Z", + "updatedTime": "2024-12-15T10:00:00Z" +} +``` + +### Error Responses + +#### 400 Bad Request + +Invalid request data: + +```json +{ + "error": { + "message": "Missing required field: workspaceId", + "code": "MISSING_REQUIRED_FIELD" + } +} +``` + +#### 422 Unprocessable Entity + +Validation error: + +```json +{ + "error": { + "message": "Invalid priority value. Must be one of: ASAP, HIGH, MEDIUM, LOW", + "code": "INVALID_PARAMETER" + } +} +``` + +## Task Creation Examples + +### 1. Simple Task (No Scheduling) + +```json +{ + "name": "Send follow-up email", + "workspaceId": "workspace_123", + "duration": "NONE" +} +``` + +### 2. Reminder Task + +```json +{ + "name": "Team standup", + "workspaceId": "workspace_123", + "dueDate": "2024-12-20T09:00:00Z", + "duration": "REMINDER" +} +``` + +### 3. Scheduled Task with Duration + +```json +{ + "name": "Code review", + "workspaceId": "workspace_123", + "dueDate": "2024-12-20T17:00:00Z", + "duration": 45, + "priority": "MEDIUM", + "assigneeId": "user_789" +} +``` + +### 4. Auto-Scheduled Task + +```json +{ + "name": "Prepare presentation", + "workspaceId": "workspace_123", + "dueDate": "2024-12-25T17:00:00Z", + "duration": 120, + "autoScheduled": { + "startDate": "2024-12-20T09:00:00Z", + "deadlineType": "SOFT", + "schedule": "Work Hours" + }, + "priority": "HIGH", + "description": "Create slides for the annual review presentation" +} +``` + +### 5. Task with Custom Fields + +To add custom field values during creation, include them in the request: + +```json +{ + "name": "Budget review", + "workspaceId": "workspace_123", + "dueDate": "2024-12-31T17:00:00Z", + "duration": 60, + "customFieldValues": { + "Budget Amount": 50000, + "Department": "Finance", + "Approved": true + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function createTask(taskData) { + const response = await fetch('https://api.usemotion.com/v1/tasks', { + method: 'POST', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(taskData) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to create task: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +const newTask = await createTask({ + name: 'Review Q4 budget', + workspaceId: 'workspace_123', + dueDate: '2024-12-31T17:00:00Z', + duration: 60, + priority: 'HIGH', + autoScheduled: { + startDate: '2024-12-15T09:00:00Z', + deadlineType: 'HARD', + schedule: 'Work Hours' + } +}); + +console.log(`Created task: ${newTask.id}`); +``` + +### Python + +```python +import requests +import json +from datetime import datetime, timedelta + +def create_task(task_data): + url = "https://api.usemotion.com/v1/tasks" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY"), + "Content-Type": "application/json" + } + + response = requests.post(url, headers=headers, json=task_data) + response.raise_for_status() + + return response.json() + +# Usage +new_task = create_task({ + "name": "Review Q4 budget", + "workspaceId": "workspace_123", + "dueDate": (datetime.now() + timedelta(days=7)).isoformat() + "Z", + "duration": 60, + "priority": "HIGH", + "autoScheduled": { + "startDate": datetime.now().isoformat() + "Z", + "deadlineType": "HARD", + "schedule": "Work Hours" + } +}) + +print(f"Created task: {new_task['id']}") +``` + +## Important Notes + +1. **Due Date Requirement**: If you specify a duration (other than "NONE"), you must provide a due date +2. **Auto-Scheduling**: The task's status must have auto-scheduling enabled for this feature to work +3. **Schedule Names**: When scheduling for another user, only "Work Hours" schedule is allowed +4. **Label Creation**: New labels are automatically created if they don't exist +5. **Status Validation**: The status must exist in the workspace +6. **Markdown Support**: Description supports GitHub Flavored Markdown +7. **Default Values**: + - Duration defaults to "NONE" if not specified + - Priority defaults to "MEDIUM" if not specified + - Status defaults to the workspace's default status \ No newline at end of file diff --git a/docs/official_api/tasks/delete-task.md b/docs/official_api/tasks/delete-task.md new file mode 100644 index 0000000..3f43b46 --- /dev/null +++ b/docs/official_api/tasks/delete-task.md @@ -0,0 +1,221 @@ +# Delete Task + +Permanently delete a task from Motion. + +## Endpoint + +``` +DELETE /v1/tasks/{id} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| id | string | Yes | The ID of the task to delete | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X DELETE https://api.usemotion.com/v1/tasks/task_123 \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (204 No Content) + +The task is successfully deleted. No response body is returned. + +### Error Responses + +#### 404 Not Found + +Task doesn't exist: + +```json +{ + "error": { + "message": "Task not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +#### 403 Forbidden + +Insufficient permissions to delete the task: + +```json +{ + "error": { + "message": "You do not have permission to delete this task", + "code": "INSUFFICIENT_PERMISSIONS" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function deleteTask(taskId) { + const response = await fetch( + `https://api.usemotion.com/v1/tasks/${taskId}`, + { + method: 'DELETE', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Task not found'); + } + const error = await response.json(); + throw new Error(`Failed to delete task: ${error.error.message}`); + } + + // Success - no content returned + return true; +} + +// Usage +try { + await deleteTask('task_123'); + console.log('Task deleted successfully'); +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Python + +```python +import requests +import os + +def delete_task(task_id): + url = f"https://api.usemotion.com/v1/tasks/{task_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.delete(url, headers=headers) + + if response.status_code == 204: + return True + elif response.status_code == 404: + raise Exception("Task not found") + else: + response.raise_for_status() + +# Usage +try: + delete_task("task_123") + print("Task deleted successfully") +except Exception as e: + print(f"Error: {e}") +``` + +### Batch Delete Example + +```javascript +async function deleteMultipleTasks(taskIds) { + const results = []; + + for (const taskId of taskIds) { + try { + await deleteTask(taskId); + results.push({ taskId, success: true }); + } catch (error) { + results.push({ + taskId, + success: false, + error: error.message + }); + } + } + + return results; +} + +// Usage +const taskIds = ['task_1', 'task_2', 'task_3']; +const deleteResults = await deleteMultipleTasks(taskIds); + +deleteResults.forEach(result => { + if (result.success) { + console.log(`✓ Deleted task ${result.taskId}`); + } else { + console.log(`✗ Failed to delete ${result.taskId}: ${result.error}`); + } +}); +``` + +### Safe Delete with Confirmation + +```javascript +async function safeDeleteTask(taskId) { + // First, fetch the task to confirm it exists + try { + const task = await getTask(taskId); + console.log(`About to delete: ${task.name}`); + + // Perform the deletion + await deleteTask(taskId); + return { success: true, deletedTask: task }; + + } catch (error) { + return { success: false, error: error.message }; + } +} +``` + +## Important Notes + +1. **Permanent Deletion**: Deleted tasks cannot be recovered +2. **No Undo**: There is no undo functionality for task deletion +3. **Permissions**: You must have appropriate permissions to delete the task +4. **Recurring Tasks**: Deleting a recurring task instance doesn't affect the recurring task template +5. **Associated Data**: Deleting a task also removes: + - All comments on the task + - Task history + - Scheduling information + - Custom field values +6. **Project Tasks**: Deleting a task removes it from its project +7. **No Cascade**: Deleting a task doesn't affect related tasks or projects + +## Best Practices + +1. **Confirm Before Deleting**: Always confirm the action with users before deletion +2. **Check Permissions**: Verify user has permission to delete before attempting +3. **Log Deletions**: Keep an audit log of deleted tasks for compliance +4. **Batch Carefully**: When batch deleting, handle errors gracefully +5. **Consider Archiving**: Instead of deleting, consider changing status to "Archived" or "Cancelled" \ No newline at end of file diff --git a/docs/official_api/tasks/get-task.md b/docs/official_api/tasks/get-task.md new file mode 100644 index 0000000..e58d10f --- /dev/null +++ b/docs/official_api/tasks/get-task.md @@ -0,0 +1,230 @@ +# Get Task + +Retrieve a single task by its ID. + +## Endpoint + +``` +GET /v1/tasks/{id} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| id | string | Yes | The ID of the task to fetch | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X GET https://api.usemotion.com/v1/tasks/550e8400-e29b-41d4-a716-446655440000 \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +Returns the complete task object: + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Review Q4 budget report", + "description": "

Review and approve the Q4 budget report before the board meeting

", + "duration": 60, + "dueDate": "2024-12-31T17:00:00Z", + "deadlineType": "HARD", + "completed": false, + "completedTime": null, + "status": { + "name": "In Progress", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + "priority": "HIGH", + "labels": [ + { "name": "finance" }, + { "name": "q4-planning" } + ], + "scheduledStart": "2024-12-30T14:00:00Z", + "scheduledEnd": "2024-12-30T15:00:00Z", + "schedulingIssue": false, + "workspace": { + "id": "workspace_123", + "name": "My Workspace", + "teamId": "team_456", + "type": "team", + "labels": [ + { "name": "finance" }, + { "name": "marketing" } + ], + "statuses": [ + { + "name": "Todo", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + { + "name": "In Progress", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + { + "name": "Done", + "isDefaultStatus": false, + "isResolvedStatus": true + } + ] + }, + "project": { + "id": "project_789", + "name": "Q4 Planning", + "description": "All Q4 planning activities", + "workspaceId": "workspace_123", + "status": { + "name": "Active", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + "createdTime": "2024-10-01T09:00:00Z", + "updatedTime": "2024-12-15T10:30:00Z" + }, + "creator": { + "id": "user_111", + "name": "Jane Smith", + "email": "jane@example.com" + }, + "assignees": [ + { + "id": "user_222", + "name": "John Doe", + "email": "john@example.com" + } + ], + "createdTime": "2024-12-01T09:00:00Z", + "updatedTime": "2024-12-15T14:30:00Z", + "chunks": [ + { + "id": "chunk_001", + "duration": 60, + "scheduledStart": "2024-12-30T14:00:00Z", + "scheduledEnd": "2024-12-30T15:00:00Z", + "completedTime": null, + "isFixed": false + } + ], + "customFieldValues": { + "Budget Amount": { + "type": "number", + "value": 50000 + }, + "Department": { + "type": "select", + "value": "Finance" + } + } +} +``` + +### Error Responses + +#### 404 Not Found + +Task with the specified ID doesn't exist: + +```json +{ + "error": { + "message": "Task not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function getTask(taskId) { + const response = await fetch( + `https://api.usemotion.com/v1/tasks/${taskId}`, + { + method: 'GET', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + throw new Error(`Failed to get task: ${response.statusText}`); + } + + return await response.json(); +} + +// Usage +try { + const task = await getTask('550e8400-e29b-41d4-a716-446655440000'); + console.log(`Task: ${task.name} - Due: ${task.dueDate}`); +} catch (error) { + console.error('Error:', error); +} +``` + +### Python + +```python +import requests +import os + +def get_task(task_id): + url = f"https://api.usemotion.com/v1/tasks/{task_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + return response.json() + +# Usage +try: + task = get_task("550e8400-e29b-41d4-a716-446655440000") + print(f"Task: {task['name']} - Due: {task['dueDate']}") +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +## Notes + +- The task object includes all relationships (workspace, project, assignees) +- Custom field values are returned as a record keyed by field name +- Scheduled times represent Motion's AI scheduling decisions +- The `chunks` array shows how Motion has broken down the task for scheduling \ No newline at end of file diff --git a/docs/official_api/tasks/list-tasks.md b/docs/official_api/tasks/list-tasks.md new file mode 100644 index 0000000..d89eca2 --- /dev/null +++ b/docs/official_api/tasks/list-tasks.md @@ -0,0 +1,334 @@ +# List Tasks + +Retrieve a paginated list of tasks with optional filtering. + +## Endpoint + +``` +GET /v1/tasks +``` + +## Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| assigneeId | string | No | Filter tasks by assignee ID | +| cursor | string | No | Pagination cursor from previous response | +| includeAllStatuses | boolean | No | Include tasks with all statuses (default: false) | +| label | string | No | Filter by label name | +| name | string | No | Filter by task name (case-insensitive) | +| projectId | string | No | Filter by project ID | +| status | array | No | Filter by specific status names | +| workspaceId | string | No | Filter by workspace ID | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Requests + +#### Basic Request + +```bash +curl -X GET "https://api.usemotion.com/v1/tasks" \ + -H "X-API-Key: YOUR_API_KEY" +``` + +#### With Filters + +```bash +curl -X GET "https://api.usemotion.com/v1/tasks?assigneeId=user_123&status=TODO,IN_PROGRESS&label=urgent" \ + -H "X-API-Key: YOUR_API_KEY" +``` + +#### With Pagination + +```bash +curl -X GET "https://api.usemotion.com/v1/tasks?cursor=eyJza2lwIjoyNX0=" \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +```json +{ + "meta": { + "nextCursor": "eyJza2lwIjoyNX0=", + "pageSize": 25 + }, + "tasks": [ + { + "id": "task_001", + "name": "Review Q4 budget report", + "description": "

Review and approve the Q4 budget report

", + "duration": 60, + "dueDate": "2024-12-31T17:00:00Z", + "deadlineType": "HARD", + "completed": false, + "status": { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + "priority": "HIGH", + "labels": [ + { "name": "finance" }, + { "name": "urgent" } + ], + "workspace": { + "id": "workspace_123", + "name": "My Workspace", + "teamId": "team_456", + "type": "team" + }, + "project": { + "id": "project_789", + "name": "Q4 Planning", + "workspaceId": "workspace_123" + }, + "assignees": [ + { + "id": "user_123", + "name": "John Doe", + "email": "john@example.com" + } + ], + "createdTime": "2024-12-01T09:00:00Z", + "updatedTime": "2024-12-15T14:30:00Z" + }, + { + "id": "task_002", + "name": "Prepare annual presentation", + "description": "

Create slides for annual review

", + "duration": 120, + "dueDate": "2024-12-28T17:00:00Z", + "deadlineType": "SOFT", + "completed": false, + "status": { + "name": "IN_PROGRESS", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + "priority": "MEDIUM", + "labels": [ + { "name": "presentation" } + ], + "workspace": { + "id": "workspace_123", + "name": "My Workspace", + "teamId": "team_456", + "type": "team" + }, + "assignees": [ + { + "id": "user_123", + "name": "John Doe", + "email": "john@example.com" + } + ], + "createdTime": "2024-12-10T10:00:00Z", + "updatedTime": "2024-12-16T11:00:00Z" + } + ] +} +``` + +### Empty Result + +```json +{ + "meta": { + "nextCursor": null, + "pageSize": 25 + }, + "tasks": [] +} +``` + +### Error Responses + +#### 400 Bad Request + +Invalid query parameters: + +```json +{ + "error": { + "message": "Invalid status value", + "code": "INVALID_PARAMETER" + } +} +``` + +## Pagination + +The API uses cursor-based pagination. To retrieve all tasks: + +1. Make initial request without cursor +2. Use `meta.nextCursor` from response for next page +3. Continue until `nextCursor` is null + +### Example Pagination Code + +```javascript +async function getAllTasks(filters = {}) { + const tasks = []; + let cursor = null; + + do { + const params = new URLSearchParams({ + ...filters, + ...(cursor && { cursor }) + }); + + const response = await fetch( + `https://api.usemotion.com/v1/tasks?${params}`, + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + const data = await response.json(); + tasks.push(...data.tasks); + cursor = data.meta.nextCursor; + + } while (cursor); + + return tasks; +} +``` + +## Filtering Examples + +### By Assignee + +``` +GET /v1/tasks?assigneeId=user_123 +``` + +### By Multiple Statuses + +``` +GET /v1/tasks?status=TODO,IN_PROGRESS +``` + +### By Project and Label + +``` +GET /v1/tasks?projectId=project_789&label=urgent +``` + +### Search by Name + +``` +GET /v1/tasks?name=budget +``` + +This performs a case-insensitive search for tasks with "budget" in the name. + +### Include All Statuses + +By default, completed tasks may not be included. To get all tasks regardless of status: + +``` +GET /v1/tasks?includeAllStatuses=true +``` + +## Code Examples + +### JavaScript with Filtering + +```javascript +async function listTasks(options = {}) { + const { + assigneeId, + projectId, + status, + label, + name, + workspaceId, + includeAllStatuses + } = options; + + const params = new URLSearchParams(); + + if (assigneeId) params.append('assigneeId', assigneeId); + if (projectId) params.append('projectId', projectId); + if (status) status.forEach(s => params.append('status', s)); + if (label) params.append('label', label); + if (name) params.append('name', name); + if (workspaceId) params.append('workspaceId', workspaceId); + if (includeAllStatuses) params.append('includeAllStatuses', 'true'); + + const response = await fetch( + `https://api.usemotion.com/v1/tasks?${params}`, + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + throw new Error(`Failed to list tasks: ${response.statusText}`); + } + + return await response.json(); +} + +// Usage +const urgentTasks = await listTasks({ + label: 'urgent', + status: ['TODO', 'IN_PROGRESS'] +}); +``` + +### Python + +```python +import requests +import os + +def list_tasks(**filters): + url = "https://api.usemotion.com/v1/tasks" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + # Handle array parameters + params = {} + for key, value in filters.items(): + if key == 'status' and isinstance(value, list): + params['status'] = value + else: + params[key] = value + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + return response.json() + +# Usage +urgent_tasks = list_tasks( + label='urgent', + status=['TODO', 'IN_PROGRESS'] +) + +print(f"Found {len(urgent_tasks['tasks'])} urgent tasks") +``` + +## Notes + +- Default page size is typically 25 tasks +- Filtering is cumulative (AND operation between different filters) +- Status filtering accepts multiple values (OR operation between statuses) +- The `name` filter performs partial, case-insensitive matching +- Empty filter results return an empty array, not an error \ No newline at end of file diff --git a/docs/official_api/tasks/move-task.md b/docs/official_api/tasks/move-task.md new file mode 100644 index 0000000..aaf5f59 --- /dev/null +++ b/docs/official_api/tasks/move-task.md @@ -0,0 +1,292 @@ +# Move Task + +Move a task to a different workspace and optionally reassign it. + +## Endpoint + +``` +PATCH /v1/tasks/{id}/move +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| id | string | Yes | The ID of the task to move | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +Content-Type: application/json +``` + +### Request Body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| workspaceId | string | Yes | Target workspace ID | +| assigneeId | string | No | New assignee ID (optional) | + +### Example Request + +```bash +curl -X PATCH https://api.usemotion.com/v1/tasks/task_123/move \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "workspaceId": "workspace_789", + "assigneeId": "user_456" + }' +``` + +## Response + +### Success Response (200 OK) + +Returns the moved task object with updated workspace and assignee: + +```json +{ + "id": "task_123", + "name": "Review Q4 budget report", + "description": "

Review and approve the Q4 budget report

", + "duration": 60, + "dueDate": "2024-12-31T17:00:00Z", + "deadlineType": "HARD", + "completed": false, + "status": { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + "priority": "HIGH", + "labels": [ + { "name": "finance" } + ], + "workspace": { + "id": "workspace_789", + "name": "Finance Team Workspace", + "teamId": "team_789", + "type": "team" + }, + "assignees": [ + { + "id": "user_456", + "name": "Jane Smith", + "email": "jane@example.com" + } + ], + "updatedTime": "2024-12-15T16:00:00Z" +} +``` + +### Error Responses + +#### 404 Not Found + +Task or workspace doesn't exist: + +```json +{ + "error": { + "message": "Task not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 400 Bad Request + +Invalid workspace or assignee: + +```json +{ + "error": { + "message": "Invalid workspace ID", + "code": "INVALID_PARAMETER" + } +} +``` + +#### 403 Forbidden + +Insufficient permissions: + +```json +{ + "error": { + "message": "You do not have access to the target workspace", + "code": "INSUFFICIENT_PERMISSIONS" + } +} +``` + +## Use Cases + +### 1. Move Task Between Teams + +```json +{ + "workspaceId": "workspace_marketing" +} +``` + +### 2. Move and Reassign + +```json +{ + "workspaceId": "workspace_engineering", + "assigneeId": "user_developer_123" +} +``` + +### 3. Move to Personal Workspace + +```json +{ + "workspaceId": "workspace_personal_456" +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function moveTask(taskId, targetWorkspaceId, assigneeId = null) { + const body = { + workspaceId: targetWorkspaceId + }; + + if (assigneeId) { + body.assigneeId = assigneeId; + } + + const response = await fetch( + `https://api.usemotion.com/v1/tasks/${taskId}/move`, + { + method: 'PATCH', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to move task: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +// Move task to different workspace +const movedTask = await moveTask( + 'task_123', + 'workspace_789' +); + +// Move and reassign +const movedAndReassigned = await moveTask( + 'task_456', + 'workspace_789', + 'user_new_assignee' +); +``` + +### Python + +```python +import requests +import json + +def move_task(task_id, workspace_id, assignee_id=None): + url = f"https://api.usemotion.com/v1/tasks/{task_id}/move" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY"), + "Content-Type": "application/json" + } + + data = {"workspaceId": workspace_id} + if assignee_id: + data["assigneeId"] = assignee_id + + response = requests.patch(url, headers=headers, json=data) + response.raise_for_status() + + return response.json() + +# Usage +# Move task to different workspace +moved_task = move_task("task_123", "workspace_789") + +# Move and reassign +moved_and_reassigned = move_task( + "task_456", + "workspace_789", + "user_new_assignee" +) +``` + +### Bulk Move Tasks + +```javascript +async function bulkMoveTasks(taskIds, targetWorkspaceId) { + const results = []; + + for (const taskId of taskIds) { + try { + const movedTask = await moveTask(taskId, targetWorkspaceId); + results.push({ + taskId, + success: true, + newWorkspace: movedTask.workspace.name + }); + } catch (error) { + results.push({ + taskId, + success: false, + error: error.message + }); + } + } + + return results; +} + +// Move multiple tasks to a new workspace +const tasksToMove = ['task_1', 'task_2', 'task_3']; +const moveResults = await bulkMoveTasks( + tasksToMove, + 'workspace_new_team' +); +``` + +## Important Considerations + +1. **Status Mapping**: Task status may change if the target workspace has different statuses +2. **Label Availability**: Labels may not transfer if they don't exist in the target workspace +3. **Project Association**: Moving a task may remove it from its current project +4. **Permissions Required**: + - Access to the source workspace + - Access to the target workspace + - Permission to assign to the specified user (if provided) +5. **Scheduling Impact**: Moving tasks may affect scheduling, especially if assignee changes +6. **Custom Fields**: Custom field values may be lost if fields don't exist in target workspace +7. **Team Limits**: Ensure the target workspace hasn't reached its task limits + +## Best Practices + +1. **Check Workspace Access**: Verify you have access to both workspaces before moving +2. **Validate Assignee**: Ensure the assignee has access to the target workspace +3. **Preserve Context**: Consider copying important information before moving +4. **Batch Moves**: Group related tasks when moving between workspaces +5. **Update References**: Update any external references to the moved tasks \ No newline at end of file diff --git a/docs/official_api/tasks/unassign-task.md b/docs/official_api/tasks/unassign-task.md new file mode 100644 index 0000000..358cef6 --- /dev/null +++ b/docs/official_api/tasks/unassign-task.md @@ -0,0 +1,261 @@ +# Unassign Task + +Remove the assignee from a task, making it unassigned. + +## Endpoint + +``` +DELETE /v1/tasks/{id}/assignee +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| id | string | Yes | The ID of the task to unassign | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X DELETE https://api.usemotion.com/v1/tasks/task_123/assignee \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (204 No Content) + +The task is successfully unassigned. No response body is returned. + +### Error Responses + +#### 404 Not Found + +Task doesn't exist: + +```json +{ + "error": { + "message": "Task not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 400 Bad Request + +Task is already unassigned: + +```json +{ + "error": { + "message": "Task has no assignee", + "code": "INVALID_REQUEST" + } +} +``` + +#### 403 Forbidden + +Insufficient permissions: + +```json +{ + "error": { + "message": "You do not have permission to modify this task", + "code": "INSUFFICIENT_PERMISSIONS" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function unassignTask(taskId) { + const response = await fetch( + `https://api.usemotion.com/v1/tasks/${taskId}/assignee`, + { + method: 'DELETE', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Task not found'); + } + const error = await response.json(); + throw new Error(`Failed to unassign task: ${error.error.message}`); + } + + // Success - no content returned + return true; +} + +// Usage +try { + await unassignTask('task_123'); + console.log('Task unassigned successfully'); +} catch (error) { + console.error('Error:', error.message); +} +``` + +### Python + +```python +import requests +import os + +def unassign_task(task_id): + url = f"https://api.usemotion.com/v1/tasks/{task_id}/assignee" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.delete(url, headers=headers) + + if response.status_code == 204: + return True + elif response.status_code == 404: + raise Exception("Task not found") + else: + error = response.json() + raise Exception(f"Failed to unassign: {error['error']['message']}") + +# Usage +try: + unassign_task("task_123") + print("Task unassigned successfully") +except Exception as e: + print(f"Error: {e}") +``` + +### Unassign with Verification + +```javascript +async function unassignTaskWithVerification(taskId) { + // First, get the task to check current assignee + const task = await getTask(taskId); + + if (!task.assignees || task.assignees.length === 0) { + console.log('Task is already unassigned'); + return { alreadyUnassigned: true }; + } + + const previousAssignee = task.assignees[0]; + + // Unassign the task + await unassignTask(taskId); + + return { + alreadyUnassigned: false, + previousAssignee: previousAssignee + }; +} + +// Usage +const result = await unassignTaskWithVerification('task_123'); +if (!result.alreadyUnassigned) { + console.log(`Unassigned from ${result.previousAssignee.name}`); +} +``` + +### Bulk Unassign Tasks + +```javascript +async function bulkUnassignTasks(taskIds) { + const results = []; + + for (const taskId of taskIds) { + try { + await unassignTask(taskId); + results.push({ + taskId, + success: true + }); + } catch (error) { + results.push({ + taskId, + success: false, + error: error.message + }); + } + } + + return results; +} + +// Unassign multiple tasks +const tasksToUnassign = ['task_1', 'task_2', 'task_3']; +const results = await bulkUnassignTasks(tasksToUnassign); + +// Report results +const successful = results.filter(r => r.success).length; +console.log(`Successfully unassigned ${successful} out of ${results.length} tasks`); +``` + +### Unassign Tasks by Assignee + +```javascript +async function unassignTasksByUser(userId) { + // First, get all tasks assigned to the user + const response = await listTasks({ assigneeId: userId }); + const tasks = response.tasks; + + console.log(`Found ${tasks.length} tasks assigned to user ${userId}`); + + // Unassign each task + const results = await bulkUnassignTasks( + tasks.map(task => task.id) + ); + + return results; +} + +// Unassign all tasks from a specific user +const unassignResults = await unassignTasksByUser('user_leaving_123'); +``` + +## Use Cases + +1. **Employee Departure**: Unassign all tasks when an employee leaves +2. **Task Queue**: Create unassigned tasks for team members to pick up +3. **Workload Balancing**: Temporarily unassign tasks for redistribution +4. **Task Templates**: Create unassigned template tasks to be assigned later + +## Important Notes + +1. **Scheduling Impact**: Unassigning a task removes it from Motion's scheduling +2. **No Partial Unassign**: Tasks can only have one assignee, so this removes all assignment +3. **Project Tasks**: Unassigning doesn't remove the task from its project +4. **Status Unchanged**: Task status remains the same after unassigning +5. **History Preserved**: Assignment history is maintained for audit purposes +6. **Re-assignment**: Use the Update Task endpoint to assign to a new user + +## Alternative Approach + +Instead of using this endpoint, you can also unassign a task by updating it with a null assignee: + +```javascript +// Alternative: Update task with null assignee +await updateTask('task_123', { + assigneeId: null +}); +``` + +Both approaches achieve the same result. \ No newline at end of file diff --git a/docs/official_api/tasks/update-task.md b/docs/official_api/tasks/update-task.md new file mode 100644 index 0000000..ccad703 --- /dev/null +++ b/docs/official_api/tasks/update-task.md @@ -0,0 +1,312 @@ +# Update Task + +Update an existing task's properties. + +## Endpoint + +``` +PATCH /v1/tasks/{id} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| id | string | Yes | The ID of the task to update | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +Content-Type: application/json +``` + +### Request Body + +All fields are optional - only include fields you want to update: + +| Field | Type | Description | +|-------|------|-------------| +| name | string | Task title | +| workspaceId | string | ID of the workspace | +| dueDate | datetime | ISO 8601 due date | +| duration | string \| number | "NONE", "REMINDER", or minutes | +| status | string | Task status name | +| autoScheduled | object \| null | Auto-scheduling configuration | +| projectId | string | ID of the project | +| description | string | Task description in GitHub Flavored Markdown | +| priority | string | "ASAP", "HIGH", "MEDIUM", or "LOW" | +| labels | array | Array of label names | +| assigneeId | string | ID of the assignee | + +### Example Request + +```bash +curl -X PATCH https://api.usemotion.com/v1/tasks/task_123 \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Review Q4 budget report - URGENT", + "priority": "ASAP", + "dueDate": "2024-12-30T17:00:00Z", + "labels": ["finance", "q4-planning", "urgent"] + }' +``` + +## Response + +### Success Response (200 OK) + +Returns the updated task object: + +```json +{ + "id": "task_123", + "name": "Review Q4 budget report - URGENT", + "description": "

Review and approve the Q4 budget report before the board meeting

", + "duration": 60, + "dueDate": "2024-12-30T17:00:00Z", + "deadlineType": "HARD", + "completed": false, + "status": { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + "priority": "ASAP", + "labels": [ + { "name": "finance" }, + { "name": "q4-planning" }, + { "name": "urgent" } + ], + "scheduledStart": "2024-12-29T09:00:00Z", + "scheduledEnd": "2024-12-29T10:00:00Z", + "schedulingIssue": false, + "workspace": { + "id": "workspace_123", + "name": "My Workspace", + "teamId": "team_456", + "type": "team" + }, + "updatedTime": "2024-12-15T15:30:00Z" +} +``` + +### Error Responses + +#### 404 Not Found + +Task doesn't exist: + +```json +{ + "error": { + "message": "Task not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 400 Bad Request + +Invalid update data: + +```json +{ + "error": { + "message": "Invalid status: INVALID_STATUS", + "code": "INVALID_PARAMETER" + } +} +``` + +## Update Examples + +### 1. Update Priority and Due Date + +```json +{ + "priority": "ASAP", + "dueDate": "2024-12-25T17:00:00Z" +} +``` + +### 2. Change Status + +```json +{ + "status": "IN_PROGRESS" +} +``` + +### 3. Update Assignment + +```json +{ + "assigneeId": "user_789" +} +``` + +### 4. Move to Different Project + +```json +{ + "projectId": "project_456" +} +``` + +### 5. Update Auto-Scheduling + +```json +{ + "autoScheduled": { + "startDate": "2024-12-20T09:00:00Z", + "deadlineType": "SOFT", + "schedule": "Work Hours" + } +} +``` + +### 6. Remove Auto-Scheduling + +```json +{ + "autoScheduled": null +} +``` + +### 7. Update Labels (Replace All) + +```json +{ + "labels": ["new-label-1", "new-label-2"] +} +``` + +### 8. Clear Description + +```json +{ + "description": "" +} +``` + +### 9. Update Custom Fields + +Include custom field updates in the request: + +```json +{ + "customFieldValues": { + "Budget Amount": 75000, + "Department": "Marketing" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function updateTask(taskId, updates) { + const response = await fetch( + `https://api.usemotion.com/v1/tasks/${taskId}`, + { + method: 'PATCH', + headers: { + 'X-API-Key': process.env.MOTION_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updates) + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to update task: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage examples +// Update priority +const updatedTask = await updateTask('task_123', { + priority: 'ASAP' +}); + +// Update multiple fields +const updatedTask2 = await updateTask('task_456', { + name: 'Updated task name', + status: 'IN_PROGRESS', + labels: ['updated', 'in-progress'], + dueDate: '2024-12-28T17:00:00Z' +}); +``` + +### Python + +```python +import requests +import json + +def update_task(task_id, updates): + url = f"https://api.usemotion.com/v1/tasks/{task_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY"), + "Content-Type": "application/json" + } + + response = requests.patch(url, headers=headers, json=updates) + response.raise_for_status() + + return response.json() + +# Usage +# Update priority +updated_task = update_task("task_123", { + "priority": "ASAP" +}) + +# Update multiple fields +updated_task2 = update_task("task_456", { + "name": "Updated task name", + "status": "IN_PROGRESS", + "labels": ["updated", "in-progress"], + "dueDate": "2024-12-28T17:00:00Z" +}) +``` + +### Batch Updates Example + +```javascript +async function batchUpdateTasks(taskIds, updates) { + const results = await Promise.all( + taskIds.map(id => updateTask(id, updates)) + ); + return results; +} + +// Update multiple tasks to high priority +const taskIds = ['task_1', 'task_2', 'task_3']; +const updatedTasks = await batchUpdateTasks(taskIds, { + priority: 'HIGH', + labels: ['batch-updated'] +}); +``` + +## Important Notes + +1. **Partial Updates**: Only include fields you want to change +2. **Label Replacement**: Labels array replaces all existing labels +3. **Status Validation**: Status must exist in the workspace +4. **Auto-Scheduling**: Requires compatible task status +5. **Workspace Changes**: Changing workspace may affect available statuses and labels +6. **Scheduling Recalculation**: Updates may trigger Motion to recalculate scheduling +7. **Due Date Changes**: Changing due date affects scheduling priority +8. **Custom Fields**: Only include custom fields you want to update; others remain unchanged \ No newline at end of file diff --git a/docs/official_api/users/README.md b/docs/official_api/users/README.md new file mode 100644 index 0000000..17fe77b --- /dev/null +++ b/docs/official_api/users/README.md @@ -0,0 +1,89 @@ +# Users API + +The Users API provides access to user information and team member details in Motion. + +## Overview + +The Users API allows you to: +- Get information about the current authenticated user +- List team members in workspaces +- Retrieve user details for task assignment +- Access user email and identification information + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/users/me` | [Get current user](./get-current-user.md) | +| GET | `/v1/users/{id}` | [Get user by ID](./get-user.md) | + +## User Object + +```javascript +{ + id: string, // Unique user identifier + name: string, // User's display name + email: string // User's email address +} +``` + +## Common Use Cases + +### 1. Get Current User Context + +```javascript +GET /v1/users/me +``` + +Useful for: +- Determining the authenticated user +- Getting user context for UI display +- Validating API key ownership + +### 2. Retrieve User for Assignment + +```javascript +GET /v1/users/{userId} +``` + +Useful for: +- Displaying assignee information +- Validating user exists before assignment +- Building user selection interfaces + +## Working with Users + +### Task Assignment + +Users are referenced when: +- Creating tasks with `assigneeId` +- Updating task assignments +- Creating recurring tasks + +### User Identification + +Users are identified by: +- Unique ID (UUID format) +- Email address (unique within organization) +- Display name (not unique) + +## Best Practices + +1. **Cache User Data**: User information changes infrequently +2. **Use IDs for Assignment**: Always use user IDs, not names or emails +3. **Validate Users**: Check user exists before assignment +4. **Handle Missing Users**: Gracefully handle deleted or inactive users + +## Important Notes + +- User creation/modification is not available via API +- User management must be done through Motion app +- API returns limited user information for privacy +- User IDs are stable and don't change + +## Related Resources + +- [Tasks API](../tasks/) - Assign tasks to users +- [Recurring Tasks API](../recurring-tasks/) - Set up recurring assignments +- [Comments API](../comments/) - See comment creators +- [Workspaces API](../workspaces/) - Users belong to workspaces \ No newline at end of file diff --git a/docs/official_api/users/get-current-user.md b/docs/official_api/users/get-current-user.md new file mode 100644 index 0000000..a515e7a --- /dev/null +++ b/docs/official_api/users/get-current-user.md @@ -0,0 +1,262 @@ +# Get Current User + +Retrieve information about the currently authenticated user (the owner of the API key). + +## Endpoint + +``` +GET /v1/users/me +``` + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X GET https://api.usemotion.com/v1/users/me \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +Returns the current user object: + +```json +{ + "id": "user_123", + "name": "John Doe", + "email": "john.doe@example.com" +} +``` + +### Error Responses + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function getCurrentUser() { + const response = await fetch( + 'https://api.usemotion.com/v1/users/me', + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to get current user: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + const currentUser = await getCurrentUser(); + console.log(`Authenticated as: ${currentUser.name} (${currentUser.email})`); + console.log(`User ID: ${currentUser.id}`); +} catch (error) { + console.error('Error:', error); +} +``` + +### Python + +```python +import requests +import os + +def get_current_user(): + url = "https://api.usemotion.com/v1/users/me" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + return response.json() + +# Usage +try: + current_user = get_current_user() + print(f"Authenticated as: {current_user['name']} ({current_user['email']})") + print(f"User ID: {current_user['id']}") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Authentication Check + +```javascript +async function verifyAuthentication() { + try { + const user = await getCurrentUser(); + return { + authenticated: true, + user: user + }; + } catch (error) { + return { + authenticated: false, + error: error.message + }; + } +} + +// Verify API key is valid +const auth = await verifyAuthentication(); +if (auth.authenticated) { + console.log(`✓ Authenticated as ${auth.user.name}`); +} else { + console.log(`✗ Authentication failed: ${auth.error}`); +} +``` + +### Initialize Application Context + +```javascript +async function initializeApp() { + try { + // Get current user for context + const currentUser = await getCurrentUser(); + + // Store in application context + const appContext = { + currentUserId: currentUser.id, + currentUserName: currentUser.name, + currentUserEmail: currentUser.email + }; + + console.log('App initialized with user context'); + return appContext; + + } catch (error) { + throw new Error(`Failed to initialize app: ${error.message}`); + } +} + +// Initialize app with user context +const context = await initializeApp(); +``` + +### Create Self-Assigned Task + +```javascript +async function createSelfAssignedTask(taskData) { + // Get current user ID + const currentUser = await getCurrentUser(); + + // Create task assigned to self + const task = await createTask({ + ...taskData, + assigneeId: currentUser.id + }); + + return task; +} + +// Create a task for yourself +const myTask = await createSelfAssignedTask({ + name: 'Review API documentation', + workspaceId: 'workspace_123', + dueDate: '2024-12-31T17:00:00Z', + duration: 60 +}); + +console.log(`Created self-assigned task: ${myTask.name}`); +``` + +## Use Cases + +### 1. API Key Validation + +Use this endpoint to verify an API key is valid: + +```javascript +async function isApiKeyValid(apiKey) { + try { + const response = await fetch('https://api.usemotion.com/v1/users/me', { + headers: { 'X-API-Key': apiKey } + }); + return response.ok; + } catch { + return false; + } +} +``` + +### 2. User Context Display + +Show the authenticated user in your application: + +```javascript +const user = await getCurrentUser(); +document.getElementById('user-info').textContent = + `Logged in as: ${user.name}`; +``` + +### 3. Audit Logging + +Track API usage by user: + +```javascript +async function logApiAction(action, details) { + const user = await getCurrentUser(); + console.log(`[${new Date().toISOString()}] ${user.email}: ${action}`, details); +} + +await logApiAction('Created task', { taskId: 'task_123' }); +``` + +### 4. Permission Checking + +Verify user identity before sensitive operations: + +```javascript +async function canPerformAdminAction() { + const user = await getCurrentUser(); + const adminEmails = ['admin@example.com', 'superuser@example.com']; + return adminEmails.includes(user.email); +} +``` + +## Response Fields + +- **id**: Unique identifier for the user +- **name**: User's display name +- **email**: User's email address + +## Notes + +- This endpoint always returns the user who owns the API key +- No parameters are needed - authentication is via API key +- Useful for determining context in multi-user applications +- The user ID can be used for task assignments +- Email is unique within an organization \ No newline at end of file diff --git a/docs/official_api/users/get-user.md b/docs/official_api/users/get-user.md new file mode 100644 index 0000000..ae1084f --- /dev/null +++ b/docs/official_api/users/get-user.md @@ -0,0 +1,359 @@ +# Get User + +Retrieve information about a specific user by their ID. + +## Endpoint + +``` +GET /v1/users/{id} +``` + +## Parameters + +### Path Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| id | string | Yes | The ID of the user to retrieve | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +curl -X GET https://api.usemotion.com/v1/users/user_456 \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +Returns the user object: + +```json +{ + "id": "user_456", + "name": "Jane Smith", + "email": "jane.smith@example.com" +} +``` + +### Error Responses + +#### 404 Not Found + +User doesn't exist: + +```json +{ + "error": { + "message": "User not found", + "code": "RESOURCE_NOT_FOUND" + } +} +``` + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +#### 403 Forbidden + +No access to user information: + +```json +{ + "error": { + "message": "You do not have access to this user", + "code": "INSUFFICIENT_PERMISSIONS" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function getUser(userId) { + const response = await fetch( + `https://api.usemotion.com/v1/users/${userId}`, + { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to get user: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +try { + const user = await getUser('user_456'); + console.log(`User: ${user.name}`); + console.log(`Email: ${user.email}`); + console.log(`ID: ${user.id}`); +} catch (error) { + console.error('Error:', error); +} +``` + +### Python + +```python +import requests +import os + +def get_user(user_id): + url = f"https://api.usemotion.com/v1/users/{user_id}" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + return response.json() + +# Usage +try: + user = get_user("user_456") + print(f"User: {user['name']}") + print(f"Email: {user['email']}") + print(f"ID: {user['id']}") + +except requests.exceptions.RequestException as e: + print(f"Error: {e}") +``` + +### Get User with Error Handling + +```javascript +async function getUserSafely(userId) { + try { + const user = await getUser(userId); + return { success: true, user }; + } catch (error) { + if (error.message.includes('not found')) { + return { success: false, error: 'User does not exist' }; + } + return { success: false, error: error.message }; + } +} + +// Safe user retrieval +const result = await getUserSafely('user_456'); +if (result.success) { + console.log(`Found user: ${result.user.name}`); +} else { + console.log(`Error: ${result.error}`); +} +``` + +### Batch Get Users + +```javascript +async function getMultipleUsers(userIds) { + const users = []; + const errors = []; + + for (const userId of userIds) { + try { + const user = await getUser(userId); + users.push(user); + } catch (error) { + errors.push({ + userId, + error: error.message + }); + } + } + + return { users, errors }; +} + +// Get multiple users +const userIds = ['user_123', 'user_456', 'user_789']; +const { users, errors } = await getMultipleUsers(userIds); + +console.log(`Retrieved ${users.length} users`); +if (errors.length > 0) { + console.log(`Failed to retrieve ${errors.length} users`); +} +``` + +### Display Task Assignee + +```javascript +async function getTaskWithAssigneeDetails(taskId) { + // First get the task + const task = await getTask(taskId); + + // Then get assignee details + const assigneeDetails = []; + for (const assignee of task.assignees) { + try { + const user = await getUser(assignee.id); + assigneeDetails.push(user); + } catch (error) { + // Handle case where user might be deleted + assigneeDetails.push({ + id: assignee.id, + name: 'Unknown User', + email: 'unknown' + }); + } + } + + return { + ...task, + assigneeDetails + }; +} + +// Get task with full assignee information +const taskWithDetails = await getTaskWithAssigneeDetails('task_123'); +console.log(`Task: ${taskWithDetails.name}`); +taskWithDetails.assigneeDetails.forEach(user => { + console.log(` Assigned to: ${user.name} (${user.email})`); +}); +``` + +### Validate User Before Assignment + +```javascript +async function validateAndAssignTask(taskId, userId) { + // Validate user exists + try { + const user = await getUser(userId); + console.log(`Assigning task to ${user.name}`); + + // Update task with validated user + const updatedTask = await updateTask(taskId, { + assigneeId: userId + }); + + return { + success: true, + message: `Task assigned to ${user.name}`, + task: updatedTask + }; + + } catch (error) { + if (error.message.includes('not found')) { + return { + success: false, + message: 'Cannot assign task: User does not exist' + }; + } + throw error; + } +} + +// Validate user before assignment +const result = await validateAndAssignTask('task_123', 'user_456'); +if (result.success) { + console.log(result.message); +} else { + console.error(result.message); +} +``` + +### Build User Directory + +```javascript +async function buildUserDirectory(workspace) { + const directory = new Map(); + + // Get all tasks to find unique users + const tasks = await listTasks({ workspaceId: workspace.id }); + + // Collect unique user IDs + const userIds = new Set(); + tasks.tasks.forEach(task => { + if (task.creator) userIds.add(task.creator.id); + task.assignees.forEach(a => userIds.add(a.id)); + }); + + // Get user details + for (const userId of userIds) { + try { + const user = await getUser(userId); + directory.set(userId, user); + } catch (error) { + console.warn(`Could not retrieve user ${userId}`); + } + } + + return directory; +} + +// Build directory of users in workspace +const userDirectory = await buildUserDirectory({ id: 'workspace_123' }); +console.log(`Found ${userDirectory.size} users in workspace`); +``` + +## Use Cases + +### 1. Display Assignee Information + +Show full user details for task assignees: + +```javascript +const user = await getUser(task.assignees[0].id); +console.log(`Assigned to: ${user.name} <${user.email}>`); +``` + +### 2. User Validation + +Verify a user exists before operations: + +```javascript +async function isValidUser(userId) { + try { + await getUser(userId); + return true; + } catch { + return false; + } +} +``` + +### 3. User Lookup + +Find user details from ID: + +```javascript +const userId = 'user_456'; // From task, comment, etc. +const user = await getUser(userId); +``` + +## Notes + +- User must exist and be accessible to the API key +- Limited to users in shared workspaces +- Returns same fields as /users/me endpoint +- Useful for resolving user IDs to names/emails +- Cannot retrieve users outside your organization \ No newline at end of file diff --git a/docs/official_api/workspaces/README.md b/docs/official_api/workspaces/README.md new file mode 100644 index 0000000..55bef7f --- /dev/null +++ b/docs/official_api/workspaces/README.md @@ -0,0 +1,143 @@ +# Workspaces API + +The Workspaces API provides access to workspace information and configuration in Motion. + +## Overview + +Workspaces in Motion: +- Organize tasks, projects, and team members +- Define available labels and statuses +- Can be team-based or individual +- Control access and visibility + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/v1/workspaces` | [List workspaces](./list-workspaces.md) | + +## Workspace Object + +```javascript +{ + id: string, // Unique workspace identifier + name: string, // Workspace display name + teamId: string, // Associated team ID + type: string, // "team" or "individual" + labels: [ // Available labels + { name: string } + ], + statuses: [ // Available statuses + { + name: string, + isDefaultStatus: boolean, + isResolvedStatus: boolean + } + ] +} +``` + +## Workspace Types + +### Team Workspace + +Shared workspace for team collaboration: +- Multiple members can access +- Shared projects and tasks +- Team-wide labels and statuses +- Collaborative environment + +### Individual Workspace + +Personal workspace for individual use: +- Private to the user +- Personal tasks and projects +- Custom labels and statuses +- Individual organization + +## Working with Workspaces + +### Task Organization + +Tasks must belong to a workspace: +```javascript +POST /v1/tasks +{ + "name": "New Task", + "workspaceId": "workspace_123" +} +``` + +### Project Creation + +Projects are created within workspaces: +```javascript +POST /v1/projects +{ + "name": "New Project", + "workspaceId": "workspace_123" +} +``` + +### Label Management + +Labels are workspace-specific: +- Each workspace has its own set of labels +- Labels can be applied to tasks within that workspace +- New labels are created automatically when used + +### Status Configuration + +Statuses are defined per workspace: +- Each workspace has its own status workflow +- One default status for new items +- Multiple resolved statuses for completion + +## Common Patterns + +### 1. Department Workspaces + +``` +- Engineering Team (team workspace) +- Marketing Team (team workspace) +- Sales Team (team workspace) +``` + +### 2. Project-Based Workspaces + +``` +- Q1 2024 Initiatives (team workspace) +- Product Launch (team workspace) +- Customer Success (team workspace) +``` + +### 3. Mixed Environment + +``` +- Personal Tasks (individual workspace) +- Team Projects (team workspace) +- Client Work (team workspace) +``` + +## Best Practices + +1. **Workspace Selection**: Choose appropriate workspace for task/project context +2. **Label Consistency**: Use consistent labeling within workspaces +3. **Status Workflows**: Understand each workspace's status flow +4. **Access Control**: Verify workspace access before operations +5. **Organization**: Use workspaces to separate different work contexts + +## Important Notes + +- Workspaces cannot be created or modified via API +- Workspace management is done through Motion app +- Users may have access to multiple workspaces +- Workspace type determines collaboration model +- All tasks and projects must belong to a workspace + +## Related Resources + +- [Tasks API](../tasks/) - Create tasks in workspaces +- [Projects API](../projects/) - Manage projects within workspaces +- [Statuses API](../statuses/) - Get workspace-specific statuses +- [Users API](../users/) - Users are members of workspaces \ No newline at end of file diff --git a/docs/official_api/workspaces/list-workspaces.md b/docs/official_api/workspaces/list-workspaces.md new file mode 100644 index 0000000..b74e759 --- /dev/null +++ b/docs/official_api/workspaces/list-workspaces.md @@ -0,0 +1,380 @@ +# List Workspaces + +Retrieve a list of workspaces accessible to the authenticated user. + +## Endpoint + +``` +GET /v1/workspaces +``` + +## Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| cursor | string | No | Pagination cursor from previous response | +| ids | array | No | Array of workspace IDs to get expanded details | + +## Request + +### Headers + +```http +X-API-Key: YOUR_API_KEY +``` + +### Example Request + +```bash +# Get all workspaces +curl -X GET https://api.usemotion.com/v1/workspaces \ + -H "X-API-Key: YOUR_API_KEY" + +# Get specific workspaces with details +curl -X GET "https://api.usemotion.com/v1/workspaces?ids=workspace_123,workspace_456" \ + -H "X-API-Key: YOUR_API_KEY" + +# With pagination +curl -X GET "https://api.usemotion.com/v1/workspaces?cursor=eyJza2lwIjoyNX0=" \ + -H "X-API-Key: YOUR_API_KEY" +``` + +## Response + +### Success Response (200 OK) + +Returns paginated workspace list: + +```json +{ + "meta": { + "nextCursor": "eyJza2lwIjoyNX0=", + "pageSize": 25 + }, + "workspaces": [ + { + "id": "workspace_123", + "name": "Engineering Team", + "teamId": "team_456", + "type": "team", + "labels": [ + { "name": "bug" }, + { "name": "feature" }, + { "name": "improvement" }, + { "name": "urgent" } + ], + "statuses": [ + { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + { + "name": "In Progress", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + { + "name": "Code Review", + "isDefaultStatus": false, + "isResolvedStatus": false + }, + { + "name": "Done", + "isDefaultStatus": false, + "isResolvedStatus": true + }, + { + "name": "Cancelled", + "isDefaultStatus": false, + "isResolvedStatus": true + } + ] + }, + { + "id": "workspace_789", + "name": "Personal Tasks", + "teamId": "team_personal", + "type": "individual", + "labels": [ + { "name": "personal" }, + { "name": "home" }, + { "name": "work" } + ], + "statuses": [ + { + "name": "TODO", + "isDefaultStatus": true, + "isResolvedStatus": false + }, + { + "name": "Done", + "isDefaultStatus": false, + "isResolvedStatus": true + } + ] + } + ] +} +``` + +### Basic Response (without ids parameter) + +When workspace IDs are not specified, returns minimal information: + +```json +{ + "meta": { + "nextCursor": null, + "pageSize": 10 + }, + "workspaces": [ + { + "id": "workspace_123", + "name": "Engineering Team" + }, + { + "id": "workspace_789", + "name": "Personal Tasks" + } + ] +} +``` + +### Error Responses + +#### 401 Unauthorized + +Invalid or missing API key: + +```json +{ + "error": { + "message": "Invalid API key", + "code": "INVALID_API_KEY" + } +} +``` + +## Code Examples + +### JavaScript + +```javascript +async function listWorkspaces(options = {}) { + const params = new URLSearchParams(); + + if (options.cursor) { + params.append('cursor', options.cursor); + } + + if (options.ids && options.ids.length > 0) { + params.append('ids', options.ids.join(',')); + } + + const url = `https://api.usemotion.com/v1/workspaces${ + params.toString() ? '?' + params.toString() : '' + }`; + + const response = await fetch(url, { + headers: { + 'X-API-Key': process.env.MOTION_API_KEY + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to list workspaces: ${error.error.message}`); + } + + return await response.json(); +} + +// Usage +// Get all workspaces +const allWorkspaces = await listWorkspaces(); +console.log(`Found ${allWorkspaces.workspaces.length} workspaces`); + +// Get specific workspaces with full details +const detailedWorkspaces = await listWorkspaces({ + ids: ['workspace_123', 'workspace_456'] +}); + +detailedWorkspaces.workspaces.forEach(ws => { + console.log(`\nWorkspace: ${ws.name} (${ws.type})`); + console.log(`Labels: ${ws.labels.map(l => l.name).join(', ')}`); + console.log(`Statuses: ${ws.statuses.map(s => s.name).join(', ')}`); +}); +``` + +### Python + +```python +import requests +import os + +def list_workspaces(cursor=None, ids=None): + url = "https://api.usemotion.com/v1/workspaces" + headers = { + "X-API-Key": os.environ.get("MOTION_API_KEY") + } + + params = {} + if cursor: + params["cursor"] = cursor + if ids: + params["ids"] = ",".join(ids) + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + return response.json() + +# Usage +# Get all workspaces +all_workspaces = list_workspaces() +print(f"Found {len(all_workspaces['workspaces'])} workspaces") + +# Get specific workspaces with details +detailed = list_workspaces(ids=["workspace_123", "workspace_456"]) +for ws in detailed["workspaces"]: + print(f"\nWorkspace: {ws['name']} ({ws['type']})") + if "labels" in ws: + print(f"Labels: {', '.join(l['name'] for l in ws['labels'])}") +``` + +### Get All Workspaces with Pagination + +```javascript +async function getAllWorkspaces() { + const allWorkspaces = []; + let cursor = null; + + do { + const result = await listWorkspaces({ cursor }); + allWorkspaces.push(...result.workspaces); + cursor = result.meta.nextCursor; + } while (cursor); + + return allWorkspaces; +} + +// Get complete workspace list +const workspaces = await getAllWorkspaces(); +console.log(`Total workspaces: ${workspaces.length}`); +``` + +### Filter Workspaces by Type + +```javascript +async function getWorkspacesByType(type) { + const all = await getAllWorkspaces(); + + // Get IDs of workspaces to get details + const ids = all.map(ws => ws.id); + + // Get detailed information + const detailed = await listWorkspaces({ ids }); + + // Filter by type + return detailed.workspaces.filter(ws => ws.type === type); +} + +// Get only team workspaces +const teamWorkspaces = await getWorkspacesByType('team'); +console.log(`Found ${teamWorkspaces.length} team workspaces`); + +// Get only individual workspaces +const personalWorkspaces = await getWorkspacesByType('individual'); +console.log(`Found ${personalWorkspaces.length} personal workspaces`); +``` + +### Workspace Selector Helper + +```javascript +async function selectWorkspace(promptText = 'Select a workspace:') { + const workspaces = await listWorkspaces(); + + console.log(promptText); + workspaces.workspaces.forEach((ws, index) => { + console.log(`${index + 1}. ${ws.name}`); + }); + + // In a real application, get user input + // For this example, return first workspace + return workspaces.workspaces[0]; +} + +// Interactive workspace selection +const selected = await selectWorkspace(); +console.log(`Selected: ${selected.name}`); +``` + +### Get Workspace Configuration + +```javascript +async function getWorkspaceConfig(workspaceId) { + const result = await listWorkspaces({ ids: [workspaceId] }); + + if (result.workspaces.length === 0) { + throw new Error('Workspace not found or no access'); + } + + const workspace = result.workspaces[0]; + + return { + id: workspace.id, + name: workspace.name, + type: workspace.type, + defaultStatus: workspace.statuses.find(s => s.isDefaultStatus)?.name, + resolvedStatuses: workspace.statuses + .filter(s => s.isResolvedStatus) + .map(s => s.name), + activeStatuses: workspace.statuses + .filter(s => !s.isResolvedStatus) + .map(s => s.name), + labelCount: workspace.labels.length, + labels: workspace.labels.map(l => l.name) + }; +} + +// Get configuration for specific workspace +const config = await getWorkspaceConfig('workspace_123'); +console.log(`Workspace: ${config.name}`); +console.log(`Default status: ${config.defaultStatus}`); +console.log(`Active statuses: ${config.activeStatuses.join(', ')}`); +console.log(`${config.labelCount} labels available`); +``` + +## Response Details + +### Basic Workspace Object + +When `ids` parameter is not provided: +- **id**: Unique workspace identifier +- **name**: Workspace display name + +### Detailed Workspace Object + +When specific workspace IDs are requested: +- **id**: Unique workspace identifier +- **name**: Workspace display name +- **teamId**: Associated team identifier +- **type**: "team" or "individual" +- **labels**: Array of available labels +- **statuses**: Array of available statuses with metadata + +### Pagination + +- Results are paginated using cursor-based pagination +- Default page size is typically 25 items +- Use `nextCursor` to retrieve additional pages +- `nextCursor` is null when no more results + +## Best Practices + +1. **Cache Results**: Workspace configuration changes infrequently +2. **Request Details When Needed**: Use `ids` parameter only when you need full details +3. **Handle Pagination**: Always check for `nextCursor` when listing all workspaces +4. **Type Filtering**: Filter by workspace type after retrieval +5. **Error Handling**: Handle cases where user has no workspace access \ No newline at end of file From 8488538dfb313aac4134c8f306a4ebd737e2dbdd Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 15:20:43 +0200 Subject: [PATCH 05/15] Enhance Motion API client and tools with new features and improvements - Added support for testing scripts in package.json. - Integrated status tools into the main API client. - Improved error handling with retry logic for API requests. - Updated various tools to support pagination and enhanced descriptions. - Added new methods for managing work schedules and custom fields. - Refined input schemas for better validation and clarity. - Enhanced project and task management functionalities with custom field support. --- package.json | 4 +- src/api/client.ts | 198 ++++++++++++++++++++++------- src/index.ts | 2 + src/tools/comment.ts | 11 +- src/tools/customField.ts | 87 +++++++++---- src/tools/project.ts | 26 +++- src/tools/recurringTask.ts | 57 ++++++--- src/tools/schedule.ts | 23 +++- src/tools/status.ts | 32 +++++ src/tools/task.ts | 75 ++++++++++- src/tools/user.ts | 8 +- src/tools/workspace.ts | 27 +++- src/types/motion.ts | 55 +++++++- test/README.md | 40 ++++++ test/run-all.ts | 55 ++++++++ test/setup.ts | 46 +++++++ test/tools/project.test.ts | 187 +++++++++++++++++++++++++++ test/tools/schedule-status.test.ts | 85 +++++++++++++ test/tools/task.test.ts | 184 +++++++++++++++++++++++++++ test/tools/user.test.ts | 82 ++++++++++++ test/tools/workspace.test.ts | 82 ++++++++++++ test/utils.ts | 65 ++++++++++ 22 files changed, 1312 insertions(+), 119 deletions(-) create mode 100644 src/tools/status.ts create mode 100644 test/README.md create mode 100644 test/run-all.ts create mode 100644 test/setup.ts create mode 100644 test/tools/project.test.ts create mode 100644 test/tools/schedule-status.test.ts create mode 100644 test/tools/task.test.ts create mode 100644 test/tools/user.test.ts create mode 100644 test/tools/workspace.test.ts create mode 100644 test/utils.ts diff --git a/package.json b/package.json index 25fd2c8..16ed7f3 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "clean": "rm -rf dist", "typecheck": "tsc --noEmit", "lint": "eslint src --ext .ts", - "format": "prettier --write \"src/**/*.ts\"" + "format": "prettier --write \"src/**/*.ts\"", + "test": "tsx --test test/**/*.test.ts", + "test:watch": "tsx --test --watch test/**/*.test.ts" }, "keywords": [ "mcp", diff --git a/src/api/client.ts b/src/api/client.ts index 8b38122..1dfa61b 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -8,8 +8,10 @@ import { MotionUser, MotionComment, MotionSchedule, + MotionWorkSchedule, MotionCustomField, MotionRecurringTask, + MotionStatus, MotionListResponse, MotionTaskCreateParams, MotionTaskUpdateParams, @@ -20,6 +22,8 @@ import { export class MotionApiClient { private axios: AxiosInstance; private queue: PQueue; + private retryDelay = 5000; // 5 seconds initial retry delay + private maxRetries = 3; constructor(config: MotionConfig) { this.axios = axios.create({ @@ -28,13 +32,14 @@ export class MotionApiClient { 'X-API-Key': config.apiKey, 'Content-Type': 'application/json', }, + timeout: 30000, // 30 second timeout }); - // Set up rate limiting queue + // Set up rate limiting queue with more conservative settings this.queue = new PQueue({ concurrency: 1, interval: 60000, // 1 minute - intervalCap: config.rateLimitPerMinute, + intervalCap: Math.floor(config.rateLimitPerMinute * 0.8), // Use 80% of limit for safety }); // Add response interceptor for error handling @@ -62,23 +67,74 @@ export class MotionApiClient { private async request(method: string, path: string, data?: any, params?: any): Promise { return this.queue.add(async () => { - const response = await this.axios.request({ - method, - url: path, - data, - params, - }); - return response.data; + let lastError: any; + + for (let attempt = 0; attempt < this.maxRetries; attempt++) { + try { + const response = await this.axios.request({ + method, + url: path, + data, + params, + }); + return response.data; + } catch (error: any) { + lastError = error; + + // Don't retry on client errors (4xx) except rate limits + if (error.response?.status >= 400 && error.response?.status < 500 && error.response?.status !== 429) { + throw error; + } + + // Retry on rate limit, network errors, or 5xx errors + if (attempt < this.maxRetries - 1) { + const delay = error.response?.status === 429 + ? this.retryDelay * 2 // Double delay for rate limits + : this.retryDelay * (attempt + 1); // Exponential backoff + + console.log(`Retrying request to ${path} after ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + throw lastError; }) as Promise; } // Workspace methods - async listWorkspaces(): Promise { - return this.request('GET', '/workspaces'); + async listWorkspaces(params?: { + cursor?: string; + ids?: string[]; + }): Promise> { + const queryParams: any = {}; + if (params?.cursor) queryParams.cursor = params.cursor; + if (params?.ids && params.ids.length > 0) { + // The API expects a comma-separated string, not an array + queryParams.ids = params.ids.join(','); + } + + const response = await this.request<{ meta: any; workspaces: MotionWorkspace[] }>( + 'GET', + '/workspaces', + undefined, + queryParams + ); + return { + meta: response.meta, + workspaces: response.workspaces, + }; } async getWorkspace(workspaceId: string): Promise { - return this.request('GET', `/workspaces/${workspaceId}`); + // The API doesn't have a GET /workspaces/{id} endpoint + // and the ids parameter seems broken, so we fetch all and filter + const response = await this.listWorkspaces(); + const workspace = response.workspaces.find((w: MotionWorkspace) => w.id === workspaceId); + if (!workspace) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + return workspace; } // Task methods @@ -120,14 +176,23 @@ export class MotionApiClient { return this.request('DELETE', `/tasks/${taskId}`); } - async moveTask(taskId: string, projectId: string): Promise { - return this.request('PATCH', `/tasks/${taskId}/move`, { projectId }); + async moveTask(taskId: string, params: { workspaceId: string; assigneeId?: string }): Promise { + return this.request('PATCH', `/tasks/${taskId}/move`, params); + } + + async unassignTask(taskId: string): Promise { + return this.request('DELETE', `/tasks/${taskId}/assignee`); } // Project methods - async listProjects(workspaceId?: string): Promise { - const params = workspaceId ? { workspaceId } : undefined; - return this.request('GET', '/projects', undefined, params); + async listProjects(workspaceId: string): Promise { + const response = await this.request<{ projects: MotionProject[] }>( + 'GET', + '/projects', + undefined, + { workspaceId } + ); + return response.projects || []; } async getProject(projectId: string): Promise { @@ -158,9 +223,14 @@ export class MotionApiClient { return this.request('GET', `/users/${userId}`); } - async listUsers(workspaceId?: string): Promise { - const params = workspaceId ? { workspaceId } : undefined; - return this.request('GET', '/users', undefined, params); + async listUsers(workspaceId: string): Promise { + const response = await this.request<{ users: MotionUser[] }>( + 'GET', + '/users', + undefined, + { workspaceId } + ); + return response.users || []; } // Schedule methods @@ -172,9 +242,24 @@ export class MotionApiClient { return this.request('GET', '/schedules', undefined, params); } + async getWorkSchedules(): Promise { + return this.request('GET', '/schedules'); + } + // Comment methods - async listComments(taskId: string): Promise { - return this.request('GET', '/comments', undefined, { taskId }); + async listComments(taskId: string, cursor?: string): Promise> { + const params: any = { taskId }; + if (cursor) params.cursor = cursor; + const response = await this.request<{ meta: any; comments: MotionComment[] }>( + 'GET', + '/comments', + undefined, + params + ); + return { + meta: response.meta, + comments: response.comments, + }; } async createComment(params: MotionCommentCreateParams): Promise { @@ -194,51 +279,65 @@ export class MotionApiClient { } // Custom field methods - async listCustomFields(workspaceId?: string): Promise { - const params = workspaceId ? { workspaceId } : undefined; - return this.request('GET', '/custom-fields', undefined, params); + async listCustomFields(workspaceId: string): Promise { + return this.request('GET', `/beta/workspaces/${workspaceId}/custom-fields`); } async createCustomField(params: { name: string; type: string; workspaceId: string; - options?: string[]; + metadata?: any; }): Promise { - return this.request('POST', '/custom-fields', params); + const { workspaceId, ...body } = params; + return this.request('POST', `/beta/workspaces/${workspaceId}/custom-fields`, body); } - async addCustomFieldToTask(taskId: string, customFieldId: string, value: any): Promise { - return this.request('POST', '/custom-fields/add-to-task', { - taskId, - customFieldId, + async addCustomFieldToTask(taskId: string, customFieldInstanceId: string, value: any): Promise { + return this.request('POST', `/beta/custom-field-values/task/${taskId}`, { + customFieldInstanceId, value, }); } async addCustomFieldToProject( projectId: string, - customFieldId: string, + customFieldInstanceId: string, value: any ): Promise { - return this.request('POST', '/custom-fields/add-to-project', { - projectId, - customFieldId, + return this.request('POST', `/beta/custom-field-values/project/${projectId}`, { + customFieldInstanceId, value, }); } - async removeCustomFieldFromTask(taskId: string, customFieldId: string): Promise { - return this.request('DELETE', '/custom-fields/delete-from-task', { - taskId, - customFieldId, - }); + async removeCustomFieldFromTask(taskId: string, valueId: string): Promise { + return this.request('DELETE', `/beta/custom-field-values/task/${taskId}/custom-fields/${valueId}`); + } + + async deleteCustomField(workspaceId: string, customFieldId: string): Promise { + return this.request('DELETE', `/beta/workspaces/${workspaceId}/custom-fields/${customFieldId}`); + } + + async removeCustomFieldFromProject(projectId: string, valueId: string): Promise { + return this.request('DELETE', `/beta/custom-field-values/project/${projectId}/custom-fields/${valueId}`); } // Recurring task methods - async listRecurringTasks(workspaceId?: string): Promise { - const params = workspaceId ? { workspaceId } : undefined; - return this.request('GET', '/recurring-tasks', undefined, params); + async listRecurringTasks(params?: { + workspaceId?: string; + cursor?: string; + }): Promise> { + const response = await this.request<{ meta: any; recurringTasks: MotionRecurringTask[] }>( + 'GET', + '/recurring-tasks', + undefined, + params + ); + return { + meta: response.meta, + recurringTasks: response.recurringTasks, + }; } async createRecurringTask(params: { @@ -249,7 +348,13 @@ export class MotionApiClient { duration?: string | number; description?: string; projectId?: string; - assigneeId?: string; + assigneeId: string; + deadlineType?: 'HARD' | 'SOFT'; + startingOn?: string; + idealTime?: string; + schedule?: string; + priority?: 'HIGH' | 'MEDIUM'; + labels?: string[]; }): Promise { return this.request('POST', '/recurring-tasks', params); } @@ -274,7 +379,8 @@ export class MotionApiClient { } // Status methods - async getStatus(statusId: string): Promise { - return this.request('GET', `/statuses/${statusId}`); + async listStatuses(workspaceId?: string): Promise { + const params = workspaceId ? { workspaceId } : undefined; + return this.request('GET', '/statuses', undefined, params); } } diff --git a/src/index.ts b/src/index.ts index 08bf380..33e6268 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { registerScheduleTools } from './tools/schedule.js'; import { registerCommentTools } from './tools/comment.js'; import { registerCustomFieldTools } from './tools/customField.js'; import { registerRecurringTaskTools } from './tools/recurringTask.js'; +import { registerStatusTools } from './tools/status.js'; async function main() { const config = getConfig(); @@ -44,6 +45,7 @@ async function main() { ...registerCommentTools(apiClient), ...registerCustomFieldTools(apiClient), ...registerRecurringTaskTools(apiClient), + ...registerStatusTools(apiClient), ]; // Handle list tools request diff --git a/src/tools/comment.ts b/src/tools/comment.ts index 683da74..bd47bcc 100644 --- a/src/tools/comment.ts +++ b/src/tools/comment.ts @@ -6,25 +6,28 @@ export function registerCommentTools(client: MotionApiClient): Tool[] { return [ { name: 'motion_list_comments', - description: 'List all comments for a specific task', + description: 'List all comments for a specific task. Supports pagination via cursor.', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'Task ID to get comments for' }, + cursor: { type: 'string', description: 'Pagination cursor from previous response' }, }, required: ['taskId'], }, handler: async (args: unknown) => { const schema = z.object({ taskId: z.string().min(1), + cursor: z.string().optional(), }); const validated = schema.parse(args); - const comments = await client.listComments(validated.taskId); + const response = await client.listComments(validated.taskId, validated.cursor); return { - comments, - count: comments.length, + comments: response.comments, + meta: response.meta, + count: response.comments?.length || 0, }; }, }, diff --git a/src/tools/customField.ts b/src/tools/customField.ts index 326ec3c..b71b68f 100644 --- a/src/tools/customField.ts +++ b/src/tools/customField.ts @@ -6,17 +6,17 @@ export function registerCustomFieldTools(client: MotionApiClient): Tool[] { return [ { name: 'motion_list_custom_fields', - description: 'List all custom fields, optionally filtered by workspace', + description: 'List all custom fields for a workspace', inputSchema: { type: 'object', properties: { - workspaceId: { type: 'string', description: 'Filter by workspace ID' }, + workspaceId: { type: 'string', description: 'Workspace ID (required)' }, }, - required: [], + required: ['workspaceId'], }, handler: async (args: unknown) => { const schema = z.object({ - workspaceId: z.string().optional(), + workspaceId: z.string().min(1), }); const validated = schema.parse(args); @@ -54,10 +54,9 @@ export function registerCustomFieldTools(client: MotionApiClient): Tool[] { description: 'Custom field type', }, workspaceId: { type: 'string', description: 'Workspace ID' }, - options: { - type: 'array', - items: { type: 'string' }, - description: 'Options for select/multiSelect fields', + metadata: { + type: 'object', + description: 'Metadata for the field (e.g., options for select fields)', }, }, required: ['name', 'type', 'workspaceId'], @@ -80,13 +79,35 @@ export function registerCustomFieldTools(client: MotionApiClient): Tool[] { 'relatedTo', ]), workspaceId: z.string().min(1), - options: z.array(z.string()).optional(), + metadata: z.object({}).passthrough().optional(), }); const validated = schema.parse(args); return await client.createCustomField(validated); }, }, + { + name: 'motion_delete_custom_field', + description: 'Delete a custom field', + inputSchema: { + type: 'object', + properties: { + workspaceId: { type: 'string', description: 'Workspace ID' }, + customFieldId: { type: 'string', description: 'Custom field ID' }, + }, + required: ['workspaceId', 'customFieldId'], + }, + handler: async (args: unknown) => { + const schema = z.object({ + workspaceId: z.string().min(1), + customFieldId: z.string().min(1), + }); + + const validated = schema.parse(args); + await client.deleteCustomField(validated.workspaceId, validated.customFieldId); + return { success: true, message: 'Custom field deleted successfully' }; + }, + }, { name: 'motion_add_custom_field_to_task', description: 'Add a custom field value to a task', @@ -94,22 +115,22 @@ export function registerCustomFieldTools(client: MotionApiClient): Tool[] { type: 'object', properties: { taskId: { type: 'string', description: 'Task ID' }, - customFieldId: { type: 'string', description: 'Custom field ID' }, + customFieldInstanceId: { type: 'string', description: 'Custom field instance ID' }, value: { description: 'Custom field value (type depends on field type)' }, }, - required: ['taskId', 'customFieldId', 'value'], + required: ['taskId', 'customFieldInstanceId', 'value'], }, handler: async (args: unknown) => { const schema = z.object({ taskId: z.string().min(1), - customFieldId: z.string().min(1), + customFieldInstanceId: z.string().min(1), value: z.any(), }); const validated = schema.parse(args); await client.addCustomFieldToTask( validated.taskId, - validated.customFieldId, + validated.customFieldInstanceId, validated.value ); return { success: true, message: 'Custom field added to task successfully' }; @@ -122,22 +143,22 @@ export function registerCustomFieldTools(client: MotionApiClient): Tool[] { type: 'object', properties: { projectId: { type: 'string', description: 'Project ID' }, - customFieldId: { type: 'string', description: 'Custom field ID' }, + customFieldInstanceId: { type: 'string', description: 'Custom field instance ID' }, value: { description: 'Custom field value (type depends on field type)' }, }, - required: ['projectId', 'customFieldId', 'value'], + required: ['projectId', 'customFieldInstanceId', 'value'], }, handler: async (args: unknown) => { const schema = z.object({ projectId: z.string().min(1), - customFieldId: z.string().min(1), + customFieldInstanceId: z.string().min(1), value: z.any(), }); const validated = schema.parse(args); await client.addCustomFieldToProject( validated.projectId, - validated.customFieldId, + validated.customFieldInstanceId, validated.value ); return { success: true, message: 'Custom field added to project successfully' }; @@ -150,20 +171,42 @@ export function registerCustomFieldTools(client: MotionApiClient): Tool[] { type: 'object', properties: { taskId: { type: 'string', description: 'Task ID' }, - customFieldId: { type: 'string', description: 'Custom field ID' }, + valueId: { type: 'string', description: 'Custom field value ID to remove' }, }, - required: ['taskId', 'customFieldId'], + required: ['taskId', 'valueId'], }, handler: async (args: unknown) => { const schema = z.object({ taskId: z.string().min(1), - customFieldId: z.string().min(1), + valueId: z.string().min(1), }); const validated = schema.parse(args); - await client.removeCustomFieldFromTask(validated.taskId, validated.customFieldId); + await client.removeCustomFieldFromTask(validated.taskId, validated.valueId); return { success: true, message: 'Custom field removed from task successfully' }; }, }, + { + name: 'motion_remove_custom_field_from_project', + description: 'Remove a custom field value from a project', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'Project ID' }, + valueId: { type: 'string', description: 'Custom field value ID to remove' }, + }, + required: ['projectId', 'valueId'], + }, + handler: async (args: unknown) => { + const schema = z.object({ + projectId: z.string().min(1), + valueId: z.string().min(1), + }); + + const validated = schema.parse(args); + await client.removeCustomFieldFromProject(validated.projectId, validated.valueId); + return { success: true, message: 'Custom field removed from project successfully' }; + }, + }, ]; -} +} \ No newline at end of file diff --git a/src/tools/project.ts b/src/tools/project.ts index 9156e15..48fcc33 100644 --- a/src/tools/project.ts +++ b/src/tools/project.ts @@ -6,17 +6,17 @@ export function registerProjectTools(client: MotionApiClient): Tool[] { return [ { name: 'motion_list_projects', - description: 'List all projects, optionally filtered by workspace', + description: 'List all projects in a workspace', inputSchema: { type: 'object', properties: { - workspaceId: { type: 'string', description: 'Filter by workspace ID' }, + workspaceId: { type: 'string', description: 'Workspace ID (required)' }, }, - required: [], + required: ['workspaceId'], }, handler: async (args: unknown) => { const schema = z.object({ - workspaceId: z.string().optional(), + workspaceId: z.string().min(1), }); const validated = schema.parse(args); @@ -55,8 +55,13 @@ export function registerProjectTools(client: MotionApiClient): Tool[] { properties: { name: { type: 'string', description: 'Project name' }, workspaceId: { type: 'string', description: 'Workspace ID' }, - description: { type: 'string', description: 'Project description' }, - status: { type: 'string', description: 'Initial project status' }, + description: { type: 'string', description: 'Project description (supports HTML/Markdown)' }, + status: { type: 'string', description: 'Initial project status (must be valid for workspace)' }, + customFieldValues: { + type: 'object', + description: 'Custom field values as key-value pairs', + additionalProperties: true, + }, }, required: ['name', 'workspaceId'], }, @@ -66,6 +71,7 @@ export function registerProjectTools(client: MotionApiClient): Tool[] { workspaceId: z.string().min(1), description: z.string().optional(), status: z.string().optional(), + customFieldValues: z.record(z.any()).optional(), }); const validated = schema.parse(args); @@ -82,6 +88,11 @@ export function registerProjectTools(client: MotionApiClient): Tool[] { name: { type: 'string', description: 'New project name' }, description: { type: 'string', description: 'New project description' }, status: { type: 'string', description: 'New project status' }, + customFieldValues: { + type: 'object', + description: 'Custom field values as key-value pairs (only include fields to update)', + additionalProperties: true, + }, }, required: ['projectId'], }, @@ -91,6 +102,7 @@ export function registerProjectTools(client: MotionApiClient): Tool[] { name: z.string().optional(), description: z.string().optional(), status: z.string().optional(), + customFieldValues: z.record(z.any()).optional(), }); const { projectId, ...updateParams } = schema.parse(args); @@ -101,6 +113,8 @@ export function registerProjectTools(client: MotionApiClient): Tool[] { if (updateParams.description !== undefined) filteredParams.description = updateParams.description; if (updateParams.status !== undefined) filteredParams.status = updateParams.status; + if (updateParams.customFieldValues !== undefined) + filteredParams.customFieldValues = updateParams.customFieldValues; return await client.updateProject(projectId, filteredParams); }, diff --git a/src/tools/recurringTask.ts b/src/tools/recurringTask.ts index 7db85a0..1b98fd6 100644 --- a/src/tools/recurringTask.ts +++ b/src/tools/recurringTask.ts @@ -6,25 +6,28 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { return [ { name: 'motion_list_recurring_tasks', - description: 'List all recurring tasks, optionally filtered by workspace', + description: 'List all recurring tasks for a specific workspace. Supports pagination via cursor.', inputSchema: { type: 'object', properties: { - workspaceId: { type: 'string', description: 'Filter by workspace ID' }, + workspaceId: { type: 'string', description: 'Workspace ID (required)' }, + cursor: { type: 'string', description: 'Pagination cursor from previous response' }, }, - required: [], + required: ['workspaceId'], }, handler: async (args: unknown) => { const schema = z.object({ - workspaceId: z.string().optional(), + workspaceId: z.string().min(1), + cursor: z.string().optional(), }); const validated = schema.parse(args); - const recurringTasks = await client.listRecurringTasks(validated.workspaceId); + const response = await client.listRecurringTasks(validated); return { - recurringTasks, - count: recurringTasks.length, + recurringTasks: response.recurringTasks, + meta: response.meta, + count: response.recurringTasks?.length || 0, }; }, }, @@ -38,8 +41,7 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { workspaceId: { type: 'string', description: 'Workspace ID' }, frequency: { type: 'string', - enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], - description: 'Recurrence frequency', + description: 'Recurrence frequency (e.g., DAILY, WEEKLY_MONDAY, MONTHLY_1, MONTHLY_LAST)', }, recurrenceRule: { type: 'string', description: 'Custom recurrence rule (optional)' }, duration: { @@ -48,20 +50,44 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { }, description: { type: 'string', description: 'Task description' }, projectId: { type: 'string', description: 'Project ID to associate with' }, - assigneeId: { type: 'string', description: 'User ID to assign to' }, + assigneeId: { type: 'string', description: 'User ID to assign to (required)' }, + deadlineType: { + type: 'string', + enum: ['HARD', 'SOFT'], + description: 'Deadline type (default: SOFT)', + }, + startingOn: { type: 'string', description: 'ISO 8601 date when to start generating tasks' }, + idealTime: { type: 'string', description: 'Preferred time of day (HH:mm format)' }, + schedule: { type: 'string', description: 'Schedule name (default: "Work Hours")' }, + priority: { + type: 'string', + enum: ['HIGH', 'MEDIUM'], + description: 'Task priority (default: MEDIUM)', + }, + labels: { + type: 'array', + items: { type: 'string' }, + description: 'Array of label names', + }, }, - required: ['name', 'workspaceId', 'frequency'], + required: ['name', 'workspaceId', 'frequency', 'assigneeId'], }, handler: async (args: unknown) => { const schema = z.object({ name: z.string().min(1), workspaceId: z.string().min(1), - frequency: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']), + frequency: z.string().min(1), recurrenceRule: z.string().optional(), duration: z.union([z.string(), z.number()]).optional(), description: z.string().optional(), projectId: z.string().optional(), - assigneeId: z.string().optional(), + assigneeId: z.string().min(1), + deadlineType: z.enum(['HARD', 'SOFT']).optional(), + startingOn: z.string().optional(), + idealTime: z.string().optional(), + schedule: z.string().optional(), + priority: z.enum(['HIGH', 'MEDIUM']).optional(), + labels: z.array(z.string()).optional(), }); const validated = schema.parse(args); @@ -97,8 +123,7 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { name: { type: 'string', description: 'New name' }, frequency: { type: 'string', - enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], - description: 'New frequency', + description: 'New frequency (e.g., DAILY, WEEKLY_MONDAY, MONTHLY_1, MONTHLY_LAST)', }, recurrenceRule: { type: 'string', description: 'New recurrence rule' }, duration: { @@ -115,7 +140,7 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { const schema = z.object({ recurringTaskId: z.string().min(1), name: z.string().optional(), - frequency: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']).optional(), + frequency: z.string().optional(), recurrenceRule: z.string().optional(), duration: z.union([z.string(), z.number()]).optional(), description: z.string().optional(), diff --git a/src/tools/schedule.ts b/src/tools/schedule.ts index df6aeed..ef8f196 100644 --- a/src/tools/schedule.ts +++ b/src/tools/schedule.ts @@ -5,8 +5,8 @@ import { Tool } from '../types/tool.js'; export function registerScheduleTools(client: MotionApiClient): Tool[] { return [ { - name: 'motion_get_schedule', - description: 'Get schedule information for a user within a date range', + name: 'motion_get_scheduled_tasks', + description: 'Get scheduled tasks for a user within a date range', inputSchema: { type: 'object', properties: { @@ -38,5 +38,22 @@ export function registerScheduleTools(client: MotionApiClient): Tool[] { }; }, }, + { + name: 'motion_get_work_schedules', + description: 'Get all available work schedules (availability hours) for the current user', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + handler: async () => { + const workSchedules = await client.getWorkSchedules(); + + return { + workSchedules, + count: workSchedules.length, + }; + }, + }, ]; -} +} \ No newline at end of file diff --git a/src/tools/status.ts b/src/tools/status.ts new file mode 100644 index 0000000..c893cdf --- /dev/null +++ b/src/tools/status.ts @@ -0,0 +1,32 @@ +import { MotionApiClient } from '../api/client.js'; +import { z } from 'zod'; +import { Tool } from '../types/tool.js'; + +export function registerStatusTools(client: MotionApiClient): Tool[] { + return [ + { + name: 'motion_list_statuses', + description: 'List all available statuses, optionally filtered by workspace', + inputSchema: { + type: 'object', + properties: { + workspaceId: { type: 'string', description: 'Filter by workspace ID (optional)' }, + }, + required: [], + }, + handler: async (args: unknown) => { + const schema = z.object({ + workspaceId: z.string().optional(), + }); + + const validated = schema.parse(args); + const statuses = await client.listStatuses(validated.workspaceId); + + return { + statuses, + count: statuses.length, + }; + }, + }, + ]; +} \ No newline at end of file diff --git a/src/tools/task.ts b/src/tools/task.ts index ee7b2e9..4fb04c8 100644 --- a/src/tools/task.ts +++ b/src/tools/task.ts @@ -115,6 +115,11 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { description: 'Label names to add', }, assigneeId: { type: 'string', description: 'User ID to assign to' }, + customFieldValues: { + type: 'object', + description: 'Custom field values as key-value pairs', + additionalProperties: true, + }, }, required: ['name', 'workspaceId'], }, @@ -140,6 +145,7 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { priority: z.enum(['ASAP', 'HIGH', 'MEDIUM', 'LOW']).optional(), labels: z.array(z.string()).optional(), assigneeId: z.string().optional(), + customFieldValues: z.record(z.any()).optional(), }); const validated = schema.parse(args); @@ -173,6 +179,29 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { items: { type: 'string' }, description: 'New labels (replaces existing)', }, + workspaceId: { type: 'string', description: 'Move to different workspace' }, + projectId: { type: 'string', description: 'Move to different project' }, + autoScheduled: { + type: ['object', 'null'], + properties: { + startDate: { type: 'string', description: 'ISO 8601 start date' }, + deadlineType: { + type: 'string', + enum: ['HARD', 'SOFT', 'NONE'], + description: 'Deadline type', + }, + schedule: { + type: 'string', + description: 'Schedule name', + }, + }, + description: 'Auto-scheduling configuration (null to disable)', + }, + customFieldValues: { + type: 'object', + description: 'Custom field values as key-value pairs', + additionalProperties: true, + }, }, required: ['taskId'], }, @@ -188,6 +217,19 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { completed: z.boolean().optional(), assigneeId: z.string().optional(), labels: z.array(z.string()).optional(), + workspaceId: z.string().optional(), + projectId: z.string().optional(), + autoScheduled: z + .union([ + z.object({ + startDate: z.string(), + deadlineType: z.enum(['HARD', 'SOFT', 'NONE']).optional(), + schedule: z.string().optional(), + }), + z.null(), + ]) + .optional(), + customFieldValues: z.record(z.any()).optional(), }); const { taskId, ...updateParams } = schema.parse(args); @@ -216,23 +258,44 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, { name: 'motion_move_task', - description: 'Move a task to a different project', + description: 'Move a task to a different workspace and optionally reassign it', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'Task ID to move' }, - projectId: { type: 'string', description: 'Target project ID' }, + workspaceId: { type: 'string', description: 'Target workspace ID' }, + assigneeId: { type: 'string', description: 'New assignee ID (optional)' }, + }, + required: ['taskId', 'workspaceId'], + }, + handler: async (args: unknown) => { + const schema = z.object({ + taskId: z.string().min(1), + workspaceId: z.string().min(1), + assigneeId: z.string().optional(), + }); + + const { taskId, ...moveParams } = schema.parse(args); + return await client.moveTask(taskId, moveParams); + }, + }, + { + name: 'motion_unassign_task', + description: 'Remove assignee from a task', + inputSchema: { + type: 'object', + properties: { + taskId: { type: 'string', description: 'Task ID to unassign' }, }, - required: ['taskId', 'projectId'], + required: ['taskId'], }, handler: async (args: unknown) => { const schema = z.object({ taskId: z.string().min(1), - projectId: z.string().min(1), }); const validated = schema.parse(args); - return await client.moveTask(validated.taskId, validated.projectId); + return await client.unassignTask(validated.taskId); }, }, { @@ -274,4 +337,4 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, }, ]; -} +} \ No newline at end of file diff --git a/src/tools/user.ts b/src/tools/user.ts index dd62305..afd61d9 100644 --- a/src/tools/user.ts +++ b/src/tools/user.ts @@ -37,17 +37,17 @@ export function registerUserTools(client: MotionApiClient): Tool[] { }, { name: 'motion_list_users', - description: 'List all users, optionally filtered by workspace', + description: 'List all users in a workspace', inputSchema: { type: 'object', properties: { - workspaceId: { type: 'string', description: 'Filter by workspace ID' }, + workspaceId: { type: 'string', description: 'Workspace ID (required)' }, }, - required: [], + required: ['workspaceId'], }, handler: async (args: unknown) => { const schema = z.object({ - workspaceId: z.string().optional(), + workspaceId: z.string().min(1), }); const validated = schema.parse(args); diff --git a/src/tools/workspace.ts b/src/tools/workspace.ts index 91d011f..60b6794 100644 --- a/src/tools/workspace.ts +++ b/src/tools/workspace.ts @@ -6,17 +6,32 @@ export function registerWorkspaceTools(client: MotionApiClient): Tool[] { return [ { name: 'motion_list_workspaces', - description: 'List all workspaces accessible to the authenticated user', + description: 'List all workspaces accessible to the authenticated user. Supports pagination and filtering by IDs.', inputSchema: { type: 'object', - properties: {}, + properties: { + cursor: { type: 'string', description: 'Pagination cursor from previous response' }, + ids: { + type: 'array', + items: { type: 'string' }, + description: 'Array of workspace IDs to get expanded details', + }, + }, required: [], }, - handler: async () => { - const workspaces = await client.listWorkspaces(); + handler: async (args: unknown) => { + const schema = z.object({ + cursor: z.string().optional(), + ids: z.array(z.string()).optional(), + }); + + const validated = schema.parse(args); + const response = await client.listWorkspaces(validated); + return { - workspaces, - count: workspaces.length, + workspaces: response.workspaces, + meta: response.meta, + count: response.workspaces?.length || 0, }; }, }, diff --git a/src/types/motion.ts b/src/types/motion.ts index 7385b3c..1d54075 100644 --- a/src/types/motion.ts +++ b/src/types/motion.ts @@ -61,10 +61,11 @@ export interface MotionProject { export interface MotionWorkspace { id: string; name: string; - teamId: string; + teamId: string | null; type: 'TEAM' | 'INDIVIDUAL'; labels: Array<{ name: string }>; - statuses: MotionStatus[]; + taskStatuses: MotionStatus[]; + users?: MotionUser[]; } export interface MotionStatus { @@ -94,6 +95,21 @@ export interface MotionSchedule { tasks: MotionTask[]; } +export interface MotionWorkSchedule { + name: string; + isDefaultTimezone: boolean; + timezone: string; + schedule: { + monday: Array<{ start: string; end: string }>; + tuesday: Array<{ start: string; end: string }>; + wednesday: Array<{ start: string; end: string }>; + thursday: Array<{ start: string; end: string }>; + friday: Array<{ start: string; end: string }>; + saturday: Array<{ start: string; end: string }>; + sunday: Array<{ start: string; end: string }>; + }; +} + export interface MotionCustomField { id: string; name: string; @@ -110,8 +126,16 @@ export interface MotionCustomField { | 'phone' | 'checkbox' | 'relatedTo'; - options?: string[]; // For select/multiSelect types workspaceId: string; + metadata?: { + format?: 'plain' | 'formatted' | 'percent'; + options?: Array<{ + id: string; + value: string; + color?: string; + }>; + toggle?: boolean; + }; } export type MotionCustomFieldValue = @@ -133,11 +157,22 @@ export interface MotionRecurringTask { name: string; description?: string; duration: string | number; - frequency: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'; - recurrenceRule: string; + frequency: string; // e.g., 'DAILY', 'WEEKLY_MONDAY', 'MONTHLY_1', 'MONTHLY_LAST' + recurrenceRule?: string; workspaceId: string; projectId?: string; assigneeId?: string; + creator?: MotionUser; + assignee?: MotionUser; + project?: MotionProject; + workspace?: MotionWorkspace; + status?: MotionStatus; + priority?: 'HIGH' | 'MEDIUM'; + labels?: Array<{ name: string }>; + deadlineType?: 'HARD' | 'SOFT'; + startingOn?: string; + idealTime?: string; + schedule?: string; } export interface MotionListResponse { @@ -164,6 +199,7 @@ export interface MotionTaskCreateParams { priority?: 'ASAP' | 'HIGH' | 'MEDIUM' | 'LOW'; labels?: string[]; assigneeId?: string; + customFieldValues?: Record; } export interface MotionTaskUpdateParams { @@ -176,6 +212,14 @@ export interface MotionTaskUpdateParams { completed?: boolean; assigneeId?: string; labels?: string[]; + workspaceId?: string; + autoScheduled?: { + startDate: string; + deadlineType?: 'HARD' | 'SOFT' | 'NONE'; + schedule?: string; + } | null; + projectId?: string; + customFieldValues?: Record; } export interface MotionProjectCreateParams { @@ -183,6 +227,7 @@ export interface MotionProjectCreateParams { workspaceId: string; description?: string; status?: string; + customFieldValues?: Record; } export interface MotionCommentCreateParams { diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..653be75 --- /dev/null +++ b/test/README.md @@ -0,0 +1,40 @@ +# Motion MCP Tests + +Simple integration tests for the Motion MCP server tools. These tests use real API calls to the Motion API. + +## Setup + +1. Ensure you have a valid `MOTION_API_KEY` in your `.env` file +2. Create a workspace named "Test" in your Motion account +3. Run tests with `npm test` + +## Test Structure + +- **setup.ts** - Test configuration and utilities +- **utils.ts** - Helper functions for testing tools +- **tools/** - Test files for each tool category + - workspace.test.ts - Workspace tools + - user.test.ts - User tools + - task.test.ts - Task tools + - project.test.ts - Project tools + - schedule-status.test.ts - Schedule and status tools + +## Running Tests + +```bash +# Run all tests +npm test + +# Run specific test file +npm test -- test/tools/task.test.ts + +# Watch mode +npm run test:watch +``` + +## Important Notes + +- Tests use real API calls (no mocks) +- Rate limiting is enforced (12 requests/minute for individual accounts) +- Tests clean up after themselves when possible +- All test data uses unique prefixes to avoid conflicts \ No newline at end of file diff --git a/test/run-all.ts b/test/run-all.ts new file mode 100644 index 0000000..02bc61c --- /dev/null +++ b/test/run-all.ts @@ -0,0 +1,55 @@ +import { spawn } from 'child_process'; +import { join } from 'path'; + +const testFiles = [ + 'test/tools/workspace.test.ts', + 'test/tools/user.test.ts', + 'test/tools/task.test.ts', + 'test/tools/project.test.ts', + 'test/tools/schedule-status.test.ts', +]; + +async function runTest(file: string): Promise { + return new Promise((resolve, reject) => { + console.log(`\n${'='.repeat(60)}`); + console.log(`Running: ${file}`); + console.log(`${'='.repeat(60)}\n`); + + const proc = spawn('npx', ['tsx', '--test', file], { + stdio: 'inherit', + shell: true, + }); + + proc.on('close', (code) => { + if (code !== 0) { + console.error(`\n❌ Test failed: ${file}`); + } else { + console.log(`\n✅ Test passed: ${file}`); + } + resolve(); + }); + + proc.on('error', (err) => { + console.error(`Failed to run test: ${file}`, err); + resolve(); + }); + }); +} + +async function runAllTests() { + console.log('🧪 Running all Motion MCP tests...\n'); + console.log('⚠️ Note: Tests use real API calls with rate limiting'); + console.log('⏱️ Tests will pause between API calls to respect limits\n'); + + for (const file of testFiles) { + await runTest(file); + // Wait between test files to avoid rate limits + await new Promise(resolve => setTimeout(resolve, 5000)); + } + + console.log(`\n${'='.repeat(60)}`); + console.log('✨ All tests completed!'); + console.log(`${'='.repeat(60)}`); +} + +runAllTests().catch(console.error); \ No newline at end of file diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..0ebd979 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,46 @@ +import { config } from 'dotenv'; +import { MotionApiClient } from '../src/api/client.js'; + +config(); + +if (!process.env.MOTION_API_KEY) { + throw new Error('MOTION_API_KEY is required for tests'); +} + +export const testClient = new MotionApiClient({ + apiKey: process.env.MOTION_API_KEY, + baseUrl: 'https://api.usemotion.com/v1', + rateLimitPerMinute: 10, // Use conservative limit for tests +}); + +export const TEST_WORKSPACE_NAME = 'Test'; + +export interface TestContext { + client: MotionApiClient; + workspaceId?: string; +} + +export async function setupTestWorkspace(): Promise { + const workspaces = await testClient.listWorkspaces(); + const testWorkspace = workspaces.workspaces.find(w => w.name === TEST_WORKSPACE_NAME); + + if (!testWorkspace) { + throw new Error(`Test workspace "${TEST_WORKSPACE_NAME}" not found. Please create it in Motion.`); + } + + return testWorkspace.id; +} + +export async function cleanupTestData(pattern: string): Promise { + const tasks = await testClient.listTasks({ name: pattern }); + + for (const task of tasks.tasks || []) { + if (task.name.includes(pattern)) { + await testClient.deleteTask(task.id); + } + } +} + +export function generateTestId(): string { + return `test-${Date.now()}-${Math.random().toString(36).substring(7)}`; +} \ No newline at end of file diff --git a/test/tools/project.test.ts b/test/tools/project.test.ts new file mode 100644 index 0000000..9759c34 --- /dev/null +++ b/test/tools/project.test.ts @@ -0,0 +1,187 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { testClient, setupTestWorkspace, generateTestId } from '../setup.js'; +import { registerProjectTools } from '../../src/tools/project.js'; +import { testTool, assertHasProperty, assertIsArray, assertIsString, createTestContext, waitForApiLimit } from '../utils.js'; + +test('Project Tools', async (t) => { + const tools = registerProjectTools(testClient); + let testWorkspaceId: string; + let testProjectId: string; + const testPrefix = generateTestId(); + const createdProjectIds: string[] = []; + + await t.beforeEach(async () => { + if (!testWorkspaceId) { + testWorkspaceId = await setupTestWorkspace(); + } + }); + + await t.test('should register 5 project tools', () => { + assert.strictEqual(tools.length, 5); + const expectedTools = [ + 'motion_list_projects', + 'motion_get_project', + 'motion_create_project', + 'motion_update_project', + 'motion_delete_project' + ]; + expectedTools.forEach((name, index) => { + assert.strictEqual(tools[index].name, name); + }); + }); + + await t.test('motion_list_projects', async (t) => { + const listTool = tools[0]; + + await createTestContext('should list all projects in workspace', async () => { + const result = await testTool(listTool, { workspaceId: testWorkspaceId }, (res) => { + assertHasProperty(res, 'projects'); + assertHasProperty(res, 'count'); + assertIsArray(res.projects, 'projects'); + }); + }); + + await createTestContext('should validate required workspaceId', async () => { + await assert.rejects( + async () => await listTool.handler({}), + { + name: 'ZodError' + } + ); + }); + }); + + await t.test('motion_create_project', async (t) => { + const createTool = tools[2]; + + await createTestContext('should create a project', async () => { + const projectName = `${testPrefix}-test-project`; + + const result = await testTool(createTool, { + name: projectName, + workspaceId: testWorkspaceId, + description: 'Test project created by automated tests' + }, (res) => { + assertHasProperty(res, 'id'); + assertHasProperty(res, 'name'); + assertHasProperty(res, 'workspaceId'); + assert.strictEqual(res.name, projectName); + assert.strictEqual(res.workspaceId, testWorkspaceId); + testProjectId = res.id; + createdProjectIds.push(res.id); + }); + + await waitForApiLimit(); + }); + + await createTestContext('should validate required fields', async () => { + await assert.rejects( + async () => await createTool.handler({}), + { + name: 'ZodError' + } + ); + + await assert.rejects( + async () => await createTool.handler({ name: 'test' }), + { + name: 'ZodError' + } + ); + }); + }); + + await t.test('motion_get_project', async (t) => { + const getTool = tools[1]; + + await createTestContext('should get project details', async () => { + assert(testProjectId, 'Test project should be created first'); + + const result = await testTool(getTool, { projectId: testProjectId }, (res) => { + assertHasProperty(res, 'id'); + assertHasProperty(res, 'name'); + assertHasProperty(res, 'workspaceId'); + assert.strictEqual(res.id, testProjectId); + }); + }); + + await createTestContext('should validate required projectId', async () => { + await assert.rejects( + async () => await getTool.handler({}), + { + name: 'ZodError' + } + ); + }); + }); + + await t.test('motion_update_project', async (t) => { + const updateTool = tools[3]; + + await createTestContext('should update project properties', async () => { + assert(testProjectId, 'Test project should be created first'); + + const newName = `${testPrefix}-updated-project`; + const newDescription = 'Updated by automated tests'; + + const result = await testTool(updateTool, { + projectId: testProjectId, + name: newName, + description: newDescription + }, (res) => { + assertHasProperty(res, 'id'); + assert.strictEqual(res.name, newName); + assert.strictEqual(res.description, newDescription); + }); + + await waitForApiLimit(); + }); + + await createTestContext('should validate required projectId', async () => { + await assert.rejects( + async () => await updateTool.handler({}), + { + name: 'ZodError' + } + ); + }); + }); + + await t.test('motion_delete_project', async (t) => { + const deleteTool = tools[4]; + + await createTestContext('should delete project', async () => { + assert(testProjectId, 'Test project should be created first'); + + const result = await testTool(deleteTool, { projectId: testProjectId }, (res) => { + assertHasProperty(res, 'success'); + assertHasProperty(res, 'message'); + assert.strictEqual(res.success, true); + }); + + await waitForApiLimit(); + }); + + await createTestContext('should validate required projectId', async () => { + await assert.rejects( + async () => await deleteTool.handler({}), + { + name: 'ZodError' + } + ); + }); + }); + + await t.after(async () => { + // Clean up all created projects at the end + for (const projectId of createdProjectIds) { + try { + await testClient.deleteProject(projectId); + await waitForApiLimit(2000); + } catch (error) { + // Project might already be deleted + } + } + }); +}); \ No newline at end of file diff --git a/test/tools/schedule-status.test.ts b/test/tools/schedule-status.test.ts new file mode 100644 index 0000000..85b8e12 --- /dev/null +++ b/test/tools/schedule-status.test.ts @@ -0,0 +1,85 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { testClient, setupTestWorkspace } from '../setup.js'; +import { registerScheduleTools } from '../../src/tools/schedule.js'; +import { registerStatusTools } from '../../src/tools/status.js'; +import { testTool, assertHasProperty, assertIsArray, createTestContext } from '../utils.js'; + +test('Schedule and Status Tools', async (t) => { + let testWorkspaceId: string; + + await t.beforeEach(async () => { + if (!testWorkspaceId) { + testWorkspaceId = await setupTestWorkspace(); + } + }); + + await t.test('Schedule Tools', async (t) => { + const tools = registerScheduleTools(testClient); + + await t.test('should register 2 schedule tools', () => { + assert.strictEqual(tools.length, 2); + assert.strictEqual(tools[0].name, 'motion_get_scheduled_tasks'); + assert.strictEqual(tools[1].name, 'motion_get_work_schedules'); + }); + + await t.test('motion_get_scheduled_tasks', async () => { + const getTool = tools[0]; + + await createTestContext('should get scheduled tasks', async () => { + const today = new Date().toISOString().split('T')[0]; + const tomorrow = new Date(Date.now() + 86400000).toISOString().split('T')[0]; + + const result = await testTool(getTool, { + startDate: today, + endDate: tomorrow + }, (res) => { + assertHasProperty(res, 'schedules'); + assertHasProperty(res, 'count'); + assertIsArray(res.schedules, 'schedules'); + }); + }); + }); + + await t.test('motion_get_work_schedules', async () => { + const getTool = tools[1]; + + await createTestContext('should get work schedules', async () => { + const result = await testTool(getTool, {}, (res) => { + assertHasProperty(res, 'workSchedules'); + assertHasProperty(res, 'count'); + assertIsArray(res.workSchedules, 'workSchedules'); + }); + }); + }); + }); + + await t.test('Status Tools', async (t) => { + const tools = registerStatusTools(testClient); + + await t.test('should register 1 status tool', () => { + assert.strictEqual(tools.length, 1); + assert.strictEqual(tools[0].name, 'motion_list_statuses'); + }); + + await t.test('motion_list_statuses', async () => { + const listTool = tools[0]; + + await createTestContext('should list all statuses', async () => { + const result = await testTool(listTool, {}, (res) => { + assertHasProperty(res, 'statuses'); + assertHasProperty(res, 'count'); + assertIsArray(res.statuses, 'statuses'); + assert(res.count >= 1, 'Should have at least one status'); + }); + }); + + await createTestContext('should filter statuses by workspace', async () => { + const result = await testTool(listTool, { workspaceId: testWorkspaceId }, (res) => { + assertHasProperty(res, 'statuses'); + assertIsArray(res.statuses, 'statuses'); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/tools/task.test.ts b/test/tools/task.test.ts new file mode 100644 index 0000000..565fe05 --- /dev/null +++ b/test/tools/task.test.ts @@ -0,0 +1,184 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { testClient, setupTestWorkspace, generateTestId } from '../setup.js'; +import { registerTaskTools } from '../../src/tools/task.js'; +import { testTool, assertHasProperty, assertIsArray, assertIsString, createTestContext, waitForApiLimit } from '../utils.js'; + +test('Task Tools', async (t) => { + const tools = registerTaskTools(testClient); + let testWorkspaceId: string; + let testTaskId: string; + const testPrefix = generateTestId(); + + await t.beforeEach(async () => { + if (!testWorkspaceId) { + testWorkspaceId = await setupTestWorkspace(); + } + }); + + await t.test('should register 9 task tools', () => { + assert.strictEqual(tools.length, 9); + const expectedTools = [ + 'motion_list_tasks', + 'motion_get_task', + 'motion_create_task', + 'motion_update_task', + 'motion_delete_task', + 'motion_move_task', + 'motion_unassign_task', + 'motion_complete_task', + 'motion_uncomplete_task' + ]; + expectedTools.forEach((name, index) => { + assert.strictEqual(tools[index].name, name); + }); + }); + + await t.test('motion_list_tasks', async (t) => { + const listTool = tools[0]; + + await createTestContext('should list all tasks', async () => { + const result = await testTool(listTool, {}, (res) => { + assertHasProperty(res, 'tasks'); + assertHasProperty(res, 'count'); + assertIsArray(res.tasks, 'tasks'); + }); + }); + + await createTestContext('should filter tasks by workspace', async () => { + const result = await testTool(listTool, { workspaceId: testWorkspaceId }, (res) => { + assertHasProperty(res, 'tasks'); + assertIsArray(res.tasks, 'tasks'); + }); + }); + }); + + await t.test('motion_create_task', async (t) => { + const createTool = tools[2]; + + await createTestContext('should create a simple task', async () => { + const taskName = `${testPrefix}-simple-task`; + + const result = await testTool(createTool, { + name: taskName, + workspaceId: testWorkspaceId, + duration: 30, + description: 'Test task created by automated tests' + }, (res) => { + assertHasProperty(res, 'id'); + assertHasProperty(res, 'name'); + assertHasProperty(res, 'workspaceId'); + assert.strictEqual(res.name, taskName); + assert.strictEqual(res.workspaceId, testWorkspaceId); + testTaskId = res.id; + }); + + await waitForApiLimit(); + }); + + await createTestContext('should validate required fields', async () => { + await assert.rejects( + async () => await createTool.handler({}), + { + name: 'ZodError' + } + ); + }); + }); + + await t.test('motion_get_task', async (t) => { + const getTool = tools[1]; + + await createTestContext('should get task details', async () => { + assert(testTaskId, 'Test task should be created first'); + + const result = await testTool(getTool, { taskId: testTaskId }, (res) => { + assertHasProperty(res, 'id'); + assertHasProperty(res, 'name'); + assertHasProperty(res, 'workspaceId'); + assertHasProperty(res, 'status'); + assert.strictEqual(res.id, testTaskId); + }); + }); + }); + + await t.test('motion_update_task', async (t) => { + const updateTool = tools[3]; + + await createTestContext('should update task properties', async () => { + assert(testTaskId, 'Test task should be created first'); + + const newName = `${testPrefix}-updated-task`; + const result = await testTool(updateTool, { + taskId: testTaskId, + name: newName, + priority: 'HIGH', + description: 'Updated by automated tests' + }, (res) => { + assertHasProperty(res, 'id'); + assert.strictEqual(res.name, newName); + assert.strictEqual(res.priority, 'HIGH'); + }); + + await waitForApiLimit(); + }); + }); + + await t.test('motion_complete_task', async (t) => { + const completeTool = tools[7]; + + await createTestContext('should mark task as completed', async () => { + assert(testTaskId, 'Test task should be created first'); + + const result = await testTool(completeTool, { taskId: testTaskId }, (res) => { + assertHasProperty(res, 'id'); + assertHasProperty(res, 'completed'); + assert.strictEqual(res.completed, true); + }); + + await waitForApiLimit(); + }); + }); + + await t.test('motion_uncomplete_task', async (t) => { + const uncompleteTool = tools[8]; + + await createTestContext('should mark task as not completed', async () => { + assert(testTaskId, 'Test task should be created first'); + + const result = await testTool(uncompleteTool, { taskId: testTaskId }, (res) => { + assertHasProperty(res, 'id'); + assertHasProperty(res, 'completed'); + assert.strictEqual(res.completed, false); + }); + + await waitForApiLimit(); + }); + }); + + await t.test('motion_delete_task', async (t) => { + const deleteTool = tools[4]; + + await createTestContext('should delete task', async () => { + assert(testTaskId, 'Test task should be created first'); + + const result = await testTool(deleteTool, { taskId: testTaskId }, (res) => { + assertHasProperty(res, 'success'); + assertHasProperty(res, 'message'); + assert.strictEqual(res.success, true); + }); + + await waitForApiLimit(); + }); + }); + + await t.afterEach(async () => { + if (testTaskId) { + try { + await testClient.deleteTask(testTaskId); + } catch (error) { + // Task might already be deleted + } + } + }); +}); \ No newline at end of file diff --git a/test/tools/user.test.ts b/test/tools/user.test.ts new file mode 100644 index 0000000..e638cc6 --- /dev/null +++ b/test/tools/user.test.ts @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { testClient, setupTestWorkspace } from '../setup.js'; +import { registerUserTools } from '../../src/tools/user.js'; +import { testTool, assertHasProperty, assertIsArray, assertIsString, createTestContext } from '../utils.js'; + +test('User Tools', async (t) => { + const tools = registerUserTools(testClient); + let currentUserId: string; + let testWorkspaceId: string; + + await t.test('should register 3 user tools', () => { + assert.strictEqual(tools.length, 3); + assert.strictEqual(tools[0].name, 'motion_get_current_user'); + assert.strictEqual(tools[1].name, 'motion_get_user'); + assert.strictEqual(tools[2].name, 'motion_list_users'); + }); + + await t.test('motion_get_current_user', async (t) => { + const getCurrentUserTool = tools[0]; + + await createTestContext('should get current user information', async () => { + const result = await testTool(getCurrentUserTool, {}, (res) => { + assertHasProperty(res, 'id'); + assertHasProperty(res, 'name'); + assertHasProperty(res, 'email'); + assertIsString(res.id, 'id'); + assertIsString(res.name, 'name'); + assertIsString(res.email, 'email'); + currentUserId = res.id; + }); + }); + }); + + await t.test('motion_get_user', async (t) => { + const getUserTool = tools[1]; + + await createTestContext('should validate required userId parameter', async () => { + await assert.rejects( + async () => await getUserTool.handler({}), + { + name: 'ZodError' + } + ); + }); + + await createTestContext('should validate userId is not empty', async () => { + await assert.rejects( + async () => await getUserTool.handler({ userId: '' }), + { + name: 'ZodError' + } + ); + }); + }); + + await t.test('motion_list_users', async (t) => { + const listUsersTool = tools[2]; + + await createTestContext('should list all users in workspace', async () => { + if (!testWorkspaceId) { + testWorkspaceId = await setupTestWorkspace(); + } + + const result = await testTool(listUsersTool, { workspaceId: testWorkspaceId }, (res) => { + assertHasProperty(res, 'users'); + assertHasProperty(res, 'count'); + assertIsArray(res.users, 'users'); + assert(res.count >= 1, 'Should have at least one user'); + }); + }); + + await createTestContext('should validate required workspaceId', async () => { + await assert.rejects( + async () => await listUsersTool.handler({}), + { + name: 'ZodError' + } + ); + }); + }); +}); \ No newline at end of file diff --git a/test/tools/workspace.test.ts b/test/tools/workspace.test.ts new file mode 100644 index 0000000..9b2bd73 --- /dev/null +++ b/test/tools/workspace.test.ts @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { testClient, setupTestWorkspace, TEST_WORKSPACE_NAME } from '../setup.js'; +import { registerWorkspaceTools } from '../../src/tools/workspace.js'; +import { testTool, assertHasProperty, assertIsArray, assertIsString, createTestContext } from '../utils.js'; + +test('Workspace Tools', async (t) => { + const tools = registerWorkspaceTools(testClient); + let testWorkspaceId: string; + + await t.test('should register 2 workspace tools', () => { + assert.strictEqual(tools.length, 2); + assert.strictEqual(tools[0].name, 'motion_list_workspaces'); + assert.strictEqual(tools[1].name, 'motion_get_workspace'); + }); + + await t.test('motion_list_workspaces', async (t) => { + const listTool = tools[0]; + + await createTestContext('should list all workspaces', async () => { + const result = await testTool(listTool, {}, (res) => { + assertHasProperty(res, 'workspaces'); + assertHasProperty(res, 'count'); + assertIsArray(res.workspaces, 'workspaces'); + assert(res.count >= 1, 'Should have at least one workspace'); + + const testWorkspace = res.workspaces.find((w: any) => w.name === TEST_WORKSPACE_NAME); + assert(testWorkspace, `Should find the "${TEST_WORKSPACE_NAME}" workspace`); + testWorkspaceId = testWorkspace.id; + }); + }); + + await createTestContext('should handle pagination cursor', async () => { + const result = await testTool(listTool, { cursor: 'test-cursor' }, (res) => { + assertHasProperty(res, 'workspaces'); + assertHasProperty(res, 'meta'); + assertIsArray(res.workspaces, 'workspaces'); + }); + }); + }); + + await t.test('motion_get_workspace', async (t) => { + const getTool = tools[1]; + + await createTestContext('should validate required workspaceId parameter', async () => { + await assert.rejects( + async () => await getTool.handler({}), + { + name: 'ZodError' + } + ); + }); + + await createTestContext('should validate workspaceId is not empty', async () => { + await assert.rejects( + async () => await getTool.handler({ workspaceId: '' }), + { + name: 'ZodError' + } + ); + }); + + await createTestContext('should get workspace details', async () => { + if (!testWorkspaceId) { + testWorkspaceId = await setupTestWorkspace(); + } + + const result = await testTool(getTool, { workspaceId: testWorkspaceId }, (res) => { + assertHasProperty(res, 'id'); + assertHasProperty(res, 'name'); + assertHasProperty(res, 'teamId'); + assertHasProperty(res, 'taskStatuses'); + assertHasProperty(res, 'labels'); + + assert.strictEqual(res.id, testWorkspaceId); + assert.strictEqual(res.name, TEST_WORKSPACE_NAME); + assertIsArray(res.taskStatuses, 'taskStatuses'); + assertIsArray(res.labels, 'labels'); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..aa9137c --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,65 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { Tool } from '../src/types/tool.js'; + +export async function testTool( + tool: Tool, + args: any, + validate?: (result: any) => void +): Promise { + try { + const result = await tool.handler(args); + + if (validate) { + validate(result); + } else { + assert(result !== undefined, 'Tool should return a result'); + } + + return result; + } catch (error) { + console.error(`Tool ${tool.name} failed:`, error); + throw error; + } +} + +export function assertHasProperty(obj: any, property: string): void { + assert(property in obj, `Object should have property "${property}"`); +} + +export function assertIsArray(value: any, propertyName: string): void { + assert(Array.isArray(value), `${propertyName} should be an array`); +} + +export function assertIsString(value: any, propertyName: string): void { + assert(typeof value === 'string', `${propertyName} should be a string`); +} + +export function assertIsNumber(value: any, propertyName: string): void { + assert(typeof value === 'number', `${propertyName} should be a number`); +} + +export function assertIsBoolean(value: any, propertyName: string): void { + assert(typeof value === 'boolean', `${propertyName} should be a boolean`); +} + +export function assertIsObject(value: any, propertyName: string): void { + assert(typeof value === 'object' && value !== null, `${propertyName} should be an object`); +} + +export async function waitForApiLimit(ms: number = 5000): Promise { + await new Promise(resolve => setTimeout(resolve, ms)); +} + +export function createTestContext(description: string, fn: () => void | Promise) { + return test(description, async () => { + console.log(`\n🧪 Running: ${description}`); + try { + await fn(); + console.log(`✅ Passed: ${description}`); + } catch (error) { + console.log(`❌ Failed: ${description}`); + throw error; + } + }); +} \ No newline at end of file From d9b3eafafc1321a04f5f94aaec16cfe35bb39e2f Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 16:04:02 +0200 Subject: [PATCH 06/15] Refactor project management tools in Motion API - Removed project update and delete functionalities as the Motion API does not support these operations. - Updated documentation to reflect the changes in project management capabilities. - Adjusted the number of registered project tools in tests and README to align with the current API limitations. --- CLAUDE.md | 2 +- README.md | 8 +-- docs/MOTION_PROJECT_LIMITATIONS.md | 44 +++++++++++++ package-lock.json | 100 ++++++++++++++++++++++++++++- package.json | 2 + src/api/client.ts | 14 ++-- src/tools/project.ts | 63 +----------------- test/tools/project.test.ts | 68 ++------------------ 8 files changed, 159 insertions(+), 142 deletions(-) create mode 100644 docs/MOTION_PROJECT_LIMITATIONS.md diff --git a/CLAUDE.md b/CLAUDE.md index 126d376..af1e06e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -This is an MCP (Model Context Protocol) server for Motion (usemotion.com) that enables AI assistants to interact with Motion's API. The server implements 32 tools across 8 categories for comprehensive task, project, and calendar management. +This is an MCP (Model Context Protocol) server for Motion (usemotion.com) that enables AI assistants to interact with Motion's API. The server implements 30 tools across 8 categories for comprehensive task, project, and calendar management. ## Key Commands diff --git a/README.md b/README.md index 27b3a92..5061b65 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ If you prefer to manually configure without using the CLI, add this to your `cla ### Core Capabilities - **Task Management**: Create, update, delete, and organize tasks with full support for Motion's auto-scheduling -- **Project Management**: Manage projects across workspaces with custom statuses +- **Project Management**: Create and view projects across workspaces (Note: Projects are read-only once created) - **Workspace Organization**: Access and manage multiple workspaces - **Team Collaboration**: User management, task assignment, and team coordination - **Comments**: Add and manage task comments for better collaboration @@ -118,18 +118,16 @@ The server includes automatic rate limiting to comply with Motion's API limits: - `motion_complete_task` - Mark tasks as completed - `motion_uncomplete_task` - Mark tasks as not completed -### Project Management (5 tools) +### Project Management (3 tools) - `motion_list_projects` - List all projects - `motion_get_project` - Get project details - `motion_create_project` - Create new projects -- `motion_update_project` - Update project information -- `motion_delete_project` - Delete projects ### Workspace Tools (2 tools) - `motion_list_workspaces` - List accessible workspaces - `motion_get_workspace` - Get workspace details -### Additional Tools (17 tools) +### Additional Tools (19 tools) - User management (3 tools) - Schedule management (1 tool) - Comment management (5 tools) diff --git a/docs/MOTION_PROJECT_LIMITATIONS.md b/docs/MOTION_PROJECT_LIMITATIONS.md new file mode 100644 index 0000000..3d3810d --- /dev/null +++ b/docs/MOTION_PROJECT_LIMITATIONS.md @@ -0,0 +1,44 @@ +# Motion API Project Limitations + +Based on testing with the Motion API (as of January 2025), projects have significant limitations compared to tasks: + +## Key Findings + +1. **Projects are Read-Only After Creation** + - The Motion API does not support updating project properties + - PATCH, PUT, and POST methods to `/projects/{id}` all return 404 + - This includes updating name, description, status, or priority + +2. **Projects Cannot Be Deleted** + - DELETE requests to `/projects/{id}` return 404 + - Once created, projects remain in the workspace permanently + +3. **Projects Have a `priorityLevel` Field** + - Projects include a `priorityLevel` field (e.g., "MEDIUM", "HIGH") + - However, this field cannot be modified via the API + - The priority is likely set through the Motion UI only + +## Test Results + +All attempted update methods failed: +``` +PATCH /projects/{id} -> 404 Not Found +PUT /projects/{id} -> 404 Not Found +POST /projects/{id} -> 404 Not Found +DELETE /projects/{id} -> 404 Not Found +``` + +## Implications + +- Projects should be carefully planned before creation +- Any changes to projects must be done through the Motion UI +- The MCP server has been updated to remove non-functional update/delete tools +- Only list, get, and create operations are supported for projects + +## Comparison with Tasks + +Unlike projects, tasks support full CRUD operations: +- Tasks can be updated (name, description, priority, status, etc.) +- Tasks can be deleted +- Tasks can be moved between projects +- Tasks have much more flexibility via the API \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 29cf036..ec6b7b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", + "@types/node-fetch": "^2.6.12", "axios": "^1.7.9", "dotenv": "^16.4.7", + "node-fetch": "^3.3.2", "p-queue": "^8.0.1", "zod": "^3.24.1" }, @@ -646,11 +648,19 @@ "version": "22.16.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz", "integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==", - "dev": true, "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", @@ -1174,6 +1184,14 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -1673,6 +1691,28 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -1802,6 +1842,17 @@ "node": ">= 0.6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2340,6 +2391,42 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3029,8 +3116,7 @@ "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/unpipe": { "version": "1.0.0", @@ -3056,6 +3142,14 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 16ed7f3..7a6caf0 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,10 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", + "@types/node-fetch": "^2.6.12", "axios": "^1.7.9", "dotenv": "^16.4.7", + "node-fetch": "^3.3.2", "p-queue": "^8.0.1", "zod": "^3.24.1" }, diff --git a/src/api/client.ts b/src/api/client.ts index 1dfa61b..eb4d076 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -203,16 +203,10 @@ export class MotionApiClient { return this.request('POST', '/projects', params); } - async updateProject( - projectId: string, - params: Partial - ): Promise { - return this.request('PATCH', `/projects/${projectId}`, params); - } - - async deleteProject(projectId: string): Promise { - return this.request('DELETE', `/projects/${projectId}`); - } + // Note: Motion API doesn't support project deletion + // async deleteProject(projectId: string): Promise { + // return this.request('DELETE', `/projects/${projectId}`); + // } // User methods async getCurrentUser(): Promise { diff --git a/src/tools/project.ts b/src/tools/project.ts index 48fcc33..4f07fa5 100644 --- a/src/tools/project.ts +++ b/src/tools/project.ts @@ -78,66 +78,7 @@ export function registerProjectTools(client: MotionApiClient): Tool[] { return await client.createProject(validated); }, }, - { - name: 'motion_update_project', - description: 'Update an existing project', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project ID to update' }, - name: { type: 'string', description: 'New project name' }, - description: { type: 'string', description: 'New project description' }, - status: { type: 'string', description: 'New project status' }, - customFieldValues: { - type: 'object', - description: 'Custom field values as key-value pairs (only include fields to update)', - additionalProperties: true, - }, - }, - required: ['projectId'], - }, - handler: async (args: unknown) => { - const schema = z.object({ - projectId: z.string().min(1), - name: z.string().optional(), - description: z.string().optional(), - status: z.string().optional(), - customFieldValues: z.record(z.any()).optional(), - }); - - const { projectId, ...updateParams } = schema.parse(args); - - // Only include non-undefined fields - const filteredParams: any = {}; - if (updateParams.name !== undefined) filteredParams.name = updateParams.name; - if (updateParams.description !== undefined) - filteredParams.description = updateParams.description; - if (updateParams.status !== undefined) filteredParams.status = updateParams.status; - if (updateParams.customFieldValues !== undefined) - filteredParams.customFieldValues = updateParams.customFieldValues; - - return await client.updateProject(projectId, filteredParams); - }, - }, - { - name: 'motion_delete_project', - description: 'Delete a project permanently', - inputSchema: { - type: 'object', - properties: { - projectId: { type: 'string', description: 'Project ID to delete' }, - }, - required: ['projectId'], - }, - handler: async (args: unknown) => { - const schema = z.object({ - projectId: z.string().min(1), - }); - - const validated = schema.parse(args); - await client.deleteProject(validated.projectId); - return { success: true, message: `Project ${validated.projectId} deleted successfully` }; - }, - }, + // Note: Motion API doesn't support project updates or deletion + // Projects are read-only once created ]; } diff --git a/test/tools/project.test.ts b/test/tools/project.test.ts index 9759c34..8453261 100644 --- a/test/tools/project.test.ts +++ b/test/tools/project.test.ts @@ -17,14 +17,12 @@ test('Project Tools', async (t) => { } }); - await t.test('should register 5 project tools', () => { - assert.strictEqual(tools.length, 5); + await t.test('should register 3 project tools', () => { + assert.strictEqual(tools.length, 3); const expectedTools = [ 'motion_list_projects', 'motion_get_project', - 'motion_create_project', - 'motion_update_project', - 'motion_delete_project' + 'motion_create_project' ]; expectedTools.forEach((name, index) => { assert.strictEqual(tools[index].name, name); @@ -116,68 +114,14 @@ test('Project Tools', async (t) => { }); }); - await t.test('motion_update_project', async (t) => { - const updateTool = tools[3]; - - await createTestContext('should update project properties', async () => { - assert(testProjectId, 'Test project should be created first'); - - const newName = `${testPrefix}-updated-project`; - const newDescription = 'Updated by automated tests'; - - const result = await testTool(updateTool, { - projectId: testProjectId, - name: newName, - description: newDescription - }, (res) => { - assertHasProperty(res, 'id'); - assert.strictEqual(res.name, newName); - assert.strictEqual(res.description, newDescription); - }); - - await waitForApiLimit(); - }); - - await createTestContext('should validate required projectId', async () => { - await assert.rejects( - async () => await updateTool.handler({}), - { - name: 'ZodError' - } - ); - }); - }); - - await t.test('motion_delete_project', async (t) => { - const deleteTool = tools[4]; - - await createTestContext('should delete project', async () => { - assert(testProjectId, 'Test project should be created first'); - - const result = await testTool(deleteTool, { projectId: testProjectId }, (res) => { - assertHasProperty(res, 'success'); - assertHasProperty(res, 'message'); - assert.strictEqual(res.success, true); - }); - - await waitForApiLimit(); - }); - - await createTestContext('should validate required projectId', async () => { - await assert.rejects( - async () => await deleteTool.handler({}), - { - name: 'ZodError' - } - ); - }); - }); + // Note: Motion API doesn't support project deletion + // Projects are read-only once created await t.after(async () => { // Clean up all created projects at the end for (const projectId of createdProjectIds) { try { - await testClient.deleteProject(projectId); + // Projects cannot be deleted via API await waitForApiLimit(2000); } catch (error) { // Project might already be deleted From a9a419f5342daa23797126d27102a63b3775c082 Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 17:39:48 +0200 Subject: [PATCH 07/15] Update README to reflect changes in configuration file naming and add development setup instructions - Changed references from `claude.json` to `claude_desktop_config.json` for clarity. - Added detailed instructions for configuring the development version of the Motion API. - Included a JSON snippet for the `mcpServers` configuration to assist users in setup. --- README.md | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5061b65..13d2b67 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,9 @@ claude mcp add motion npx -- -y @rf-d/motion-mcp The above command creates the configuration, but you need to manually add your API key: 1. Open your Claude configuration file: - - **macOS**: `~/Library/Application Support/Claude/claude.json` - - **Windows**: `%APPDATA%\Claude\claude.json` - - **Linux**: `~/.config/claude/claude.json` + - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` + - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + - **Linux**: `~/.config/claude/claude_desktop_config.json` 2. Find the `motion` entry that was just added and update it to include your API key: @@ -68,8 +68,32 @@ echo "MOTION_API_KEY=your_motion_api_key_here" > .env npm run dev ``` +**For development version, add this to your `claude_desktop_config.json` configuration:** + +```json +{ + "mcpServers": { + "motion": { + "command": "npm", + "args": [ + "--prefix", + "/path/to/your/motion-mcp", + "--silent", + "run", + "start" + ], + "env": { + "MOTION_API_KEY": "YOUR_API_KEY" + } + } + } +} +``` + +Replace `/path/to/your/motion-mcp` with the actual path to your cloned repository. + #### Manual Configuration -If you prefer to manually configure without using the CLI, add this to your `claude.json`: +If you prefer to manually configure without using the CLI, add this to your `claude_desktop_config.json`: ```json { @@ -240,7 +264,7 @@ This is an unofficial integration and is not affiliated with, officially maintai ### Common Issues **"Motion API key not found" error** -- Make sure you've added the `MOTION_API_KEY` to your claude.json configuration +- Make sure you've added the `MOTION_API_KEY` to your claude_desktop_config.json configuration - Verify the key is correct and hasn't expired - Restart Claude Desktop after adding the key From 2e8a49f15101fa36caab5601bd2396715089fc0a Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 18:59:45 +0200 Subject: [PATCH 08/15] Update CLAUDE.md and README to reflect tool changes and API updates - Updated CLAUDE.md to indicate an increase in tools from 30 to 34 and categories from 8 to 9. - Revised README to adjust the number of task management tools from 8 to 7 and additional tools from 19 to 22, reflecting recent API changes. - Removed the `motion_update_recurring_task` and task completion functionalities from the codebase, streamlining the task management tools. - Updated input schemas to require necessary fields for task creation and management. --- CLAUDE.md | 2 +- README.md | 13 ++++++----- src/api/client.ts | 10 -------- src/tools/recurringTask.ts | 48 -------------------------------------- src/tools/task.ts | 42 ++------------------------------- src/types/motion.ts | 1 - 6 files changed, 10 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index af1e06e..47c9bd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -This is an MCP (Model Context Protocol) server for Motion (usemotion.com) that enables AI assistants to interact with Motion's API. The server implements 30 tools across 8 categories for comprehensive task, project, and calendar management. +This is an MCP (Model Context Protocol) server for Motion (usemotion.com) that enables AI assistants to interact with Motion's API. The server implements 34 tools across 9 categories for comprehensive task, project, and calendar management. ## Key Commands diff --git a/README.md b/README.md index 13d2b67..bcbb862 100644 --- a/README.md +++ b/README.md @@ -132,15 +132,14 @@ The server includes automatic rate limiting to comply with Motion's API limits: ## Available Tools -### Task Management (8 tools) +### Task Management (7 tools) - `motion_list_tasks` - List tasks with filtering and pagination - `motion_get_task` - Get detailed task information - `motion_create_task` - Create new tasks with auto-scheduling - `motion_update_task` - Update task properties - `motion_delete_task` - Delete tasks -- `motion_move_task` - Move tasks between projects -- `motion_complete_task` - Mark tasks as completed -- `motion_uncomplete_task` - Mark tasks as not completed +- `motion_move_task` - Move tasks between workspaces +- `motion_unassign_task` - Remove assignee from task ### Project Management (3 tools) - `motion_list_projects` - List all projects @@ -151,12 +150,14 @@ The server includes automatic rate limiting to comply with Motion's API limits: - `motion_list_workspaces` - List accessible workspaces - `motion_get_workspace` - Get workspace details -### Additional Tools (19 tools) +### Additional Tools (22 tools) - User management (3 tools) - Schedule management (1 tool) - Comment management (5 tools) - Custom field management (5 tools) -- Recurring task management (5 tools) +- Recurring task management (4 tools) +- Schedule management (2 tools) +- Status management (1 tool) ## Usage Examples diff --git a/src/api/client.ts b/src/api/client.ts index eb4d076..1ef3046 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -357,16 +357,6 @@ export class MotionApiClient { return this.request('GET', `/recurring-tasks/${recurringTaskId}`); } - async updateRecurringTask( - recurringTaskId: string, - params: Partial - ): Promise { - return this.request( - 'PATCH', - `/recurring-tasks/${recurringTaskId}`, - params - ); - } async deleteRecurringTask(recurringTaskId: string): Promise { return this.request('DELETE', `/recurring-tasks/${recurringTaskId}`); diff --git a/src/tools/recurringTask.ts b/src/tools/recurringTask.ts index 1b98fd6..a1f0076 100644 --- a/src/tools/recurringTask.ts +++ b/src/tools/recurringTask.ts @@ -113,54 +113,6 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { return await client.getRecurringTask(validated.recurringTaskId); }, }, - { - name: 'motion_update_recurring_task', - description: 'Update an existing recurring task', - inputSchema: { - type: 'object', - properties: { - recurringTaskId: { type: 'string', description: 'Recurring task ID to update' }, - name: { type: 'string', description: 'New name' }, - frequency: { - type: 'string', - description: 'New frequency (e.g., DAILY, WEEKLY_MONDAY, MONTHLY_1, MONTHLY_LAST)', - }, - recurrenceRule: { type: 'string', description: 'New recurrence rule' }, - duration: { - type: ['string', 'number'], - description: 'New duration', - }, - description: { type: 'string', description: 'New description' }, - projectId: { type: 'string', description: 'New project ID' }, - assigneeId: { type: 'string', description: 'New assignee ID' }, - }, - required: ['recurringTaskId'], - }, - handler: async (args: unknown) => { - const schema = z.object({ - recurringTaskId: z.string().min(1), - name: z.string().optional(), - frequency: z.string().optional(), - recurrenceRule: z.string().optional(), - duration: z.union([z.string(), z.number()]).optional(), - description: z.string().optional(), - projectId: z.string().optional(), - assigneeId: z.string().optional(), - }); - - const { recurringTaskId, ...updateParams } = schema.parse(args); - - // Filter out undefined values - const filteredParams: any = {}; - Object.keys(updateParams).forEach((key) => { - if ((updateParams as any)[key] !== undefined) { - filteredParams[key] = (updateParams as any)[key]; - } - }); - - return await client.updateRecurringTask(recurringTaskId, filteredParams); - }, - }, { name: 'motion_delete_recurring_task', description: 'Delete a recurring task permanently', diff --git a/src/tools/task.ts b/src/tools/task.ts index 4fb04c8..8cba4f6 100644 --- a/src/tools/task.ts +++ b/src/tools/task.ts @@ -97,6 +97,7 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { description: 'Schedule name (must be "Work Hours" for other users)', }, }, + required: ['startDate', 'deadlineType'], description: 'Auto-scheduling configuration (null to disable)', }, projectId: { type: 'string', description: 'Project ID to associate with' }, @@ -172,7 +173,6 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { description: 'New priority', }, description: { type: 'string', description: 'New description' }, - completed: { type: 'boolean', description: 'Mark as completed/uncompleted' }, assigneeId: { type: 'string', description: 'New assignee ID' }, labels: { type: 'array', @@ -195,6 +195,7 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { description: 'Schedule name', }, }, + required: ['startDate', 'deadlineType'], description: 'Auto-scheduling configuration (null to disable)', }, customFieldValues: { @@ -214,7 +215,6 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { status: z.string().optional(), priority: z.enum(['ASAP', 'HIGH', 'MEDIUM', 'LOW']).optional(), description: z.string().optional(), - completed: z.boolean().optional(), assigneeId: z.string().optional(), labels: z.array(z.string()).optional(), workspaceId: z.string().optional(), @@ -298,43 +298,5 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { return await client.unassignTask(validated.taskId); }, }, - { - name: 'motion_complete_task', - description: 'Mark a task as completed', - inputSchema: { - type: 'object', - properties: { - taskId: { type: 'string', description: 'Task ID to complete' }, - }, - required: ['taskId'], - }, - handler: async (args: unknown) => { - const schema = z.object({ - taskId: z.string().min(1), - }); - - const validated = schema.parse(args); - return await client.updateTask(validated.taskId, { completed: true }); - }, - }, - { - name: 'motion_uncomplete_task', - description: 'Mark a task as not completed', - inputSchema: { - type: 'object', - properties: { - taskId: { type: 'string', description: 'Task ID to uncomplete' }, - }, - required: ['taskId'], - }, - handler: async (args: unknown) => { - const schema = z.object({ - taskId: z.string().min(1), - }); - - const validated = schema.parse(args); - return await client.updateTask(validated.taskId, { completed: false }); - }, - }, ]; } \ No newline at end of file diff --git a/src/types/motion.ts b/src/types/motion.ts index 1d54075..b47ec08 100644 --- a/src/types/motion.ts +++ b/src/types/motion.ts @@ -209,7 +209,6 @@ export interface MotionTaskUpdateParams { status?: string; priority?: 'ASAP' | 'HIGH' | 'MEDIUM' | 'LOW'; description?: string; - completed?: boolean; assigneeId?: string; labels?: string[]; workspaceId?: string; From 167d52f28248de6b3512d653da0572eea1acea70 Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 19:07:40 +0200 Subject: [PATCH 09/15] Enhance task and recurring task descriptions for clarity - Updated descriptions for `motion_create_recurring_task` and `motion_create_task` to specify the nature of task generation and requirements for due dates. - Clarified input schema descriptions for `dueDate`, `duration`, and `autoScheduled` fields in both task creation and update functionalities to improve user understanding. --- src/tools/recurringTask.ts | 2 +- src/tools/task.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/tools/recurringTask.ts b/src/tools/recurringTask.ts index a1f0076..50eef30 100644 --- a/src/tools/recurringTask.ts +++ b/src/tools/recurringTask.ts @@ -33,7 +33,7 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { }, { name: 'motion_create_recurring_task', - description: 'Create a new recurring task', + description: 'Create a new recurring task template that will generate tasks automatically', inputSchema: { type: 'object', properties: { diff --git a/src/tools/task.ts b/src/tools/task.ts index 8cba4f6..14fdb1a 100644 --- a/src/tools/task.ts +++ b/src/tools/task.ts @@ -68,7 +68,7 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, { name: 'motion_create_task', - description: 'Create a new task in Motion', + description: 'Create a new task in Motion. Note: dueDate is required when duration is not "NONE" or when using autoScheduled.', inputSchema: { type: 'object', properties: { @@ -76,11 +76,11 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { workspaceId: { type: 'string', description: 'Workspace ID' }, dueDate: { type: 'string', - description: 'ISO 8601 due date (required for scheduled tasks)', + description: 'ISO 8601 due date. REQUIRED when: 1) duration is not "NONE" (i.e., "REMINDER" or minutes), or 2) autoScheduled is provided', }, duration: { type: ['string', 'number'], - description: 'Duration: "NONE", "REMINDER", or minutes as integer', + description: 'Duration: "NONE" (no scheduling), "REMINDER" (requires dueDate), or minutes as integer (requires dueDate). Default: "NONE"', }, status: { type: 'string', description: 'Task status (defaults to workspace default)' }, autoScheduled: { @@ -98,7 +98,7 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, }, required: ['startDate', 'deadlineType'], - description: 'Auto-scheduling configuration (null to disable)', + description: 'Auto-scheduling configuration (requires dueDate). Set to null or omit to disable auto-scheduling.', }, projectId: { type: 'string', description: 'Project ID to associate with' }, description: { @@ -155,18 +155,18 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, { name: 'motion_update_task', - description: 'Update an existing task', + description: 'Update an existing task. All fields are optional - only include fields you want to change.', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'Task ID to update' }, name: { type: 'string', description: 'New task title' }, - dueDate: { type: 'string', description: 'New due date (ISO 8601)' }, + dueDate: { type: 'string', description: 'New due date (ISO 8601). Note: Required if changing duration from "NONE" to a scheduled value' }, duration: { type: ['string', 'number'], - description: 'Duration: "NONE", "REMINDER", or minutes', + description: 'Duration: "NONE" (unscheduled), "REMINDER", or minutes. Note: Changing from "NONE" requires dueDate', }, - status: { type: 'string', description: 'New status' }, + status: { type: 'string', description: 'New status (must exist in workspace). Use to mark tasks as completed by setting a resolved status' }, priority: { type: 'string', enum: ['ASAP', 'HIGH', 'MEDIUM', 'LOW'], @@ -196,7 +196,7 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, }, required: ['startDate', 'deadlineType'], - description: 'Auto-scheduling configuration (null to disable)', + description: 'Auto-scheduling configuration. Set to null to disable auto-scheduling. Requires task to have a dueDate.', }, customFieldValues: { type: 'object', From c3b48156f64c80b492898b7077e837d34a281ee5 Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 19:42:29 +0200 Subject: [PATCH 10/15] Refactor MotionApiClient and tool descriptions for improved clarity - Cleaned up whitespace and formatting in the MotionApiClient class for better readability. - Enhanced descriptions in various tool registrations to ensure consistency and clarity. - Updated task-related tools to include new functionalities for completing and uncompleting tasks, with appropriate input schemas. - Adjusted test cases to reflect changes in task properties and ensure accurate assertions. --- src/api/client.ts | 111 +++++++++++++++++++++++---------- src/tools/customField.ts | 2 +- src/tools/project.ts | 10 ++- src/tools/recurringTask.ts | 11 +++- src/tools/schedule.ts | 2 +- src/tools/status.ts | 2 +- src/tools/task.ts | 123 ++++++++++++++++++++++++++++++++++--- src/tools/workspace.ts | 5 +- test/tools/task.test.ts | 6 +- 9 files changed, 218 insertions(+), 54 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 1ef3046..30621c5 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -68,7 +68,7 @@ export class MotionApiClient { private async request(method: string, path: string, data?: any, params?: any): Promise { return this.queue.add(async () => { let lastError: any; - + for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { const response = await this.axios.request({ @@ -80,24 +80,31 @@ export class MotionApiClient { return response.data; } catch (error: any) { lastError = error; - + // Don't retry on client errors (4xx) except rate limits - if (error.response?.status >= 400 && error.response?.status < 500 && error.response?.status !== 429) { + if ( + error.response?.status >= 400 && + error.response?.status < 500 && + error.response?.status !== 429 + ) { throw error; } - + // Retry on rate limit, network errors, or 5xx errors if (attempt < this.maxRetries - 1) { - const delay = error.response?.status === 429 - ? this.retryDelay * 2 // Double delay for rate limits - : this.retryDelay * (attempt + 1); // Exponential backoff - - console.log(`Retrying request to ${path} after ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); + const delay = + error.response?.status === 429 + ? this.retryDelay * 2 // Double delay for rate limits + : this.retryDelay * (attempt + 1); // Exponential backoff + + console.log( + `Retrying request to ${path} after ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); } } } - + throw lastError; }) as Promise; } @@ -113,7 +120,7 @@ export class MotionApiClient { // The API expects a comma-separated string, not an array queryParams.ids = params.ids.join(','); } - + const response = await this.request<{ meta: any; workspaces: MotionWorkspace[] }>( 'GET', '/workspaces', @@ -176,7 +183,10 @@ export class MotionApiClient { return this.request('DELETE', `/tasks/${taskId}`); } - async moveTask(taskId: string, params: { workspaceId: string; assigneeId?: string }): Promise { + async moveTask( + taskId: string, + params: { workspaceId: string; assigneeId?: string } + ): Promise { return this.request('PATCH', `/tasks/${taskId}/move`, params); } @@ -218,12 +228,9 @@ export class MotionApiClient { } async listUsers(workspaceId: string): Promise { - const response = await this.request<{ users: MotionUser[] }>( - 'GET', - '/users', - undefined, - { workspaceId } - ); + const response = await this.request<{ users: MotionUser[] }>('GET', '/users', undefined, { + workspaceId, + }); return response.users || []; } @@ -274,7 +281,10 @@ export class MotionApiClient { // Custom field methods async listCustomFields(workspaceId: string): Promise { - return this.request('GET', `/beta/workspaces/${workspaceId}/custom-fields`); + return this.request( + 'GET', + `/beta/workspaces/${workspaceId}/custom-fields` + ); } async createCustomField(params: { @@ -284,10 +294,18 @@ export class MotionApiClient { metadata?: any; }): Promise { const { workspaceId, ...body } = params; - return this.request('POST', `/beta/workspaces/${workspaceId}/custom-fields`, body); + return this.request( + 'POST', + `/beta/workspaces/${workspaceId}/custom-fields`, + body + ); } - async addCustomFieldToTask(taskId: string, customFieldInstanceId: string, value: any): Promise { + async addCustomFieldToTask( + taskId: string, + customFieldInstanceId: string, + value: any + ): Promise { return this.request('POST', `/beta/custom-field-values/task/${taskId}`, { customFieldInstanceId, value, @@ -306,21 +324,30 @@ export class MotionApiClient { } async removeCustomFieldFromTask(taskId: string, valueId: string): Promise { - return this.request('DELETE', `/beta/custom-field-values/task/${taskId}/custom-fields/${valueId}`); + return this.request( + 'DELETE', + `/beta/custom-field-values/task/${taskId}/custom-fields/${valueId}` + ); } async deleteCustomField(workspaceId: string, customFieldId: string): Promise { - return this.request('DELETE', `/beta/workspaces/${workspaceId}/custom-fields/${customFieldId}`); + return this.request( + 'DELETE', + `/beta/workspaces/${workspaceId}/custom-fields/${customFieldId}` + ); } async removeCustomFieldFromProject(projectId: string, valueId: string): Promise { - return this.request('DELETE', `/beta/custom-field-values/project/${projectId}/custom-fields/${valueId}`); + return this.request( + 'DELETE', + `/beta/custom-field-values/project/${projectId}/custom-fields/${valueId}` + ); } // Recurring task methods - async listRecurringTasks(params?: { - workspaceId?: string; - cursor?: string; + async listRecurringTasks(params?: { + workspaceId?: string; + cursor?: string; }): Promise> { const response = await this.request<{ meta: any; recurringTasks: MotionRecurringTask[] }>( 'GET', @@ -357,14 +384,36 @@ export class MotionApiClient { return this.request('GET', `/recurring-tasks/${recurringTaskId}`); } - async deleteRecurringTask(recurringTaskId: string): Promise { return this.request('DELETE', `/recurring-tasks/${recurringTaskId}`); } // Status methods async listStatuses(workspaceId?: string): Promise { - const params = workspaceId ? { workspaceId } : undefined; - return this.request('GET', '/statuses', undefined, params); + // The /statuses endpoint requires a workspaceId parameter + // If no workspaceId is provided, we get statuses from all workspaces + if (workspaceId) { + const params = { workspaceId }; + return this.request('GET', '/statuses', undefined, params); + } + + // Get all workspaces and collect their unique statuses + const response = await this.listWorkspaces(); + const allStatuses: MotionStatus[] = []; + const statusNames = new Set(); + + for (const workspace of response.workspaces) { + if (workspace.taskStatuses) { + for (const status of workspace.taskStatuses) { + // Use status name as unique identifier since statuses don't have IDs + if (!statusNames.has(status.name)) { + statusNames.add(status.name); + allStatuses.push(status); + } + } + } + } + + return allStatuses; } } diff --git a/src/tools/customField.ts b/src/tools/customField.ts index b71b68f..24accfd 100644 --- a/src/tools/customField.ts +++ b/src/tools/customField.ts @@ -209,4 +209,4 @@ export function registerCustomFieldTools(client: MotionApiClient): Tool[] { }, }, ]; -} \ No newline at end of file +} diff --git a/src/tools/project.ts b/src/tools/project.ts index 4f07fa5..f62b55f 100644 --- a/src/tools/project.ts +++ b/src/tools/project.ts @@ -55,8 +55,14 @@ export function registerProjectTools(client: MotionApiClient): Tool[] { properties: { name: { type: 'string', description: 'Project name' }, workspaceId: { type: 'string', description: 'Workspace ID' }, - description: { type: 'string', description: 'Project description (supports HTML/Markdown)' }, - status: { type: 'string', description: 'Initial project status (must be valid for workspace)' }, + description: { + type: 'string', + description: 'Project description (supports HTML/Markdown)', + }, + status: { + type: 'string', + description: 'Initial project status (must be valid for workspace)', + }, customFieldValues: { type: 'object', description: 'Custom field values as key-value pairs', diff --git a/src/tools/recurringTask.ts b/src/tools/recurringTask.ts index 50eef30..6fdef96 100644 --- a/src/tools/recurringTask.ts +++ b/src/tools/recurringTask.ts @@ -6,7 +6,8 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { return [ { name: 'motion_list_recurring_tasks', - description: 'List all recurring tasks for a specific workspace. Supports pagination via cursor.', + description: + 'List all recurring tasks for a specific workspace. Supports pagination via cursor.', inputSchema: { type: 'object', properties: { @@ -41,7 +42,8 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { workspaceId: { type: 'string', description: 'Workspace ID' }, frequency: { type: 'string', - description: 'Recurrence frequency (e.g., DAILY, WEEKLY_MONDAY, MONTHLY_1, MONTHLY_LAST)', + description: + 'Recurrence frequency (e.g., DAILY, WEEKLY_MONDAY, MONTHLY_1, MONTHLY_LAST)', }, recurrenceRule: { type: 'string', description: 'Custom recurrence rule (optional)' }, duration: { @@ -56,7 +58,10 @@ export function registerRecurringTaskTools(client: MotionApiClient): Tool[] { enum: ['HARD', 'SOFT'], description: 'Deadline type (default: SOFT)', }, - startingOn: { type: 'string', description: 'ISO 8601 date when to start generating tasks' }, + startingOn: { + type: 'string', + description: 'ISO 8601 date when to start generating tasks', + }, idealTime: { type: 'string', description: 'Preferred time of day (HH:mm format)' }, schedule: { type: 'string', description: 'Schedule name (default: "Work Hours")' }, priority: { diff --git a/src/tools/schedule.ts b/src/tools/schedule.ts index ef8f196..287536f 100644 --- a/src/tools/schedule.ts +++ b/src/tools/schedule.ts @@ -56,4 +56,4 @@ export function registerScheduleTools(client: MotionApiClient): Tool[] { }, }, ]; -} \ No newline at end of file +} diff --git a/src/tools/status.ts b/src/tools/status.ts index c893cdf..0012fd5 100644 --- a/src/tools/status.ts +++ b/src/tools/status.ts @@ -29,4 +29,4 @@ export function registerStatusTools(client: MotionApiClient): Tool[] { }, }, ]; -} \ No newline at end of file +} diff --git a/src/tools/task.ts b/src/tools/task.ts index 14fdb1a..d3e5ccf 100644 --- a/src/tools/task.ts +++ b/src/tools/task.ts @@ -1,6 +1,7 @@ import { MotionApiClient } from '../api/client.js'; import { z } from 'zod'; import { Tool } from '../types/tool.js'; +import { MotionStatus } from '../types/motion.js'; export function registerTaskTools(client: MotionApiClient): Tool[] { return [ @@ -68,7 +69,8 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, { name: 'motion_create_task', - description: 'Create a new task in Motion. Note: dueDate is required when duration is not "NONE" or when using autoScheduled.', + description: + 'Create a new task in Motion. Note: dueDate is required when duration is not "NONE" or when using autoScheduled.', inputSchema: { type: 'object', properties: { @@ -76,11 +78,13 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { workspaceId: { type: 'string', description: 'Workspace ID' }, dueDate: { type: 'string', - description: 'ISO 8601 due date. REQUIRED when: 1) duration is not "NONE" (i.e., "REMINDER" or minutes), or 2) autoScheduled is provided', + description: + 'ISO 8601 due date. REQUIRED when: 1) duration is not "NONE" (i.e., "REMINDER" or minutes), or 2) autoScheduled is provided', }, duration: { type: ['string', 'number'], - description: 'Duration: "NONE" (no scheduling), "REMINDER" (requires dueDate), or minutes as integer (requires dueDate). Default: "NONE"', + description: + 'Duration: "NONE" (no scheduling), "REMINDER" (requires dueDate), or minutes as integer (requires dueDate). Default: "NONE"', }, status: { type: 'string', description: 'Task status (defaults to workspace default)' }, autoScheduled: { @@ -98,7 +102,8 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, }, required: ['startDate', 'deadlineType'], - description: 'Auto-scheduling configuration (requires dueDate). Set to null or omit to disable auto-scheduling.', + description: + 'Auto-scheduling configuration (requires dueDate). Set to null or omit to disable auto-scheduling.', }, projectId: { type: 'string', description: 'Project ID to associate with' }, description: { @@ -155,18 +160,28 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, { name: 'motion_update_task', - description: 'Update an existing task. All fields are optional - only include fields you want to change.', + description: + 'Update an existing task. All fields are optional - only include fields you want to change.', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'Task ID to update' }, name: { type: 'string', description: 'New task title' }, - dueDate: { type: 'string', description: 'New due date (ISO 8601). Note: Required if changing duration from "NONE" to a scheduled value' }, + dueDate: { + type: 'string', + description: + 'New due date (ISO 8601). Note: Required if changing duration from "NONE" to a scheduled value', + }, duration: { type: ['string', 'number'], - description: 'Duration: "NONE" (unscheduled), "REMINDER", or minutes. Note: Changing from "NONE" requires dueDate', + description: + 'Duration: "NONE" (unscheduled), "REMINDER", or minutes. Note: Changing from "NONE" requires dueDate', + }, + status: { + type: 'string', + description: + 'New status (must exist in workspace). Use to mark tasks as completed by setting a resolved status', }, - status: { type: 'string', description: 'New status (must exist in workspace). Use to mark tasks as completed by setting a resolved status' }, priority: { type: 'string', enum: ['ASAP', 'HIGH', 'MEDIUM', 'LOW'], @@ -196,7 +211,8 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { }, }, required: ['startDate', 'deadlineType'], - description: 'Auto-scheduling configuration. Set to null to disable auto-scheduling. Requires task to have a dueDate.', + description: + 'Auto-scheduling configuration. Set to null to disable auto-scheduling. Requires task to have a dueDate.', }, customFieldValues: { type: 'object', @@ -298,5 +314,92 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { return await client.unassignTask(validated.taskId); }, }, + { + name: 'motion_complete_task', + description: 'Mark a task as completed by setting its status to a resolved status', + inputSchema: { + type: 'object', + properties: { + taskId: { type: 'string', description: 'Task ID to complete' }, + status: { + type: 'string', + description: + 'Resolved status name (optional, uses first resolved status if not provided)', + }, + }, + required: ['taskId'], + }, + handler: async (args: unknown) => { + const schema = z.object({ + taskId: z.string().min(1), + status: z.string().optional(), + }); + + const validated = schema.parse(args); + + // If no status provided, we need to find a resolved status + let statusName = validated.status; + if (!statusName) { + // Get the task to find its workspace + const task = await client.getTask(validated.taskId); + // Get statuses for the workspace + const statuses = await client.listStatuses(task.workspace.id); + const resolvedStatus = statuses.find((s: MotionStatus) => s.isResolvedStatus); + if (!resolvedStatus) { + throw new Error('No resolved status found in workspace'); + } + statusName = resolvedStatus.name; + } + + return await client.updateTask(validated.taskId, { status: statusName }); + }, + }, + { + name: 'motion_uncomplete_task', + description: 'Mark a task as not completed by setting its status to an unresolved status', + inputSchema: { + type: 'object', + properties: { + taskId: { type: 'string', description: 'Task ID to uncomplete' }, + status: { + type: 'string', + description: 'Unresolved status name (optional, uses default status if not provided)', + }, + }, + required: ['taskId'], + }, + handler: async (args: unknown) => { + const schema = z.object({ + taskId: z.string().min(1), + status: z.string().optional(), + }); + + const validated = schema.parse(args); + + // If no status provided, we need to find an unresolved status + let statusName = validated.status; + if (!statusName) { + // Get the task to find its workspace + const task = await client.getTask(validated.taskId); + // Get statuses for the workspace + const statuses = await client.listStatuses(task.workspace.id); + const unresolvedStatus = statuses.find( + (s: MotionStatus) => !s.isResolvedStatus && s.isDefaultStatus + ); + if (!unresolvedStatus) { + // Fallback to any unresolved status + const anyUnresolvedStatus = statuses.find((s: MotionStatus) => !s.isResolvedStatus); + if (!anyUnresolvedStatus) { + throw new Error('No unresolved status found in workspace'); + } + statusName = anyUnresolvedStatus.name; + } else { + statusName = unresolvedStatus.name; + } + } + + return await client.updateTask(validated.taskId, { status: statusName }); + }, + }, ]; -} \ No newline at end of file +} diff --git a/src/tools/workspace.ts b/src/tools/workspace.ts index 60b6794..9627976 100644 --- a/src/tools/workspace.ts +++ b/src/tools/workspace.ts @@ -6,7 +6,8 @@ export function registerWorkspaceTools(client: MotionApiClient): Tool[] { return [ { name: 'motion_list_workspaces', - description: 'List all workspaces accessible to the authenticated user. Supports pagination and filtering by IDs.', + description: + 'List all workspaces accessible to the authenticated user. Supports pagination and filtering by IDs.', inputSchema: { type: 'object', properties: { @@ -27,7 +28,7 @@ export function registerWorkspaceTools(client: MotionApiClient): Tool[] { const validated = schema.parse(args); const response = await client.listWorkspaces(validated); - + return { workspaces: response.workspaces, meta: response.meta, diff --git a/test/tools/task.test.ts b/test/tools/task.test.ts index 565fe05..f88edaf 100644 --- a/test/tools/task.test.ts +++ b/test/tools/task.test.ts @@ -67,9 +67,9 @@ test('Task Tools', async (t) => { }, (res) => { assertHasProperty(res, 'id'); assertHasProperty(res, 'name'); - assertHasProperty(res, 'workspaceId'); + assertHasProperty(res, 'workspace'); assert.strictEqual(res.name, taskName); - assert.strictEqual(res.workspaceId, testWorkspaceId); + assert.strictEqual(res.workspace.id, testWorkspaceId); testTaskId = res.id; }); @@ -95,7 +95,7 @@ test('Task Tools', async (t) => { const result = await testTool(getTool, { taskId: testTaskId }, (res) => { assertHasProperty(res, 'id'); assertHasProperty(res, 'name'); - assertHasProperty(res, 'workspaceId'); + assertHasProperty(res, 'workspace'); assertHasProperty(res, 'status'); assert.strictEqual(res.id, testTaskId); }); From 000f551bbb4b60e52326d7aa655800e5132c68b8 Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 19:45:45 +0200 Subject: [PATCH 11/15] Enhance MotionApiClient to filter out includeAllStatuses parameter - Added logic to filter out the includeAllStatuses parameter when it is set to false in the MotionApiClient's task retrieval method. - Improved handling of request parameters to ensure cleaner API calls. --- src/api/client.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/api/client.ts b/src/api/client.ts index 30621c5..1c5eb3c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -155,11 +155,17 @@ export class MotionApiClient { status?: string[]; workspaceId?: string; }): Promise> { + // Filter out includeAllStatuses if it's false + const filteredParams = params ? { ...params } : undefined; + if (filteredParams && filteredParams.includeAllStatuses === false) { + delete filteredParams.includeAllStatuses; + } + const response = await this.request<{ meta: any; tasks: MotionTask[] }>( 'GET', '/tasks', undefined, - params + filteredParams ); return { meta: response.meta, From 1bcf9bc30e7a4c34b97dca8f21e8e0d2f836afa5 Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 19:47:22 +0200 Subject: [PATCH 12/15] Silence console output during retries in MotionApiClient to prevent MCP protocol corruption - Removed console log statements that output retry information when handling rate limits. - Implemented silent retry mechanism to maintain the integrity of the MCP protocol during API request retries. --- src/api/client.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 1c5eb3c..f0ad4ab 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -97,9 +97,7 @@ export class MotionApiClient { ? this.retryDelay * 2 // Double delay for rate limits : this.retryDelay * (attempt + 1); // Exponential backoff - console.log( - `Retrying request to ${path} after ${delay}ms (attempt ${attempt + 1}/${this.maxRetries})` - ); + // Silent retry - no console output to avoid corrupting MCP protocol await new Promise((resolve) => setTimeout(resolve, delay)); } } From b97094ecdb769696fbe412e8fc42466bba8cb3da Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 19:56:06 +0200 Subject: [PATCH 13/15] Update README to clarify installation procedures for Claude Desktop and Claude Code applications - Added sections distinguishing between Claude Desktop and Claude Code, outlining their respective installation methods. - Included detailed steps for configuring the Motion MCP server for both applications. - Enhanced troubleshooting tips for common issues related to API key configuration and application setup. --- README.md | 134 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 101 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index bcbb862..81023bb 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,15 @@ [![MCP Version](https://img.shields.io/badge/MCP-1.0.4-blue)](https://modelcontextprotocol.io) [![Node Version](https://img.shields.io/badge/node-%3E%3D20.0.0-green)](https://nodejs.org) +## Claude Desktop vs Claude Code + +**Important**: There are two different Claude applications that can use this MCP server: + +- **🖥️ Claude Desktop**: The desktop application you download and install on your computer +- **⌨️ Claude Code**: A command-line interface (CLI) tool for developers + +Each has different installation procedures - make sure you follow the correct instructions for your application! + ## Quick Start ### Step 1: Get Your Motion API Key @@ -17,24 +26,37 @@ 3. Create a new API key 4. Copy the key immediately (it's only shown once!) -### Step 2: Install the MCP Server +### Step 2: Choose Your Claude Application + +**Important**: This MCP server works with both Claude applications, but they have different installation methods: + +- **Claude Desktop** (Desktop App): Requires manual JSON configuration +- **Claude Code** (CLI Tool): Has built-in MCP installation commands + +Choose the installation method below based on which Claude application you're using. + +--- + +## Installation for Claude Desktop (Desktop App) -Run this command to add the Motion MCP server to Claude: +### Step 1: Install via NPX (Recommended) + +Run this command to add the Motion MCP server to Claude Desktop: ```bash -claude mcp add motion npx -- -y @rf-d/motion-mcp +npx @rf-d/motion-mcp --setup-claude-desktop ``` -### Step 3: Add Your API Key +If the above command doesn't work, follow the manual installation steps below. -The above command creates the configuration, but you need to manually add your API key: +### Step 2: Manual Installation -1. Open your Claude configuration file: +1. Find your Claude Desktop configuration file: - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - **Linux**: `~/.config/claude/claude_desktop_config.json` -2. Find the `motion` entry that was just added and update it to include your API key: +2. Add the following to your configuration file: ```json { @@ -43,20 +65,61 @@ The above command creates the configuration, but you need to manually add your A "command": "npx", "args": ["-y", "@rf-d/motion-mcp"], "env": { - "MOTION_API_KEY": "your_motion_api_key_here" + "MOTION_API_KEY": "your_motion_api_key_here", + "MOTION_RATE_LIMIT_PER_MINUTE": "12" } } } } ``` -3. Save the file and restart Claude Desktop +3. Replace `your_motion_api_key_here` with your actual Motion API key +4. Restart Claude Desktop for the changes to take effect + +--- + +## Installation for Claude Code (CLI Tool) + +### Step 1: Install Claude Code CLI + +First, install the Claude Code CLI if you haven't already: + +```bash +npm install -g @anthropic-ai/claude-code +``` + +### Step 2: Add Motion MCP Server -### Alternative Installation Methods +Run this command to add the Motion MCP server to Claude Code: -#### Via GitHub (Development) ```bash -git clone https://github.com/RF-D/motion-mcp.git +claude mcp add motion npx -- -y @rf-d/motion-mcp +``` + +### Step 3: Configure Your API Key + +Set your Motion API key as an environment variable: + +```bash +export MOTION_API_KEY="your_motion_api_key_here" +``` + +Or create a `.env` file in your project directory: + +```bash +echo "MOTION_API_KEY=your_motion_api_key_here" > .env +``` + +--- + +## Alternative Installation Methods + +### Via GitHub (Development) + +For development installation, we recommend using [mjaskolski's fork](https://github.com/mjaskolski/motion-mcp) which is up-to-date, has fewer errors, full documentation, and comprehensive tests: + +```bash +git clone https://github.com/mjaskolski/motion-mcp.git cd motion-mcp npm install npm run build @@ -68,7 +131,7 @@ echo "MOTION_API_KEY=your_motion_api_key_here" > .env npm run dev ``` -**For development version, add this to your `claude_desktop_config.json` configuration:** +**For Claude Desktop (development version):** ```json { @@ -90,26 +153,14 @@ npm run dev } ``` -Replace `/path/to/your/motion-mcp` with the actual path to your cloned repository. - -#### Manual Configuration -If you prefer to manually configure without using the CLI, add this to your `claude_desktop_config.json`: +**For Claude Code (development version):** -```json -{ - "mcpServers": { - "motion": { - "command": "npx", - "args": ["-y", "@rf-d/motion-mcp"], - "env": { - "MOTION_API_KEY": "your_motion_api_key_here", - "MOTION_RATE_LIMIT_PER_MINUTE": "12" // 12 for individual, 120 for teams - } - } - } -} +```bash +claude mcp add motion-dev npm -- --prefix /path/to/your/motion-mcp --silent run start ``` +Replace `/path/to/your/motion-mcp` with the actual path to your cloned repository. + ## Features ### Core Capabilities @@ -265,9 +316,10 @@ This is an unofficial integration and is not affiliated with, officially maintai ### Common Issues **"Motion API key not found" error** -- Make sure you've added the `MOTION_API_KEY` to your claude_desktop_config.json configuration +- **Claude Desktop**: Make sure you've added the `MOTION_API_KEY` to your `claude_desktop_config.json` configuration +- **Claude Code**: Ensure your API key is set as an environment variable or in a `.env` file - Verify the key is correct and hasn't expired -- Restart Claude Desktop after adding the key +- Restart the application after adding the key **"Rate limit exceeded" error** - Individual accounts are limited to 12 requests per minute @@ -276,7 +328,18 @@ This is an unofficial integration and is not affiliated with, officially maintai **"Command not found" error** - Make sure you have Node.js >= 20.0.0 installed -- Try running with the full path: `npx @rf-d/motion-mcp` +- **Claude Desktop**: Try running with the full path: `npx @rf-d/motion-mcp` +- **Claude Code**: Ensure the Claude Code CLI is installed: `npm install -g @anthropic-ai/claude-code` + +**Claude Desktop configuration file not found** +- The configuration file is created automatically when you first run Claude Desktop +- If it doesn't exist, create it manually at the correct location for your operating system +- Ensure the JSON syntax is valid (no trailing commas, proper brackets) + +**Claude Code MCP server not loading** +- Run `claude mcp list` to see if the server is properly registered +- Check that your API key environment variable is set correctly +- Try removing and re-adding the server: `claude mcp remove motion && claude mcp add motion npx -- -y @rf-d/motion-mcp` ## Support @@ -284,6 +347,11 @@ This is an unofficial integration and is not affiliated with, officially maintai - **Discussions**: [GitHub Discussions](https://github.com/RF-D/motion-mcp/discussions) - **NPM Package**: [npmjs.com/package/@rf-d/motion-mcp](https://www.npmjs.com/package/@rf-d/motion-mcp) +### Need Help Identifying Your Claude Application? + +- **Claude Desktop**: If you downloaded and installed a desktop app from [claude.ai](https://claude.ai) +- **Claude Code**: If you installed it with `npm install -g @anthropic-ai/claude-code` and run it with the `claude` command + --- Built with love for the Motion and MCP communities \ No newline at end of file From c9fae00a57231e2891021e89b24dd33fa2e7f28d Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 19:58:05 +0200 Subject: [PATCH 14/15] Refactor installation instructions in README for Claude Desktop - Removed the NPX installation step and consolidated it into a manual configuration section. - Updated the instructions to streamline the setup process for users. --- README.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index 81023bb..545dbf8 100644 --- a/README.md +++ b/README.md @@ -39,17 +39,7 @@ Choose the installation method below based on which Claude application you're us ## Installation for Claude Desktop (Desktop App) -### Step 1: Install via NPX (Recommended) - -Run this command to add the Motion MCP server to Claude Desktop: - -```bash -npx @rf-d/motion-mcp --setup-claude-desktop -``` - -If the above command doesn't work, follow the manual installation steps below. - -### Step 2: Manual Installation +### Manual Configuration 1. Find your Claude Desktop configuration file: - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` From 2212b71e68082b20a69d5a0bbbe1b24960b24c42 Mon Sep 17 00:00:00 2001 From: Michal Jaskolski Date: Tue, 15 Jul 2025 20:07:58 +0200 Subject: [PATCH 15/15] Update task input schema to make deadlineType mandatory - Changed the deadlineType field in task registration from optional to mandatory in the input schema. - Ensured consistency in task definitions across the application. --- src/tools/task.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/task.ts b/src/tools/task.ts index d3e5ccf..38f11ba 100644 --- a/src/tools/task.ts +++ b/src/tools/task.ts @@ -140,7 +140,7 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { .union([ z.object({ startDate: z.string(), - deadlineType: z.enum(['HARD', 'SOFT', 'NONE']).optional(), + deadlineType: z.enum(['HARD', 'SOFT', 'NONE']), schedule: z.string().optional(), }), z.null(), @@ -239,7 +239,7 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { .union([ z.object({ startDate: z.string(), - deadlineType: z.enum(['HARD', 'SOFT', 'NONE']).optional(), + deadlineType: z.enum(['HARD', 'SOFT', 'NONE']), schedule: z.string().optional(), }), z.null(),