"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs = __importStar(require("node:fs"));
const os = __importStar(require("node:os"));
const path = __importStar(require("node:path"));
const node_test_1 = require("node:test");
const strict_1 = __importDefault(require("node:assert/strict"));
const node_test_2 = require("node:test");
const clickup_1 = require("../providers/clickup");
const jira_1 = require("../providers/jira");
const linear_1 = require("../providers/linear");
const monday_1 = require("../providers/monday");
const notion_1 = require("../providers/notion");
const trello_1 = require("../providers/trello");
const logger_1 = require("../logger");
const baseClickUpConfig = {
    clickupApiKey: 'test-key',
    clickupTeamId: 'team1',
    clickupTag: 'aidev',
    assigneeTag: '',
};
const baseJiraConfig = {
    jiraBaseUrl: 'https://example.atlassian.net',
    jiraEmail: 'test@example.com',
    jiraApiToken: 'token',
    jiraProject: 'PROJ',
    jiraLabel: 'aidev',
    assigneeTag: '',
};
const baseLinearConfig = {
    linearApiKey: 'lin_api_test',
    linearTeamId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
    linearLabel: 'aidev',
    linearPendingStatus: 'Backlog',
    linearInReviewStatus: 'In Review',
    assigneeTag: '',
};
const baseMondayConfig = {
    mondayApiToken: 'test-token',
    mondayBoardId: '12345',
    mondayStatusColumnId: 'status',
    mondayGroupId: 'topics',
    clickupPendingStatus: 'Working on it',
    clickupInReviewStatus: 'Done',
};
const baseNotionConfig = {
    notionApiKey: 'test-notion-key',
    notionDatabaseId: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6',
    notionStatusProperty: 'Status',
    notionPendingStatus: 'pending',
    notionInReviewStatus: 'review',
};
const baseTrelloConfig = {
    trelloApiKey: 'trello-key',
    trelloToken: 'trello-token',
    trelloBoardId: 'board1',
    trelloLabel: 'aidev',
    trelloOpenList: 'To Do',
    trelloPendingList: 'Blocked',
    trelloInProgressList: 'Doing',
    trelloInReviewList: 'In Review',
    trelloOpenStatus: 'open',
    trelloPendingStatus: 'pending',
    trelloInReviewStatus: 'review',
};
function jsonResponse(body) {
    return {
        ok: true,
        status: 200,
        statusText: 'OK',
        text: async () => JSON.stringify(body),
        json: async () => body,
        arrayBuffer: async () => Buffer.from(JSON.stringify(body)),
    };
}
function binaryResponse(text) {
    return {
        ok: true,
        status: 200,
        statusText: 'OK',
        text: async () => text,
        json: async () => ({ text }),
        arrayBuffer: async () => Buffer.from(text),
    };
}
function mockFetch(body) {
    node_test_2.mock.method(globalThis, 'fetch', async () => jsonResponse(body));
}
function withTempCwd(fn) {
    const previous = process.cwd();
    const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aidev-provider-test-'));
    process.chdir(tmpDir);
    return fn(tmpDir).finally(() => {
        process.chdir(previous);
        fs.rmSync(tmpDir, { recursive: true, force: true });
    });
}
function mockJiraCommentsFetch(comments, attachments = []) {
    node_test_2.mock.method(globalThis, 'fetch', async (input) => {
        const url = String(input);
        if (url.includes('?fields=attachment')) {
            return jsonResponse({ fields: { attachment: attachments } });
        }
        return jsonResponse({ comments });
    });
}
// ─── ClickUpProvider.getComments ─────────────────────────────────────────────
(0, node_test_1.describe)('ClickUpProvider.fetchTasks', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('downloads task attachments and appends local asset paths to the description', async () => {
        await withTempCwd(async (cwd) => {
            node_test_2.mock.method(globalThis, 'fetch', async (input, init) => {
                const url = String(input);
                if (url.includes('/team/team1/task')) {
                    return jsonResponse({
                        tasks: [
                            {
                                id: 'task1',
                                name: 'Task with file',
                                description: 'Handle the attached screenshot.',
                                status: { status: 'open' },
                                priority: { id: '1' },
                                url: 'https://app.clickup.com/t/task1',
                                tags: [{ name: 'aidev' }],
                            },
                        ],
                    });
                }
                if (url.endsWith('/task/task1')) {
                    return jsonResponse({
                        attachments: [
                            {
                                id: 'att1',
                                title: 'Screenshot 1.png',
                                url: 'https://files.example/screenshot.png',
                            },
                        ],
                    });
                }
                strict_1.default.equal(init?.headers instanceof Object ? init.headers.Authorization : undefined, 'test-key');
                return binaryResponse('image-bytes');
            });
            const provider = new clickup_1.ClickUpProvider(baseClickUpConfig);
            const tasks = await provider.fetchTasks();
            strict_1.default.equal(tasks.length, 1);
            strict_1.default.match(tasks[0].description, /Local asset files/);
            strict_1.default.match(tasks[0].description, /\.aidev\/assets\/task1\/att1-Screenshot-1\.png/);
            strict_1.default.ok(fs.existsSync(path.join(cwd, '.aidev', 'assets', 'task1', 'att1-Screenshot-1.png')));
        });
    });
});
(0, node_test_1.describe)('ClickUpProvider.fetchBoardTasks', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('uses one team list call and does not download attachments', async () => {
        let taskListCalls = 0;
        let taskDetailCalls = 0;
        node_test_2.mock.method(globalThis, 'fetch', async (input) => {
            const url = String(input);
            if (url.includes('/team/team1/task')) {
                taskListCalls += 1;
                return jsonResponse({
                    tasks: [
                        {
                            id: 'open1',
                            name: 'Open task',
                            markdown_description: 'Long body',
                            status: { status: 'open' },
                            priority: null,
                            url: 'https://app.clickup.com/t/open1',
                            tags: [],
                        },
                        {
                            id: 'prog1',
                            name: 'In progress task',
                            markdown_description: 'Also long',
                            status: { status: 'in progress' },
                            priority: null,
                            url: 'https://app.clickup.com/t/prog1',
                            tags: [],
                        },
                    ],
                });
            }
            if (url.includes('/task/')) {
                taskDetailCalls += 1;
            }
            return jsonResponse({});
        });
        const provider = new clickup_1.ClickUpProvider(baseClickUpConfig);
        const tasks = await provider.fetchBoardTasks();
        strict_1.default.equal(taskListCalls, 1);
        strict_1.default.equal(taskDetailCalls, 0);
        strict_1.default.equal(tasks.length, 2);
        strict_1.default.equal(tasks.find((t) => t.id === 'prog1')?.description, '');
        strict_1.default.equal(tasks.find((t) => t.id === 'open1')?.description, '');
    });
});
(0, node_test_1.describe)('ClickUpProvider.fetchTasks — wildcard tag', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('omits tag filter from API call when tag is "*"', async () => {
        await withTempCwd(async () => {
            let capturedUrl = '';
            node_test_2.mock.method(globalThis, 'fetch', async (input) => {
                const url = String(input);
                if (url.includes('/team/team1/task')) {
                    capturedUrl = url;
                    return jsonResponse({
                        tasks: [
                            {
                                id: 'task1',
                                name: 'Any-tag task',
                                description: 'No specific tag required.',
                                status: { status: 'open' },
                                priority: { id: '1' },
                                url: 'https://app.clickup.com/t/task1',
                                tags: [{ name: 'random' }],
                            },
                        ],
                    });
                }
                if (url.endsWith('/task/task1')) {
                    return jsonResponse({ attachments: [] });
                }
                return jsonResponse({});
            });
            const provider = new clickup_1.ClickUpProvider({
                ...baseClickUpConfig,
                clickupTag: '*',
            });
            const tasks = await provider.fetchTasks();
            strict_1.default.equal(tasks.length, 1);
            strict_1.default.equal(tasks[0].name, 'Any-tag task');
            // The URL should NOT contain a tags[] parameter
            strict_1.default.ok(!capturedUrl.includes('tags%5B%5D'), 'should omit tags[] filter when tag is "*"');
            strict_1.default.ok(!capturedUrl.includes('tags[]='), 'should omit tags[] filter when tag is "*"');
        });
    });
});
(0, node_test_1.describe)('ClickUpProvider.getComments', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('sorts newest-first API response into oldest-first (ascending by date)', async () => {
        mockFetch({
            comments: [
                // ClickUp returns newest first
                { id: '3', comment_text: 'aidev-continue', comment: [], user: { username: 'user', id: 1 }, date: '3000' },
                { id: '2', comment_text: 'Some answer', comment: [], user: { username: 'user', id: 1 }, date: '2000' },
                { id: '1', comment_text: '[aidev] Clarification question', comment: [], user: { username: 'bot', id: 2 }, date: '1000' },
            ],
        });
        const provider = new clickup_1.ClickUpProvider(baseClickUpConfig);
        const comments = await provider.getComments('task1');
        strict_1.default.equal(comments[0].text, '[aidev] Clarification question'); // oldest first
        strict_1.default.equal(comments[2].text, 'aidev-continue'); // newest last
    });
    (0, node_test_1.it)('uses comment_text when it is a plain string', async () => {
        mockFetch({
            comments: [
                { id: '1', comment_text: 'hello world', comment: [], user: { username: 'user', id: 1 }, date: '1000' },
            ],
        });
        const provider = new clickup_1.ClickUpProvider(baseClickUpConfig);
        const comments = await provider.getComments('task1');
        strict_1.default.equal(comments[0].text, 'hello world');
    });
    (0, node_test_1.it)('falls back to comment array when comment_text is empty (rich-text editor)', async () => {
        mockFetch({
            comments: [
                {
                    id: '1',
                    comment_text: '',
                    comment: [
                        { text: 'aidev-continue', attributes: {} },
                        { text: '\n', attributes: { 'block-id': 'abc' } },
                    ],
                    user: { username: 'user', id: 1 },
                    date: '1000',
                },
            ],
        });
        const provider = new clickup_1.ClickUpProvider(baseClickUpConfig);
        const comments = await provider.getComments('task1');
        strict_1.default.ok(comments[0].text.includes('aidev-continue'));
    });
    (0, node_test_1.it)('returns empty text when both comment_text and comment array are empty', async () => {
        mockFetch({
            comments: [
                { id: '1', comment_text: '', comment: [], user: { username: 'user', id: 1 }, date: '1000' },
            ],
        });
        const provider = new clickup_1.ClickUpProvider(baseClickUpConfig);
        const comments = await provider.getComments('task1');
        strict_1.default.equal(comments[0].text, '');
    });
    (0, node_test_1.it)('trigger word is detected when newest comment contains it (real-world ordering)', async () => {
        // Simulate ClickUp newest-first response: aidev-continue is newest (highest date)
        // but sits at index 0 in the API response. After sorting it must be last.
        mockFetch({
            comments: [
                { id: '5', comment_text: 'aidev-continue', comment: [], user: { username: 'david', id: 1 }, date: '5000' },
                { id: '4', comment_text: 'User answer here', comment: [], user: { username: 'david', id: 1 }, date: '4000' },
                { id: '3', comment_text: '[aidev] All AI runners failed.', comment: [], user: { username: 'bot', id: 2 }, date: '3000' },
                { id: '2', comment_text: '[aidev] Starting implementation', comment: [], user: { username: 'bot', id: 2 }, date: '2000' },
                { id: '1', comment_text: '[aidev] Clarification question', comment: [], user: { username: 'bot', id: 2 }, date: '1000' },
            ],
        });
        const provider = new clickup_1.ClickUpProvider(baseClickUpConfig);
        const comments = await provider.getComments('task1');
        const last = comments[comments.length - 1];
        strict_1.default.ok(last.text.toLowerCase().includes('aidev-continue'));
    });
});
// ─── JiraProvider.getComments ─────────────────────────────────────────────────
(0, node_test_1.describe)('JiraProvider.getComments', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    function adfComment(text) {
        return {
            type: 'doc',
            version: 1,
            content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
        };
    }
    (0, node_test_1.it)('extracts plain text from ADF body', async () => {
        mockJiraCommentsFetch([
            { id: '1', body: adfComment('hello world'), author: { displayName: 'Alice', accountId: 'a1' }, created: '2024-01-01T10:00:00.000Z' },
        ]);
        const provider = new jira_1.JiraProvider(baseJiraConfig);
        const comments = await provider.getComments('PROJ-1');
        strict_1.default.equal(comments[0].text, 'hello world');
    });
    (0, node_test_1.it)('sorts out-of-order API response into ascending date order', async () => {
        mockJiraCommentsFetch([
            { id: '3', body: adfComment('aidev-continue'), author: { displayName: 'Alice', accountId: 'a1' }, created: '2024-01-01T12:00:00.000Z' },
            { id: '1', body: adfComment('[aidev] Starting'), author: { displayName: 'bot', accountId: 'b1' }, created: '2024-01-01T10:00:00.000Z' },
            { id: '2', body: adfComment('User reply'), author: { displayName: 'Alice', accountId: 'a1' }, created: '2024-01-01T11:00:00.000Z' },
        ]);
        const provider = new jira_1.JiraProvider(baseJiraConfig);
        const comments = await provider.getComments('PROJ-1');
        strict_1.default.equal(comments[0].text, '[aidev] Starting'); // oldest first
        strict_1.default.equal(comments[2].text, 'aidev-continue'); // newest last
    });
    (0, node_test_1.it)('trigger word is detected when newest comment contains it', async () => {
        mockJiraCommentsFetch([
            { id: '1', body: adfComment('[aidev] Clarification needed'), author: { displayName: 'bot', accountId: 'b1' }, created: '2024-01-01T10:00:00.000Z' },
            { id: '2', body: adfComment('aidev-continue'), author: { displayName: 'Alice', accountId: 'a1' }, created: '2024-01-01T11:00:00.000Z' },
        ]);
        const provider = new jira_1.JiraProvider(baseJiraConfig);
        const comments = await provider.getComments('PROJ-1');
        const last = comments[comments.length - 1];
        strict_1.default.ok(last.text.toLowerCase().includes('aidev-continue'));
    });
    (0, node_test_1.it)('appends local asset paths when a comment links a native Jira attachment', async () => {
        await withTempCwd(async (cwd) => {
            node_test_2.mock.method(globalThis, 'fetch', async (input) => {
                const url = String(input);
                if (url.includes('?fields=attachment')) {
                    return jsonResponse({
                        fields: {
                            attachment: [
                                {
                                    id: '101',
                                    filename: 'trace.json',
                                    content: 'https://example.atlassian.net/rest/api/3/attachment/content/101',
                                },
                            ],
                        },
                    });
                }
                if (url.endsWith('/comment')) {
                    return jsonResponse({
                        comments: [
                            {
                                id: '1',
                                body: {
                                    type: 'doc',
                                    version: 1,
                                    content: [
                                        {
                                            type: 'paragraph',
                                            content: [
                                                { type: 'text', text: 'Please inspect ' },
                                                {
                                                    type: 'text',
                                                    text: 'the trace',
                                                    marks: [
                                                        {
                                                            type: 'link',
                                                            attrs: {
                                                                href: 'https://example.atlassian.net/rest/api/3/attachment/content/101',
                                                            },
                                                        },
                                                    ],
                                                },
                                            ],
                                        },
                                    ],
                                },
                                author: { displayName: 'Alice', accountId: 'a1' },
                                created: '2024-01-01T10:00:00.000Z',
                            },
                        ],
                    });
                }
                return binaryResponse('{"trace":true}');
            });
            const provider = new jira_1.JiraProvider(baseJiraConfig);
            const comments = await provider.getComments('PROJ-1');
            strict_1.default.match(comments[0].text, /Local asset files referenced by this comment/);
            strict_1.default.match(comments[0].text, /\.aidev\/assets\/PROJ-1\/101-trace\.json/);
            strict_1.default.ok(fs.existsSync(path.join(cwd, '.aidev', 'assets', 'PROJ-1', '101-trace.json')));
        });
    });
});
(0, node_test_1.describe)('JiraProvider.fetchTasks — wildcard label', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('omits label clause from JQL when label is "*"', async () => {
        await withTempCwd(async () => {
            let capturedUrl = '';
            node_test_2.mock.method(globalThis, 'fetch', async (input) => {
                const url = String(input);
                if (url.includes('/search/jql')) {
                    capturedUrl = url;
                    return jsonResponse({
                        issues: [
                            {
                                id: '2001',
                                key: 'PROJ-5',
                                fields: {
                                    summary: 'Unlabelled task',
                                    description: null,
                                    status: { name: 'Open' },
                                    priority: { id: '3' },
                                    labels: [],
                                    self: 'https://example.atlassian.net/rest/api/3/issue/2001',
                                    attachment: [],
                                },
                            },
                        ],
                    });
                }
                return jsonResponse({});
            });
            const provider = new jira_1.JiraProvider({
                ...baseJiraConfig,
                jiraLabel: '*',
            });
            const tasks = await provider.fetchTasks();
            strict_1.default.equal(tasks.length, 1);
            strict_1.default.equal(tasks[0].id, 'PROJ-5');
            // JQL should NOT contain "AND labels =" clause
            const decodedUrl = decodeURIComponent(capturedUrl);
            strict_1.default.ok(!decodedUrl.includes('AND labels ='), 'should omit labels clause from JQL when label is "*"');
        });
    });
});
(0, node_test_1.describe)('JiraProvider.fetchTasks', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('downloads issue attachments and appends local asset paths to the description', async () => {
        await withTempCwd(async (cwd) => {
            node_test_2.mock.method(globalThis, 'fetch', async (input) => {
                const url = String(input);
                if (url.includes('/search/jql')) {
                    return jsonResponse({
                        issues: [
                            {
                                id: '1001',
                                key: 'PROJ-1',
                                fields: {
                                    summary: 'Handle uploaded trace',
                                    description: {
                                        type: 'doc',
                                        version: 1,
                                        content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Review the trace file.' }] }],
                                    },
                                    status: { name: 'Open' },
                                    priority: { id: '2' },
                                    labels: ['aidev'],
                                    self: 'https://example.atlassian.net/rest/api/3/issue/1001',
                                    attachment: [
                                        {
                                            id: '101',
                                            filename: 'trace.json',
                                            content: 'https://example.atlassian.net/rest/api/3/attachment/content/101',
                                        },
                                    ],
                                },
                            },
                        ],
                    });
                }
                return binaryResponse('{"trace":true}');
            });
            const provider = new jira_1.JiraProvider(baseJiraConfig);
            const tasks = await provider.fetchTasks();
            strict_1.default.equal(tasks.length, 1);
            strict_1.default.match(tasks[0].description, /Local asset files/);
            strict_1.default.match(tasks[0].description, /\.aidev\/assets\/PROJ-1\/101-trace\.json/);
            strict_1.default.ok(fs.existsSync(path.join(cwd, '.aidev', 'assets', 'PROJ-1', '101-trace.json')));
        });
    });
});
// ─── LinearProvider ─────────────────────────────────────────────────────────
(0, node_test_1.describe)('LinearProvider.fetchTasks — wildcard label', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('omits label filter from GraphQL query when label is "*"', async () => {
        let capturedFilter = {};
        node_test_2.mock.method(globalThis, 'fetch', async (_url, init) => {
            const body = init?.body ? JSON.parse(init.body) : {};
            if (body.query?.includes('workflowStates')) {
                return jsonResponse({
                    data: { workflowStates: { nodes: [{ id: 's1', name: 'Backlog', type: 'unstarted' }] } },
                });
            }
            if (body.query?.includes('issues')) {
                capturedFilter = body.variables?.filter || {};
                return jsonResponse({
                    data: {
                        issues: {
                            nodes: [
                                {
                                    id: 'issue-uuid-2',
                                    identifier: 'ENG-99',
                                    title: 'No label task',
                                    description: 'Should appear without label filter.',
                                    url: 'https://linear.app/org/issue/ENG-99',
                                    state: { id: 's1', name: 'Backlog', type: 'unstarted' },
                                    priority: 1,
                                    labels: { nodes: [] },
                                },
                            ],
                        },
                    },
                });
            }
            return jsonResponse({ data: {} });
        });
        const provider = new linear_1.LinearProvider({
            ...baseLinearConfig,
            linearLabel: '*',
        });
        const tasks = await provider.fetchTasks();
        strict_1.default.equal(tasks.length, 1);
        strict_1.default.equal(tasks[0].id, 'ENG-99');
        // Filter should NOT contain a labels key
        strict_1.default.equal(capturedFilter.labels, undefined, 'should not include labels filter when label is "*"');
    });
});
(0, node_test_1.describe)('LinearProvider.fetchTasks', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('returns issues with identifier as id and state name as status', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async (_url, init) => {
            const body = init?.body ? JSON.parse(init.body) : {};
            if (body.query?.includes('workflowStates')) {
                return jsonResponse({
                    data: { workflowStates: { nodes: [{ id: 's1', name: 'Backlog', type: 'unstarted' }, { id: 's2', name: 'In Review', type: 'started' }] } },
                });
            }
            if (body.query?.includes('issues')) {
                return jsonResponse({
                    data: {
                        issues: {
                            nodes: [
                                {
                                    id: 'issue-uuid-1',
                                    identifier: 'ENG-42',
                                    title: 'Fix login',
                                    description: 'Fix the login flow.',
                                    url: 'https://linear.app/org/issue/ENG-42',
                                    state: { id: 's1', name: 'Backlog', type: 'unstarted' },
                                    priority: 2,
                                    labels: { nodes: [{ name: 'aidev' }] },
                                },
                            ],
                        },
                    },
                });
            }
            return jsonResponse({ data: {} });
        });
        const provider = new linear_1.LinearProvider(baseLinearConfig);
        const tasks = await provider.fetchTasks();
        strict_1.default.equal(tasks.length, 1);
        strict_1.default.equal(tasks[0].id, 'ENG-42');
        strict_1.default.equal(tasks[0].name, 'Fix login');
        strict_1.default.equal(tasks[0].description, 'Fix the login flow.');
        strict_1.default.equal(tasks[0].status, 'Backlog');
        strict_1.default.equal(tasks[0].url, 'https://linear.app/org/issue/ENG-42');
        strict_1.default.deepEqual(tasks[0].tags, ['aidev']);
        strict_1.default.equal(tasks[0].priority, 2);
    });
});
(0, node_test_1.describe)('LinearProvider.getComments', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('returns comments sorted ascending by date', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async (_url, init) => {
            const body = init?.body ? JSON.parse(init.body) : {};
            if (body.query?.includes('issues') && body.variables?.filter?.team?.key) {
                return jsonResponse({ data: { issues: { nodes: [{ id: 'issue-uuid-1' }] } } });
            }
            if (body.query?.includes('IssueComments') && body.variables?.issueId) {
                return jsonResponse({
                    data: {
                        comments: {
                            nodes: [
                                {
                                    id: 'c1',
                                    body: '[aidev] Starting',
                                    parentId: null,
                                    user: { name: 'Bot', id: 'u1' },
                                    botActor: null,
                                    externalUser: null,
                                    createdAt: '2024-01-01T10:00:00.000Z',
                                },
                                {
                                    id: 'c2',
                                    body: 'aidev-continue',
                                    parentId: null,
                                    user: { name: 'Alice', id: 'u2' },
                                    botActor: null,
                                    externalUser: null,
                                    createdAt: '2024-01-01T11:00:00.000Z',
                                },
                            ],
                            pageInfo: { hasNextPage: false, endCursor: null },
                        },
                    },
                });
            }
            return jsonResponse({ data: {} });
        });
        const provider = new linear_1.LinearProvider(baseLinearConfig);
        const comments = await provider.getComments('ENG-42');
        strict_1.default.equal(comments.length, 2);
        strict_1.default.equal(comments[0].text, '[aidev] Starting');
        strict_1.default.equal(comments[1].text, 'aidev-continue');
        strict_1.default.equal(comments[1].author, 'Alice');
    });
});
(0, node_test_1.describe)('LinearProvider.createTask — labels', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('resolves an existing label and creates a missing one, then attaches both ids', async () => {
        const labelCreateCalls = [];
        let issueCreateInput;
        node_test_2.mock.method(globalThis, 'fetch', async (_url, init) => {
            const body = init?.body ? JSON.parse(init.body) : {};
            const query = body.query;
            if (query.includes('issueLabels')) {
                return jsonResponse({
                    data: {
                        issueLabels: {
                            nodes: [{ id: 'label-existing-id', name: 'Backend' }],
                            pageInfo: { hasNextPage: false, endCursor: null },
                        },
                    },
                });
            }
            if (query.includes('issueLabelCreate')) {
                labelCreateCalls.push(body.variables?.input ?? {});
                const name = body.variables?.input?.name;
                return jsonResponse({
                    data: {
                        issueLabelCreate: {
                            success: true,
                            issueLabel: { id: 'label-new-id', name },
                        },
                    },
                });
            }
            if (query.includes('issueCreate')) {
                issueCreateInput = body.variables?.input;
                return jsonResponse({
                    data: {
                        issueCreate: {
                            success: true,
                            issue: { id: 'issue-uuid', identifier: 'ENG-7', url: 'https://linear.app/org/issue/ENG-7' },
                        },
                    },
                });
            }
            return jsonResponse({ data: {} });
        });
        const provider = new linear_1.LinearProvider(baseLinearConfig);
        const result = await provider.createTask({
            title: 'New task',
            description: 'desc',
            tags: ['backend', 'urgent'],
        });
        strict_1.default.equal(result.id, 'ENG-7');
        strict_1.default.equal(labelCreateCalls.length, 1, 'should issueLabelCreate exactly once');
        strict_1.default.equal(labelCreateCalls[0].name, 'urgent');
        strict_1.default.equal(labelCreateCalls[0].teamId, 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
        strict_1.default.ok(issueCreateInput, 'issueCreate must have been called');
        strict_1.default.deepEqual(issueCreateInput.labelIds, ['label-existing-id', 'label-new-id']);
    });
    (0, node_test_1.it)('paginates issueLabels and finds an existing label on a later page', async () => {
        const labelCreateCalls = [];
        let issueCreateInput;
        const cursors = [];
        node_test_2.mock.method(globalThis, 'fetch', async (_url, init) => {
            const body = init?.body ? JSON.parse(init.body) : {};
            const query = body.query;
            if (query.includes('issueLabels')) {
                const after = body.variables?.after ?? null;
                cursors.push(after);
                if (after === null) {
                    return jsonResponse({
                        data: {
                            issueLabels: {
                                nodes: [{ id: 'label-page1', name: 'Frontend' }],
                                pageInfo: { hasNextPage: true, endCursor: 'cursor-1' },
                            },
                        },
                    });
                }
                return jsonResponse({
                    data: {
                        issueLabels: {
                            nodes: [{ id: 'label-page2', name: 'Backend' }],
                            pageInfo: { hasNextPage: false, endCursor: 'cursor-2' },
                        },
                    },
                });
            }
            if (query.includes('issueLabelCreate')) {
                labelCreateCalls.push(body.variables?.input ?? {});
                return jsonResponse({ data: { issueLabelCreate: { success: true, issueLabel: null } } });
            }
            if (query.includes('issueCreate')) {
                issueCreateInput = body.variables?.input;
                return jsonResponse({
                    data: {
                        issueCreate: {
                            success: true,
                            issue: { id: 'issue-uuid', identifier: 'ENG-11', url: 'https://linear.app/org/issue/ENG-11' },
                        },
                    },
                });
            }
            return jsonResponse({ data: {} });
        });
        const provider = new linear_1.LinearProvider(baseLinearConfig);
        const result = await provider.createTask({
            title: 'Paginated label',
            description: '',
            tags: ['backend'],
        });
        strict_1.default.equal(result.id, 'ENG-11');
        strict_1.default.deepEqual(cursors, [null, 'cursor-1'], 'should walk pages until hasNextPage is false');
        strict_1.default.equal(labelCreateCalls.length, 0, 'should not create a label that exists on a later page');
        strict_1.default.ok(issueCreateInput, 'issueCreate must have been called');
        strict_1.default.deepEqual(issueCreateInput.labelIds, ['label-page2']);
    });
    (0, node_test_1.it)('omits labelIds entirely when params.tags is empty', async () => {
        let issueLabelsCalled = false;
        let issueCreateInput;
        node_test_2.mock.method(globalThis, 'fetch', async (_url, init) => {
            const body = init?.body ? JSON.parse(init.body) : {};
            const query = body.query;
            if (query.includes('issueLabels')) {
                issueLabelsCalled = true;
                return jsonResponse({ data: { issueLabels: { nodes: [] } } });
            }
            if (query.includes('issueCreate')) {
                issueCreateInput = body.variables?.input;
                return jsonResponse({
                    data: {
                        issueCreate: {
                            success: true,
                            issue: { id: 'issue-uuid', identifier: 'ENG-8', url: 'https://linear.app/org/issue/ENG-8' },
                        },
                    },
                });
            }
            return jsonResponse({ data: {} });
        });
        const provider = new linear_1.LinearProvider(baseLinearConfig);
        await provider.createTask({ title: 'No tags', description: '', tags: [] });
        strict_1.default.equal(issueLabelsCalled, false, 'should not query labels when tags is empty');
        strict_1.default.ok(issueCreateInput, 'issueCreate must have been called');
        strict_1.default.equal('labelIds' in issueCreateInput, false, 'input must not contain labelIds');
    });
});
(0, node_test_1.describe)('LinearProvider.updateStatus — state type fallback', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('matches by Linear state type when no state name matches the configured status', async () => {
        let updatedStateId;
        node_test_2.mock.method(globalThis, 'fetch', async (_url, init) => {
            const body = init?.body ? JSON.parse(init.body) : {};
            const query = body.query;
            if (query.includes('workflowStates')) {
                return jsonResponse({
                    data: {
                        workflowStates: {
                            nodes: [
                                { id: 'state-triage', name: 'Triage', type: 'backlog' },
                                { id: 'state-doing', name: 'Doing', type: 'started' },
                                { id: 'state-shipped', name: 'Shipped', type: 'completed' },
                            ],
                        },
                    },
                });
            }
            if (query.includes('issues') && body.variables?.filter?.team?.key) {
                return jsonResponse({ data: { issues: { nodes: [{ id: 'issue-uuid-99' }] } } });
            }
            if (query.includes('issueUpdate')) {
                updatedStateId = body.variables?.input?.stateId;
                return jsonResponse({ data: { issueUpdate: { success: true } } });
            }
            return jsonResponse({ data: {} });
        });
        const provider = new linear_1.LinearProvider(baseLinearConfig);
        await provider.updateStatus('ENG-99', 'Backlog');
        strict_1.default.equal(updatedStateId, 'state-triage');
    });
});
(0, node_test_1.describe)('LinearProvider.createTask — assignee resolution', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('extracts email from "username <email>" format and queries by email', async () => {
        const userFilters = [];
        let issueCreateInput;
        node_test_2.mock.method(globalThis, 'fetch', async (_url, init) => {
            const body = init?.body ? JSON.parse(init.body) : {};
            const query = body.query;
            if (query.includes('users')) {
                userFilters.push(body.variables?.filter ?? {});
                return jsonResponse({
                    data: { users: { nodes: [{ id: 'user-uuid-1', email: 'alice@example.com', displayName: 'Alice' }] } },
                });
            }
            if (query.includes('issueCreate')) {
                issueCreateInput = body.variables?.input;
                return jsonResponse({
                    data: {
                        issueCreate: {
                            success: true,
                            issue: { id: 'issue-uuid', identifier: 'ENG-10', url: 'https://linear.app/org/issue/ENG-10' },
                        },
                    },
                });
            }
            return jsonResponse({ data: {} });
        });
        const provider = new linear_1.LinearProvider({
            ...baseLinearConfig,
            assigneeTag: 'Alice <alice@example.com>',
        });
        const result = await provider.createTask({ title: 'Assigned', description: '', tags: [] });
        strict_1.default.equal(result.id, 'ENG-10');
        strict_1.default.ok(issueCreateInput, 'issueCreate must have been called');
        strict_1.default.equal(issueCreateInput.assigneeId, 'user-uuid-1');
        strict_1.default.equal(userFilters.length, 1, 'should query users exactly once when email lookup succeeds');
        strict_1.default.deepEqual(userFilters[0], { email: { eq: 'alice@example.com' } });
    });
    (0, node_test_1.it)('warns and creates the issue without assignee when no user matches', async () => {
        let issueCreateInput;
        node_test_2.mock.method(globalThis, 'fetch', async (_url, init) => {
            const body = init?.body ? JSON.parse(init.body) : {};
            const query = body.query;
            if (query.includes('users')) {
                return jsonResponse({ data: { users: { nodes: [] } } });
            }
            if (query.includes('issueCreate')) {
                issueCreateInput = body.variables?.input;
                return jsonResponse({
                    data: {
                        issueCreate: {
                            success: true,
                            issue: { id: 'issue-uuid', identifier: 'ENG-9', url: 'https://linear.app/org/issue/ENG-9' },
                        },
                    },
                });
            }
            return jsonResponse({ data: {} });
        });
        const warn = node_test_2.mock.method(logger_1.logger, 'warn', () => { });
        const provider = new linear_1.LinearProvider({
            ...baseLinearConfig,
            assigneeTag: 'unknown@example.com',
        });
        const result = await provider.createTask({ title: 'No assignee', description: '', tags: [] });
        strict_1.default.equal(result.id, 'ENG-9');
        strict_1.default.ok(issueCreateInput, 'issueCreate must have been called');
        strict_1.default.equal('assigneeId' in issueCreateInput, false, 'input must not contain assigneeId');
        strict_1.default.ok(warn.mock.callCount() >= 1, 'logger.warn must be called at least once');
        const firstCallArg = warn.mock.calls[0]?.arguments?.[0] ?? '';
        strict_1.default.match(String(firstCallArg), /unknown@example\.com/);
    });
});
// ─── MondayProvider ───────────────────────────────────────────────────────────
(0, node_test_1.describe)('MondayProvider.fetchTasks', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('returns items whose status matches pending or in-review', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async (_input, init) => {
            const body = typeof (init?.body) === 'string' ? JSON.parse(init.body) : {};
            const query = body.query;
            if (query.includes('boards') && query.includes('items_page')) {
                return jsonResponse({
                    data: {
                        boards: [
                            {
                                items_page: {
                                    cursor: null,
                                    items: [
                                        {
                                            id: '1001',
                                            name: 'Task one',
                                            url: 'https://example.monday.com/boards/12345/pulses/1001',
                                            description: { description: 'Do something' },
                                            column_values: [
                                                { id: 'status', value: '{"label":"Working on it"}', text: 'Working on it' },
                                            ],
                                        },
                                        {
                                            id: '1002',
                                            name: 'Task two',
                                            url: 'https://example.monday.com/boards/12345/pulses/1002',
                                            description: { description: '' },
                                            column_values: [
                                                { id: 'status', value: '{"label":"Done"}', text: 'Done' },
                                            ],
                                        },
                                        {
                                            id: '1003',
                                            name: 'Task three',
                                            url: '',
                                            description: {},
                                            column_values: [
                                                { id: 'status', value: '{}', text: 'Stuck' },
                                            ],
                                        },
                                    ],
                                },
                            },
                        ],
                    },
                });
            }
            return jsonResponse({});
        });
        const provider = new monday_1.MondayProvider(baseMondayConfig);
        const tasks = await provider.fetchTasks();
        strict_1.default.equal(tasks.length, 2);
        strict_1.default.equal(tasks[0].id, '1001');
        strict_1.default.equal(tasks[0].name, 'Task one');
        strict_1.default.equal(tasks[0].status, 'Working on it');
        strict_1.default.equal(tasks[1].id, '1002');
        strict_1.default.equal(tasks[1].status, 'Done');
    });
});
(0, node_test_1.describe)('MondayProvider.getComments', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('returns updates sorted ascending by date', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async () => jsonResponse({
            data: {
                items: [
                    {
                        updates: [
                            { id: 'u2', text_body: 'Newest', body: '<p>Newest</p>', created_at: '2024-01-02T12:00:00Z', creator: { id: 'u1', name: 'Alice' } },
                            { id: 'u1', text_body: 'Oldest', body: '<p>Oldest</p>', created_at: '2024-01-01T10:00:00Z', creator: { id: 'u1', name: 'Alice' } },
                        ],
                    },
                ],
            },
        }));
        const provider = new monday_1.MondayProvider(baseMondayConfig);
        const comments = await provider.getComments('1001');
        strict_1.default.equal(comments.length, 2);
        strict_1.default.equal(comments[0].text, 'Oldest');
        strict_1.default.equal(comments[1].text, 'Newest');
    });
});
// ─── NotionProvider ─────────────────────────────────────────────────────────
(0, node_test_1.describe)('NotionProvider.fetchTasks', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('returns pages with pending or review status from database query', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async (input, init) => {
            const url = String(input);
            if (url.includes('/databases/') && url.includes('/query') === false && (init?.method ?? 'GET') === 'GET') {
                return jsonResponse({
                    id: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6',
                    properties: {
                        Name: { type: 'title' },
                        Status: { type: 'status' },
                    },
                });
            }
            if (url.includes('/query')) {
                return jsonResponse({
                    results: [
                        {
                            id: 'a1b2c3d4-e5f6-7890-1234-5678abcdef01',
                            url: 'https://notion.so/a1b2c3d4e5f6789012345678abcdef01',
                            properties: {
                                Name: { title: [{ plain_text: 'Task one' }] },
                                Status: { status: { name: 'pending' } },
                                Description: { rich_text: [{ plain_text: 'Do something' }] },
                            },
                        },
                        {
                            id: 'a1b2c3d4-e5f6-7890-1234-5678abcdef02',
                            url: 'https://notion.so/page-two',
                            properties: {
                                Name: { title: [{ plain_text: 'Task two' }] },
                                Status: { status: { name: 'review' } },
                            },
                        },
                    ],
                    next_cursor: null,
                    has_more: false,
                });
            }
            return jsonResponse({});
        });
        const provider = new notion_1.NotionProvider(baseNotionConfig);
        const tasks = await provider.fetchTasks();
        strict_1.default.equal(tasks.length, 2);
        strict_1.default.equal(tasks[0].id, 'a1b2c3d4e5f6789012345678abcdef01');
        strict_1.default.equal(tasks[0].name, 'Task one');
        strict_1.default.equal(tasks[0].description, 'Do something');
        strict_1.default.equal(tasks[0].status, 'pending');
        strict_1.default.equal(tasks[0].url, 'https://notion.so/a1b2c3d4e5f6789012345678abcdef01');
        strict_1.default.equal(tasks[1].name, 'Task two');
        strict_1.default.equal(tasks[1].status, 'review');
    });
});
// ─── TrelloProvider ─────────────────────────────────────────────────────────
(0, node_test_1.describe)('TrelloProvider.fetchTasks', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('returns cards assigned to token user with label in open or pending lists', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async (input) => {
            const url = String(input);
            if (url.includes('/members/me')) {
                return jsonResponse({ id: 'member-me' });
            }
            if (url.includes('/boards/board1/lists')) {
                return jsonResponse([
                    { id: 'list-open', name: 'To Do' },
                    { id: 'list-pending', name: 'Blocked' },
                ]);
            }
            if (url.includes('/boards/board1/cards')) {
                return jsonResponse([
                    {
                        id: 'card1',
                        name: 'Do work',
                        desc: 'Details',
                        url: 'https://trello.com/c/abc/card1',
                        idList: 'list-open',
                        idMembers: ['member-me'],
                        labels: [{ id: 'l1', name: 'aidev' }],
                    },
                    {
                        id: 'card2',
                        name: 'Wrong assignee',
                        desc: '',
                        url: 'https://trello.com/c/def/card2',
                        idList: 'list-open',
                        idMembers: ['other'],
                        labels: [{ id: 'l1', name: 'aidev' }],
                    },
                    {
                        id: 'card3',
                        name: 'In Doing column',
                        desc: '',
                        url: 'https://trello.com/c/ghi/card3',
                        idList: 'list-doing',
                        idMembers: ['member-me'],
                        labels: [{ id: 'l1', name: 'aidev' }],
                    },
                ]);
            }
            if (url.includes('/cards/card1/attachments')) {
                return jsonResponse([]);
            }
            return jsonResponse({});
        });
        const provider = new trello_1.TrelloProvider(baseTrelloConfig);
        const tasks = await provider.fetchTasks();
        strict_1.default.equal(tasks.length, 1);
        strict_1.default.equal(tasks[0].id, 'card1');
        strict_1.default.equal(tasks[0].status, 'open');
        strict_1.default.deepEqual(tasks[0].tags, ['aidev']);
    });
    (0, node_test_1.it)('omits label filter when TRELLO_LABEL is *', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async (input) => {
            const url = String(input);
            if (url.includes('/members/me')) {
                return jsonResponse({ id: 'member-me' });
            }
            if (url.includes('/boards/board1/lists')) {
                return jsonResponse([
                    { id: 'list-open', name: 'To Do' },
                    { id: 'list-pending', name: 'Blocked' },
                ]);
            }
            if (url.includes('/boards/board1/cards')) {
                return jsonResponse([
                    {
                        id: 'card1',
                        name: 'No label',
                        desc: '',
                        url: 'https://trello.com/c/abc/card1',
                        idList: 'list-open',
                        idMembers: ['member-me'],
                        labels: [],
                    },
                ]);
            }
            if (url.includes('/cards/card1/attachments')) {
                return jsonResponse([]);
            }
            return jsonResponse({});
        });
        const provider = new trello_1.TrelloProvider({
            ...baseTrelloConfig,
            trelloLabel: '*',
        });
        const tasks = await provider.fetchTasks();
        strict_1.default.equal(tasks.length, 1);
        strict_1.default.equal(tasks[0].name, 'No label');
    });
});
(0, node_test_1.describe)('TrelloProvider.getComments', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('returns comment actions sorted ascending by date', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async (input) => {
            const url = String(input);
            if (url.includes('/cards/card1/actions')) {
                return jsonResponse([
                    {
                        id: 'a2',
                        type: 'commentCard',
                        date: '2024-01-02T12:00:00.000Z',
                        data: { text: 'Newest' },
                        idMemberCreator: 'u1',
                        memberCreator: { id: 'u1', fullName: 'Alice', username: 'alice' },
                    },
                    {
                        id: 'a1',
                        type: 'commentCard',
                        date: '2024-01-01T10:00:00.000Z',
                        data: { text: 'Oldest' },
                        idMemberCreator: 'u1',
                        memberCreator: { id: 'u1', fullName: 'Alice', username: 'alice' },
                    },
                ]);
            }
            return jsonResponse({});
        });
        const provider = new trello_1.TrelloProvider(baseTrelloConfig);
        const comments = await provider.getComments('card1');
        strict_1.default.equal(comments.length, 2);
        strict_1.default.equal(comments[0].text, 'Oldest');
        strict_1.default.equal(comments[1].text, 'Newest');
    });
});
(0, node_test_1.describe)('TrelloProvider.updateStatus', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('PUTs card with idList for in progress', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async (input, init) => {
            const url = String(input);
            if (url.includes('/boards/board1/lists')) {
                return jsonResponse([
                    { id: 'list-todo', name: 'To Do' },
                    { id: 'list-doing', name: 'Doing' },
                ]);
            }
            if (url.includes('/cards/card1') && init?.method === 'PUT') {
                strict_1.default.ok(url.includes('idList=list-doing'));
                return jsonResponse({ id: 'card1', idList: 'list-doing' });
            }
            return jsonResponse({});
        });
        const provider = new trello_1.TrelloProvider(baseTrelloConfig);
        await provider.updateStatus('card1', 'in progress');
    });
});
(0, node_test_1.describe)('NotionProvider.getComments', () => {
    (0, node_test_1.afterEach)(() => node_test_2.mock.restoreAll());
    (0, node_test_1.it)('returns comments sorted ascending by date', async () => {
        node_test_2.mock.method(globalThis, 'fetch', async (input) => {
            const url = String(input);
            if (url.includes('/comments')) {
                return jsonResponse({
                    results: [
                        { id: 'c2', created_time: '2024-01-02T12:00:00.000Z', created_by: { id: 'u1' }, rich_text: [{ plain_text: 'Newest' }] },
                        { id: 'c1', created_time: '2024-01-01T10:00:00.000Z', created_by: { id: 'u1' }, rich_text: [{ plain_text: 'Oldest' }] },
                    ],
                    next_cursor: null,
                    has_more: false,
                });
            }
            return jsonResponse({});
        });
        const provider = new notion_1.NotionProvider(baseNotionConfig);
        const comments = await provider.getComments('a1b2c3d4e5f6789012345678abcdef01');
        strict_1.default.equal(comments.length, 2);
        strict_1.default.equal(comments[0].text, 'Oldest');
        strict_1.default.equal(comments[1].text, 'Newest');
    });
});
//# sourceMappingURL=providers.test.js.map