diff --git a/docs-web/content/docs/developer-mcp-tools.mdx b/docs-web/content/docs/developer-mcp-tools.mdx index d5a1258305..0eeab55074 100644 --- a/docs-web/content/docs/developer-mcp-tools.mdx +++ b/docs-web/content/docs/developer-mcp-tools.mdx @@ -65,6 +65,20 @@ Every tool requires `runtimeRoles: ["project_manager"]` and is enabled by defaul | `manage_preview` | `list_sessions`, `start_session`, `stop_session`, `rebuild_session`, `remove_session`, `get_logs`, `get_url`, `get_script`, `update_script` | | `manage_telemetry` | `get_project_stats_snapshot`, `get_project_execution_snapshot`, `list_execution_invocations`, `list_execution_invocation_messages`, `list_sprint_runs`, `list_task_dispatches` | +### Sprint issue imports + +`manage_sprints` action `import_issues` accepts `provider` values `github`, `gitlab`, `jira`, `notion`, `asana`, and `linear`. + +- GitHub and GitLab require saved effective `git.githubToken` / `git.gitlabToken` settings. +- Jira requires saved Jira host, email, API token, and usually a default project key. +- Notion requires `notion.apiToken`; optional `databaseId` narrows page/database imports. +- Asana requires `asana.apiToken` plus `workspaceId` for workspace search or `providerProjectId` for project task fallback. +- Linear requires `linear.apiToken`; optional `teamId`, `teamKey`, and `providerProjectId` narrow issue search. + +Common import fields include `search`, `state`, `status`, `labels`, `assignee`, `issueKeys`, `issueNumbers`, `issueRefs`, `externalIds`, `includeConversation`, `limit`, `sprintId`, `attachToSprint`, and `planAfterImport`. Use `externalIds` for explicit Notion page/database ids, Asana task gids, and Linear issue ids or identifiers. + +Notion, Asana, and Linear imports are read-only: Code UX searches, fetches prompt context, attaches linked-source records, and can plan from imported scope, but it does not close, complete, archive, or transition those provider items. + For the full per-action payloads and return shapes, see [Management actions](/docs/developer-management-actions). ## Approval handshake (destructive actions) diff --git a/docs-web/content/docs/user-dashboard-sprints.mdx b/docs-web/content/docs/user-dashboard-sprints.mdx index 0c53609ac3..24ffee0437 100644 --- a/docs-web/content/docs/user-dashboard-sprints.mdx +++ b/docs-web/content/docs/user-dashboard-sprints.mdx @@ -81,9 +81,11 @@ Importing is the recommended way to template sprints across projects when [Quick You can also import linked sprint scope from configured external systems. GitHub and GitLab imports use the saved effective `git.githubToken` and `git.gitlabToken` settings. Jira imports use the existing Jira settings block: site URL, email, API token, default project, import transition, close transition, and Jira auto-close behavior. +Notion, Asana, and Linear imports are API-backed and read-only. Notion uses `notion.apiToken` plus an optional `databaseId`, Asana uses `asana.apiToken` plus `workspaceId` or project fallback, and Linear uses `linear.apiToken` plus optional `teamId`, `teamKey`, or project filters. These importers can search, fetch readable body/comment context, attach linked scope to a sprint, and plan from that imported scope; they do not archive Notion pages, complete Asana tasks, transition Linear issues, or close those external items. + The shared settings model also supports Notion, Asana, Linear, Miro, Lucid, Figma, and Mural connection blocks. These provider blocks store enablement, token/secret, base URL, workspace/team/project identifiers, database/board/document/file identifiers, and a default search limit. They are saved and resolved through the same system → project → sprint settings hierarchy as other sprint integrations. -Linked sprint records preserve numeric issue numbers for GitHub, GitLab, and Jira. Non-numeric external objects are stored with an `externalId`, `sourceKind`, and `sourceProvider`, so future importer flows can attach pages, boards, documents, files, tasks, and canvas objects without inventing fake issue numbers. +Linked sprint records preserve numeric issue numbers for GitHub, GitLab, and Jira. Non-numeric external objects are stored with an `externalId`, `sourceKind`, and `sourceProvider`, so Notion pages/databases, Asana tasks, Linear issues, and future board/document/file/canvas importers can attach scope without inventing fake issue numbers. ## Sprint settings overrides diff --git a/docs/dashboard/sprint-imports.md b/docs/dashboard/sprint-imports.md index 3cbe0a9ba7..2802955848 100644 --- a/docs/dashboard/sprint-imports.md +++ b/docs/dashboard/sprint-imports.md @@ -1,8 +1,8 @@ # Sprint Imports -Sprint imports support three production paths from the Sprints page: structured markdown bundles, GitHub/GitLab issue imports, and Jira issue imports. +Sprint imports support production paths from the Sprints page and MCP: structured markdown bundles, GitHub/GitLab issue imports, Jira issue imports, and read-only linked-scope imports from Notion, Asana, and Linear. -Internal MCP clients use the same importer services through `manage_sprints` action `import_issues`. For payload examples covering search-only imports, assigned-work searches, explicit Jira keys, explicit GitHub/GitLab issue numbers, sprint attachment, and plan-after-import flows, see [MCP Tools and Contracts: `manage_sprints import_issues`](../mcp/tools-and-contracts.md#manage_sprints-import_issues). +Internal MCP clients use the same importer services through `manage_sprints` action `import_issues`. For payload examples covering search-only imports, assigned-work searches, explicit Jira keys, explicit GitHub/GitLab issue numbers, explicit Notion/Asana/Linear external IDs, sprint attachment, and plan-after-import flows, see [MCP Tools and Contracts: `manage_sprints import_issues`](../mcp/tools-and-contracts.md#manage_sprints-import_issues). ## Markdown Import @@ -71,12 +71,22 @@ Repository issues have two import modes. `Import as linked issues` creates linke Issue import uses the saved integration tokens: - GitHub: system/project effective `git.githubToken`, usually configured in Settings -> Integrations. - GitLab: system/project effective `git.gitlabToken`, usually configured in Settings -> Integrations or seeded from `GITLAB_TOKEN` / `GLAB_TOKEN` host hints. +- Notion: system/project effective `notion.apiToken`; `databaseId` can narrow search or explicitly import a database. +- Asana: system/project effective `asana.apiToken`; workspace search uses `workspaceId`, while project fallback uses `projectId`. +- Linear: system/project effective `linear.apiToken`; `teamId`, `teamKey`, and `projectId` can narrow issue search. When the GitHub token is empty, GitHub issue search, issue context loading, and auto-close fail with a token-required error. Code UX does not fall back to local `gh` or `glab` CLI authentication for dashboard or MCP importer workflows; Docker auth-copy mount settings help worker containers, but issue search, explicit import, linked sprint attachment, planning imports, and close operations need saved GitHub/GitLab tokens. +Notion, Asana, and Linear importer workflows use direct provider APIs through `fetch` and require saved tokens before any network request is made. They are read/attach only: Code UX searches, fetches readable context, persists local linked-source records, enriches sprint prompts, and can plan from that imported scope, but it does not archive Notion pages, complete Asana tasks, transition Linear issues, or close those external items. + +Provider-specific search behavior: +- Notion uses `POST https://api.notion.com/v1/search` with `Authorization: Bearer` and `Notion-Version`, maps pages and databases, and reads page/database block children into prompt markdown when blocks are readable. +- Asana uses `GET https://app.asana.com/api/1.0/workspaces/{workspace_gid}/tasks/search` for workspace task search and falls back to project tasks when a project id is supplied. When conversation context is requested, task stories/comments are appended to prompt markdown. +- Linear uses `POST https://api.linear.app/graphql` for issue search/filter queries and explicit issue fetches. Results include description, labels, state, team/project, assignee, URL, and comments when conversation context is requested. + ## Project-Management And Canvas Integration Settings -Code UX now carries shared typed settings for additional importer providers: Notion, Asana, Linear, Miro, Lucid, Figma, and Mural. These settings are available at system scope, project scope, sprint effective-settings resolution, and the dashboard type contracts. Jira continues to use the existing `jira` settings block, and GitHub/GitLab continue to use `git.githubToken` and `git.gitlabToken`. +Code UX carries shared typed settings for additional importer providers: Notion, Asana, Linear, Miro, Lucid, Figma, and Mural. Notion, Asana, and Linear have API-backed sprint importers today. Miro, Lucid, Figma, and Mural currently have settings and linked-source persistence contracts but no API-backed sprint importer in this flow yet. Jira continues to use the existing `jira` settings block, and GitHub/GitLab continue to use `git.githubToken` and `git.gitlabToken`. Each new provider settings block stores only strings and a bounded numeric search limit: diff --git a/docs/mcp/tools-and-contracts.md b/docs/mcp/tools-and-contracts.md index 2946937d30..9db1b3faa6 100644 --- a/docs/mcp/tools-and-contracts.md +++ b/docs/mcp/tools-and-contracts.md @@ -437,15 +437,19 @@ For sprint create/update calls: ### `manage_sprints import_issues` -`manage_sprints` action `import_issues` is the MCP contract for Jira, GitHub, and GitLab issue importer access. Internal MCP clients use it for search-only discovery, assigned-work searches, explicit ticket imports, linked sprint issue attachment, and optional planning after import. +`manage_sprints` action `import_issues` is the MCP contract for GitHub, GitLab, Jira, Notion, Asana, and Linear importer access. Internal MCP clients use it for search-only discovery, assigned-work searches, explicit ticket or external-object imports, linked sprint issue attachment, and optional planning after import. Provider requirements: - GitHub imports require a saved effective `git.githubToken` in system or project settings. - GitLab imports require a saved effective `git.gitlabToken` in system or project settings. - Jira imports require Jira integration settings: host/site URL, account email, API token, and usually a default project key. +- Notion imports require a saved effective `notion.apiToken`. `databaseId` can narrow page search or explicitly import a database. +- Asana imports require a saved effective `asana.apiToken` plus either `workspaceId` for workspace task search or `providerProjectId` / `asana.projectId` for project task fallback. +- Linear imports require a saved effective `linear.apiToken`. `teamId`, `teamKey`, and `providerProjectId` can narrow issue search when configured or supplied. - Importer workflows do not fall back to local CLI authentication. A locally authenticated `gh`, `glab`, or Git remote is not enough for MCP issue search, explicit import, sprint attachment, or planning import paths. +- Notion, Asana, and Linear imports are read/attach only. Code UX does not transition, complete, close, or otherwise mutate those external work items. -Search/import callers can provide `provider` (`github`, `gitlab`, or `jira`), `repository`, `hostDomain`, `projectKey`, `search`, `state`, `status`, `labels`, `assignee`, `assigneeText`, `issueKeys`, `issueNumbers`, `issueRefs`, `includeConversation`, `limit`, and optional sprint attachment fields. `sprintId` and `attachToSprint` represent sprint attachment intent. `planAfterImport`, `autoStart`, `planningAgentPresetId`, `replan`, and `overrides` represent optional planning intent after import. +Search/import callers can provide `provider` (`github`, `gitlab`, `jira`, `notion`, `asana`, or `linear`), `repository`, `hostDomain`, `workspaceId`, `providerProjectId`, `externalProjectId`, `asanaProjectId`, `linearProjectId`, `teamId`, `teamKey`, `databaseId`, `projectKey`, `search`, `state`, `status`, `labels`, `assignee`, `assigneeText`, `issueKeys`, `issueNumbers`, `issueRefs`, `externalIds`, `includeConversation`, `limit`, and optional sprint attachment fields. `sprintId` and `attachToSprint` represent sprint attachment intent. `planAfterImport`, `autoStart`, `planningAgentPresetId`, `replan`, and `overrides` represent optional planning intent after import. Search-only GitHub example: @@ -491,6 +495,50 @@ Assigned-to-me Jira example: } ``` +Search-only Notion example: + +```json +{ + "action": "import_issues", + "projectId": "project-123", + "provider": "notion", + "databaseId": "notion-database-id", + "search": "roadmap acceptance criteria", + "limit": 10 +} +``` + +Search-only Asana example: + +```json +{ + "action": "import_issues", + "projectId": "project-123", + "provider": "asana", + "workspaceId": "asana-workspace-gid", + "providerProjectId": "asana-project-gid", + "search": "checkout import", + "includeConversation": true, + "limit": 20 +} +``` + +Search-only Linear example: + +```json +{ + "action": "import_issues", + "projectId": "project-123", + "provider": "linear", + "teamKey": "ENG", + "state": "In Progress", + "labels": ["import"], + "search": "checkout", + "includeConversation": true, + "limit": 20 +} +``` + Explicit Jira key example: ```json @@ -532,6 +580,18 @@ Explicit GitHub issue number example: } ``` +Explicit external object example: + +```json +{ + "action": "import_issues", + "projectId": "project-123", + "provider": "notion", + "externalIds": ["notion-page-id"], + "includeConversation": false +} +``` + Attach imported issues to an existing sprint: ```json @@ -572,12 +632,13 @@ Attach imported issues and run planning after the sprint goal is enriched: Result shape: - Search mode returns `mode: "search"` and populates `searchedIssues` with lightweight normalized issue summaries. -- Explicit-reference mode returns `mode: "explicit"` and populates `importedContexts` with prompt contexts that can include full issue body and conversation text. +- Explicit-reference mode returns `mode: "explicit"` and populates `importedContexts` with prompt contexts that can include full issue body and conversation text. For Notion, Asana, and Linear, explicit imports use `externalIds`. - When `sprintId` is supplied and `attachToSprint` is not `false`, the response includes persisted `linkedIssues` metadata records and the updated `sprint`. - When `planAfterImport` is `true`, the response includes the optional `planning` result from sprint planning. `planAfterImport` requires `sprintId` because planning runs against an existing sprint. Persistence and prompt behavior: - `issueKeys` and Jira-style refs such as `OPS-123` resolve through Jira. `issueNumbers` and refs such as `#42` or `!42` resolve through GitHub/GitLab when `repository` and `hostDomain` are provided or inferable from the project. +- `externalIds` resolve through Notion page/database fetches, Asana task fetches, or Linear issue fetches. Search results and explicit contexts normalize to linked-source records with `externalId`, `sourceKind`, stable display keys, source URL, preview text, metadata, and prompt markdown when readable provider content is available. - Full issue body and comment/conversation text are merged into the sprint goal under `## Linked Issues` before planning so the Planning agent receives the complete context. - Linked issue persistence stores metadata only: provider, repository or project key, issue key/number, title, labels, assignees, status, source URL, and related tracking fields. Full remote issue bodies and comments remain prompt-only data and are not stored in linked issue rows. - Issue search and import are not destructive actions. Sprint deletion remains approval-gated. diff --git a/src/contracts/mcp-tool-definitions.ts b/src/contracts/mcp-tool-definitions.ts index 4b5e501c14..10469467e7 100644 --- a/src/contracts/mcp-tool-definitions.ts +++ b/src/contracts/mcp-tool-definitions.ts @@ -64,18 +64,26 @@ export const TOOL_DEFINITIONS = [ goal: { type: "string", description: "Optional for create and update." }, goalMarkdown: { type: "string", description: "Alias for goal. Optional for create and update." }, originalPrompt: { type: "string", description: "Optional source prompt for create and update." }, - status: { type: "string", description: "Optional sprint status for create and update, or issue/Jira status for import_issues." }, + status: { type: "string", description: "Optional sprint status for create/update, Jira status (open, in_progress, done, all), or provider status for import_issues." }, showcasePinned: { type: "boolean", description: "Optional for create and update." }, agentPresetId: { type: "string", description: "Optional for create and update." }, planningAgentPresetId: { type: "string", description: "Optional for plan." }, autoStart: { type: "boolean", description: "Optional for plan and for import_issues when planAfterImport is true." }, replan: { type: "boolean", description: "Optional for plan." }, search: { type: "string", description: "Optional issue search query for import_issues." }, - provider: { type: "string", enum: ["github", "gitlab", "jira"], description: "Optional issue provider for import_issues." }, + provider: { type: "string", enum: ["github", "gitlab", "jira", "notion", "asana", "linear"], description: "Optional issue provider for import_issues." }, repository: { type: "string", description: "Optional repository owner/name or project path for import_issues." }, hostDomain: { type: "string", description: "Optional issue host domain for import_issues, such as github.com, gitlab.com, or a Jira site domain." }, + workspaceId: { type: "string", description: "Optional provider workspace id for Notion, Asana, and Linear imports." }, + providerProjectId: { type: "string", description: "Optional provider project id for Asana or Linear imports. Use this instead of Code UX projectId." }, + externalProjectId: { type: "string", description: "Alias for providerProjectId on import_issues." }, + asanaProjectId: { type: "string", description: "Alias for providerProjectId when importing Asana tasks." }, + linearProjectId: { type: "string", description: "Alias for providerProjectId when importing Linear issues." }, + teamId: { type: "string", description: "Optional Linear team id for import_issues." }, + teamKey: { type: "string", description: "Optional Linear team key for import_issues." }, + databaseId: { type: "string", description: "Optional Notion database id for import_issues." }, projectKey: { type: "string", description: "Optional Jira project key for import_issues." }, - state: { type: "string", enum: ["open", "closed", "all"], description: "Optional GitHub/GitLab issue state for import_issues." }, + state: { type: "string", description: "Optional GitHub/GitLab issue state (open, closed, all) or Linear workflow state for import_issues." }, labels: { type: "array", items: { type: "string" }, description: "Optional labels to filter or identify imported issues." }, assignee: { type: "string", description: "Optional GitHub/GitLab assignee filter for import_issues." }, assigneeText: { type: "string", description: "Optional Jira assignee text filter for import_issues, such as me, unassigned, or an account identifier." }, @@ -83,6 +91,7 @@ export const TOOL_DEFINITIONS = [ issueKeys: { type: "array", items: { type: "string" }, description: "Optional explicit Jira issue keys for import_issues." }, issueNumbers: { type: "array", items: { type: "number" }, description: "Optional explicit GitHub/GitLab issue numbers for import_issues." }, issueRefs: { type: "array", items: { type: "string" }, description: "Optional explicit issue references for import_issues, such as #123 or OPS-42." }, + externalIds: { type: "array", items: { type: "string" }, description: "Optional explicit Notion page/database ids, Asana task gids, or Linear issue ids/identifiers for import_issues." }, includeConversation: { type: "boolean", description: "Optional import_issues flag controlling whether issue comments/conversation are included." }, attachToSprint: { type: "boolean", description: "Optional import_issues flag controlling whether matching issues should be attached to sprintId when provided." }, planAfterImport: { type: "boolean", description: "Optional import_issues flag requesting sprint planning after imported issues are attached." }, diff --git a/src/contracts/project-management-types.ts b/src/contracts/project-management-types.ts index 944f7ba225..51f3b91e09 100644 --- a/src/contracts/project-management-types.ts +++ b/src/contracts/project-management-types.ts @@ -61,10 +61,16 @@ export type JiraIssueSearchSortDirection = "asc" | "desc"; export interface RepositoryIssueSearchInput { provider?: LinkedIssueProvider; externalId?: string | null; + externalIds?: string[]; sourceKind?: LinkedIssueSourceKind; sourceProvider?: LinkedIssueProvider; repository?: string; hostDomain?: string; + workspaceId?: string; + projectId?: string; + teamId?: string; + teamKey?: string; + databaseId?: string; projectKey?: string; search?: string; state?: RepositoryIssueSearchState; @@ -149,6 +155,7 @@ export interface SprintLinkedIssueInput { issueAuthor?: string | null; issueCreatedAt?: string | null; issueUpdatedAt?: string | null; + metadata?: Record; } export interface SprintImportedTaskInput { diff --git a/src/mcp/management/sprint-actions.ts b/src/mcp/management/sprint-actions.ts index de812aecb9..6e4b536d1d 100644 --- a/src/mcp/management/sprint-actions.ts +++ b/src/mcp/management/sprint-actions.ts @@ -68,6 +68,9 @@ function normalizeLinkedIssues(value: unknown): CreateSprintInput["linkedIssues" function toLinkedIssueInput(issue: SprintLinkedIssueInput): SprintLinkedIssueInput { return { provider: issue.provider, + sourceProvider: issue.sourceProvider, + sourceKind: issue.sourceKind, + externalId: issue.externalId, hostDomain: issue.hostDomain, projectKey: issue.projectKey, repository: issue.repository, @@ -90,6 +93,11 @@ function hasSearchFilters(input: IssueSearchInput): boolean { input.search || input.repository || input.hostDomain + || input.workspaceId + || input.providerProjectId + || input.teamId + || input.teamKey + || input.databaseId || input.projectKey || input.state || input.status @@ -110,18 +118,23 @@ function hasSearchFilters(input: IssueSearchInput): boolean { } function hasExplicitIssueReferences(input: IssueSearchInput): boolean { - return Boolean(input.issueKeys?.length || input.issueNumbers?.length || input.issueRefs?.length); + return Boolean(input.issueKeys?.length || input.issueNumbers?.length || input.issueRefs?.length || input.externalIds?.length); } function buildImportIssueSearchInput(payload: Record): IssueSearchInput { const input: IssueSearchInput = { search: readString(payload, "search"), - provider: parseOptionalEnumStrict(payload, "provider", ["github", "gitlab", "jira"] as const), + provider: parseOptionalEnumStrict(payload, "provider", ["github", "gitlab", "jira", "notion", "asana", "linear"] as const), repository: readString(payload, "repository"), hostDomain: readString(payload, "hostDomain"), + workspaceId: readString(payload, "workspaceId"), + providerProjectId: readString(payload, "providerProjectId") || readString(payload, "externalProjectId") || readString(payload, "asanaProjectId") || readString(payload, "linearProjectId"), + teamId: readString(payload, "teamId"), + teamKey: readString(payload, "teamKey"), + databaseId: readString(payload, "databaseId"), projectKey: readString(payload, "projectKey"), - state: parseOptionalEnumStrict(payload, "state", ["open", "closed", "all"] as const), - status: parseOptionalEnumStrict(payload, "status", ["open", "in_progress", "done", "all"] as const), + state: parseImportState(payload), + status: parseImportStatus(payload), labels: readStringArray(payload, "labels"), assignee: readString(payload, "assignee"), assigneeText: readString(payload, "assigneeText"), @@ -132,6 +145,7 @@ function buildImportIssueSearchInput(payload: Record): IssueSea issueKeys: readStringArray(payload, "issueKeys"), issueNumbers: readNumberArray(payload, "issueNumbers"), issueRefs: readStringArray(payload, "issueRefs"), + externalIds: readStringArray(payload, "externalIds"), includeConversation: payload.includeConversation === false ? false : undefined, createdAfter: readString(payload, "createdAfter"), createdBefore: readString(payload, "createdBefore"), @@ -144,6 +158,22 @@ function buildImportIssueSearchInput(payload: Record): IssueSea return input; } +function parseImportState(payload: Record): IssueSearchInput["state"] { + const provider = typeof payload.provider === "string" ? payload.provider.trim().toLowerCase() : ""; + if (provider === "linear" || provider === "asana" || provider === "notion") { + return readString(payload, "state"); + } + return parseOptionalEnumStrict(payload, "state", ["open", "closed", "all"] as const); +} + +function parseImportStatus(payload: Record): IssueSearchInput["status"] { + const provider = typeof payload.provider === "string" ? payload.provider.trim().toLowerCase() : ""; + if (provider === "linear" || provider === "asana" || provider === "notion") { + return readString(payload, "status"); + } + return parseOptionalEnumStrict(payload, "status", ["open", "in_progress", "done", "all"] as const); +} + function assertSprintBelongsToProject(sprint: SprintRecord | null | undefined, sprintId: string, projectId: string): SprintRecord { if (!sprint) { throw new Error(`Sprint not found: ${sprintId}`); @@ -345,9 +375,12 @@ export class SprintActions { linkedIssues = importResult.linkedIssues; warnings = importResult.warnings; - if (importedContexts.length > 0 && sprint) { + const promptIssues = explicitMode + ? importedContexts + : searchedIssues.filter((issue) => Boolean(issue.issueBodyMarkdown?.trim() || issue.issueConversationMarkdown?.trim())); + if (promptIssues.length > 0 && sprint) { sprint = this.deps.projectManagementRepository.updateSprint(sprintId, { - goal: mergePromptWithLinkedIssues(sprint.goal, importedContexts), + goal: mergePromptWithLinkedIssues(sprint.goal, promptIssues), }); } } diff --git a/src/server/sprint-routes.ts b/src/server/sprint-routes.ts index 8ac0c7a8fd..32696f9154 100644 --- a/src/server/sprint-routes.ts +++ b/src/server/sprint-routes.ts @@ -262,18 +262,28 @@ export function registerSprintRoutes(router: Express, deps: DashboardDependencie } function parseRepositoryIssueSearchQuery(query: Record): IssueSearchInput { + const provider = parseRepositoryProvider(query.provider); return { - provider: parseRepositoryProvider(query.provider), + provider, repository: parseTrimmedQueryString(query.repository, "repository"), hostDomain: parseTrimmedQueryString(query.hostDomain, "hostDomain"), + workspaceId: parseTrimmedQueryString(query.workspaceId, "workspaceId"), + providerProjectId: parseTrimmedQueryString(query.projectId, "projectId"), + teamId: parseTrimmedQueryString(query.teamId, "teamId"), + teamKey: parseTrimmedQueryString(query.teamKey, "teamKey"), + databaseId: parseTrimmedQueryString(query.databaseId, "databaseId"), + projectKey: parseTrimmedQueryString(query.projectKey, "projectKey"), search: parseTrimmedQueryString(query.search, "search"), - state: parseRepositoryIssueState(query.state), + state: parseRepositoryIssueState(query.state, provider), + status: parseImportStatus(query.status, provider), labels: parseIssueLabels(query.labels), assignee: parseTrimmedQueryString(query.assignee, "assignee"), author: parseTrimmedQueryString(query.author, "author"), reporter: parseTrimmedQueryString(query.reporter, "reporter"), milestone: parseTrimmedQueryString(query.milestone, "milestone"), issueText: parseTrimmedQueryString(query.issueText, "issueText"), + externalIds: parseIssueLabels(query.externalIds), + includeConversation: parseQueryBoolean(query.includeConversation, "includeConversation"), createdAfter: parseDateLikeString(query.createdAfter, "createdAfter"), createdBefore: parseDateLikeString(query.createdBefore, "createdBefore"), updatedAfter: parseDateLikeString(query.updatedAfter, "updatedAfter"), @@ -334,23 +344,40 @@ function parseRepositoryProvider(value: unknown): RepositoryIssueSearchInput["pr if (!trimmed) { return undefined; } - if (trimmed !== "github" && trimmed !== "gitlab") { - throw new Error("Invalid value for provider. Must be one of: github, gitlab"); + if (trimmed !== "github" && trimmed !== "gitlab" && trimmed !== "jira" && trimmed !== "notion" && trimmed !== "asana" && trimmed !== "linear") { + throw new Error("Invalid value for provider. Must be one of: github, gitlab, jira, notion, asana, linear"); } return trimmed; } -function parseRepositoryIssueState(value: unknown): RepositoryIssueSearchState | undefined { +function parseRepositoryIssueState(value: unknown, provider?: RepositoryIssueSearchInput["provider"]): IssueSearchInput["state"] { const trimmed = parseTrimmedQueryString(value, "state"); if (!trimmed) { return undefined; } + if (provider === "notion" || provider === "asana" || provider === "linear") { + return trimmed; + } if (trimmed !== "open" && trimmed !== "closed" && trimmed !== "all") { throw new Error("Invalid value for state. Must be one of: open, closed, all"); } return trimmed; } +function parseImportStatus(value: unknown, provider?: RepositoryIssueSearchInput["provider"]): IssueSearchInput["status"] { + const trimmed = parseTrimmedQueryString(value, "status"); + if (!trimmed) { + return undefined; + } + if (provider === "notion" || provider === "asana" || provider === "linear") { + return trimmed; + } + if (trimmed !== "open" && trimmed !== "in_progress" && trimmed !== "done" && trimmed !== "all") { + throw new Error("Invalid value for status. Must be one of: open, in_progress, done, all"); + } + return trimmed; +} + function parseRepositorySortField(value: unknown): RepositoryIssueSearchSortField | undefined { const trimmed = parseTrimmedQueryString(value, "sortField"); if (!trimmed) { @@ -449,3 +476,23 @@ function parseClampedLimit(value: unknown, min: number, max: number, fieldName: } return Math.max(min, Math.min(max, Math.trunc(numeric))); } + +function parseQueryBoolean(value: unknown, fieldName: string): boolean | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(`Invalid value for ${fieldName}. Must be a boolean.`); + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (normalized === "true" || normalized === "1") { + return true; + } + if (normalized === "false" || normalized === "0") { + return false; + } + throw new Error(`Invalid value for ${fieldName}. Must be a boolean.`); +} diff --git a/src/services/asana-api-client.ts b/src/services/asana-api-client.ts new file mode 100644 index 0000000000..00ac9aab73 --- /dev/null +++ b/src/services/asana-api-client.ts @@ -0,0 +1,226 @@ +export interface AsanaSearchInput { + workspaceId?: string; + projectId?: string; + search?: string; + status?: string; + labels?: string[]; + assignee?: string; + externalIds?: string[]; + includeConversation?: boolean; + limit: number; +} + +export interface AsanaTaskItem { + id: string; + gid: string; + title: string; + url: string; + completed: boolean; + state: string; + labels: string[]; + assignees: string[]; + bodyMarkdown: string; + conversationMarkdown: string; + createdAt: string | null; + updatedAt: string | null; + issueAuthor: string | null; + metadata: Record; +} + +const ASANA_API_BASE_URL = "https://app.asana.com/api/1.0"; +const TASK_FIELDS = [ + "gid", + "name", + "notes", + "permalink_url", + "completed", + "completed_at", + "created_at", + "modified_at", + "assignee.name", + "memberships.project.gid", + "memberships.project.name", + "memberships.section.name", + "projects.gid", + "projects.name", + "tags.name", + "created_by.name", +].join(","); + +interface AsanaResponse { + data?: T; +} + +interface AsanaListResponse { + data?: T[]; +} + +interface AsanaUser { + gid?: string; + name?: string; +} + +interface AsanaTask { + gid?: string; + name?: string; + notes?: string | null; + permalink_url?: string; + completed?: boolean; + completed_at?: string | null; + created_at?: string | null; + modified_at?: string | null; + assignee?: AsanaUser | null; + created_by?: AsanaUser | null; + projects?: Array<{ gid?: string; name?: string }>; + tags?: Array<{ name?: string }>; + memberships?: Array<{ + project?: { gid?: string; name?: string }; + section?: { name?: string }; + }>; +} + +interface AsanaStory { + gid?: string; + type?: string; + text?: string; + created_at?: string | null; + created_by?: AsanaUser | null; +} + +export async function searchTasks(token: string, input: AsanaSearchInput): Promise { + if (input.externalIds && input.externalIds.length > 0) { + return getTasks(token, input.externalIds, input); + } + + const payload = input.projectId + ? await listProjectTasks(token, input) + : await searchWorkspaceTasks(token, input); + + return Promise.all((payload.data || []) + .filter((task): task is AsanaTask & { gid: string } => typeof task.gid === "string") + .slice(0, input.limit) + .map((task) => toAsanaTaskItem(token, task, input.includeConversation === true))); +} + +export async function getTasks(token: string, externalIds: string[], input: Omit): Promise { + const items: AsanaTaskItem[] = []; + for (const gid of uniqueStrings(externalIds).slice(0, input.limit)) { + const task = await getTask(token, gid); + if (task?.gid) { + items.push(await toAsanaTaskItem(token, task as AsanaTask & { gid: string }, input.includeConversation !== false)); + } + } + return items; +} + +async function searchWorkspaceTasks(token: string, input: AsanaSearchInput): Promise> { + if (!input.workspaceId) { + throw new Error("Asana workspace ID is required for task search."); + } + const url = new URL(`${ASANA_API_BASE_URL}/workspaces/${encodeURIComponent(input.workspaceId)}/tasks/search`); + url.searchParams.set("limit", String(input.limit)); + url.searchParams.set("opt_fields", TASK_FIELDS); + if (input.search?.trim()) { + url.searchParams.set("text", input.search.trim()); + } + if (input.status === "closed" || input.status === "done") { + url.searchParams.set("completed", "true"); + } else if (input.status === "open" || input.status === "in_progress" || !input.status || input.status === "all") { + url.searchParams.set("completed", input.status === "all" ? "any" : "false"); + } + if (input.assignee?.trim()) { + url.searchParams.set("assignee.any", input.assignee.trim()); + } + if (input.projectId?.trim()) { + url.searchParams.set("projects.any", input.projectId.trim()); + } + if (input.labels && input.labels.length > 0) { + url.searchParams.set("tags.any", input.labels.join(",")); + } + return requestAsana>(url.toString(), token); +} + +async function listProjectTasks(token: string, input: AsanaSearchInput): Promise> { + const url = new URL(`${ASANA_API_BASE_URL}/projects/${encodeURIComponent(input.projectId || "")}/tasks`); + url.searchParams.set("limit", String(input.limit)); + url.searchParams.set("opt_fields", TASK_FIELDS); + return requestAsana>(url.toString(), token); +} + +async function getTask(token: string, gid: string): Promise { + const url = new URL(`${ASANA_API_BASE_URL}/tasks/${encodeURIComponent(gid)}`); + url.searchParams.set("opt_fields", TASK_FIELDS); + const payload = await requestAsana>(url.toString(), token); + return payload.data || null; +} + +async function toAsanaTaskItem(token: string, task: AsanaTask & { gid: string }, includeConversation: boolean): Promise { + const stories = includeConversation ? await listStories(token, task.gid) : []; + const projects = (task.projects || task.memberships?.map((membership) => membership.project).filter(Boolean) || []) + .map((project) => project?.name || project?.gid || "") + .filter(Boolean); + return { + id: task.gid, + gid: task.gid, + title: task.name || "Untitled Asana task", + url: task.permalink_url || `https://app.asana.com/0/0/${task.gid}`, + completed: task.completed === true, + state: task.completed === true ? "completed" : "open", + labels: (task.tags || []).map((tag) => tag.name || "").filter(Boolean), + assignees: task.assignee?.name ? [task.assignee.name] : [], + bodyMarkdown: normalizeMarkdown(task.notes || ""), + conversationMarkdown: formatStoriesMarkdown(stories), + createdAt: task.created_at || null, + updatedAt: task.modified_at || task.completed_at || null, + issueAuthor: task.created_by?.name || null, + metadata: { + gid: task.gid, + projects, + sections: (task.memberships || []).map((membership) => membership.section?.name || "").filter(Boolean), + }, + }; +} + +async function listStories(token: string, gid: string): Promise { + const url = new URL(`${ASANA_API_BASE_URL}/tasks/${encodeURIComponent(gid)}/stories`); + url.searchParams.set("limit", "100"); + url.searchParams.set("opt_fields", "gid,type,text,created_at,created_by.name"); + const payload = await requestAsana>(url.toString(), token); + return (payload.data || []).filter((story) => story.type === "comment" && Boolean(story.text?.trim())); +} + +async function requestAsana(url: string, token: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${token}`, + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Asana API request failed (${response.status} ${response.statusText})${text ? `: ${truncatePreview(text)}` : ""}`); + } + return await response.json() as T; +} + +function formatStoriesMarkdown(stories: AsanaStory[]): string { + return stories.map((story, index) => { + const author = story.created_by?.name || "unknown"; + const body = normalizeMarkdown(story.text || "") || "_No comment body provided._"; + return `##### Comment ${index + 1} - @${author}${story.created_at ? ` - ${story.created_at}` : ""}\n\n${body}`; + }).join("\n\n"); +} + +function normalizeMarkdown(value: string): string { + return value.replace(/\r\n/g, "\n").trim(); +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function truncatePreview(value: string): string { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > 220 ? `${compact.slice(0, 217)}...` : compact; +} diff --git a/src/services/linear-api-client.ts b/src/services/linear-api-client.ts new file mode 100644 index 0000000000..61da3df2ad --- /dev/null +++ b/src/services/linear-api-client.ts @@ -0,0 +1,307 @@ +export interface LinearSearchInput { + search?: string; + status?: string; + state?: string; + labels?: string[]; + assignee?: string; + teamId?: string; + teamKey?: string; + projectId?: string; + externalIds?: string[]; + includeConversation?: boolean; + limit: number; +} + +export interface LinearIssueItem { + id: string; + identifier: string; + title: string; + url: string; + state: string; + labels: string[]; + assignees: string[]; + bodyMarkdown: string; + conversationMarkdown: string; + createdAt: string | null; + updatedAt: string | null; + issueAuthor: string | null; + teamKey: string | null; + projectName: string | null; + metadata: Record; +} + +const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"; + +const ISSUE_FIELDS = ` + id + identifier + title + description + url + createdAt + updatedAt + state { name type } + labels { nodes { name } } + assignee { name displayName email } + creator { name displayName email } + team { id key name } + project { id name url } +`; + +interface LinearGraphQlResponse { + data?: T; + errors?: Array<{ message?: string }>; +} + +interface LinearConnection { + nodes?: T[]; +} + +interface LinearIssueRaw { + id?: string; + identifier?: string; + title?: string; + description?: string | null; + url?: string; + createdAt?: string | null; + updatedAt?: string | null; + state?: { name?: string; type?: string } | null; + labels?: LinearConnection<{ name?: string }>; + assignee?: { name?: string; displayName?: string; email?: string } | null; + creator?: { name?: string; displayName?: string; email?: string } | null; + team?: { id?: string; key?: string; name?: string } | null; + project?: { id?: string; name?: string; url?: string } | null; + comments?: LinearConnection; +} + +interface LinearCommentRaw { + body?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + url?: string | null; + user?: { name?: string; displayName?: string; email?: string } | null; +} + +export async function searchIssues(token: string, input: LinearSearchInput): Promise { + const filter = buildLinearIssueFilter(input); + const data = await requestLinear<{ + issues: LinearConnection; + }>(token, ` + query CodeUxIssueSearch($first: Int!, $filter: IssueFilter) { + issues(first: $first, filter: $filter, orderBy: updatedAt) { + nodes { ${ISSUE_FIELDS} } + } + } + `, { + first: input.limit, + filter, + }); + + const issues = data.issues.nodes || []; + return Promise.all(issues + .filter((issue): issue is LinearIssueRaw & { id: string } => typeof issue.id === "string") + .slice(0, input.limit) + .map((issue) => toLinearIssueItem(token, issue, input.includeConversation === true))); +} + +export async function getIssues(token: string, externalIds: string[], input: Omit): Promise { + const items: LinearIssueItem[] = []; + for (const externalId of uniqueStrings(externalIds).slice(0, input.limit)) { + const issue = await getIssueByExternalId(token, externalId); + if (issue) { + items.push(await toLinearIssueItem(token, issue, input.includeConversation !== false)); + } + } + return items; +} + +async function getIssueByExternalId(token: string, externalId: string): Promise<(LinearIssueRaw & { id: string }) | null> { + const filter = { + or: [ + { id: { eq: externalId } }, + { identifier: { eq: externalId.toUpperCase() } }, + ], + }; + const data = await requestLinear<{ + issues: LinearConnection; + }>(token, ` + query CodeUxIssueByExternalId($filter: IssueFilter) { + issues(first: 1, filter: $filter) { + nodes { ${ISSUE_FIELDS} } + } + } + `, { filter }); + const issue = data.issues.nodes?.[0]; + return issue && typeof issue.id === "string" ? issue as LinearIssueRaw & { id: string } : null; +} + +async function toLinearIssueItem(token: string, issue: LinearIssueRaw & { id: string }, includeConversation: boolean): Promise { + const comments = includeConversation ? await getIssueComments(token, issue.id) : []; + const assignee = formatLinearUser(issue.assignee); + const creator = formatLinearUser(issue.creator); + return { + id: issue.id, + identifier: issue.identifier || issue.id, + title: issue.title || "Untitled Linear issue", + url: issue.url || "", + state: issue.state?.name || issue.state?.type || "open", + labels: (issue.labels?.nodes || []).map((label) => label.name || "").filter(Boolean), + assignees: assignee ? [assignee] : [], + bodyMarkdown: normalizeMarkdown(issue.description || ""), + conversationMarkdown: formatCommentsMarkdown(comments), + createdAt: issue.createdAt || null, + updatedAt: issue.updatedAt || null, + issueAuthor: creator || null, + teamKey: issue.team?.key || null, + projectName: issue.project?.name || null, + metadata: { + identifier: issue.identifier || null, + teamId: issue.team?.id || null, + teamKey: issue.team?.key || null, + teamName: issue.team?.name || null, + projectId: issue.project?.id || null, + projectName: issue.project?.name || null, + projectUrl: issue.project?.url || null, + stateType: issue.state?.type || null, + }, + }; +} + +async function getIssueComments(token: string, issueId: string): Promise { + const data = await requestLinear<{ + issue: LinearIssueRaw | null; + }>(token, ` + query CodeUxIssueComments($id: String!) { + issue(id: $id) { + comments(first: 100) { + nodes { + body + createdAt + updatedAt + url + user { name displayName email } + } + } + } + } + `, { id: issueId }); + return data.issue?.comments?.nodes || []; +} + +async function requestLinear(token: string, query: string, variables: Record): Promise { + const response = await fetch(LINEAR_GRAPHQL_URL, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ query, variables }), + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Linear API request failed (${response.status} ${response.statusText})${text ? `: ${truncatePreview(text)}` : ""}`); + } + const payload = await response.json() as LinearGraphQlResponse; + if (payload.errors && payload.errors.length > 0) { + throw new Error(`Linear API request failed: ${payload.errors.map((error) => error.message || "GraphQL error").join("; ")}`); + } + if (!payload.data) { + throw new Error("Linear API response did not include data."); + } + return payload.data; +} + +function buildLinearIssueFilter(input: LinearSearchInput): Record | undefined { + const clauses: Record[] = []; + const externalIds = uniqueStrings(input.externalIds || []); + if (externalIds.length > 0) { + clauses.push({ + or: externalIds.flatMap((externalId) => [ + { id: { eq: externalId } }, + { identifier: { eq: externalId.toUpperCase() } }, + ]), + }); + } + if (input.search?.trim()) { + const search = input.search.trim(); + clauses.push({ + or: [ + { title: { containsIgnoreCase: search } }, + { description: { containsIgnoreCase: search } }, + { identifier: { eq: search.toUpperCase() } }, + ], + }); + } + const state = input.state?.trim() || input.status?.trim(); + if (state && state !== "all") { + if (state === "open") { + clauses.push({ state: { type: { neq: "completed" } } }); + } else if (state === "closed" || state === "done") { + clauses.push({ state: { type: { eq: "completed" } } }); + } else { + clauses.push({ state: { name: { eq: state } } }); + } + } + if (input.labels && input.labels.length > 0) { + clauses.push({ labels: { some: { name: { in: input.labels } } } }); + } + if (input.assignee?.trim()) { + clauses.push({ + assignee: { + or: [ + { name: { eq: input.assignee.trim() } }, + { displayName: { eq: input.assignee.trim() } }, + { email: { eq: input.assignee.trim() } }, + ], + }, + }); + } + if (input.teamId?.trim()) { + clauses.push({ team: { id: { eq: input.teamId.trim() } } }); + } + if (input.teamKey?.trim()) { + clauses.push({ team: { key: { eq: input.teamKey.trim().toUpperCase() } } }); + } + if (input.projectId?.trim()) { + clauses.push({ project: { id: { eq: input.projectId.trim() } } }); + } + if (clauses.length === 0) { + return undefined; + } + return clauses.length === 1 ? clauses[0] : { and: clauses }; +} + +function formatCommentsMarkdown(comments: LinearCommentRaw[]): string { + return comments + .filter((comment) => Boolean(comment.body?.trim())) + .map((comment, index) => { + const author = formatLinearUser(comment.user) || "unknown"; + const meta = [ + `Comment ${index + 1}`, + `@${author}`, + comment.createdAt || "", + comment.updatedAt && comment.updatedAt !== comment.createdAt ? `updated ${comment.updatedAt}` : "", + comment.url ? `[source](${comment.url})` : "", + ].filter(Boolean).join(" - "); + return `##### ${meta}\n\n${normalizeMarkdown(comment.body || "") || "_No comment body provided._"}`; + }) + .join("\n\n"); +} + +function formatLinearUser(user: { name?: string; displayName?: string; email?: string } | null | undefined): string { + return user?.displayName || user?.name || user?.email || ""; +} + +function normalizeMarkdown(value: string): string { + return value.replace(/\r\n/g, "\n").trim(); +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function truncatePreview(value: string): string { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > 220 ? `${compact.slice(0, 217)}...` : compact; +} diff --git a/src/services/notion-api-client.ts b/src/services/notion-api-client.ts new file mode 100644 index 0000000000..3653283b02 --- /dev/null +++ b/src/services/notion-api-client.ts @@ -0,0 +1,248 @@ +export interface NotionSearchInput { + search?: string; + databaseId?: string; + externalIds?: string[]; + limit: number; +} + +export interface NotionItem { + id: string; + object: "page" | "database"; + title: string; + url: string; + archived: boolean; + createdTime: string | null; + lastEditedTime: string | null; + bodyMarkdown: string; + metadata: Record; +} + +const NOTION_API_BASE_URL = "https://api.notion.com/v1"; +const NOTION_VERSION = "2022-06-28"; + +interface NotionRichText { + plain_text?: string; +} + +interface NotionTitleProperty { + title?: NotionRichText[]; +} + +interface NotionRawObject { + id?: string; + object?: string; + url?: string; + archived?: boolean; + created_time?: string; + last_edited_time?: string; + title?: NotionRichText[]; + properties?: Record; + parent?: Record; +} + +interface NotionBlock { + id?: string; + type?: string; + has_children?: boolean; + [key: string]: unknown; +} + +interface NotionListResponse { + results?: T[]; + has_more?: boolean; + next_cursor?: string | null; +} + +export async function searchObjects(token: string, input: NotionSearchInput): Promise { + const body: Record = { + page_size: input.limit, + sort: { + direction: "descending", + timestamp: "last_edited_time", + }, + }; + if (input.search?.trim()) { + body.query = input.search.trim(); + } + + const payload = await requestNotion>(`${NOTION_API_BASE_URL}/search`, token, { + method: "POST", + body, + }); + + return Promise.all((payload.results || []) + .filter((item): item is NotionRawObject & { id: string } => isNotionSearchObject(item)) + .filter((item) => !input.databaseId || getNotionParentDatabaseId(item) === input.databaseId) + .slice(0, input.limit) + .map((item) => toNotionItem(token, item))); +} + +export async function getObjects(token: string, externalIds: string[], limit = 50): Promise { + const items: NotionItem[] = []; + for (const externalId of uniqueStrings(externalIds).slice(0, limit)) { + const item = await getObject(token, externalId); + if (item) { + items.push(item); + } + } + return items; +} + +async function getObject(token: string, externalId: string): Promise { + const page = await tryRequestNotion(`${NOTION_API_BASE_URL}/pages/${encodeURIComponent(externalId)}`, token); + if (page && isNotionSearchObject(page)) { + return toNotionItem(token, page); + } + + const database = await tryRequestNotion(`${NOTION_API_BASE_URL}/databases/${encodeURIComponent(externalId)}`, token); + if (database && isNotionSearchObject(database)) { + return toNotionItem(token, database); + } + + return null; +} + +async function toNotionItem(token: string, item: NotionRawObject & { id: string }): Promise { + const blocks = await listBlockChildren(token, item.id); + return { + id: item.id, + object: item.object === "database" ? "database" : "page", + title: getNotionTitle(item), + url: item.url || `https://www.notion.so/${item.id.replace(/-/g, "")}`, + archived: item.archived === true, + createdTime: item.created_time || null, + lastEditedTime: item.last_edited_time || null, + bodyMarkdown: blocksToMarkdown(blocks), + metadata: { + object: item.object || null, + parent: item.parent || null, + databaseId: getNotionParentDatabaseId(item), + }, + }; +} + +async function listBlockChildren(token: string, blockId: string): Promise { + const blocks: NotionBlock[] = []; + let cursor: string | null = null; + do { + const url = new URL(`${NOTION_API_BASE_URL}/blocks/${encodeURIComponent(blockId)}/children`); + url.searchParams.set("page_size", "100"); + if (cursor) { + url.searchParams.set("start_cursor", cursor); + } + const payload = await tryRequestNotion>(url.toString(), token); + if (!payload) { + return blocks; + } + blocks.push(...(payload.results || [])); + cursor = payload.has_more ? payload.next_cursor || null : null; + } while (cursor); + return blocks; +} + +async function requestNotion(url: string, token: string, options: { + method?: string; + body?: Record; +} = {}): Promise { + const response = await fetch(url, { + method: options.method || "GET", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + "Notion-Version": NOTION_VERSION, + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Notion API request failed (${response.status} ${response.statusText})${text ? `: ${truncatePreview(text)}` : ""}`); + } + return await response.json() as T; +} + +async function tryRequestNotion(url: string, token: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${token}`, + "Notion-Version": NOTION_VERSION, + }, + }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Notion API request failed (${response.status} ${response.statusText})${text ? `: ${truncatePreview(text)}` : ""}`); + } + return await response.json() as T; +} + +function isNotionSearchObject(item: NotionRawObject): item is NotionRawObject & { id: string } { + return typeof item.id === "string" && (item.object === "page" || item.object === "database"); +} + +function getNotionTitle(item: NotionRawObject): string { + const directTitle = richTextToPlain(item.title); + if (directTitle) { + return directTitle; + } + for (const value of Object.values(item.properties || {})) { + const title = value && typeof value === "object" ? richTextToPlain((value as NotionTitleProperty).title) : ""; + if (title) { + return title; + } + } + return item.object === "database" ? "Untitled database" : "Untitled page"; +} + +function getNotionParentDatabaseId(item: NotionRawObject): string | null { + const parent = item.parent || {}; + return typeof parent.database_id === "string" ? parent.database_id : null; +} + +function blocksToMarkdown(blocks: NotionBlock[]): string { + return blocks + .map((block) => blockToMarkdown(block)) + .filter(Boolean) + .join("\n\n") + .trim(); +} + +function blockToMarkdown(block: NotionBlock): string { + const type = block.type; + const value = typeof type === "string" && block[type] && typeof block[type] === "object" + ? block[type] as Record + : {}; + const text = richTextToPlain(value.rich_text as NotionRichText[] | undefined); + if (!text) { + return ""; + } + if (type === "heading_1") return `# ${text}`; + if (type === "heading_2") return `## ${text}`; + if (type === "heading_3") return `### ${text}`; + if (type === "bulleted_list_item") return `- ${text}`; + if (type === "numbered_list_item") return `1. ${text}`; + if (type === "to_do") return `- [${value.checked === true ? "x" : " "}] ${text}`; + if (type === "quote") return `> ${text}`; + if (type === "code") return `\`\`\`\n${text}\n\`\`\``; + return text; +} + +function richTextToPlain(value: NotionRichText[] | undefined): string { + return (value || []) + .map((entry) => entry.plain_text || "") + .join("") + .trim(); +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function truncatePreview(value: string): string { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > 220 ? `${compact.slice(0, 217)}...` : compact; +} diff --git a/src/services/sprint-issue-service.ts b/src/services/sprint-issue-service.ts index 0094f9bfd2..02c54551ef 100644 --- a/src/services/sprint-issue-service.ts +++ b/src/services/sprint-issue-service.ts @@ -20,15 +20,24 @@ import { createLogger, type Logger } from "../shared/logging/logger.js"; import { resolveRepositoryHost } from "../infrastructure/git/repository-host-resolver.js"; import { execFile } from "child_process"; import * as jiraApiClient from "./jira-api-client.js"; +import * as notionApiClient from "./notion-api-client.js"; +import * as asanaApiClient from "./asana-api-client.js"; +import * as linearApiClient from "./linear-api-client.js"; export interface IssueSearchInput { provider?: LinkedIssueProvider; repository?: string; hostDomain?: string; + workspaceId?: string; + projectId?: string; + providerProjectId?: string; + teamId?: string; + teamKey?: string; + databaseId?: string; projectKey?: string; search?: string; - state?: RepositoryIssueSearchInput["state"]; - status?: JiraIssueSearchInput["status"]; + state?: RepositoryIssueSearchInput["state"] | string; + status?: JiraIssueSearchInput["status"] | string; labels?: string[]; assignee?: string; assigneeText?: string; @@ -39,6 +48,7 @@ export interface IssueSearchInput { issueKeys?: string[]; issueNumbers?: number[]; issueRefs?: string[]; + externalIds?: string[]; includeConversation?: boolean; createdAfter?: string; createdBefore?: string; @@ -55,6 +65,9 @@ interface IssueServiceDeps { runCommand?: (command: string, args: string[]) => Promise; logger?: Logger; jiraApiClient?: typeof jiraApiClient; + notionApiClient?: typeof notionApiClient; + asanaApiClient?: typeof asanaApiClient; + linearApiClient?: typeof linearApiClient; } interface LocalCommandResult { @@ -168,13 +181,25 @@ export class SprintIssueService { return this.searchJiraIssuesForProject(project, searchInput, settings, limit); } + if (provider === "notion") { + return this.searchNotionSources(searchInput, settings, limit); + } + + if (provider === "asana") { + return this.searchAsanaTasks(searchInput, settings, limit); + } + + if (provider === "linear") { + return this.searchLinearIssues(searchInput, settings, limit); + } + const target = resolveIssueTarget(project, searchInput, provider); if (target.provider === "github") { return this.searchGitHubIssues({ ...target, token: settings.git.githubToken, search: searchInput.search, - state: searchInput.state || "open", + state: normalizeRepositoryIssueStateValue(searchInput.state) || "open", labels: searchInput.labels || [], assignee: searchInput.assignee, author: searchInput.author, @@ -195,7 +220,7 @@ export class SprintIssueService { ...target, token: settings.git.gitlabToken || "", search: searchInput.search, - state: searchInput.state || "open", + state: normalizeRepositoryIssueStateValue(searchInput.state) || "open", labels: searchInput.labels || [], assignee: searchInput.assignee, author: searchInput.author, @@ -239,6 +264,12 @@ export class SprintIssueService { contexts.push(await this.getGitHubIssuePromptContext(issue, settings.git.githubToken || "")); } else if (issue.provider === "gitlab") { contexts.push(await this.getGitLabIssuePromptContext(issue, settings.git.gitlabToken || "")); + } else if (issue.provider === "notion") { + contexts.push(await this.getNotionPromptContext(issue, settings)); + } else if (issue.provider === "asana") { + contexts.push(await this.getAsanaPromptContext(issue, settings)); + } else if (issue.provider === "linear") { + contexts.push(await this.getLinearPromptContext(issue, settings)); } else { contexts.push(await this.getJiraIssuePromptContext(issue, settings)); } @@ -472,7 +503,7 @@ export class SprintIssueService { const jiraInput: JiraIssueSearchInput = { projectKey: input.projectKey || settings.jira.defaultProject, search: input.search, - status: input.status, + status: normalizeJiraIssueStatusValue(input.status), assignee: normalizeJiraAssignee(input.assignee), assigneeText: input.assigneeText || input.assignee, labels: input.labels || [], @@ -498,6 +529,86 @@ export class SprintIssueService { return issues.map((issue) => normalizeJiraIssueSummary(issue, hostDomain, project)); } + private async searchNotionSources( + input: IssueSearchInput, + settings: DashboardSettings, + limit: number, + ): Promise { + const token = settings.notion.apiToken.trim(); + if (!token) { + throw new Error("Notion API token must be configured in Settings -> Integrations."); + } + const client = this.deps.notionApiClient ?? notionApiClient; + const externalIds = input.externalIds || []; + const items = externalIds.length > 0 + ? await client.getObjects(token, externalIds, limit) + : await client.searchObjects(token, { + search: input.search, + databaseId: input.databaseId || settings.notion.databaseId || undefined, + limit: effectiveImporterLimit(limit, settings.notion.defaultSearchLimit), + }); + return items.map((item) => normalizeNotionItem(item)); + } + + private async searchAsanaTasks( + input: IssueSearchInput, + settings: DashboardSettings, + limit: number, + ): Promise { + const token = settings.asana.apiToken.trim(); + if (!token) { + throw new Error("Asana API token must be configured in Settings -> Integrations."); + } + const workspaceId = input.workspaceId || settings.asana.workspaceId || undefined; + const providerProjectId = input.providerProjectId || settings.asana.projectId || undefined; + if (!workspaceId && !providerProjectId && !(input.externalIds && input.externalIds.length > 0)) { + throw new Error("Asana workspace ID or project ID must be configured in Settings -> Integrations."); + } + const client = this.deps.asanaApiClient ?? asanaApiClient; + const items = await client.searchTasks(token, { + workspaceId, + projectId: providerProjectId, + search: input.search, + status: input.status, + labels: input.labels || [], + assignee: input.assignee, + externalIds: input.externalIds, + includeConversation: input.includeConversation === true, + limit: effectiveImporterLimit(limit, settings.asana.defaultSearchLimit), + }); + return items.map((item) => normalizeAsanaTask(item)); + } + + private async searchLinearIssues( + input: IssueSearchInput, + settings: DashboardSettings, + limit: number, + ): Promise { + const token = settings.linear.apiToken.trim(); + if (!token) { + throw new Error("Linear API token must be configured in Settings -> Integrations."); + } + const client = this.deps.linearApiClient ?? linearApiClient; + const items = input.externalIds && input.externalIds.length > 0 + ? await client.getIssues(token, input.externalIds, { + includeConversation: input.includeConversation !== false, + limit: effectiveImporterLimit(limit, settings.linear.defaultSearchLimit), + }) + : await client.searchIssues(token, { + search: input.search, + status: input.status, + state: input.state, + labels: input.labels || [], + assignee: input.assignee, + teamId: input.teamId || settings.linear.teamId || undefined, + teamKey: input.teamKey || settings.linear.teamKey || undefined, + projectId: input.providerProjectId || settings.linear.projectId || undefined, + includeConversation: input.includeConversation === true, + limit: effectiveImporterLimit(limit, settings.linear.defaultSearchLimit), + }); + return items.map((item) => normalizeLinearIssue(item)); + } + private async getGitHubIssuePromptContext(input: IssuePromptContextInput, tokenValue: string): Promise { const token = tokenValue.trim(); if (!token) { @@ -697,6 +808,90 @@ export class SprintIssueService { }); } + private async getNotionPromptContext(input: IssuePromptContextInput, settings: DashboardSettings): Promise { + const token = settings.notion.apiToken.trim(); + if (!token) { + throw new Error("Notion API token must be configured in Settings -> Integrations."); + } + const externalId = requireExternalPromptId(input, "Notion"); + const client = this.deps.notionApiClient ?? notionApiClient; + const item = (await client.getObjects(token, [externalId], 1))[0]; + if (!item) { + throw new Error(`Notion source not found: ${externalId}`); + } + return buildIssuePromptContext(input, { + title: item.title, + url: item.url, + state: item.archived ? "archived" : "open", + body: item.bodyMarkdown, + author: null, + createdAt: item.createdTime, + updatedAt: item.lastEditedTime, + labels: [item.object], + assignees: [], + conversationMarkdown: "", + includeConversation: false, + }); + } + + private async getAsanaPromptContext(input: IssuePromptContextInput, settings: DashboardSettings): Promise { + const token = settings.asana.apiToken.trim(); + if (!token) { + throw new Error("Asana API token must be configured in Settings -> Integrations."); + } + const externalId = requireExternalPromptId(input, "Asana"); + const client = this.deps.asanaApiClient ?? asanaApiClient; + const item = (await client.getTasks(token, [externalId], { + includeConversation: input.includeConversation !== false, + limit: 1, + }))[0]; + if (!item) { + throw new Error(`Asana task not found: ${externalId}`); + } + return buildIssuePromptContext(input, { + title: item.title, + url: item.url, + state: item.state, + body: item.bodyMarkdown, + author: item.issueAuthor, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + labels: item.labels, + assignees: item.assignees, + conversationMarkdown: item.conversationMarkdown, + includeConversation: input.includeConversation !== false, + }); + } + + private async getLinearPromptContext(input: IssuePromptContextInput, settings: DashboardSettings): Promise { + const token = settings.linear.apiToken.trim(); + if (!token) { + throw new Error("Linear API token must be configured in Settings -> Integrations."); + } + const externalId = requireExternalPromptId(input, "Linear"); + const client = this.deps.linearApiClient ?? linearApiClient; + const item = (await client.getIssues(token, [externalId], { + includeConversation: input.includeConversation !== false, + limit: 1, + }))[0]; + if (!item) { + throw new Error(`Linear issue not found: ${externalId}`); + } + return buildIssuePromptContext(input, { + title: item.title, + url: item.url, + state: item.state, + body: item.bodyMarkdown, + author: item.issueAuthor, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + labels: item.labels, + assignees: item.assignees, + conversationMarkdown: item.conversationMarkdown, + includeConversation: input.includeConversation !== false, + }); + } + private requireProject(projectId: string): ProjectSummary { const project = this.deps.projectManagementRepository.getProject(projectId); if (!project) { @@ -815,8 +1010,8 @@ interface BuildIssuePromptContextOptions { function resolveIssueProvider(project: ProjectSummary, input: IssueSearchInput): LinkedIssueProvider { const provider = input.provider || project.gitProvider; - if (provider !== "github" && provider !== "gitlab" && provider !== "jira") { - throw new Error("Select a GitHub, GitLab, or Jira-backed project before importing issues."); + if (provider === "local" || !isIssueImportProvider(provider)) { + throw new Error("Select a GitHub, GitLab, Jira, Notion, Asana, or Linear provider before importing issues."); } return provider; } @@ -862,26 +1057,42 @@ function buildExplicitIssuePromptInputs( } const repositoryProvider = preferredProvider === "gitlab" ? "gitlab" : preferredProvider === "github" ? "github" : undefined; - if (!repositoryProvider) { - return contexts; - } - - const target = tryResolveRepositoryIssueTarget(project, input, repositoryProvider); - if (!target) { - return contexts; + if (repositoryProvider) { + const target = tryResolveRepositoryIssueTarget(project, input, repositoryProvider); + if (target) { + for (const issueNumber of collectRepositoryIssueNumbers(input)) { + contexts.push({ + provider: target.provider, + hostDomain: target.hostDomain, + repository: target.repository, + issueNumber, + issueKey: `${target.provider === "github" ? "#" : "!"}${issueNumber}`, + title: `${target.repository}#${issueNumber}`, + url: repositoryIssueUrl(target, issueNumber), + includeConversation: input.includeConversation !== false, + }); + } + } } - for (const issueNumber of collectRepositoryIssueNumbers(input)) { - contexts.push({ - provider: target.provider, - hostDomain: target.hostDomain, - repository: target.repository, - issueNumber, - issueKey: `${target.provider === "github" ? "#" : "!"}${issueNumber}`, - title: `${target.repository}#${issueNumber}`, - url: repositoryIssueUrl(target, issueNumber), - includeConversation: input.includeConversation !== false, - }); + const externalProvider = isExternalIssueImportProvider(preferredProvider) ? preferredProvider : undefined; + if (externalProvider) { + const externalIds = collectExternalIds(input, externalProvider); + for (const externalId of externalIds) { + contexts.push({ + provider: externalProvider, + sourceProvider: externalProvider, + sourceKind: externalProvider === "notion" && input.databaseId === externalId ? "database" : defaultSourceKindForProvider(externalProvider), + externalId, + hostDomain: defaultHostForExternalProvider(externalProvider), + repository: defaultRepositoryForExternalProvider(externalProvider, input, settings), + issueNumber: null, + issueKey: displayKeyForExternalProvider(externalProvider, externalId, externalProvider === "notion" && input.databaseId === externalId ? "database" : undefined), + title: displayKeyForExternalProvider(externalProvider, externalId, externalProvider === "notion" && input.databaseId === externalId ? "database" : undefined), + url: defaultExternalUrl(externalProvider, externalId), + includeConversation: input.includeConversation !== false, + }); + } } return contexts; @@ -933,6 +1144,14 @@ function collectRepositoryIssueNumbers(input: IssueSearchInput): number[] { .filter((value) => Number.isFinite(value) && value > 0))); } +function collectExternalIds(input: IssueSearchInput, provider: LinkedIssueProvider): string[] { + const values = [...(input.externalIds || [])]; + if (provider === "notion" && input.databaseId) { + values.push(input.databaseId); + } + return uniqueStrings(values); +} + function extractJiraIssueKeys(value: string): string[] { return Array.from(value.matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/gi)) .map((match) => match[1]) @@ -980,6 +1199,99 @@ function normalizeJiraIssueSummary(issue: JiraIssueSearchResult, hostDomain: str }; } +function normalizeNotionItem(item: notionApiClient.NotionItem): RepositoryIssueSearchResult { + const sourceKind = item.object === "database" ? "database" : "page"; + return { + provider: "notion", + sourceProvider: "notion", + sourceKind, + externalId: item.id, + hostDomain: "notion.so", + repository: sourceKind, + issueNumber: null, + issueKey: displayKeyForExternalProvider("notion", item.id, item.object), + title: item.title, + url: item.url, + state: item.archived ? "archived" : "open", + labels: [item.object], + assignees: [], + bodyPreview: truncatePreview(item.bodyMarkdown), + issueBodyMarkdown: item.bodyMarkdown, + createdAt: item.createdTime, + updatedAt: item.lastEditedTime, + issueAuthor: null, + issueReporter: null, + issueMilestone: null, + issueType: item.object, + issuePriority: null, + issueCommentCount: null, + metadata: item.metadata, + }; +} + +function normalizeAsanaTask(item: asanaApiClient.AsanaTaskItem): RepositoryIssueSearchResult { + return { + provider: "asana", + sourceProvider: "asana", + sourceKind: "task", + externalId: item.gid, + hostDomain: "app.asana.com", + repository: "tasks", + issueNumber: null, + issueKey: displayKeyForExternalProvider("asana", item.gid), + title: item.title, + url: item.url, + state: item.state, + labels: item.labels, + assignees: item.assignees, + bodyPreview: truncatePreview(item.bodyMarkdown), + issueBodyMarkdown: item.bodyMarkdown, + issueConversationMarkdown: item.conversationMarkdown, + includeConversation: item.conversationMarkdown.trim().length > 0, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + issueAuthor: item.issueAuthor, + issueReporter: item.issueAuthor, + issueMilestone: null, + issueType: "Task", + issuePriority: null, + issueCommentCount: item.conversationMarkdown ? item.conversationMarkdown.split("##### Comment ").length - 1 : 0, + metadata: item.metadata, + }; +} + +function normalizeLinearIssue(item: linearApiClient.LinearIssueItem): RepositoryIssueSearchResult { + return { + provider: "linear", + sourceProvider: "linear", + sourceKind: "issue", + externalId: item.id, + hostDomain: "linear.app", + repository: item.teamKey || "issues", + projectKey: item.teamKey || undefined, + issueNumber: null, + issueKey: item.identifier, + title: item.title, + url: item.url, + state: item.state, + labels: item.labels, + assignees: item.assignees, + bodyPreview: truncatePreview(item.bodyMarkdown), + issueBodyMarkdown: item.bodyMarkdown, + issueConversationMarkdown: item.conversationMarkdown, + includeConversation: item.conversationMarkdown.trim().length > 0, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + issueAuthor: item.issueAuthor, + issueReporter: item.issueAuthor, + issueMilestone: item.projectName, + issueType: "Issue", + issuePriority: null, + issueCommentCount: item.conversationMarkdown ? item.conversationMarkdown.split("##### Comment ").length - 1 : 0, + metadata: item.metadata, + }; +} + function parseIssueNumberFromJiraKey(issueKey: string): number | null { const match = issueKey.trim().match(/-(\d+)$/); if (!match?.[1]) { @@ -1002,10 +1314,16 @@ function normalizeIssueSearchInput(input: IssueSearchInput): IssueSearchInput { provider: normalizeIssueProviderValue(input.provider), repository: normalizeOptionalString(input.repository), hostDomain: normalizeOptionalString(input.hostDomain)?.toLowerCase(), + workspaceId: normalizeOptionalString(input.workspaceId), + projectId: normalizeOptionalString(input.projectId), + providerProjectId: normalizeOptionalString(input.providerProjectId || input.projectId), + teamId: normalizeOptionalString(input.teamId), + teamKey: normalizeOptionalString(input.teamKey), + databaseId: normalizeOptionalString(input.databaseId), projectKey: normalizeOptionalString(input.projectKey), search: normalizeOptionalString(input.search), - state: normalizeRepositoryIssueStateValue(input.state), - status: normalizeJiraIssueStatusValue(input.status), + state: normalizeIssueStateValue(input.state), + status: normalizeIssueStatusValue(input.status), labels: normalizeStringList(input.labels).slice(0, 12), assignee: normalizeOptionalString(input.assignee), assigneeText: normalizeOptionalString(input.assigneeText), @@ -1016,6 +1334,7 @@ function normalizeIssueSearchInput(input: IssueSearchInput): IssueSearchInput { issueKeys: normalizeStringList(input.issueKeys), issueNumbers: normalizeIssueNumbers(input.issueNumbers), issueRefs: normalizeStringList(input.issueRefs), + externalIds: normalizeStringList(input.externalIds), createdAfter: normalizeOptionalString(input.createdAfter), createdBefore: normalizeOptionalString(input.createdBefore), updatedAfter: normalizeOptionalString(input.updatedAfter), @@ -1065,14 +1384,23 @@ function normalizeIssueNumbers(values: number[] | undefined): number[] { } function normalizeIssueProviderValue(value: LinkedIssueProvider | undefined): LinkedIssueProvider | undefined { - return value === "github" || value === "gitlab" || value === "jira" ? value : undefined; + return value && isIssueImportProvider(value) ? value : undefined; +} + +function normalizeIssueStateValue(value: IssueSearchInput["state"]): IssueSearchInput["state"] { + const trimmed = normalizeOptionalString(value); + return trimmed; } -function normalizeRepositoryIssueStateValue(value: IssueSearchInput["state"]): IssueSearchInput["state"] { +function normalizeRepositoryIssueStateValue(value: IssueSearchInput["state"]): RepositoryIssueSearchInput["state"] { return value === "open" || value === "closed" || value === "all" ? value : undefined; } -function normalizeJiraIssueStatusValue(value: IssueSearchInput["status"]): IssueSearchInput["status"] { +function normalizeIssueStatusValue(value: IssueSearchInput["status"]): IssueSearchInput["status"] { + return normalizeOptionalString(value); +} + +function normalizeJiraIssueStatusValue(value: IssueSearchInput["status"]): JiraIssueSearchInput["status"] { return value === "open" || value === "in_progress" || value === "done" || value === "all" ? value : undefined; } @@ -1140,16 +1468,22 @@ export function normalizeIssuePromptContextInputs(issues: IssuePromptContextInpu for (const issue of issues) { const hostDomain = issue.hostDomain.trim().toLowerCase(); const repository = issue.repository.trim().replace(/^\/+|\/+$/g, ""); - if (typeof issue.issueNumber !== "number") { - continue; - } - const issueNumber = Math.trunc(issue.issueNumber); + const issueNumber = typeof issue.issueNumber === "number" ? Math.trunc(issue.issueNumber) : null; + const externalId = issue.externalId?.trim() || null; const title = issue.title.trim(); const url = issue.url.trim(); - if ((issue.provider !== "github" && issue.provider !== "gitlab" && issue.provider !== "jira") || !hostDomain || !repository || !title || !url || !Number.isFinite(issueNumber) || issueNumber < 1) { + if (!isIssueImportProvider(issue.provider) || !hostDomain || !repository || !title || !url) { continue; } - const key = `${issue.provider}:${hostDomain}:${repository}:${issueNumber}`; + const isNumericProvider = issue.provider === "github" || issue.provider === "gitlab" || issue.provider === "jira"; + if (isNumericProvider && (!Number.isFinite(issueNumber) || issueNumber === null || issueNumber < 1)) { + continue; + } + if (!isNumericProvider && !externalId) { + continue; + } + const keyValue = isNumericProvider ? `number:${issueNumber}` : `external:${externalId}`; + const key = `${issue.provider}:${hostDomain}:${repository}:${keyValue}`; if (seen.has(key)) { continue; } @@ -1158,8 +1492,11 @@ export function normalizeIssuePromptContextInputs(issues: IssuePromptContextInpu ...issue, hostDomain, repository, - issueNumber, - issueKey: issue.issueKey?.trim() || `${issue.provider === "github" ? "#" : issue.provider === "gitlab" ? "!" : ""}${issueNumber}`, + sourceProvider: issue.sourceProvider || issue.provider, + sourceKind: issue.sourceKind || defaultSourceKindForProvider(issue.provider), + externalId: externalId ?? undefined, + issueNumber: issueNumber ?? undefined, + issueKey: issue.issueKey?.trim() || (isNumericProvider ? `${issue.provider === "github" ? "#" : issue.provider === "gitlab" ? "!" : ""}${issueNumber}` : displayKeyForExternalProvider(issue.provider, externalId || title)), title, url, state: issue.state?.trim() || "open", @@ -1174,11 +1511,14 @@ export function normalizeIssuePromptContextInputs(issues: IssuePromptContextInpu function buildIssuePromptContext(input: IssuePromptContextInput, options: BuildIssuePromptContextOptions): IssuePromptContext { return { provider: input.provider, + sourceProvider: input.sourceProvider || input.provider, + sourceKind: input.sourceKind || defaultSourceKindForProvider(input.provider), + externalId: input.externalId, hostDomain: input.hostDomain, projectKey: input.projectKey, repository: input.repository, issueNumber: input.issueNumber, - issueKey: input.issueKey || `${input.provider === "github" ? "#" : "!"}${input.issueNumber}`, + issueKey: input.issueKey || (typeof input.issueNumber === "number" ? `${input.provider === "github" ? "#" : input.provider === "gitlab" ? "!" : ""}${input.issueNumber}` : displayKeyForExternalProvider(input.provider, input.externalId || input.title)), title: options.title || input.title, url: options.url || input.url, state: options.state || input.state || "open", @@ -1202,6 +1542,79 @@ function defaultHostForProvider(provider: string): string { return provider === "gitlab" ? "gitlab.com" : "github.com"; } +function isIssueImportProvider(provider: LinkedIssueProvider | undefined): provider is "github" | "gitlab" | "jira" | "notion" | "asana" | "linear" { + return provider === "github" + || provider === "gitlab" + || provider === "jira" + || provider === "notion" + || provider === "asana" + || provider === "linear"; +} + +function isExternalIssueImportProvider(provider: LinkedIssueProvider | undefined): provider is "notion" | "asana" | "linear" { + return provider === "notion" || provider === "asana" || provider === "linear"; +} + +function defaultSourceKindForProvider(provider: LinkedIssueProvider): "issue" | "task" | "page" | "database" { + if (provider === "asana") return "task"; + if (provider === "notion") return "page"; + return "issue"; +} + +function defaultHostForExternalProvider(provider: "notion" | "asana" | "linear"): string { + if (provider === "notion") return "notion.so"; + if (provider === "asana") return "app.asana.com"; + return "linear.app"; +} + +function defaultRepositoryForExternalProvider( + provider: "notion" | "asana" | "linear", + input: IssueSearchInput, + settings: DashboardSettings, +): string { + if (provider === "notion") return input.databaseId || settings.notion.databaseId || "workspace"; + if (provider === "asana") return input.providerProjectId || input.projectId || settings.asana.projectId || input.workspaceId || settings.asana.workspaceId || "tasks"; + return input.teamKey || settings.linear.teamKey || input.teamId || settings.linear.teamId || "issues"; +} + +function displayKeyForExternalProvider(provider: LinkedIssueProvider, externalId: string, sourceKind?: string): string { + if (provider === "notion") { + return `${sourceKind === "database" ? "database" : "page"}:${shortExternalId(externalId)}`; + } + if (provider === "asana") { + return `task:${shortExternalId(externalId)}`; + } + if (provider === "linear") { + return externalId.includes("-") && /^[A-Z]+-\d+$/i.test(externalId) ? externalId.toUpperCase() : `issue:${shortExternalId(externalId)}`; + } + return externalId; +} + +function defaultExternalUrl(provider: "notion" | "asana" | "linear", externalId: string): string { + if (provider === "notion") return `https://www.notion.so/${externalId.replace(/-/g, "")}`; + if (provider === "asana") return `https://app.asana.com/0/0/${externalId}`; + return `https://linear.app/issue/${externalId}`; +} + +function shortExternalId(externalId: string): string { + return externalId.length > 12 ? externalId.slice(0, 12) : externalId; +} + +function effectiveImporterLimit(requestLimit: number, defaultLimit: number): number { + if (Number.isFinite(requestLimit)) { + return requestLimit; + } + return Math.max(1, Math.min(100, Math.trunc(defaultLimit))); +} + +function requireExternalPromptId(input: IssuePromptContextInput, providerName: string): string { + const externalId = input.externalId?.trim(); + if (!externalId) { + throw new Error(`${providerName} externalId is required for prompt context.`); + } + return externalId; +} + export function clampLimit(limit: number | undefined): number { if (!Number.isFinite(limit)) { return 30; diff --git a/tests/backend/mcp/management-sprint-actions.test.ts b/tests/backend/mcp/management-sprint-actions.test.ts index 0957d1da23..ece77c1d2d 100644 --- a/tests/backend/mcp/management-sprint-actions.test.ts +++ b/tests/backend/mcp/management-sprint-actions.test.ts @@ -277,7 +277,7 @@ describe("SprintActions", () => { projectId: "p1", provider: "bitbucket", search: "bug", - }))).rejects.toThrow("Invalid value for provider. Must be one of: github, gitlab, jira"); + }))).rejects.toThrow("Invalid value for provider. Must be one of: github, gitlab, jira, notion, asana, linear"); expect(sprintIssueService.searchIssues).not.toHaveBeenCalled(); }); @@ -366,6 +366,65 @@ describe("SprintActions", () => { }); }); + it("searches Linear issues without requiring a sprint", async () => { + const mockIssues = [{ + provider: "linear", + sourceProvider: "linear", + sourceKind: "issue", + externalId: "lin-id-1", + hostDomain: "linear.app", + repository: "LIN", + issueNumber: null, + issueKey: "LIN-42", + title: "Linear Issue", + url: "https://linear.app/acme/issue/LIN-42/linear-issue", + state: "In Progress", + labels: ["integration"], + assignees: ["alice"], + bodyPreview: "preview", + createdAt: null, + updatedAt: null, + issueAuthor: null, + issueReporter: null, + issueMilestone: null, + issueType: "Issue", + issuePriority: null, + issueCommentCount: null, + }]; + vi.mocked(sprintIssueService.searchIssues).mockResolvedValue(mockIssues as any); + + const result = await sprintActions.handleSprintAction(makeArgs("import_issues", { + projectId: "p1", + provider: "linear", + search: " import ", + state: "In Progress", + labels: [" integration "], + teamKey: " lin ", + externalProjectId: " project-1 ", + includeConversation: true, + limit: 5, + })); + + expect(sprintIssueService.searchIssues).toHaveBeenCalledWith("p1", expect.objectContaining({ + provider: "linear", + search: "import", + state: "In Progress", + labels: ["integration"], + teamKey: "lin", + providerProjectId: "project-1", + includeConversation: undefined, + limit: 5, + })); + expect(projectRepo.replaceSprintLinkedIssues).not.toHaveBeenCalled(); + expect(result.result).toMatchObject({ + mode: "search", + provider: "linear", + searchedIssues: mockIssues, + linkedIssues: [], + planning: null, + }); + }); + it("imports explicit Jira issue keys into a sprint", async () => { const contexts = [{ provider: "jira", @@ -420,6 +479,62 @@ describe("SprintActions", () => { }); }); + it("imports explicit Notion external IDs into a sprint and appends prompt context", async () => { + const contexts = [{ + provider: "notion", + sourceProvider: "notion", + sourceKind: "page", + externalId: "page-1", + hostDomain: "notion.so", + repository: "workspace", + issueNumber: null, + issueKey: "page:page-1", + title: "Notion roadmap", + url: "https://www.notion.so/page-1", + state: "open", + labels: ["page"], + assignees: [], + issueBodyMarkdown: "Notion acceptance criteria", + issueConversationMarkdown: "", + includeConversation: false, + issueAuthor: null, + issueCreatedAt: "2026-05-01T00:00:00.000Z", + issueUpdatedAt: "2026-05-02T00:00:00.000Z", + }]; + const linkedRecords = [{ id: "link-1", externalId: "page-1" }]; + vi.mocked(sprintIssueService.getIssuePromptContextsForReferences).mockResolvedValue(contexts as any); + vi.mocked(projectRepo.getSprint).mockReturnValue({ id: "s1", projectId: "p1", goal: "Existing goal" } as any); + vi.mocked(projectRepo.replaceSprintLinkedIssues).mockReturnValue(linkedRecords as any); + vi.mocked(projectRepo.updateSprint).mockReturnValue({ id: "s1", projectId: "p1", goal: "updated" } as any); + + const result = await sprintActions.handleSprintAction(makeArgs("import_issues", { + projectId: "p1", + sprintId: "s1", + provider: "notion", + externalIds: [" page-1 "], + })); + + expect(sprintIssueService.getIssuePromptContextsForReferences).toHaveBeenCalledWith("p1", expect.objectContaining({ + provider: "notion", + externalIds: ["page-1"], + })); + expect(sprintIssueService.importLinkedIssues).toHaveBeenCalledWith("s1", "p1", [expect.objectContaining({ + provider: "notion", + sourceKind: "page", + externalId: "page-1", + issueNumber: null, + })]); + expect(projectRepo.updateSprint).toHaveBeenCalledWith("s1", { + goal: expect.stringContaining("Notion acceptance criteria"), + }); + expect(result.result).toMatchObject({ + mode: "explicit", + provider: "notion", + importedContexts: contexts, + linkedIssues: linkedRecords, + }); + }); + it("returns Jira import transition warnings without dropping linked issues", async () => { const contexts = [{ provider: "jira", @@ -680,6 +795,12 @@ describe("SprintActions", () => { issueKeys: ["OPS-42"], issueNumbers: [42], issueRefs: ["#42", "OPS-42"], + externalIds: ["page-1", "LIN-42"], + workspaceId: "workspace-1", + providerProjectId: "provider-project-1", + teamId: "team-1", + teamKey: "LIN", + databaseId: "database-1", includeConversation: true, attachToSprint: true, planAfterImport: true, diff --git a/tests/backend/mcp/mcp-management.test.ts b/tests/backend/mcp/mcp-management.test.ts index b75986852d..0c9cb8bc5d 100644 --- a/tests/backend/mcp/mcp-management.test.ts +++ b/tests/backend/mcp/mcp-management.test.ts @@ -405,8 +405,8 @@ describe("ManagementToolHandler", () => { const properties = schema?.properties ?? {}; expect(properties.action?.enum).toContain("import_issues"); - expect(properties.provider?.enum).toEqual(["github", "gitlab", "jira"]); - expect(properties.state?.enum).toEqual(["open", "closed", "all"]); + expect(properties.provider?.enum).toEqual(["github", "gitlab", "jira", "notion", "asana", "linear"]); + expect(properties.state).toMatchObject({ type: "string" }); expect(properties.labels).toMatchObject({ type: "array", items: { type: "string" } }); expect(properties.issueKeys).toMatchObject({ type: "array", items: { type: "string" } }); expect(properties.issueNumbers).toMatchObject({ type: "array", items: { type: "number" } }); diff --git a/tests/backend/server/sprint-issue-search-routes.test.ts b/tests/backend/server/sprint-issue-search-routes.test.ts index 1dfc6f18da..913fb530f9 100644 --- a/tests/backend/server/sprint-issue-search-routes.test.ts +++ b/tests/backend/server/sprint-issue-search-routes.test.ts @@ -77,6 +77,37 @@ describe("sprint issue search routes", () => { })); }); + it("parses external provider issue search filters", async () => { + const searchIssues = vi.fn(async () => []); + const app = createApp({ + sprintIssueService: { + searchIssues, + }, + } as unknown as DashboardDependencies); + + const response = await request(app).get( + "/api/projects/project-1/issues?provider=linear&workspaceId=workspace-1&projectId=provider-project-1&teamId=team-1&teamKey=LIN&databaseId=db-1&projectKey=OPS&search=import&state=In%20Progress&status=started&labels=integration,triage&externalIds=LIN-42,lin-id-1&includeConversation=true&limit=10" + ); + + expect(response.status).toBe(200); + expect(searchIssues).toHaveBeenCalledWith("project-1", expect.objectContaining({ + provider: "linear", + workspaceId: "workspace-1", + providerProjectId: "provider-project-1", + teamId: "team-1", + teamKey: "LIN", + databaseId: "db-1", + projectKey: "OPS", + search: "import", + state: "In Progress", + status: "started", + labels: ["integration", "triage"], + externalIds: ["LIN-42", "lin-id-1"], + includeConversation: true, + limit: 10, + })); + }); + it("rejects invalid issue search enums and dates", async () => { const app = createApp({ sprintIssueService: { searchIssues: vi.fn(async () => []) }, @@ -84,6 +115,7 @@ describe("sprint issue search routes", () => { } as unknown as DashboardDependencies); expect((await request(app).get("/api/projects/project-1/issues?sortField=bogus")).status).toBe(400); + expect((await request(app).get("/api/projects/project-1/issues?provider=github&limit=many")).status).toBe(400); expect((await request(app).get("/api/projects/project-1/jira/search?updatedAfter=not-a-date")).status).toBe(400); expect((await request(app).get("/api/projects/project-1/jira/search?status=bogus")).status).toBe(400); }); diff --git a/tests/backend/services/sprint-issue-service.test.ts b/tests/backend/services/sprint-issue-service.test.ts index 11601d96e1..829a855ca4 100644 --- a/tests/backend/services/sprint-issue-service.test.ts +++ b/tests/backend/services/sprint-issue-service.test.ts @@ -651,6 +651,310 @@ describe("SprintIssueService", () => { expect(jiraApiClient.searchIssues).not.toHaveBeenCalled(); }); + it("requires a Notion token before searching Notion sources", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { + getProject: () => project, + } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + notion: { + ...DEFAULT_DASHBOARD_SETTINGS.notion, + apiToken: "", + }, + }), + }); + + await expect(service.searchIssues(project.id, { + provider: "notion", + search: "roadmap", + })).rejects.toThrow("Notion API token must be configured in Settings -> Integrations."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("searches Notion pages and maps readable block context", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (url === "https://api.notion.com/v1/search") { + expect(init?.method).toBe("POST"); + expect((init?.headers as Record).Authorization).toBe("Bearer notion-token"); + expect((init?.headers as Record)["Notion-Version"]).toBeTruthy(); + expect(JSON.parse(String(init?.body))).toEqual(expect.objectContaining({ + query: "roadmap", + page_size: 25, + })); + return new Response(JSON.stringify({ + results: [{ + id: "page-1", + object: "page", + url: "https://www.notion.so/page-1", + archived: false, + created_time: "2026-05-01T00:00:00.000Z", + last_edited_time: "2026-05-02T00:00:00.000Z", + properties: { + Name: { title: [{ plain_text: "Roadmap page" }] }, + }, + }], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "https://api.notion.com/v1/blocks/page-1/children?page_size=100") { + return new Response(JSON.stringify({ + results: [{ + id: "block-1", + type: "paragraph", + paragraph: { rich_text: [{ plain_text: "Acceptance criteria from Notion." }] }, + }], + has_more: false, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { + getProject: () => project, + } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + notion: { + ...DEFAULT_DASHBOARD_SETTINGS.notion, + apiToken: "notion-token", + }, + }), + }); + + const issues = await service.searchIssues(project.id, { + provider: "notion", + search: "roadmap", + limit: 25, + }); + + expect(issues).toEqual([ + expect.objectContaining({ + provider: "notion", + sourceProvider: "notion", + sourceKind: "page", + externalId: "page-1", + hostDomain: "notion.so", + repository: "page", + issueNumber: null, + issueKey: "page:page-1", + title: "Roadmap page", + issueBodyMarkdown: "Acceptance criteria from Notion.", + bodyPreview: "Acceptance criteria from Notion.", + }), + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("uses Asana project task fallback and includes task stories when requested", async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.startsWith("https://app.asana.com/api/1.0/projects/proj-1/tasks")) { + const parsed = new URL(url); + expect(parsed.searchParams.get("limit")).toBe("10"); + return new Response(JSON.stringify({ + data: [{ + gid: "task-1", + name: "Ship Asana import", + notes: "Asana task notes", + permalink_url: "https://app.asana.com/0/proj-1/task-1", + completed: false, + created_at: "2026-05-01T00:00:00.000Z", + modified_at: "2026-05-02T00:00:00.000Z", + assignee: { name: "Alice" }, + created_by: { name: "Bob" }, + tags: [{ name: "integration" }], + projects: [{ gid: "proj-1", name: "Imports" }], + }], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url.startsWith("https://app.asana.com/api/1.0/tasks/task-1/stories")) { + return new Response(JSON.stringify({ + data: [{ + gid: "story-1", + type: "comment", + text: "Asana comment", + created_at: "2026-05-03T00:00:00.000Z", + created_by: { name: "Carol" }, + }], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { + getProject: () => project, + } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + asana: { + ...DEFAULT_DASHBOARD_SETTINGS.asana, + apiToken: "asana-token", + projectId: "proj-1", + }, + }), + }); + + const issues = await service.searchIssues(project.id, { + provider: "asana", + includeConversation: true, + limit: 10, + }); + + expect(issues[0]).toEqual(expect.objectContaining({ + provider: "asana", + sourceKind: "task", + externalId: "task-1", + issueKey: "task:task-1", + issueBodyMarkdown: "Asana task notes", + issueConversationMarkdown: expect.stringContaining("Asana comment"), + issueAuthor: "Bob", + assignees: ["Alice"], + labels: ["integration"], + })); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("requires Asana token and workspace or project configuration before searching", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { + getProject: () => project, + } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + asana: { + ...DEFAULT_DASHBOARD_SETTINGS.asana, + apiToken: "asana-token", + }, + }), + }); + + await expect(service.searchIssues(project.id, { + provider: "asana", + search: "import", + })).rejects.toThrow("Asana workspace ID or project ID must be configured in Settings -> Integrations."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("searches Linear issues and includes comments when requested", async () => { + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + const payload = JSON.parse(String(init?.body)); + if (String(payload.query).includes("CodeUxIssueSearch")) { + expect(payload.variables.first).toBe(5); + expect(payload.variables.filter).toEqual(expect.objectContaining({ + and: expect.any(Array), + })); + return new Response(JSON.stringify({ + data: { + issues: { + nodes: [{ + id: "lin-id-1", + identifier: "LIN-42", + title: "Ship Linear import", + description: "Linear description", + url: "https://linear.app/acme/issue/LIN-42/ship-linear-import", + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-02T00:00:00.000Z", + state: { name: "In Progress", type: "started" }, + labels: { nodes: [{ name: "integration" }] }, + assignee: { displayName: "Alice" }, + creator: { displayName: "Bob" }, + team: { id: "team-1", key: "LIN", name: "Linear Team" }, + project: { id: "project-1", name: "Imports", url: "https://linear.app/acme/project/imports" }, + }], + }, + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (String(payload.query).includes("CodeUxIssueComments")) { + return new Response(JSON.stringify({ + data: { + issue: { + comments: { + nodes: [{ + body: "Linear comment", + createdAt: "2026-05-03T00:00:00.000Z", + user: { displayName: "Carol" }, + url: "https://linear.app/comment/1", + }], + }, + }, + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response(JSON.stringify({ errors: [{ message: "unexpected query" }] }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { + getProject: () => project, + } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + linear: { + ...DEFAULT_DASHBOARD_SETTINGS.linear, + apiToken: "linear-token", + teamKey: "LIN", + }, + }), + }); + + const issues = await service.searchIssues(project.id, { + provider: "linear", + search: "import", + labels: ["integration"], + state: "In Progress", + includeConversation: true, + limit: 5, + }); + + expect(issues[0]).toEqual(expect.objectContaining({ + provider: "linear", + sourceKind: "issue", + externalId: "lin-id-1", + repository: "LIN", + issueKey: "LIN-42", + issueBodyMarkdown: "Linear description", + issueConversationMarkdown: expect.stringContaining("Linear comment"), + issueMilestone: "Imports", + assignees: ["Alice"], + })); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("requires a Linear token before searching Linear issues", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const service = new SprintIssueService({ + projectManagementRepository: { + getProject: () => project, + } as any, + getDashboardSettings: () => ({ + ...DEFAULT_DASHBOARD_SETTINGS, + linear: { + ...DEFAULT_DASHBOARD_SETTINGS.linear, + apiToken: "", + }, + }), + }); + + await expect(service.searchIssues(project.id, { + provider: "linear", + search: "import", + })).rejects.toThrow("Linear API token must be configured in Settings -> Integrations."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("searches Jira issues through the generic issue service and clamps limits", async () => { const jiraApiClient = { searchIssues: vi.fn(async () => [{