diff --git a/.github/hooks/session-logger/README.md b/.github/hooks/session-logger/README.md new file mode 100644 index 0000000..a31a7a0 --- /dev/null +++ b/.github/hooks/session-logger/README.md @@ -0,0 +1,58 @@ +--- +name: 'Session Logger' +description: 'Logs all Copilot coding agent session activity for audit and analysis' +tags: ['logging', 'audit', 'analytics'] +--- + +# Session Logger Hook + +Comprehensive logging for GitHub Copilot coding agent sessions, tracking session starts, ends, and user prompts for audit trails and usage analytics. + +## Overview + +This hook provides detailed logging of Copilot coding agent activity: +- Session start/end times with working directory context +- User prompt submission events +- Configurable log levels + +## Features + +- **Session Tracking**: Log session start and end events +- **Prompt Logging**: Record when user prompts are submitted +- **Structured Logging**: JSON format for easy parsing +- **Privacy Aware**: Configurable to disable logging entirely + +## Installation + +1. Copy this hook folder to your repository's `.github/hooks/` directory: + ```bash + cp -r hooks/session-logger .github/hooks/ + ``` + +2. Create the logs directory: + ```bash + mkdir -p logs/copilot + ``` + +3. Ensure scripts are executable: + ```bash + chmod +x .github/hooks/session-logger/*.sh + ``` + +4. Commit the hook configuration to your repository's default branch + +## Log Format + +Session events are written to `logs/copilot/session.log` and prompt events to `logs/copilot/prompts.log` in JSON format: + +```json +{"timestamp":"2024-01-15T10:30:00Z","event":"sessionStart","cwd":"/workspace/project"} +{"timestamp":"2024-01-15T10:35:00Z","event":"sessionEnd"} +``` + +## Privacy & Security + +- Add `logs/` to `.gitignore` to avoid committing session data +- Use `LOG_LEVEL=ERROR` to only log errors +- Set `SKIP_LOGGING=true` environment variable to disable +- Logs are stored locally only \ No newline at end of file diff --git a/.github/hooks/session-logger/hooks.json b/.github/hooks/session-logger/hooks.json new file mode 100644 index 0000000..f019a4f --- /dev/null +++ b/.github/hooks/session-logger/hooks.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": ".github/hooks/session-logger/log-session-start.sh", + "cwd": ".", + "timeoutSec": 5 + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": ".github/hooks/session-logger/log-session-end.sh", + "cwd": ".", + "timeoutSec": 5 + } + ], + "userPromptSubmitted": [ + { + "type": "command", + "bash": ".github/hooks/session-logger/log-prompt.sh", + "cwd": ".", + "env": { + "LOG_LEVEL": "INFO" + }, + "timeoutSec": 5 + } + ] + } +} \ No newline at end of file diff --git a/.github/hooks/session-logger/log-prompt.sh b/.github/hooks/session-logger/log-prompt.sh new file mode 100644 index 0000000..5875bfb --- /dev/null +++ b/.github/hooks/session-logger/log-prompt.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Log session end event + +set -euo pipefail + +# Skip if logging disabled +if [[ "${SKIP_LOGGING:-}" == "true" ]]; then + exit 0 +fi + +# Read input from Copilot +INPUT=$(cat) + +# Create logs directory if it doesn't exist +mkdir -p logs/copilot + +# Extract timestamp +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Log session end +echo "{\"timestamp\":\"$TIMESTAMP\",\"event\":\"sessionEnd\"}" >> logs/copilot/session.log + +echo "📝 Session end logged" +exit 0 \ No newline at end of file diff --git a/.github/hooks/session-logger/log-session-end.sh b/.github/hooks/session-logger/log-session-end.sh new file mode 100644 index 0000000..5875bfb --- /dev/null +++ b/.github/hooks/session-logger/log-session-end.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Log session end event + +set -euo pipefail + +# Skip if logging disabled +if [[ "${SKIP_LOGGING:-}" == "true" ]]; then + exit 0 +fi + +# Read input from Copilot +INPUT=$(cat) + +# Create logs directory if it doesn't exist +mkdir -p logs/copilot + +# Extract timestamp +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Log session end +echo "{\"timestamp\":\"$TIMESTAMP\",\"event\":\"sessionEnd\"}" >> logs/copilot/session.log + +echo "📝 Session end logged" +exit 0 \ No newline at end of file diff --git a/.github/hooks/session-logger/log-session-start.sh b/.github/hooks/session-logger/log-session-start.sh new file mode 100644 index 0000000..11c4109 --- /dev/null +++ b/.github/hooks/session-logger/log-session-start.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Log session start event + +set -euo pipefail + +# Skip if logging disabled +if [[ "${SKIP_LOGGING:-}" == "true" ]]; then + exit 0 +fi + +# Read input from Copilot +INPUT=$(cat) + +# Create logs directory if it doesn't exist +mkdir -p logs/copilot + +# Extract timestamp and session info +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +CWD=$(pwd) + +# Log session start (use jq for proper JSON encoding) +jq -Rn --arg timestamp "$TIMESTAMP" --arg cwd "$CWD" '{"timestamp":$timestamp,"event":"sessionStart","cwd":$cwd}' >> logs/copilot/session.log + +echo "📝 Session logged" +exit 0 \ No newline at end of file diff --git a/.github/skills/context-map/SKILL.md b/.github/skills/context-map/SKILL.md new file mode 100644 index 0000000..45b5f07 --- /dev/null +++ b/.github/skills/context-map/SKILL.md @@ -0,0 +1,52 @@ +--- +name: context-map +description: 'Generate a map of all files relevant to a task before making changes' +--- + +# Context Map + +Before implementing any changes, analyze the codebase and create a context map. + +## Task + +{{task_description}} + +## Instructions + +1. Search the codebase for files related to this task +2. Identify direct dependencies (imports/exports) +3. Find related tests +4. Look for similar patterns in existing code + +## Output Format + +```markdown +## Context Map + +### Files to Modify +| File | Purpose | Changes Needed | +|------|---------|----------------| +| path/to/file | description | what changes | + +### Dependencies (may need updates) +| File | Relationship | +|------|--------------| +| path/to/dep | imports X from modified file | + +### Test Files +| Test | Coverage | +|------|----------| +| path/to/test | tests affected functionality | + +### Reference Patterns +| File | Pattern | +|------|---------| +| path/to/similar | example to follow | + +### Risk Assessment +- [ ] Breaking changes to public API +- [ ] Database migrations needed +- [ ] Configuration changes required +``` + +Do not proceed with implementation until this map is reviewed. \ No newline at end of file diff --git a/.github/skills/conventional-commit/SKILL.md b/.github/skills/conventional-commit/SKILL.md new file mode 100644 index 0000000..3465092 --- /dev/null +++ b/.github/skills/conventional-commit/SKILL.md @@ -0,0 +1,72 @@ +--- +name: conventional-commit +description: 'Prompt and workflow for generating conventional commit messages using a structured XML format. Guides users to create standardized, descriptive commit messages in line with the Conventional Commits specification, including instructions, examples, and validation.' +--- + +### Instructions + +```xml + This file contains a prompt template for generating conventional commit messages. It provides instructions, examples, and formatting guidelines to help users write standardized, descriptive commit messages in accordance with the Conventional Commits specification. +``` + +### Workflow + +**Follow these steps:** + +1. Run `git status` to review changed files. +2. Run `git diff` or `git diff --cached` to inspect changes. +3. Stage your changes with `git add `. +4. Construct your commit message using the following XML structure. +5. After generating your commit message, Copilot will automatically run the following command in your integrated terminal (no confirmation needed): + +```bash +git commit -m "type(scope): description" +``` + +6. Just execute this prompt and Copilot will handle the commit for you in the terminal. + +### Commit Message Structure + +```xml + + feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert + () + A short, imperative summary of the change + (optional: more detailed explanation) +
(optional: e.g. BREAKING CHANGE: details, or issue references)
+
+``` + +### Examples + +```xml + + feat(parser): add ability to parse arrays + fix(ui): correct button alignment + docs: update README with usage instructions + refactor: improve performance of data processing + chore: update dependencies + feat!: send email on registration (BREAKING CHANGE: email service required) + +``` + +### Validation + +```xml + + Must be one of the allowed types. See https://www.conventionalcommits.org/en/v1.0.0/#specification + Optional, but recommended for clarity. + Required. Use the imperative mood (e.g., "add", not "added"). + Optional. Use for additional context. +
Use for breaking changes or issue references.
+
+``` + +### Final Step + +```xml + + git commit -m "type(scope): description" + Replace with your constructed message. Include body and footer if needed. + +``` \ No newline at end of file diff --git a/.github/skills/github-issues/SKILL.md b/.github/skills/github-issues/SKILL.md new file mode 100644 index 0000000..4619bac --- /dev/null +++ b/.github/skills/github-issues/SKILL.md @@ -0,0 +1,201 @@ +--- +name: github-issues +description: 'Create, update, and manage GitHub issues using MCP tools. Use this skill when users want to create bug reports, feature requests, or task issues, update existing issues, add labels/assignees/milestones, set issue fields (dates, priority, custom fields), set issue types, manage issue workflows, link issues, add dependencies, or track blocked-by/blocking relationships. Triggers on requests like "create an issue", "file a bug", "request a feature", "update issue X", "set the priority", "set the start date", "link issues", "add dependency", "blocked by", "blocking", or any GitHub issue management task.' +--- + +# GitHub Issues + +Manage GitHub issues using the `@modelcontextprotocol/server-github` MCP server. + +## Available Tools + +### MCP Tools (read operations) + +| Tool | Purpose | +|------|---------| +| `mcp__github__issue_read` | Read issue details, sub-issues, comments, labels (methods: get, get_comments, get_sub_issues, get_labels) | +| `mcp__github__list_issues` | List and filter repository issues by state, labels, date | +| `mcp__github__search_issues` | Search issues across repos using GitHub search syntax | +| `mcp__github__projects_list` | List projects, project fields, project items, status updates | +| `mcp__github__projects_get` | Get details of a project, field, item, or status update | +| `mcp__github__projects_write` | Add/update/delete project items, create status updates | + +### CLI / REST API (write operations) + +The MCP server does not currently support creating, updating, or commenting on issues. Use `gh api` for these operations. + +| Operation | Command | +|-----------|---------| +| Create issue | `gh api repos/{owner}/{repo}/issues -X POST -f title=... -f body=...` | +| Update issue | `gh api repos/{owner}/{repo}/issues/{number} -X PATCH -f title=... -f state=...` | +| Add comment | `gh api repos/{owner}/{repo}/issues/{number}/comments -X POST -f body=...` | +| Close issue | `gh api repos/{owner}/{repo}/issues/{number} -X PATCH -f state=closed` | +| Set issue type | Include `-f type=Bug` in the create call (REST API only, not supported by `gh issue create` CLI) | + +**Note:** `gh issue create` works for basic issue creation but does **not** support the `--type` flag. Use `gh api` when you need to set issue types. + +## Workflow + +1. **Determine action**: Create, update, or query? +2. **Gather context**: Get repo info, existing labels, milestones if needed +3. **Structure content**: Use appropriate template from [references/templates.md](references/templates.md) +4. **Execute**: Use MCP tools for reads, `gh api` for writes +5. **Confirm**: Report the issue URL to user + +## Creating Issues + +Use `gh api` to create issues. This supports all parameters including issue types. + +```bash +gh api repos/{owner}/{repo}/issues \ + -X POST \ + -f title="Issue title" \ + -f body="Issue body in markdown" \ + -f type="Bug" \ + --jq '{number, html_url}' +``` + +### Optional Parameters + +Add any of these flags to the `gh api` call: + +``` +-f type="Bug" # Issue type (Bug, Feature, Task, Epic, etc.) +-f labels[]="bug" # Labels (repeat for multiple) +-f assignees[]="username" # Assignees (repeat for multiple) +-f milestone=1 # Milestone number +``` + +**Issue types** are organization-level metadata. To discover available types, use: +```bash +gh api graphql -f query='{ organization(login: "ORG") { issueTypes(first: 10) { nodes { name } } } }' --jq '.data.organization.issueTypes.nodes[].name' +``` + +**Prefer issue types over labels for categorization.** When issue types are available (e.g., Bug, Feature, Task), use the `type` parameter instead of applying equivalent labels like `bug` or `enhancement`. Issue types are the canonical way to categorize issues on GitHub. Only fall back to labels when the org has no issue types configured. + +### Title Guidelines + +- Be specific and actionable +- Keep under 72 characters +- When issue types are set, don't add redundant prefixes like `[Bug]` +- Examples: + - `Login fails with SSO enabled` (with type=Bug) + - `Add dark mode support` (with type=Feature) + - `Add unit tests for auth module` (with type=Task) + +### Body Structure + +Always use the templates in [references/templates.md](references/templates.md). Choose based on issue type: + +| User Request | Template | +|--------------|----------| +| Bug, error, broken, not working | Bug Report | +| Feature, enhancement, add, new | Feature Request | +| Task, chore, refactor, update | Task | + +## Updating Issues + +Use `gh api` with PATCH: + +```bash +gh api repos/{owner}/{repo}/issues/{number} \ + -X PATCH \ + -f state=closed \ + -f title="Updated title" \ + --jq '{number, html_url}' +``` + +Only include fields you want to change. Available fields: `title`, `body`, `state` (open/closed), `labels`, `assignees`, `milestone`. + +## Examples + +### Example 1: Bug Report + +**User**: "Create a bug issue - the login page crashes when using SSO" + +**Action**: +```bash +gh api repos/github/awesome-copilot/issues \ + -X POST \ + -f title="Login page crashes when using SSO" \ + -f type="Bug" \ + -f body="## Description +The login page crashes when users attempt to authenticate using SSO. + +## Steps to Reproduce +1. Navigate to login page +2. Click 'Sign in with SSO' +3. Page crashes + +## Expected Behavior +SSO authentication should complete and redirect to dashboard. + +## Actual Behavior +Page becomes unresponsive and displays error." \ + --jq '{number, html_url}' +``` + +### Example 2: Feature Request + +**User**: "Create a feature request for dark mode with high priority" + +**Action**: +```bash +gh api repos/github/awesome-copilot/issues \ + -X POST \ + -f title="Add dark mode support" \ + -f type="Feature" \ + -f labels[]="high-priority" \ + -f body="## Summary +Add dark mode theme option for improved user experience and accessibility. + +## Motivation +- Reduces eye strain in low-light environments +- Increasingly expected by users + +## Proposed Solution +Implement theme toggle with system preference detection. + +## Acceptance Criteria +- [ ] Toggle switch in settings +- [ ] Persists user preference +- [ ] Respects system preference by default" \ + --jq '{number, html_url}' +``` + +## Common Labels + +Use these standard labels when applicable: + +| Label | Use For | +|-------|---------| +| `bug` | Something isn't working | +| `enhancement` | New feature or improvement | +| `documentation` | Documentation updates | +| `good first issue` | Good for newcomers | +| `help wanted` | Extra attention needed | +| `question` | Further information requested | +| `wontfix` | Will not be addressed | +| `duplicate` | Already exists | +| `high-priority` | Urgent issues | + +## Tips + +- Always confirm the repository context before creating issues +- Ask for missing critical information rather than guessing +- Link related issues when known: `Related to #123` +- For updates, fetch current issue first to preserve unchanged fields + +## Extended Capabilities + +The following features require REST or GraphQL APIs beyond the basic MCP tools. Each is documented in its own reference file so the agent only loads the knowledge it needs. + +| Capability | When to use | Reference | +|------------|-------------|-----------| +| Advanced search | Complex queries with boolean logic, date ranges, cross-repo search, issue field filters (`field.name:value`) | [references/search.md](references/search.md) | +| Sub-issues & parent issues | Breaking work into hierarchical tasks | [references/sub-issues.md](references/sub-issues.md) | +| Issue dependencies | Tracking blocked-by / blocking relationships | [references/dependencies.md](references/dependencies.md) | +| Issue types (advanced) | GraphQL operations beyond MCP `list_issue_types` / `type` param | [references/issue-types.md](references/issue-types.md) | +| Projects V2 | Project boards, progress reports, field management | [references/projects.md](references/projects.md) | +| Issue fields | Custom metadata: dates, priority, text, numbers (private preview) | [references/issue-fields.md](references/issue-fields.md) | +| Images in issues | Embedding images in issue bodies and comments via CLI | [references/images.md](references/images.md) | diff --git a/.github/skills/github-issues/references/dependencies.md b/.github/skills/github-issues/references/dependencies.md new file mode 100644 index 0000000..6ad7562 --- /dev/null +++ b/.github/skills/github-issues/references/dependencies.md @@ -0,0 +1,71 @@ +# Issue Dependencies (Blocked By / Blocking) + +Dependencies let you mark that an issue is blocked by another issue. This creates a formal dependency relationship visible in the UI and trackable via API. No MCP tools exist for dependencies; use REST or GraphQL directly. + +## Using REST API + +**List issues blocking this issue:** +``` +GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by +``` + +**Add a blocking dependency:** +``` +POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by +Body: { "issue_id": 12345 } +``` + +The `issue_id` is the numeric issue **ID** (not the issue number). + +**Remove a blocking dependency:** +``` +DELETE /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by/{issue_id} +``` + +## Using GraphQL + +**Read dependencies:** +```graphql +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + blockedBy(first: 10) { nodes { number title state } } + blocking(first: 10) { nodes { number title state } } + issueDependenciesSummary { blockedBy blocking totalBlockedBy totalBlocking } + } + } +} +``` + +**Add a dependency:** +```graphql +mutation { + addBlockedBy(input: { + issueId: "BLOCKED_ISSUE_NODE_ID" + blockingIssueId: "BLOCKING_ISSUE_NODE_ID" + }) { + blockingIssue { number title } + } +} +``` + +**Remove a dependency:** +```graphql +mutation { + removeBlockedBy(input: { + issueId: "BLOCKED_ISSUE_NODE_ID" + blockingIssueId: "BLOCKING_ISSUE_NODE_ID" + }) { + blockingIssue { number title } + } +} +``` + +## Tracked issues (read-only) + +Task-list tracking relationships are available via GraphQL as read-only fields: + +- `trackedIssues(first: N)` - issues tracked in this issue's task list +- `trackedInIssues(first: N)` - issues whose task lists reference this issue + +These are set automatically when issues are referenced in task lists (`- [ ] #123`). There are no mutations to manage them. diff --git a/.github/skills/github-issues/references/images.md b/.github/skills/github-issues/references/images.md new file mode 100644 index 0000000..f6dec63 --- /dev/null +++ b/.github/skills/github-issues/references/images.md @@ -0,0 +1,116 @@ +# Images in Issues and Comments + +How to embed images in GitHub issue bodies and comments programmatically via the CLI. + +## Methods (ranked by reliability) + +### 1. GitHub Contents API (recommended for private repos) + +Push image files to a branch in the same repo, then reference them with a URL that works for authenticated viewers. + +**Step 1: Create a branch** + +```bash +# Get the SHA of the default branch +SHA=$(gh api repos/{owner}/{repo}/git/ref/heads/main --jq '.object.sha') + +# Create a new branch +gh api repos/{owner}/{repo}/git/refs -X POST \ + -f ref="refs/heads/{username}/images" \ + -f sha="$SHA" +``` + +**Step 2: Upload images via Contents API** + +```bash +# Base64-encode the image and upload +BASE64=$(base64 -i /path/to/image.png) + +gh api repos/{owner}/{repo}/contents/docs/images/my-image.png \ + -X PUT \ + -f message="Add image" \ + -f content="$BASE64" \ + -f branch="{username}/images" \ + --jq '.content.path' +``` + +Repeat for each image. The Contents API creates a commit per file. + +**Step 3: Reference in markdown** + +```markdown +![Description](https://github.com/{owner}/{repo}/raw/{username}/images/docs/images/my-image.png) +``` + +> **Important:** Use `github.com/{owner}/{repo}/raw/{branch}/{path}` format, NOT `raw.githubusercontent.com`. The `raw.githubusercontent.com` URLs return 404 for private repos. The `github.com/.../raw/...` format works because the browser sends auth cookies when the viewer is logged in and has repo access. + +**Pros:** Works for any repo the viewer has access to, images live in version control, no expiration. +**Cons:** Creates commits, viewers must be authenticated, images won't render in email notifications or for users without repo access. + +### 2. Gist hosting (public images only) + +Upload images as files in a gist. Only works for images you're comfortable making public. + +```bash +# Create a gist with a placeholder file +gh gist create --public -f description.md <<< "Image hosting gist" + +# Note: gh gist edit does NOT support binary files. +# You must use the API to add binary content to gists. +``` + +> **Limitation:** Gists don't support binary file uploads via the CLI. You'd need to base64-encode and store as text, which won't render as images. Not recommended. + +### 3. Browser upload (most reliable rendering) + +The most reliable way to get permanent image URLs is through the GitHub web UI: + +1. Open the issue/comment in a browser +2. Drag-drop or paste the image into the comment editor +3. GitHub generates a permanent `https://github.com/user-attachments/assets/{UUID}` URL +4. These URLs work for anyone, even without repo access, and render in email notifications + +> **Why the API can't do this:** GitHub's `upload/policies/assets` endpoint requires a browser session (CSRF token + cookies). It returns an HTML error page when called with API tokens. There is no public API for generating `user-attachments` URLs. + +## Taking screenshots programmatically + +Use `puppeteer-core` with local Chrome to screenshot HTML mockups: + +```javascript +const puppeteer = require('puppeteer-core'); + +const browser = await puppeteer.launch({ + executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + defaultViewport: { width: 900, height: 600, deviceScaleFactor: 2 } +}); + +const page = await browser.newPage(); +await page.setContent(htmlString); + +// Screenshot specific elements +const elements = await page.$$('.section'); +for (let i = 0; i < elements.length; i++) { + await elements[i].screenshot({ path: `mockup-${i + 1}.png` }); +} + +await browser.close(); +``` + +> **Note:** MCP Playwright may not connect to localhost due to network isolation. Use puppeteer-core with a local Chrome installation instead. + +## Quick reference + +| Method | Private repos | Permanent | No auth needed | API-only | +|--------|:---:|:---:|:---:|:---:| +| Contents API + `github.com/raw/` | ✅ | ✅ | ❌ | ✅ | +| Browser drag-drop (`user-attachments`) | ✅ | ✅ | ✅ | ❌ | +| `raw.githubusercontent.com` | ❌ (404) | ✅ | ❌ | ✅ | +| Gist | Public only | ✅ | ✅ | ❌ (no binary) | + +## Common pitfalls + +- **`raw.githubusercontent.com` returns 404 for private repos** even with a valid token in the URL. GitHub's CDN does not pass auth headers through. +- **API download URLs are temporary.** URLs returned by `gh api repos/.../contents/...` with `download_url` include a token that expires. +- **`upload/policies/assets` requires a browser session.** Do not attempt to call this endpoint from the CLI. +- **Base64 encoding for large files** can hit API payload limits. The Contents API has a ~100MB file size limit but practical limits are lower for base64-encoded payloads. +- **Email notifications** will not render images that require authentication. If email readability matters, use the browser upload method. diff --git a/.github/skills/github-issues/references/issue-fields.md b/.github/skills/github-issues/references/issue-fields.md new file mode 100644 index 0000000..4ab668c --- /dev/null +++ b/.github/skills/github-issues/references/issue-fields.md @@ -0,0 +1,191 @@ +# Issue Fields (GraphQL, Private Preview) + +> **Private preview:** Issue fields are currently in private preview. Request access at https://github.com/orgs/community/discussions/175366 + +Issue fields are custom metadata (dates, text, numbers, single-select) defined at the organization level and set per-issue. They are separate from labels, milestones, and assignees. Common examples: Start Date, Target Date, Priority, Impact, Effort. + +**Important:** All issue field queries and mutations require the `GraphQL-Features: issue_fields` HTTP header. Without it, the fields are not visible in the schema. + +**Prefer issue fields over project fields.** When you need to set metadata like dates, priority, or status on an issue, use issue fields (which live on the issue itself) rather than project fields (which live on a project item). Issue fields travel with the issue across projects and views, while project fields are scoped to a single project. Only use project fields when issue fields are not available or when the field is project-specific (e.g., sprint iterations). + +## Discovering available fields + +Fields are defined at the org level. List them before trying to set values: + +```graphql +# Header: GraphQL-Features: issue_fields +{ + organization(login: "OWNER") { + issueFields(first: 30) { + nodes { + __typename + ... on IssueFieldDate { id name } + ... on IssueFieldText { id name } + ... on IssueFieldNumber { id name } + ... on IssueFieldSingleSelect { id name options { id name color } } + } + } + } +} +``` + +Field types: `IssueFieldDate`, `IssueFieldText`, `IssueFieldNumber`, `IssueFieldSingleSelect`. + +For single-select fields, you need the option `id` (not the name) to set values. + +## Reading field values on an issue + +```graphql +# Header: GraphQL-Features: issue_fields +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + issueFieldValues(first: 20) { + nodes { + __typename + ... on IssueFieldDateValue { + value + field { ... on IssueFieldDate { id name } } + } + ... on IssueFieldTextValue { + value + field { ... on IssueFieldText { id name } } + } + ... on IssueFieldNumberValue { + value + field { ... on IssueFieldNumber { id name } } + } + ... on IssueFieldSingleSelectValue { + name + color + field { ... on IssueFieldSingleSelect { id name } } + } + } + } + } + } +} +``` + +## Setting field values + +Use `setIssueFieldValue` to set one or more fields at once. You need the issue's node ID and the field IDs from the discovery query above. + +```graphql +# Header: GraphQL-Features: issue_fields +mutation { + setIssueFieldValue(input: { + issueId: "ISSUE_NODE_ID" + issueFields: [ + { fieldId: "IFD_xxx", dateValue: "2026-04-15" } + { fieldId: "IFT_xxx", textValue: "some text" } + { fieldId: "IFN_xxx", numberValue: 3.0 } + { fieldId: "IFSS_xxx", singleSelectOptionId: "OPTION_ID" } + ] + }) { + issue { id title } + } +} +``` + +Each entry in `issueFields` takes a `fieldId` plus exactly one value parameter: + +| Field type | Value parameter | Format | +|-----------|----------------|--------| +| Date | `dateValue` | ISO 8601 date string, e.g. `"2026-04-15"` | +| Text | `textValue` | String | +| Number | `numberValue` | Float | +| Single select | `singleSelectOptionId` | ID from the field's `options` list | + +To clear a field value, set `delete: true` instead of a value parameter. + +## Workflow for setting fields + +1. **Discover fields** - query the org's `issueFields` to get field IDs and option IDs +2. **Get the issue node ID** - from `repository.issue.id` +3. **Set values** - call `setIssueFieldValue` with the issue node ID and field entries +4. **Batch when possible** - multiple fields can be set in a single mutation call + +## Example: Set dates and priority on an issue + +```bash +gh api graphql \ + -H "GraphQL-Features: issue_fields" \ + -f query=' +mutation { + setIssueFieldValue(input: { + issueId: "I_kwDOxxx" + issueFields: [ + { fieldId: "IFD_startDate", dateValue: "2026-04-01" } + { fieldId: "IFD_targetDate", dateValue: "2026-04-30" } + { fieldId: "IFSS_priority", singleSelectOptionId: "OPTION_P1" } + ] + }) { + issue { id title } + } +}' +``` + +## Searching by field values + +### GraphQL bulk query (recommended) + +The most reliable way to find issues by field value is to fetch issues via GraphQL and filter by `issueFieldValues`. The search qualifier syntax (`field.name:value`) is not yet reliable across all environments. + +```bash +# Find all open P1 issues in a repo +gh api graphql -H "GraphQL-Features: issue_fields" -f query=' +{ + repository(owner: "OWNER", name: "REPO") { + issues(first: 100, states: OPEN) { + nodes { + number + title + updatedAt + assignees(first: 3) { nodes { login } } + issueFieldValues(first: 10) { + nodes { + __typename + ... on IssueFieldSingleSelectValue { + name + field { ... on IssueFieldSingleSelect { name } } + } + } + } + } + } + } +}' --jq ' + [.data.repository.issues.nodes[] | + select(.issueFieldValues.nodes[] | + select(.field.name == "Priority" and .name == "P1") + ) | + {number, title, updatedAt, assignees: [.assignees.nodes[].login]} + ]' +``` + +**Schema notes for `IssueFieldSingleSelectValue`:** +- The selected option's display text is in `.name` (not `.value`) +- Also available: `.color`, `.description`, `.id` +- The parent field reference is in `.field` (use inline fragment to get the field name) + +### Search qualifier syntax (experimental) + +Issue fields may also be searchable using dot notation in search queries. This requires `advanced_search=true` on REST or `ISSUE_ADVANCED` search type on GraphQL, but results are inconsistent and may return 0 results even when matching issues exist. + +``` +field.priority:P0 # Single-select equals value +field.target-date:>=2026-04-01 # Date comparison +has:field.priority # Has any value set +no:field.priority # Has no value set +``` + +Field names use the **slug** (lowercase, hyphens for spaces). For example, "Target Date" becomes `target-date`. + +```bash +# REST API (may not return results in all environments) +gh api "search/issues?q=repo:owner/repo+field.priority:P0+is:open&advanced_search=true" \ + --jq '.items[] | "#\(.number): \(.title)"' +``` + +> **Warning:** The colon notation (`field:Priority:P1`) is silently ignored. If using search qualifiers, always use dot notation (`field.priority:P1`). However, the GraphQL bulk query approach above is more reliable. See [search.md](search.md) for the full search guide. diff --git a/.github/skills/github-issues/references/issue-types.md b/.github/skills/github-issues/references/issue-types.md new file mode 100644 index 0000000..f605d7b --- /dev/null +++ b/.github/skills/github-issues/references/issue-types.md @@ -0,0 +1,72 @@ +# Issue Types (Advanced GraphQL) + +Issue types (Bug, Feature, Task, Epic, etc.) are defined at the **organization** level and inherited by repositories. They categorize issues beyond labels. + +For basic usage, the MCP tools handle issue types natively. Call `mcp__github__list_issue_types` to discover types, and pass `type: "Bug"` to `mcp__github__create_issue` or `mcp__github__update_issue`. This reference covers advanced GraphQL operations. + +## GraphQL Feature Header + +All GraphQL issue type operations require the `GraphQL-Features: issue_types` HTTP header. + +## List types (org or repo level) + +```graphql +# Header: GraphQL-Features: issue_types +{ + organization(login: "OWNER") { + issueTypes(first: 20) { + nodes { id name color description isEnabled } + } + } +} +``` + +Types can also be listed per-repo via `repository.issueTypes` or looked up by name via `repository.issueType(name: "Bug")`. + +## Read an issue's type + +```graphql +# Header: GraphQL-Features: issue_types +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + issueType { id name color } + } + } +} +``` + +## Set type on an existing issue + +```graphql +# Header: GraphQL-Features: issue_types +mutation { + updateIssueIssueType(input: { + issueId: "ISSUE_NODE_ID" + issueTypeId: "IT_xxx" + }) { + issue { id issueType { name } } + } +} +``` + +## Create issue with type + +```graphql +# Header: GraphQL-Features: issue_types +mutation { + createIssue(input: { + repositoryId: "REPO_NODE_ID" + title: "Fix login bug" + issueTypeId: "IT_xxx" + }) { + issue { id number issueType { name } } + } +} +``` + +To clear the type, set `issueTypeId` to `null`. + +## Available colors + +`GRAY`, `BLUE`, `GREEN`, `YELLOW`, `ORANGE`, `RED`, `PINK`, `PURPLE` diff --git a/.github/skills/github-issues/references/projects.md b/.github/skills/github-issues/references/projects.md new file mode 100644 index 0000000..803187e --- /dev/null +++ b/.github/skills/github-issues/references/projects.md @@ -0,0 +1,272 @@ +# Projects V2 + +GitHub Projects V2 is managed via GraphQL. The MCP server provides three tools that wrap the GraphQL API, so you typically don't need raw GraphQL. + +## Using MCP tools (preferred) + +**List projects:** +Call `mcp__github__projects_list` with `method: "list_projects"`, `owner`, and `owner_type` ("user" or "organization"). + +**List project fields:** +Call `mcp__github__projects_list` with `method: "list_project_fields"` and `project_number`. + +**List project items:** +Call `mcp__github__projects_list` with `method: "list_project_items"` and `project_number`. + +**Add an issue/PR to a project:** +Call `mcp__github__projects_write` with `method: "add_project_item"`, `project_id` (node ID), and `content_id` (issue/PR node ID). + +**Update a project item field value:** +Call `mcp__github__projects_write` with `method: "update_project_item"`, `project_id`, `item_id`, `field_id`, and `value` (object with one of: `text`, `number`, `date`, `singleSelectOptionId`, `iterationId`). + +**Delete a project item:** +Call `mcp__github__projects_write` with `method: "delete_project_item"`, `project_id`, and `item_id`. + +## Workflow for project operations + +1. **Find the project** — see [Finding a project by name](#finding-a-project-by-name) below +2. **Discover fields** - use `projects_list` with `list_project_fields` to get field IDs and option IDs +3. **Find items** - use `projects_list` with `list_project_items` to get item IDs +4. **Mutate** - use `projects_write` to add, update, or delete items + +## Finding a project by name + +> **⚠️ Known issue:** `projectsV2(query: "…")` does keyword search, not exact name match, and returns results sorted by recency. Common words like "issue" or "bug" return hundreds of false positives. The actual project may be buried dozens of pages deep. + +Use this priority order: + +### 1. Direct lookup (if you know the number) +```bash +gh api graphql -f query='{ + organization(login: "ORG") { + projectV2(number: 42) { id title } + } +}' --jq '.data.organization.projectV2' +``` + +### 2. Reverse lookup from a known issue (most reliable) +If the user mentions an issue, epic, or milestone that's in the project, query that issue's `projectItems` to discover the project: + +```bash +gh api graphql -f query='{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + projectItems(first: 10) { + nodes { + id + project { number title id } + } + } + } + } +}' --jq '.data.repository.issue.projectItems.nodes[] | {number: .project.number, title: .project.title, id: .project.id}' +``` + +This is the most reliable approach for large orgs where name search fails. + +### 3. GraphQL name search with client-side filtering (fallback) +Query a large page and filter client-side for an exact title match: + +```bash +gh api graphql -f query='{ + organization(login: "ORG") { + projectsV2(first: 100, query: "search term") { + nodes { number title id } + } + } +}' --jq '.data.organization.projectsV2.nodes[] | select(.title | test("(?i)^exact name$"))' +``` + +If this returns nothing, paginate with `after` cursor or broaden the regex. Results are sorted by recency so older projects require pagination. + +### 4. MCP tool (small orgs only) +Call `mcp__github__projects_list` with `method: "list_projects"`. This works well for orgs with <50 projects but has no name filter, so you must scan all results. + +## Project discovery for progress reports + +When a user asks for a progress update on a project (e.g., "Give me a progress update for Project X"), follow this workflow: + +1. **Find the project** — use the [finding a project](#finding-a-project-by-name) strategies above. Ask the user for a known issue number if name search fails. + +2. **Discover fields** - call `projects_list` with `list_project_fields` to find the Status field (its options tell you the workflow stages) and any Iteration field (to scope to the current sprint). + +3. **Get all items** - call `projects_list` with `list_project_items`. For large projects (100+ items), paginate through all pages. Each item includes its field values (status, iteration, assignees). + +4. **Build the report** - group items by Status field value and count them. For iteration-based projects, filter to the current iteration first. Present a breakdown like: + + ``` + Project: Issue Fields (Iteration 42, Mar 2-8) + 15 actionable items: + 🎉 Done: 4 (27%) + In Review: 3 + In Progress: 3 + Ready: 2 + Blocked: 2 + ``` + +5. **Add context** - if items have sub-issues, include `subIssuesSummary` counts. If items have dependencies, note blocked items and what blocks them. + +## OAuth Scope Requirements + +| Operation | Required scope | +|-----------|---------------| +| Read projects, fields, items | `read:project` | +| Add/update/delete items, change field values | `project` | + +**Common pitfall:** The default `gh auth` token often only has `read:project`. Mutations will fail with `INSUFFICIENT_SCOPES`. To add the write scope: + +```bash +gh auth refresh -h github.com -s project +``` + +This triggers a browser-based OAuth flow. You must complete it before mutations will work. + +## Finding an Issue's Project Item ID + +When you know the issue but need its project item ID (e.g., to update its Status), query from the issue side: + +```bash +gh api graphql -f query=' +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + projectItems(first: 5) { + nodes { + id + project { title number } + fieldValues(first: 10) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + } + } + } + } + } + } +}' --jq '.data.repository.issue.projectItems.nodes' +``` + +This returns the item ID, project info, and current field values in one query. + +## Using GraphQL via gh api (recommended) + +Use `gh api graphql` to run GraphQL queries and mutations. This is more reliable than MCP tools for write operations. + +**Find a project and its Status field options:** +```bash +gh api graphql -f query=' +{ + organization(login: "ORG") { + projectV2(number: 5) { + id + title + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + } + } +}' --jq '.data.organization.projectV2' +``` + +**List all fields (including iterations):** +```bash +gh api graphql -f query=' +{ + node(id: "PROJECT_ID") { + ... on ProjectV2 { + fields(first: 20) { + nodes { + ... on ProjectV2Field { id name } + ... on ProjectV2SingleSelectField { id name options { id name } } + ... on ProjectV2IterationField { id name configuration { iterations { id startDate } } } + } + } + } + } +}' --jq '.data.node.fields.nodes' +``` + +**Update a field value (e.g., set Status to "In Progress"):** +```bash +gh api graphql -f query=' +mutation { + updateProjectV2ItemFieldValue(input: { + projectId: "PROJECT_ID" + itemId: "ITEM_ID" + fieldId: "FIELD_ID" + value: { singleSelectOptionId: "OPTION_ID" } + }) { + projectV2Item { id } + } +}' +``` + +Value accepts one of: `text`, `number`, `date`, `singleSelectOptionId`, `iterationId`. + +**Add an item:** +```bash +gh api graphql -f query=' +mutation { + addProjectV2ItemById(input: { + projectId: "PROJECT_ID" + contentId: "ISSUE_OR_PR_NODE_ID" + }) { + item { id } + } +}' +``` + +**Delete an item:** +```bash +gh api graphql -f query=' +mutation { + deleteProjectV2Item(input: { + projectId: "PROJECT_ID" + itemId: "ITEM_ID" + }) { + deletedItemId + } +}' +``` + +## End-to-End Example: Set Issue Status to "In Progress" + +```bash +# 1. Get the issue's project item ID, project ID, and current status +gh api graphql -f query='{ + repository(owner: "github", name: "planning-tracking") { + issue(number: 2574) { + projectItems(first: 1) { + nodes { id project { id title } } + } + } + } +}' --jq '.data.repository.issue.projectItems.nodes[0]' + +# 2. Get the Status field ID and "In Progress" option ID +gh api graphql -f query='{ + node(id: "PROJECT_ID") { + ... on ProjectV2 { + field(name: "Status") { + ... on ProjectV2SingleSelectField { id options { id name } } + } + } + } +}' --jq '.data.node.field' + +# 3. Update the status +gh api graphql -f query='mutation { + updateProjectV2ItemFieldValue(input: { + projectId: "PROJECT_ID" + itemId: "ITEM_ID" + fieldId: "FIELD_ID" + value: { singleSelectOptionId: "IN_PROGRESS_OPTION_ID" } + }) { projectV2Item { id } } +}' +``` diff --git a/.github/skills/github-issues/references/search.md b/.github/skills/github-issues/references/search.md new file mode 100644 index 0000000..9e08efa --- /dev/null +++ b/.github/skills/github-issues/references/search.md @@ -0,0 +1,231 @@ +# Advanced Issue Search + +The `search_issues` MCP tool uses GitHub's issue search query format for cross-repo searches, supporting implicit-AND queries, date ranges, and metadata filters (but not explicit OR/NOT operators). + +## When to Use Search vs List vs Advanced Search + +There are three ways to find issues, each with different capabilities: + +| Capability | `list_issues` (MCP) | `search_issues` (MCP) | Advanced search (`gh api`) | +|-----------|---------------------|----------------------|---------------------------| +| **Scope** | Single repo only | Cross-repo, cross-org | Cross-repo, cross-org | +| **Issue field filters** (`field.priority:P0`) | No | No | **Yes** (dot notation) | +| **Issue type filter** (`type:Bug`) | No | Yes | Yes | +| **Boolean logic** (AND/OR/NOT, nesting) | No | Yes (implicit AND only) | **Yes** (explicit AND/OR/NOT) | +| **Label/state/date filters** | Yes | Yes | Yes | +| **Assignee/author/mentions** | No | Yes | Yes | +| **Negation** (`-label:x`, `no:label`) | No | Yes | Yes | +| **Text search** (title/body/comments) | No | Yes | Yes | +| **`since` filter** | Yes | No | No | +| **Result limit** | No cap (paginate all) | 1,000 max | 1,000 max | +| **How to call** | MCP tool directly | MCP tool directly | `gh api` with `advanced_search=true` | + +**Decision guide:** +- **Single repo, simple filters (state, labels, recent updates):** use `list_issues` +- **Cross-repo, text search, author/assignee, issue types:** use `search_issues` +- **Issue field values (Priority, dates, custom fields) or complex boolean logic:** use `gh api` with `advanced_search=true` + +## Query Syntax + +The `query` parameter is a string of search terms and qualifiers. A space between terms is implicit AND. + +### Scoping + +``` +repo:owner/repo # Single repo (auto-added if you pass owner+repo params) +org:github # All repos in an org +user:octocat # All repos owned by user +in:title # Search only in title +in:body # Search only in body +in:comments # Search only in comments +``` + +### State & Close Reason + +``` +is:open # Open issues (auto-added: is:issue) +is:closed # Closed issues +reason:completed # Closed as completed +reason:"not planned" # Closed as not planned +``` + +### People + +``` +author:username # Created by +assignee:username # Assigned to +mentions:username # Mentions user +commenter:username # Has comment from +involves:username # Author OR assignee OR mentioned OR commenter +author:@me # Current authenticated user +team:org/team # Team mentioned +``` + +### Labels, Milestones, Projects, Types + +``` +label:"bug" # Has label (quote multi-word labels) +label:bug label:priority # Has BOTH labels (AND) +label:bug,enhancement # Has EITHER label (OR) +-label:wontfix # Does NOT have label +milestone:"v2.0" # In milestone +project:github/57 # In project board +type:"Bug" # Issue type +``` + +### Missing Metadata + +``` +no:label # No labels assigned +no:milestone # No milestone +no:assignee # Unassigned +no:project # Not in any project +``` + +### Dates + +All date qualifiers support `>`, `<`, `>=`, `<=`, and range (`..`) operators with ISO 8601 format: + +``` +created:>2026-01-01 # Created after Jan 1 +updated:>=2026-03-01 # Updated since Mar 1 +closed:2026-01-01..2026-02-01 # Closed in January +created:<2026-01-01 # Created before Jan 1 +``` + +### Linked Content + +``` +linked:pr # Issue has a linked PR +-linked:pr # Issues not yet linked to any PR +linked:issue # PR is linked to an issue +``` + +### Numeric Filters + +``` +comments:>10 # More than 10 comments +comments:0 # No comments +interactions:>100 # Reactions + comments > 100 +reactions:>50 # More than 50 reactions +``` + +### Boolean Logic & Nesting + +Use `AND`, `OR`, and parentheses (up to 5 levels deep, max 5 operators): + +``` +label:bug AND assignee:octocat +assignee:octocat OR assignee:hubot +(type:"Bug" AND label:P1) OR (type:"Feature" AND label:P1) +-author:app/dependabot # Exclude bot issues +``` + +A space between terms without an explicit operator is treated as AND. + +## Common Query Patterns + +**Unassigned bugs:** +``` +repo:owner/repo type:"Bug" no:assignee is:open +``` + +**Issues closed this week:** +``` +repo:owner/repo is:closed closed:>=2026-03-01 +``` + +**Stale open issues (no updates in 90 days):** +``` +repo:owner/repo is:open updated:<2026-01-01 +``` + +**Open issues without a linked PR (needs work):** +``` +repo:owner/repo is:open -linked:pr +``` + +**Issues I'm involved in across an org:** +``` +org:github involves:@me is:open +``` + +**High-activity issues:** +``` +repo:owner/repo is:open comments:>20 +``` + +**Issues by type and priority label:** +``` +repo:owner/repo type:"Epic" label:P1 is:open +``` + +## Issue Field Search + +> **Reliability warning:** The `field.name:value` search qualifier syntax is experimental and may return 0 results even when matching issues exist. For reliable filtering by field values, use the GraphQL bulk query approach documented in [issue-fields.md](issue-fields.md#searching-by-field-values). + +Issue fields can theoretically be searched via the `field.name:value` qualifier using **advanced search mode**. This works in the web UI but results from the API are inconsistent. + +### REST API + +Add `advanced_search=true` as a query parameter: + +```bash +gh api "search/issues?q=org:github+field.priority:P0+type:Epic+is:open&advanced_search=true" \ + --jq '.items[] | "#\(.number): \(.title)"' +``` + +### GraphQL + +Use `type: ISSUE_ADVANCED` instead of `type: ISSUE`: + +```graphql +{ + search(query: "org:github field.priority:P0 type:Epic is:open", type: ISSUE_ADVANCED, first: 10) { + issueCount + nodes { + ... on Issue { number title } + } + } +} +``` + +### Issue Field Qualifiers + +The syntax uses **dot notation** with the field's slug name (lowercase, hyphens for spaces): + +``` +field.priority:P0 # Single-select field equals value +field.priority:P1 # Different option value +field.target-date:>=2026-04-01 # Date comparison +has:field.priority # Has any value set +no:field.priority # Has no value set +``` + +**MCP limitation:** The `search_issues` MCP tool does not pass `advanced_search=true`. You must use `gh api` directly for issue field searches. + +### Common Field Search Patterns + +**P0 epics across an org:** +``` +org:github field.priority:P0 type:Epic is:open +``` + +**Issues with a target date this quarter:** +``` +org:github field.target-date:>=2026-04-01 field.target-date:<=2026-06-30 is:open +``` + +**Open bugs missing priority:** +``` +org:github no:field.priority type:Bug is:open +``` + +## Limitations + +- Query text: max **256 characters** (excluding operators/qualifiers) +- Boolean operators: max **5** AND/OR/NOT per query +- Results: max **1,000** total (use `list_issues` if you need all issues) +- Repo scan: searches up to **4,000** matching repositories +- Rate limit: **30 requests/minute** for authenticated search +- Issue field search requires `advanced_search=true` (REST) or `ISSUE_ADVANCED` (GraphQL); not available through MCP `search_issues` diff --git a/.github/skills/github-issues/references/sub-issues.md b/.github/skills/github-issues/references/sub-issues.md new file mode 100644 index 0000000..aac288e --- /dev/null +++ b/.github/skills/github-issues/references/sub-issues.md @@ -0,0 +1,137 @@ +# Sub-Issues and Parent Issues + +Sub-issues let you break down work into hierarchical tasks. Each parent issue can have up to 100 sub-issues, nested up to 8 levels deep. Sub-issues can span repositories within the same owner. + +## Recommended Workflow + +The simplest way to create a sub-issue is **two steps**: create the issue, then link it. + +```bash +# Step 1: Create the issue and capture its numeric ID +ISSUE_ID=$(gh api repos/{owner}/{repo}/issues \ + -X POST \ + -f title="Sub-task title" \ + -f body="Description" \ + --jq '.id') + +# Step 2: Link it as a sub-issue of the parent +# IMPORTANT: sub_issue_id must be an integer. Use --input (not -f) to send JSON. +echo "{\"sub_issue_id\": $ISSUE_ID}" | gh api repos/{owner}/{repo}/issues/{parent_number}/sub_issues -X POST --input - +``` + +**Why `--input` instead of `-f`?** The `gh api -f` flag sends all values as strings, but the API requires `sub_issue_id` as an integer. Using `-f sub_issue_id=12345` will return a 422 error. + +Alternatively, use GraphQL `createIssue` with `parentIssueId` to do it in one step (see GraphQL section below). + +## Using MCP tools + +**List sub-issues:** +Call `mcp__github__issue_read` with `method: "get_sub_issues"`, `owner`, `repo`, and `issue_number`. + +**Create an issue as a sub-issue:** +There is no MCP tool for creating sub-issues directly. Use the workflow above or GraphQL. + +## Using REST API + +**List sub-issues:** +```bash +gh api repos/{owner}/{repo}/issues/{issue_number}/sub_issues +``` + +**Get parent issue:** +```bash +gh api repos/{owner}/{repo}/issues/{issue_number}/parent +``` + +**Add an existing issue as a sub-issue:** +```bash +# sub_issue_id is the numeric issue ID (not the issue number) +# Get it from the .id field when creating or fetching an issue +echo '{"sub_issue_id": 12345}' | gh api repos/{owner}/{repo}/issues/{parent_number}/sub_issues -X POST --input - +``` + +To move a sub-issue that already has a parent, add `"replace_parent": true` to the JSON body. + +**Remove a sub-issue:** +```bash +echo '{"sub_issue_id": 12345}' | gh api repos/{owner}/{repo}/issues/{parent_number}/sub_issue -X DELETE --input - +``` + +**Reprioritize a sub-issue:** +```bash +echo '{"sub_issue_id": 6, "after_id": 5}' | gh api repos/{owner}/{repo}/issues/{parent_number}/sub_issues/priority -X PATCH --input - +``` + +Use `after_id` or `before_id` to position the sub-issue relative to another. + +## Using GraphQL + +**Read parent and sub-issues:** +```graphql +{ + repository(owner: "OWNER", name: "REPO") { + issue(number: 123) { + parent { number title } + subIssues(first: 50) { + nodes { number title state } + } + subIssuesSummary { total completed percentCompleted } + } + } +} +``` + +**Add a sub-issue:** +```graphql +mutation { + addSubIssue(input: { + issueId: "PARENT_NODE_ID" + subIssueId: "CHILD_NODE_ID" + }) { + issue { id } + subIssue { id number title } + } +} +``` + +You can also use `subIssueUrl` instead of `subIssueId` (pass the issue's HTML URL). Add `replaceParent: true` to move a sub-issue from another parent. + +**Create an issue directly as a sub-issue:** +```graphql +mutation { + createIssue(input: { + repositoryId: "REPO_NODE_ID" + title: "Implement login validation" + parentIssueId: "PARENT_NODE_ID" + }) { + issue { id number } + } +} +``` + +**Remove a sub-issue:** +```graphql +mutation { + removeSubIssue(input: { + issueId: "PARENT_NODE_ID" + subIssueId: "CHILD_NODE_ID" + }) { + issue { id } + } +} +``` + +**Reprioritize a sub-issue:** +```graphql +mutation { + reprioritizeSubIssue(input: { + issueId: "PARENT_NODE_ID" + subIssueId: "CHILD_NODE_ID" + afterId: "OTHER_CHILD_NODE_ID" + }) { + issue { id } + } +} +``` + +Use `afterId` or `beforeId` to position relative to another sub-issue. diff --git a/.github/skills/github-issues/references/templates.md b/.github/skills/github-issues/references/templates.md new file mode 100644 index 0000000..c05b408 --- /dev/null +++ b/.github/skills/github-issues/references/templates.md @@ -0,0 +1,90 @@ +# Issue Templates + +Copy and customize these templates for issue bodies. + +## Bug Report Template + +```markdown +## Description +[Clear description of the bug] + +## Steps to Reproduce +1. [First step] +2. [Second step] +3. [And so on...] + +## Expected Behavior +[What should happen] + +## Actual Behavior +[What actually happens] + +## Environment +- Browser: [e.g., Chrome 120] +- OS: [e.g., macOS 14.0] +- Version: [e.g., v1.2.3] + +## Screenshots/Logs +[If applicable] + +## Additional Context +[Any other relevant information] +``` + +## Feature Request Template + +```markdown +## Summary +[One-line description of the feature] + +## Motivation +[Why is this feature needed? What problem does it solve?] + +## Proposed Solution +[How should this feature work?] + +## Acceptance Criteria +- [ ] [Criterion 1] +- [ ] [Criterion 2] +- [ ] [Criterion 3] + +## Alternatives Considered +[Other approaches considered and why they weren't chosen] + +## Additional Context +[Mockups, examples, or related issues] +``` + +## Task Template + +```markdown +## Objective +[What needs to be accomplished] + +## Details +[Detailed description of the work] + +## Checklist +- [ ] [Subtask 1] +- [ ] [Subtask 2] +- [ ] [Subtask 3] + +## Dependencies +[Any blockers or related work] + +## Notes +[Additional context or considerations] +``` + +## Minimal Template + +For simple issues: + +```markdown +## Description +[What and why] + +## Tasks +- [ ] [Task 1] +- [ ] [Task 2] +``` diff --git a/.gitignore b/.gitignore index 59e33cd..b9c9de1 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,10 @@ test-results/ # Misc .turbo/ + +# Maestro test credentials +.maestro/.env.local + +# Simulator/emulator env overrides +frontend/.env.ios.local +frontend/.env.android.local diff --git a/.maestro/flows/.env.example b/.maestro/flows/.env.example new file mode 100644 index 0000000..0d61d04 --- /dev/null +++ b/.maestro/flows/.env.example @@ -0,0 +1,10 @@ +# Maestro E2E environment variables +# Copy to .maestro/flows/.env and fill in real values — this file is gitignored. +# +# MAESTRO_TEST_EMAIL / MAESTRO_TEST_PASSWORD are no longer required: +# test users are created dynamically by scripts/setup.js. + +# Apple Calendar integration test credentials +# Generate App-Specific Password at: https://appleid.apple.com → App-Specific Passwords +APPLE_TEST_EMAIL=your-icloud@icloud.com +APPLE_TEST_PASSWORD=xxxx-xxxx-xxxx-xxxx diff --git a/.maestro/flows/app-flow.yaml b/.maestro/flows/app-flow.yaml new file mode 100644 index 0000000..88c0290 --- /dev/null +++ b/.maestro/flows/app-flow.yaml @@ -0,0 +1,42 @@ +appId: com.ordrctrl.app + +onFlowStart: + - runScript: ../scripts/setup.js + +onFlowComplete: + - runScript: ../scripts/teardown.js +--- +# Main app E2E flow: sign in → verify feed → complete a task +# Covers FR-010 (unauthenticated guard), FR-011 (feed load), FR-012 (task completion) +# +# onFlowStart creates a fresh test user with a seeded task (output.testUser). +# onFlowComplete always runs — deletes the test user even if the flow fails. + +# Start with a clean slate +- clearState +- launchApp + +# FR-010: verify unauthenticated users see the login screen +- assertVisible: + text: "Sign In" +- assertVisible: + text: "Email" + +# FR-011: authenticate and verify feed loads +- runFlow: ../subflows/login.yaml + +# Wait for the seeded task to appear in the feed (confirms login + feed load) +- extendedWaitUntil: + visible: + text: "Mark complete" + timeout: 30000 + +# FR-012: mark the first task complete +- waitForAnimationToEnd +- tapOn: + text: "Mark complete" +- waitForAnimationToEnd +- extendedWaitUntil: + notVisible: + text: "Mark complete" + timeout: 20000 diff --git a/.maestro/flows/auth.yaml b/.maestro/flows/auth.yaml deleted file mode 100644 index 5173e49..0000000 --- a/.maestro/flows/auth.yaml +++ /dev/null @@ -1,31 +0,0 @@ -appId: com.ordrctrl.app ---- -# Flow 1: auth.yaml — launch app, verify login screen, authenticate, verify feed loads -# FR-010, FR-011 | maestro-flows-contract.md Flow 1 - -# Step 1: Launch fresh (clear any existing session) -- clearState -- launchApp - -# Step 2: Verify login screen shown when unauthenticated (FR-010) -- assertVisible: - text: "Sign In" -- assertVisible: - text: "Email" - -# Step 3: Authenticate with test credentials (FR-011) -- tapOn: - text: "Email" -- inputText: ${MAESTRO_TEST_EMAIL} - -- tapOn: - text: "Password" -- inputText: ${MAESTRO_TEST_PASSWORD} - -- tapOn: - text: "Sign In" - -# Step 4: Verify feed loads after successful auth (FR-011) -- waitForAnimationToEnd -- assertVisible: - text: "ordrctrl" diff --git a/.maestro/flows/config.yaml b/.maestro/flows/config.yaml new file mode 100644 index 0000000..19e4237 --- /dev/null +++ b/.maestro/flows/config.yaml @@ -0,0 +1,7 @@ +# config.yaml — Maestro workspace config for .maestro/flows/ +# Run: maestro test .maestro/flows/ +executionOrder: + continueOnFailure: false + flowsOrder: + - app-flow + - integrations diff --git a/.maestro/flows/feed-load.yaml b/.maestro/flows/feed-load.yaml deleted file mode 100644 index d417236..0000000 --- a/.maestro/flows/feed-load.yaml +++ /dev/null @@ -1,25 +0,0 @@ -appId: com.ordrctrl.app ---- -# Flow 2: feed-load.yaml — verify feed loads with tasks after authentication -# FR-011 | maestro-flows-contract.md Flow 2 -# Assumes authenticated session established by auth.yaml - -# Launch app (session persists from auth.yaml) -- launchApp - -# Verify feed header is visible -- assertVisible: - text: "ordrctrl" - -# Verify at least one section is present (optional flags prevent failure when section absent) -- waitForAnimationToEnd -- assertVisible: - text: "UPCOMING" - optional: true -- assertVisible: - text: "NO DATE" - optional: true - -# FR-011: verify at least one task item is visible (non-optional — core requirement) -- assertVisible: - id: "Mark complete" diff --git a/.maestro/flows/integrations.yaml b/.maestro/flows/integrations.yaml new file mode 100644 index 0000000..540cfeb --- /dev/null +++ b/.maestro/flows/integrations.yaml @@ -0,0 +1,60 @@ +appId: com.ordrctrl.app + +onFlowStart: + - runScript: ../scripts/setup.js + +onFlowComplete: + - runScript: ../scripts/teardown.js +--- +# Integrations flow: sign in → navigate to settings → connect Apple Calendar +# +# Apple Calendar is tested because it uses an in-app credential form (no external OAuth). +# Gmail and Microsoft To Do use OAuth redirects to an external browser and +# cannot be automated end-to-end with Maestro. +# +# onFlowStart creates a fresh test user (output.testUser). +# onFlowComplete always runs — deletes the test user even if the flow fails. +# +# Requires in shell env (or .env in .maestro/flows/): +# APPLE_TEST_EMAIL — iCloud email address +# APPLE_TEST_PASSWORD — App-Specific Password (generated at appleid.apple.com) + +# Start fresh and authenticate +- clearState +- launchApp +- runFlow: ../subflows/login.yaml + +# Open the account menu and navigate to integrations +- tapOn: + text: "Account menu" +- waitForAnimationToEnd +- tapOn: + text: "Integrations" +- waitForAnimationToEnd + +# Verify the integrations page loaded and all providers are visible +- assertVisible: + text: "Apple Calendar" +- assertVisible: + text: "Gmail" +- assertVisible: + text: "Microsoft To Do" + +# Connect Apple Calendar with test credentials +- tapOn: + text: "+ Add account" +- tapOn: + text: "iCloud Email" +- inputText: ${APPLE_TEST_EMAIL} + +- tapOn: + text: "App-Specific Password" +- inputText: ${APPLE_TEST_PASSWORD} + +- tapOn: + text: "Connect" + +# Verify the integration connected successfully +- waitForAnimationToEnd +- assertVisible: + text: "Connected" diff --git a/.maestro/flows/task-complete.yaml b/.maestro/flows/task-complete.yaml deleted file mode 100644 index c7c59ff..0000000 --- a/.maestro/flows/task-complete.yaml +++ /dev/null @@ -1,24 +0,0 @@ -appId: com.ordrctrl.app ---- -# Flow 3: task-complete.yaml — complete a task and verify it moves to Completed section -# FR-012 | maestro-flows-contract.md Flow 3 -# Assumes authenticated session and at least one task visible - -- launchApp -- waitForAnimationToEnd - -# Verify we're on the feed -- assertVisible: - text: "ordrctrl" - -# Tap the first visible task's complete checkbox -# Capacitor WebView exposes aria-label as accessibility ID on iOS and Android -- tapOn: - accessibility id: "Mark complete" - -# Wait for state update animation -- waitForAnimationToEnd - -# Verify Completed section appears -- assertVisible: - text: "Completed" diff --git a/.maestro/scripts/setup.js b/.maestro/scripts/setup.js new file mode 100644 index 0000000..bb38d35 --- /dev/null +++ b/.maestro/scripts/setup.js @@ -0,0 +1,44 @@ +// setup.js — Maestro setup script +// Creates a fresh test user, logs in, and seeds a task so the feed is non-empty. +// Stores credentials in output.testUser so all sub-flows can reference them. +// Requires ENABLE_TEST_ROUTES=true in backend .env. + +var apiBase = maestro.platform === 'android' ? 'http://10.0.2.2:4000' : 'http://localhost:4000'; +var email = 'e2e-' + Date.now() + '@ordrctrl.test'; +var password = 'TestPass1!'; + +// 1. Create pre-verified test user +var createResp = http.post(apiBase + '/api/test/create-user', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email, password: password }) +}); +if (!createResp.ok) { + throw new Error('Failed to create test user (' + createResp.status + '): ' + createResp.body); +} + +// 2. Login to obtain a session cookie +var loginResp = http.post(apiBase + '/api/auth/login', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email, password: password }) +}); +if (!loginResp.ok) { + throw new Error('Failed to login test user (' + loginResp.status + '): ' + loginResp.body); +} + +// Extract session cookie (Set-Cookie: sessionId=xxx; Path=/; ...) +var setCookie = loginResp.headers['Set-Cookie'] || loginResp.headers['set-cookie'] || ''; +var sessionCookie = setCookie.split(';')[0]; // "sessionId=abc123" + +// 3. Seed a task so the feed has at least one item with "Mark complete" +var taskResp = http.post(apiBase + '/api/tasks', { + headers: { + 'Content-Type': 'application/json', + 'Cookie': sessionCookie + }, + body: JSON.stringify({ title: 'E2E test task' }) +}); +if (!taskResp.ok) { + throw new Error('Failed to seed task (' + taskResp.status + '): ' + taskResp.body); +} + +output.testUser = { email: email, password: password }; diff --git a/.maestro/scripts/teardown.js b/.maestro/scripts/teardown.js new file mode 100644 index 0000000..2796820 --- /dev/null +++ b/.maestro/scripts/teardown.js @@ -0,0 +1,21 @@ +// teardown.js — Maestro onFlowComplete hook +// Always runs after a flow completes (pass or fail) via the onFlowComplete hook. +// Deletes the test user created by setup.js. +// Gracefully handles: user already deleted (404), setup never completed (no output.testUser). +// Requires ENABLE_TEST_ROUTES=true in backend .env. + +var apiBase = maestro.platform === 'android' ? 'http://10.0.2.2:4000' : 'http://localhost:4000'; + +// Guard: if setup.js never completed (e.g. onFlowStart itself failed), skip cleanup. +if (!output.testUser || !output.testUser.email) { + console.log('[teardown] No test user in output — skipping cleanup'); +} else { + var response = http.delete(apiBase + '/api/test/delete-user', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: output.testUser.email }) + }); + + if (!response.ok && response.status !== 404) { + throw new Error('Failed to delete test user (' + response.status + '): ' + response.body); + } +} diff --git a/.maestro/subflows/login.yaml b/.maestro/subflows/login.yaml new file mode 100644 index 0000000..2929269 --- /dev/null +++ b/.maestro/subflows/login.yaml @@ -0,0 +1,25 @@ +appId: com.ordrctrl.app +--- +# Subflow: login.yaml — authenticate without clearing state +# Intended to be called via runFlow from flows that require auth. +# Does NOT call clearState — caller controls app/session state. +# +# Uses enterKeyhint="next" on the email input so pressing Return +# moves focus to the password field natively — more reliable than +# tapOn in WKWebView where accessibility activation can misfire. + +- tapOn: + text: "Email" +- inputText: ${output.testUser.email} +- pressKey: enter + +- inputText: ${output.testUser.password} + +- tapOn: + text: "Sign In" + +- waitForAnimationToEnd +# Assert something only visible on the FEED page, not the login page. +# "ordrctrl" appears on both, so assert the login form is gone instead. +- assertNotVisible: + text: "Sign In" diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..8bae80d --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,804 @@ +{ + "version": "2.0.0", + "tasks": [ + + // ============================================================ + // INDIVIDUAL TASKS — run any single command standalone + // ============================================================ + + // Backend + { + "label": "Backend: Dev", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Dev (Device)", + "type": "shell", + "command": "pnpm --filter backend dev:device", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Ngrok", + "type": "shell", + "command": "pnpm --filter backend dev:ngrok", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Test", + "type": "shell", + "command": "pnpm --filter backend test", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Test (Watch)", + "type": "shell", + "command": "pnpm --filter backend test:watch", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Backend: Test (Contract)", + "type": "shell", + "command": "pnpm --filter backend test:contract", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // Frontend + { + "label": "Frontend: Dev", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Frontend: Dev (Device)", + "type": "shell", + "command": "pnpm --filter frontend dev:device", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Frontend: Test", + "type": "shell", + "command": "pnpm --filter frontend test", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Frontend: Test (Watch)", + "type": "shell", + "command": "pnpm --filter frontend test:watch", + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Frontend: E2E (Playwright)", + "type": "shell", + "command": "pnpm --filter frontend test:e2e", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // iOS + { + "label": "iOS: Cap Sync", + "type": "shell", + "command": "pnpm exec cap sync ios", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "iOS: Run Simulator", + "type": "shell", + "command": "pnpm exec cap run ios", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "iOS: E2E (Maestro)", + "type": "shell", + "command": "maestro test .maestro/flows/", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // Android + { + "label": "Android: Cap Sync", + "type": "shell", + "command": "pnpm exec cap sync android", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Android: Run Emulator", + "type": "shell", + "command": "pnpm exec cap run android", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Android: E2E (Maestro)", + "type": "shell", + "command": "maestro test .maestro/flows/", + "group": "test", + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // Desktop (Tauri) + { + "label": "Desktop: Dev (Tauri)", + "type": "shell", + "command": "pnpm exec tauri dev", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "isBackground": true, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "Desktop: Build (Tauri)", + "type": "shell", + "command": "pnpm exec tauri build", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // Kills dev servers (ports 3000/4000), Tauri, Capacitor, Vitest watch, + // and Maestro. Terminals with close: true vanish automatically. + { + "label": "⚡ Stop: All", + "type": "shell", + "command": "for pid in $(lsof -ti:3000,4000 2>/dev/null) $(pgrep -f vitest 2>/dev/null) $(pgrep -f maestro 2>/dev/null) $(pgrep -f 'cargo run' 2>/dev/null) $(pgrep -f 'cap run' 2>/dev/null); do pgid=$(ps -o pgid= -p $pid 2>/dev/null | tr -d ' '); [ -n \"$pgid\" ] && kill -- -$pgid 2>/dev/null; done; sleep 0.5; echo 'All tasks stopped'", + "presentation": { "panel": "shared", "reveal": "silent", "close": true }, + "problemMatcher": [] + }, + + // ============================================================ + // SCENARIO LEAF TASKS — used only by compound tasks below. + // Each scenario gets a unique presentation.group so VS Code + // renders those terminals as split panes within one tab. + // close: true auto-closes the terminal when the process exits + // (triggered by ⚡ Stop: All Dev before switching scenarios). + // ============================================================ + + // --- ▶ Web: Dev (2 terminals) --- + { + "label": "[web-dev] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "web-dev", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[web-dev] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "group": "web-dev", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Web: Dev + E2E (3 terminals) --- + { + "label": "[web-e2e] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "web-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[web-e2e] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev", + "isBackground": true, + "presentation": { "group": "web-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[web-e2e] Playwright", + "type": "shell", + "command": "pnpm --filter frontend test:e2e", + "isBackground": true, + "presentation": { "group": "web-e2e", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // --- ▶ iOS: Simulator Dev (3 terminals) --- + { + "label": "[ios-sim] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-sim] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:ios", + "isBackground": true, + "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-sim] Simulator", + "type": "shell", + "command": "xcrun simctl shutdown all 2>/dev/null; pnpm exec cap run ios --live-reload --host localhost", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "ios-sim", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ iOS: Device Dev (3 terminals — real device via ngrok) --- + { + "label": "[ios-device] Backend", + "type": "shell", + "command": "pnpm --filter backend dev:device", + "isBackground": true, + "presentation": { "group": "ios-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-device] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:device", + "isBackground": true, + "presentation": { "group": "ios-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-device] Ngrok", + "type": "shell", + "command": "pnpm --filter backend dev:ngrok", + "isBackground": true, + "presentation": { "group": "ios-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Android: Emulator Dev (3 terminals) --- + { + "label": "[android-emu] Backend", + "type": "shell", + "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-emu] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:android", + "isBackground": true, + "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-emu] Emulator", + "type": "shell", + "command": "pnpm exec cap run android --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "android-emu", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Android: Device Dev (3 terminals — real device via ngrok) --- + { + "label": "[android-device] Backend", + "type": "shell", + "command": "pnpm --filter backend dev:device", + "isBackground": true, + "presentation": { "group": "android-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-device] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:device", + "isBackground": true, + "presentation": { "group": "android-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-device] Ngrok", + "type": "shell", + "command": "pnpm --filter backend dev:ngrok", + "isBackground": true, + "presentation": { "group": "android-device", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Desktop: Dev (2 terminals) --- + { + "label": "[desktop-dev] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "desktop-dev", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[desktop-dev] Tauri", + "type": "shell", + "command": "pnpm exec tauri dev", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "desktop-dev", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Test: Unit Watch (2 terminals) --- + { + "label": "[test-watch] Backend", + "type": "shell", + "command": "pnpm --filter backend test:watch", + "isBackground": true, + "presentation": { "group": "test-watch", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[test-watch] Frontend", + "type": "shell", + "command": "pnpm --filter frontend test:watch", + "isBackground": true, + "presentation": { "group": "test-watch", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Test: All (4 terminals) --- + { + "label": "[test-all] Backend Unit", + "type": "shell", + "command": "pnpm --filter backend test", + "isBackground": true, + "presentation": { "group": "test-all", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[test-all] Backend Contract", + "type": "shell", + "command": "pnpm --filter backend test:contract", + "isBackground": true, + "presentation": { "group": "test-all", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[test-all] Frontend Unit", + "type": "shell", + "command": "pnpm --filter frontend test", + "isBackground": true, + "presentation": { "group": "test-all", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[test-all] Frontend E2E", + "type": "shell", + "command": "pnpm --filter frontend test:e2e", + "isBackground": true, + "presentation": { "group": "test-all", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ iOS: E2E (4 terminals) --- + { + "label": "[ios-e2e] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:ios", + "isBackground": true, + "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e] Simulator", + "type": "shell", + "command": "xcrun simctl shutdown all 2>/dev/null; pnpm exec cap run ios --live-reload --host localhost", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e] Maestro", + "type": "shell", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 5 && maestro test .maestro/flows/", + "presentation": { "group": "ios-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Android: E2E (4 terminals) --- + { + "label": "[android-e2e] Backend", + "type": "shell", + "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-e2e] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:android", + "isBackground": true, + "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-e2e] Emulator", + "type": "shell", + "command": "pnpm exec cap run android --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + { + "label": "[android-e2e] Maestro", + "type": "shell", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 5 && maestro test .maestro/flows/", + "presentation": { "group": "android-e2e", "panel": "new", "reveal": "always", "close": true }, + "problemMatcher": [] + }, + + // --- ▶ Test: All (keep) — same as above but terminals stay open --- + { + "label": "[test-all-keep] Backend Unit", + "type": "shell", + "command": "pnpm --filter backend test", + "presentation": { "group": "test-all-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[test-all-keep] Backend Contract", + "type": "shell", + "command": "pnpm --filter backend test:contract", + "presentation": { "group": "test-all-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[test-all-keep] Frontend Unit", + "type": "shell", + "command": "pnpm --filter frontend test", + "presentation": { "group": "test-all-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[test-all-keep] Frontend E2E", + "type": "shell", + "command": "pnpm --filter frontend test:e2e", + "presentation": { "group": "test-all-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // --- ▶ Test: Unit Watch (keep) --- + { + "label": "[test-watch-keep] Backend", + "type": "shell", + "command": "pnpm --filter backend test:watch", + "isBackground": true, + "presentation": { "group": "test-watch-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[test-watch-keep] Frontend", + "type": "shell", + "command": "pnpm --filter frontend test:watch", + "isBackground": true, + "presentation": { "group": "test-watch-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // --- ▶ iOS: E2E (keep) --- + { + "label": "[ios-e2e-keep] Backend", + "type": "shell", + "command": "pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e-keep] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:ios", + "isBackground": true, + "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e-keep] Simulator", + "type": "shell", + "command": "xcrun simctl shutdown all 2>/dev/null; pnpm exec cap run ios --live-reload --host localhost", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[ios-e2e-keep] Maestro", + "type": "shell", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for iOS app install on simulator...' && until xcrun simctl get_app_container booted com.ordrctrl.app 2>/dev/null; do sleep 5; done && sleep 5 && maestro test .maestro/flows/", + "presentation": { "group": "ios-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + // --- ▶ Android: E2E (keep) --- + { + "label": "[android-e2e-keep] Backend", + "type": "shell", + "command": "NATIVE_APP_ORIGINS=\"http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):3000,capacitor://localhost,http://localhost\" pnpm --filter backend dev", + "isBackground": true, + "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[android-e2e-keep] Frontend", + "type": "shell", + "command": "pnpm --filter frontend dev:android", + "isBackground": true, + "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[android-e2e-keep] Emulator", + "type": "shell", + "command": "pnpm exec cap run android --live-reload --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)", + "options": { "cwd": "${workspaceFolder}/frontend" }, + "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + { + "label": "[android-e2e-keep] Maestro", + "type": "shell", + "command": "export JAVA_HOME=$HOME/.sdkman/candidates/java/current && export PATH=$JAVA_HOME/bin:$PATH && echo 'Waiting for Vite dev server...' && until curl -s http://localhost:3000 >/dev/null 2>&1; do sleep 3; done && echo 'Waiting for Android app install on emulator...' && until adb shell pm list packages 2>/dev/null | grep -q 'com.ordrctrl.app'; do sleep 5; done && sleep 5 && maestro test .maestro/flows/", + "presentation": { "group": "android-e2e-keep", "panel": "new", "reveal": "always" }, + "problemMatcher": [] + }, + + + // + // Dev scenarios use a two-level structure: + // Outer (sequence): ⚡ Stop: All Dev → (start) + // Inner (parallel): all leaf terminals launch simultaneously + // + // When switching scenarios, the stop task kills dev ports + // (3000, 4000) and Tauri/Capacitor processes. Because leaf + // tasks have close: true, their terminals vanish automatically, + // giving you a clean split view for the new scenario. + // ============================================================ + + // Inner parallel start tasks (not meant to be run directly) + { + "label": "▶ Web: Dev (start)", + "dependsOn": ["[web-dev] Backend", "[web-dev] Frontend"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Web: Dev + E2E (start)", + "dependsOn": ["[web-e2e] Backend", "[web-e2e] Frontend", "[web-e2e] Playwright"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ iOS: Simulator Dev (start)", + "dependsOn": ["[ios-sim] Backend", "[ios-sim] Frontend", "[ios-sim] Simulator"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ iOS: Device Dev (start)", + "dependsOn": ["[ios-device] Backend", "[ios-device] Frontend", "[ios-device] Ngrok"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Android: Emulator Dev (start)", + "dependsOn": ["[android-emu] Backend", "[android-emu] Frontend", "[android-emu] Emulator"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Android: Device Dev (start)", + "dependsOn": ["[android-device] Backend", "[android-device] Frontend", "[android-device] Ngrok"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Desktop: Dev (start)", + "dependsOn": ["[desktop-dev] Backend", "[desktop-dev] Tauri"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + + // Outer tasks — stop existing servers, then open new split view + { + "label": "▶ Web: Dev", + "dependsOn": ["⚡ Stop: All", "▶ Web: Dev (start)"], + "dependsOrder": "sequence", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": [] + }, + { + "label": "▶ Web: Dev + E2E", + "dependsOn": ["⚡ Stop: All", "▶ Web: Dev + E2E (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ iOS: Simulator Dev", + "dependsOn": ["⚡ Stop: All", "▶ iOS: Simulator Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ iOS: Device Dev", + "dependsOn": ["⚡ Stop: All", "▶ iOS: Device Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Android: Emulator Dev", + "dependsOn": ["⚡ Stop: All", "▶ Android: Emulator Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Android: Device Dev", + "dependsOn": ["⚡ Stop: All", "▶ Android: Device Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Desktop: Dev", + "dependsOn": ["⚡ Stop: All", "▶ Desktop: Dev (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + // Inner parallel start tasks for test scenarios + { + "label": "▶ Test: Unit Watch (start)", + "dependsOn": ["[test-watch] Backend", "[test-watch] Frontend"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Test: All (start)", + "dependsOn": [ + "[test-all] Backend Unit", + "[test-all] Backend Contract", + "[test-all] Frontend Unit", + "[test-all] Frontend E2E" + ], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ iOS: E2E (start)", + "dependsOn": ["[ios-e2e] Backend", "[ios-e2e] Frontend", "[ios-e2e] Simulator", "[ios-e2e] Maestro"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Android: E2E (start)", + "dependsOn": ["[android-e2e] Backend", "[android-e2e] Frontend", "[android-e2e] Emulator", "[android-e2e] Maestro"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Test: Unit Watch", + "dependsOn": ["⚡ Stop: All", "▶ Test: Unit Watch (start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Test: All", + "dependsOn": ["⚡ Stop: All", "▶ Test: All (start)"], + "dependsOrder": "sequence", + "group": { "kind": "test", "isDefault": true }, + "problemMatcher": [] + }, + { + "label": "▶ iOS: E2E", + "dependsOn": ["⚡ Stop: All", "▶ iOS: E2E (start)"], + "dependsOrder": "sequence", + "group": "test", + "problemMatcher": [] + }, + { + "label": "▶ Android: E2E", + "dependsOn": ["⚡ Stop: All", "▶ Android: E2E (start)"], + "dependsOrder": "sequence", + "group": "test", + "problemMatcher": [] + }, + + // (keep) variants — same stop-then-start flow, but terminals stay + // open after completion so you can review results. + { + "label": "▶ Test: Unit Watch (keep)", + "dependsOn": ["⚡ Stop: All", "▶ Test: Unit Watch (keep-start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Test: All (keep)", + "dependsOn": ["⚡ Stop: All", "▶ Test: All (keep-start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ iOS: E2E (keep)", + "dependsOn": ["⚡ Stop: All", "▶ iOS: E2E (keep-start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Android: E2E (keep)", + "dependsOn": ["⚡ Stop: All", "▶ Android: E2E (keep-start)"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "▶ Test: Unit Watch (keep-start)", + "dependsOn": ["[test-watch-keep] Backend", "[test-watch-keep] Frontend"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Test: All (keep-start)", + "dependsOn": [ + "[test-all-keep] Backend Unit", + "[test-all-keep] Backend Contract", + "[test-all-keep] Frontend Unit", + "[test-all-keep] Frontend E2E" + ], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ iOS: E2E (keep-start)", + "dependsOn": ["[ios-e2e-keep] Backend", "[ios-e2e-keep] Frontend", "[ios-e2e-keep] Simulator", "[ios-e2e-keep] Maestro"], + "dependsOrder": "parallel", + "problemMatcher": [] + }, + { + "label": "▶ Android: E2E (keep-start)", + "dependsOn": ["[android-e2e-keep] Backend", "[android-e2e-keep] Frontend", "[android-e2e-keep] Emulator", "[android-e2e-keep] Maestro"], + "dependsOrder": "parallel", + "problemMatcher": [] + } + ] +} diff --git a/backend/.env.example b/backend/.env.example index 68d1efa..a91b9b7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,10 +30,14 @@ API_URL="http://localhost:4000" # Native app origins — comma-separated list of allowed webview origins for Capacitor (iOS/Android) # and Tauri (macOS/Windows). These are merged with APP_URL in the backend CORS allowlist so that # credentialed requests from native app webviews are accepted. +# For iOS simulator / Android emulator live-reload, also add your LAN IP: +# NATIVE_APP_ORIGINS=capacitor://localhost,tauri://localhost,http://tauri.localhost,http://YOUR_LAN_IP:3000 +# Find your LAN IP: ipconfig getifaddr en0 NATIVE_APP_ORIGINS=capacitor://localhost,tauri://localhost,http://tauri.localhost # Environment NODE_ENV="development" +# ENABLE_TEST_ROUTES="true" # Uncomment to enable /api/test/* routes for E2E test setup/teardown. NEVER enable in production. PORT="4000" # LOG_LEVEL="info" # optional — trace | debug | info | warn | error | fatal diff --git a/backend/src/api/test.routes.ts b/backend/src/api/test.routes.ts new file mode 100644 index 0000000..d7881d3 --- /dev/null +++ b/backend/src/api/test.routes.ts @@ -0,0 +1,82 @@ +// Test-only routes — only registered when ENABLE_TEST_ROUTES=true. +// NEVER enable in production. +// +// POST /api/test/create-user — creates a pre-verified test user +// DELETE /api/test/delete-user — deletes a test user by email + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { prisma } from '../lib/db.js'; +import { logger } from '../lib/logger.js'; + +const SALT_ROUNDS = 12; + +const createUserSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(8) + .regex(/[A-Z]/, 'Must contain uppercase') + .regex(/[0-9]/, 'Must contain number'), +}); + +const deleteUserSchema = z.object({ + email: z.string().email(), +}); + +export async function registerTestRoutes(app: FastifyInstance): Promise { + // POST /api/test/create-user + app.post('/api/test/create-user', async (request: FastifyRequest, reply: FastifyReply) => { + const result = createUserSchema.safeParse(request.body); + if (!result.success) { + return reply.status(422).send({ error: 'Validation Error', details: result.error.flatten() }); + } + + const { email, password } = result.data; + const existing = await prisma.user.findUnique({ where: { email: email.toLowerCase() } }); + if (existing) { + return reply.status(409).send({ error: 'Email already registered' }); + } + + const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); + const user = await prisma.user.create({ + data: { + email: email.toLowerCase(), + passwordHash, + authProvider: 'email', + emailVerified: true, + }, + }); + + logger.info('[test] Created test user', { userId: user.id, email: user.email }); + return reply.status(201).send({ email: user.email }); + }); + + // DELETE /api/test/delete-user + app.delete('/api/test/delete-user', async (request: FastifyRequest, reply: FastifyReply) => { + const result = deleteUserSchema.safeParse(request.body); + if (!result.success) { + return reply.status(422).send({ error: 'Validation Error', details: result.error.flatten() }); + } + + const { email } = result.data; + const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } }); + if (!user) { + return reply.status(404).send({ error: 'User not found' }); + } + + await prisma.user.delete({ where: { id: user.id } }); + logger.info('[test] Deleted test user', { userId: user.id, email: user.email }); + return reply.status(204).send(); + }); + + // POST /api/test/cleanup — deletes all users with @ordrctrl.test emails (orphan cleanup) + app.post('/api/test/cleanup', async (_request: FastifyRequest, reply: FastifyReply) => { + const deleted = await prisma.user.deleteMany({ + where: { email: { endsWith: '@ordrctrl.test' } }, + }); + logger.info('[test] Cleaned up test users', { count: deleted.count }); + return reply.status(200).send({ deleted: deleted.count }); + }); +} diff --git a/backend/src/app.ts b/backend/src/app.ts index c828ded..a899902 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -60,6 +60,13 @@ export async function createApp(): Promise { // Inbox routes (010-task-inbox) await registerInboxRoutes(app); + // Test-only routes (E2E setup/teardown — never enable in production) + if (process.env.ENABLE_TEST_ROUTES === 'true') { + const { registerTestRoutes } = await import('./api/test.routes.js'); + await registerTestRoutes(app); + app.log.warn('ENABLE_TEST_ROUTES is enabled — test endpoints are active'); + } + // Error handlers app.setErrorHandler(errorHandler); app.setNotFoundHandler(notFoundHandler); diff --git a/docker-compose.yml b/docker-compose.yml index b9d722d..20e18b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - services: postgres: image: postgres:16-alpine diff --git a/docs/development.md b/docs/development.md index 7254378..935eee0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -90,7 +90,7 @@ TOKEN_ENCRYPTION_KEY="" | `MICROSOFT_TENANT_ID` | `common` | Azure AD tenant ID — defaults to multi-tenant | | `APP_URL` | `http://localhost:3000` | Frontend URL | | `API_URL` | `http://localhost:4000` | Backend URL | -| `NATIVE_APP_ORIGINS` | `capacitor://localhost,tauri://localhost,http://tauri.localhost` | Allowed CORS origins for native webviews | +| `NATIVE_APP_ORIGINS` | `capacitor://localhost,tauri://localhost,http://tauri.localhost` | Allowed CORS origins for native webviews. For Android emulator live-reload, the VS Code tasks automatically append `http://10.0.2.2:3000` at runtime. | | `NODE_ENV` | `development` | | | `PORT` | `4000` | Backend port | | `LOG_LEVEL` | `info` | Log verbosity — `trace` \| `debug` \| `info` \| `warn` \| `error` \| `fatal` | @@ -107,6 +107,26 @@ TOKEN_ENCRYPTION_KEY="" | `VITE_DEV_APPLE_APP_SPECIFIC_PASSWORD` | *(optional)* | Pre-fills Apple CalDAV credential form locally | | `E2E_SESSION_COOKIE` | *(optional)* | Session token for authenticated Playwright e2e tests — see [E2E testing](#e2e-testing) | +#### Platform-specific frontend env overrides + +Each platform has its own env file loaded on top of `frontend/.env`. Copy the example and fill in values once: + +| Platform | Script | Example file | Local file (gitignored) | Notes | +|----------|--------|-------------|------------------------|-------| +| iOS simulator | `pnpm dev:ios` | `.env.ios.example` | `.env.ios.local` | Uses `localhost` — iOS simulator shares Mac's network stack | +| Android emulator | `pnpm dev:android` | `.env.android.example` | `.env.android.local` | Uses `10.0.2.2` — Android's alias for host loopback | +| Physical device | `pnpm dev:device` | `.env.device.example` | `.env.device.local` | Uses ngrok HTTPS URL | + +```bash +# One-time setup for iOS simulator +cp frontend/.env.ios.example frontend/.env.ios.local +# No edits needed — localhost works out of the box + +# One-time setup for Android emulator +cp frontend/.env.android.example frontend/.env.android.local +# No edits needed — 10.0.2.2 is the standard Android host alias +``` + --- ## One-time integration setup @@ -184,6 +204,12 @@ cd .. ### Start dev servers +The quickest way is to use the VS Code task (see [VS Code tasks](#vs-code-tasks) below): + +> `Cmd+Shift+B` → opens backend + frontend in a split terminal view + +Or from the terminal: + ```bash pnpm dev ``` @@ -199,6 +225,60 @@ cd frontend && pnpm dev # frontend only --- +## VS Code tasks + +The project ships with a full set of VS Code tasks (`.vscode/tasks.json`) that open split-terminal views and manage process lifecycle automatically. They are the recommended way to run dev servers and tests during active development. + +### How to run a task + +**Command Palette** (recommended): `Cmd+Shift+P` → *Tasks: Run Task* → pick a task +**Keyboard shortcut**: `Cmd+Shift+B` runs the default build task (`▶ Web: Dev`) + +--- + +### Scenario tasks — dev + +Each scenario task stops any running dev servers, then opens a split-terminal view with everything needed for that platform. + +| Task | Terminals opened | Use when | +|------|-----------------|----------| +| `▶ Web: Dev` | Backend · Frontend | Building or testing in a browser | +| `▶ Web: Dev + E2E` | Backend · Frontend · Playwright | Running Playwright tests against a live dev server | +| `▶ iOS: Simulator Dev` | Backend · Frontend · iOS Simulator | Testing the Capacitor iOS app in the Xcode simulator | +| `▶ iOS: Device Dev` | Backend (device) · Frontend (device) · ngrok | Testing on a physical iOS device | +| `▶ Android: Emulator Dev` | Backend · Frontend · Android Emulator | Testing the Capacitor Android app in an emulator | +| `▶ Android: Device Dev` | Backend (device) · Frontend (device) · ngrok | Testing on a physical Android device | +| `▶ Desktop: Dev` | Backend · Tauri | Working on the Tauri desktop app | + +**Switching scenarios:** just run the new scenario task — the stop step runs automatically, previous terminals close, and the new split view opens. + +**Manually stopping everything:** run `⚡ Stop: All` from the task picker. + +--- + +### Scenario tasks — testing + +These do not stop running dev servers first (test runners are independent). + +| Task | Terminals opened | Use when | +|------|-----------------|----------| +| `▶ Test: All` | Backend unit · Backend contract · Frontend unit · Frontend E2E | Full pre-commit test pass (terminals auto-close) | +| `▶ Test: All (keep)` | same | Same, but terminals stay open to review results | +| `▶ Test: Unit Watch` | Backend watch · Frontend watch | TDD / watching tests while editing (terminals auto-close) | +| `▶ Test: Unit Watch (keep)` | same | Same, but terminals stay open | +| `▶ iOS: E2E` | Backend · Frontend · iOS Simulator · Maestro | iOS Maestro flow tests — boots simulator, waits for app, then runs tests (terminals auto-close) | +| `▶ iOS: E2E (keep)` | same | Same, but terminals stay open | +| `▶ Android: E2E` | Backend · Frontend · Android Emulator · Maestro | Android Maestro flow tests — boots emulator, waits for app, then runs tests (terminals auto-close) | +| `▶ Android: E2E (keep)` | same | Same, but terminals stay open | + +--- + +### Individual tasks + +Every command is also available as a standalone task for one-off use (e.g. running a single test suite, syncing Capacitor, or building the Tauri app without opening a full scenario). Find them in the task picker under their platform prefix: `Backend:`, `Frontend:`, `iOS:`, `Android:`, `Desktop:`. + +--- + ## Testing scenarios ### Web (browser) @@ -211,17 +291,17 @@ With `pnpm dev` running, open `http://localhost:3000`. Sign in with email/passwo **Prerequisites:** Xcode 15+, Capacitor iOS project initialized (run once: `cd frontend && pnpm cap add ios`) +The VS Code task `▶ iOS: Simulator Dev` handles everything automatically. To run manually: + ```bash # 1. Sync Capacitor cd frontend && pnpm cap sync ios -# 2. Open in Xcode -pnpm cap open ios - -# 3. In Xcode: select an iPhone simulator → Run (▶) +# 2. Run with live-reload (connects simulator to local Vite dev server) +pnpm cap run ios --live-reload --host localhost ``` -The app will connect to `http://localhost:4000` directly — no ngrok needed for the simulator. +The iOS simulator shares the Mac's network stack — `localhost` resolves to your Mac directly, so no ngrok or LAN IP setup is needed. **Sign in with Apple on simulator:** Ensure you have an Apple ID configured in the simulator's **Settings → Sign in to your iPhone** before testing. @@ -349,17 +429,31 @@ cd frontend && pnpm exec playwright show-report ### Maestro (iOS / Android native) -**Prerequisites:** Maestro CLI ≥ v1.38 (`curl -Ls "https://get.maestro.mobile.dev" | bash`), a running iOS or Android simulator with the app installed. +**Prerequisites:** Maestro CLI ≥ v1.38 (`curl -Ls "https://get.maestro.mobile.dev" | bash`), a stable Java LTS (Java 21 recommended — see below), and a running iOS simulator or Android emulator with the app installed. + +**One-time setup — credentials:** + +```bash +cp .maestro/.env.example .maestro/.env.local +# Edit .maestro/.env.local with your test account credentials +``` + +The `▶ iOS: E2E` and `▶ Android: E2E` VS Code tasks load `.maestro/.env.local` automatically. ```bash -# Run all flows in order (auth → feed-load → task-complete) -MAESTRO_TEST_EMAIL= MAESTRO_TEST_PASSWORD= maestro test .maestro/flows/ +# Run all flows manually (auth → feed-load → task-complete) +maestro test --env MAESTRO_TEST_EMAIL= --env MAESTRO_TEST_PASSWORD= .maestro/flows/ # Run a single flow -maestro test .maestro/flows/auth.yaml +maestro test --env MAESTRO_TEST_EMAIL= --env MAESTRO_TEST_PASSWORD= .maestro/flows/auth.yaml ``` -Flows skip gracefully when `MAESTRO_TEST_EMAIL` is not set. +**Java version:** Maestro 2.3.0 fails to parse Java EA version strings (e.g. `27-ea`). Install a stable LTS via SDKMAN: + +```bash +sdk install java 21.0.10-tem +sdk default java 21.0.10-tem +``` --- diff --git a/frontend/.env.android.example b/frontend/.env.android.example new file mode 100644 index 0000000..658c0b2 --- /dev/null +++ b/frontend/.env.android.example @@ -0,0 +1,4 @@ +# Android Emulator overrides — copy to .env.android.local +# Android emulator uses 10.0.2.2 as the alias for the host Mac's localhost. +VITE_API_URL=http://10.0.2.2:4000 +VITE_APP_URL=http://10.0.2.2:3000 diff --git a/frontend/.env.ios.example b/frontend/.env.ios.example new file mode 100644 index 0000000..129ef2b --- /dev/null +++ b/frontend/.env.ios.example @@ -0,0 +1,5 @@ +# iOS Simulator overrides — copy to .env.simulator.local +# iOS simulator shares the Mac network stack, so localhost works directly. +# No changes needed from defaults — this file is a no-op placeholder. +VITE_API_URL=http://localhost:4000 +VITE_APP_URL=http://localhost:3000 diff --git a/frontend/index.html b/frontend/index.html index 8672dc2..a959b27 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + ordrctrl diff --git a/frontend/package.json b/frontend/package.json index 96acb6e..0e87888 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,9 @@ "lint": "eslint src --ext ts,tsx", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "dev:android": "vite --mode android --host", + "dev:ios": "vite --mode ios --host" }, "dependencies": { "@capacitor/android": "^8.2.0", diff --git a/frontend/src/app/feed/page.tsx b/frontend/src/app/feed/page.tsx index 271b680..d97986d 100644 --- a/frontend/src/app/feed/page.tsx +++ b/frontend/src/app/feed/page.tsx @@ -107,9 +107,9 @@ function FeedPageContent() { const undatedItems = items.filter((i) => i.dueAt === null); return ( -
+
{/* Top nav */} -
+
ordrctrl @@ -190,7 +190,8 @@ function FeedPageContent() {
{/* Main content */} -
+
+
{error && (
{error} @@ -331,6 +332,7 @@ function FeedPageContent() { )}
+
{/* FAB — Add task (normal feed only) */} {!showDismissed && !showAddForm && ( @@ -338,7 +340,7 @@ function FeedPageContent() { type="button" onClick={() => setShowAddForm(true)} aria-label="Add task" - className="fixed bottom-6 right-6 w-12 h-12 bg-black border-0 cursor-pointer flex items-center justify-center shadow-lg z-20" + className="fixed bottom-[calc(1.5rem+env(safe-area-inset-bottom))] right-6 w-12 h-12 bg-black border-0 cursor-pointer flex items-center justify-center shadow-lg z-20" > @@ -371,7 +373,7 @@ function FeedPageContent() { {/* Cleared completed toast */} {clearedCount !== null && ( -
+
Cleared {clearedCount} completed task{clearedCount !== 1 ? 's' : ''} — find them in{' '} @@ -391,7 +393,7 @@ function FeedPageContent() { {/* Undo toast for dismiss */} {undoToast && ( -
+
{undoToast.message}
{/* Content */} diff --git a/frontend/src/components/inbox/InboxPage.tsx b/frontend/src/components/inbox/InboxPage.tsx index c382753..e187058 100644 --- a/frontend/src/components/inbox/InboxPage.tsx +++ b/frontend/src/components/inbox/InboxPage.tsx @@ -22,9 +22,9 @@ export function InboxPage() { } = useInbox(); return ( -
+
{/* Top nav */} -
+
ordrctrl @@ -37,7 +37,8 @@ export function InboxPage() {
{/* Main content */} -
+
+

Inbox

{!loading && total > 0 && ( @@ -90,6 +91,7 @@ export function InboxPage() {
)}
+
); }