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/.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..47c9bd8 --- /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 34 tools across 9 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 diff --git a/README.md b/README.md index 27b3a92..545dbf8 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,27 @@ 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 -Run this command to add the Motion MCP server to Claude: +**Important**: This MCP server works with both Claude applications, but they have different installation methods: -```bash -claude mcp add motion npx -- -y @rf-d/motion-mcp -``` +- **Claude Desktop** (Desktop App): Requires manual JSON configuration +- **Claude Code** (CLI Tool): Has built-in MCP installation commands -### Step 3: Add Your API Key +Choose the installation method below based on which Claude application you're using. -The above command creates the configuration, but you need to manually add your API key: +--- + +## Installation for Claude Desktop (Desktop App) + +### Manual Configuration -1. Open your Claude configuration file: - - **macOS**: `~/Library/Application Support/Claude/claude.json` - - **Windows**: `%APPDATA%\Claude\claude.json` - - **Linux**: `~/.config/claude/claude.json` +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 +55,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,30 +121,42 @@ echo "MOTION_API_KEY=your_motion_api_key_here" > .env npm run dev ``` -#### Manual Configuration -If you prefer to manually configure without using the CLI, add this to your `claude.json`: +**For Claude Desktop (development version):** ```json { "mcpServers": { "motion": { - "command": "npx", - "args": ["-y", "@rf-d/motion-mcp"], + "command": "npm", + "args": [ + "--prefix", + "/path/to/your/motion-mcp", + "--silent", + "run", + "start" + ], "env": { - "MOTION_API_KEY": "your_motion_api_key_here", - "MOTION_RATE_LIMIT_PER_MINUTE": "12" // 12 for individual, 120 for teams + "MOTION_API_KEY": "YOUR_API_KEY" } } } } ``` +**For Claude Code (development version):** + +```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 - **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 @@ -108,33 +173,32 @@ 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 (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 (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 @@ -242,9 +306,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.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 @@ -253,7 +318,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 @@ -261,6 +337,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 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/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 diff --git a/package-lock.json b/package-lock.json index 0e9ed28..ec6b7b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,25 @@ { - "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": { "@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" }, + "bin": { + "motion-mcp": "dist/index.js" + }, "devDependencies": { "@types/json-schema": "^7.0.15", "@types/node": "^22.10.2", @@ -643,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", @@ -1171,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", @@ -1670,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", @@ -1799,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", @@ -2337,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", @@ -3026,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", @@ -3053,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 25fd2c8..7a6caf0 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", @@ -46,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 8b38122..f0ad4ab 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,79 @@ 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 + + // Silent retry - no console output to avoid corrupting MCP protocol + 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 @@ -92,11 +153,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, @@ -120,14 +187,26 @@ 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 { @@ -138,16 +217,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 { @@ -158,9 +231,11 @@ 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 +247,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 +284,85 @@ 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 +373,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); } @@ -258,23 +388,36 @@ 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}`); } // Status methods - async getStatus(statusId: string): Promise { - return this.request('GET', `/statuses/${statusId}`); + async listStatuses(workspaceId?: string): Promise { + // 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/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..24accfd 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' }; + }, + }, ]; } diff --git a/src/tools/project.ts b/src/tools/project.ts index 9156e15..f62b55f 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,19 @@ 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,64 +77,14 @@ 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); 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' }, - }, - 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(), - }); - - 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; - - 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/src/tools/recurringTask.ts b/src/tools/recurringTask.ts index 7db85a0..6fdef96 100644 --- a/src/tools/recurringTask.ts +++ b/src/tools/recurringTask.ts @@ -6,31 +6,35 @@ 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, }; }, }, { 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: { @@ -38,8 +42,8 @@ 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 +52,47 @@ 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); @@ -87,55 +118,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', - enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], - description: 'New frequency', - }, - 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.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']).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/schedule.ts b/src/tools/schedule.ts index df6aeed..287536f 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, + }; + }, + }, ]; } diff --git a/src/tools/status.ts b/src/tools/status.ts new file mode 100644 index 0000000..0012fd5 --- /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, + }; + }, + }, + ]; +} diff --git a/src/tools/task.ts b/src/tools/task.ts index ee7b2e9..38f11ba 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', + 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 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: { @@ -97,7 +101,9 @@ export function registerTaskTools(client: MotionApiClient): Tool[] { description: 'Schedule name (must be "Work Hours" for other users)', }, }, - description: 'Auto-scheduling configuration (null to disable)', + required: ['startDate', 'deadlineType'], + 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: { @@ -115,6 +121,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'], }, @@ -129,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(), @@ -140,6 +151,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); @@ -148,31 +160,65 @@ 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 (must exist in workspace). Use to mark tasks as completed by setting a resolved status', }, - status: { type: 'string', description: 'New status' }, priority: { type: 'string', enum: ['ASAP', 'HIGH', 'MEDIUM', 'LOW'], 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', 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', + }, + }, + required: ['startDate', 'deadlineType'], + description: + 'Auto-scheduling configuration. Set to null to disable auto-scheduling. Requires task to have a dueDate.', + }, + customFieldValues: { + type: 'object', + description: 'Custom field values as key-value pairs', + additionalProperties: true, + }, }, required: ['taskId'], }, @@ -185,9 +231,21 @@ 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(), + projectId: z.string().optional(), + autoScheduled: z + .union([ + z.object({ + startDate: z.string(), + deadlineType: z.enum(['HARD', 'SOFT', 'NONE']), + schedule: z.string().optional(), + }), + z.null(), + ]) + .optional(), + customFieldValues: z.record(z.any()).optional(), }); const { taskId, ...updateParams } = schema.parse(args); @@ -216,61 +274,131 @@ 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); }, }, { name: 'motion_complete_task', - description: 'Mark a task as completed', + 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); - return await client.updateTask(validated.taskId, { completed: true }); + + // 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', + 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); - return await client.updateTask(validated.taskId, { completed: false }); + + // 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 }); }, }, ]; 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..9627976 100644 --- a/src/tools/workspace.ts +++ b/src/tools/workspace.ts @@ -6,17 +6,33 @@ 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..b47ec08 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 { @@ -173,9 +209,16 @@ export interface MotionTaskUpdateParams { status?: string; priority?: 'ASAP' | 'HIGH' | 'MEDIUM' | 'LOW'; description?: string; - 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 +226,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..8453261 --- /dev/null +++ b/test/tools/project.test.ts @@ -0,0 +1,131 @@ +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 3 project tools', () => { + assert.strictEqual(tools.length, 3); + const expectedTools = [ + 'motion_list_projects', + 'motion_get_project', + 'motion_create_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' + } + ); + }); + }); + + // 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 { + // Projects cannot be deleted via API + 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..f88edaf --- /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, 'workspace'); + assert.strictEqual(res.name, taskName); + assert.strictEqual(res.workspace.id, 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, 'workspace'); + 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