Files
gitea/tests/e2e/issue-project.test.ts
Icy Avocado 81692ceafa Allow multiple projects per issue and pull requests (#36784)
Add ability to add and remove multiple projects per issue
and pull request.

Resolve #12974

---------

Signed-off-by: Icy Avocado <avocado@ovacoda.com>
Co-authored-by: Tyrone Yeh <siryeh@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: OpenCode (gpt-5.2-codex) <opencode@openai.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
2026-04-30 22:38:05 +08:00

444 lines
17 KiB
TypeScript

import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, createProject, createProjectColumn, randomString} from './utils.ts';
test('assign issue to project and change column', async ({page}) => {
const repoName = `e2e-issue-project-${randomString(8)}`;
const user = env.GITEA_TEST_E2E_USER;
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName})]);
await page.goto(`/${user}/${repoName}/projects/new`);
await page.locator('input[name="title"]').fill('Kanban Board');
await page.getByRole('button', {name: 'Create Project'}).click();
const projectLink = page.locator('.milestone-list a', {hasText: 'Kanban Board'}).first();
await expect(projectLink).toBeVisible();
const href = await projectLink.getAttribute('href');
const projectID = href!.split('/').pop()!;
// columns created via POST because the web UI uses modals that are hard to drive
await Promise.all([
...['Backlog', 'In Progress', 'Done'].map((title) => createProjectColumn(page.request, user, repoName, projectID, title)),
apiCreateIssue(page.request, {owner: user, repo: repoName, title: 'Column picker test'}),
]);
await page.goto(`/${user}/${repoName}/issues/1`);
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
await page.locator('.sidebar-project-combo > .ui.dropdown .item:has-text("Kanban Board")').click();
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
await page.locator('.sidebar-project-column-combo .ui.dropdown').click();
await page.locator('.sidebar-project-column-combo .ui.dropdown .item:has-text("In Progress")').click();
await expect(page.locator('.sidebar-project-column-combo .ui.dropdown .fixed-text')).toHaveText('In Progress');
await apiDeleteRepo(page.request, user, repoName);
});
test('create a project', async ({page}) => {
const repoName = `e2e-project-repo-${Date.now()}`;
const projectTitle = 'Test Project';
await login(page);
await apiCreateRepo(page.request, {name: repoName});
try {
// Navigate to new project page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/new`);
// Fill in project details
await page.getByLabel('Title').fill(projectTitle);
// Submit the form
await page.getByRole('button', {name: 'Create Project'}).click();
// Verify project was created by checking we're redirected to the projects list
await expect(page).toHaveURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects$`));
// Verify the project appears in the list
await expect(page.locator('.milestone-list')).toContainText(projectTitle);
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('assign issue to multiple projects via sidebar', async ({page}) => {
const repoName = `e2e-multi-project-${Date.now()}`;
const project1Title = 'Project Alpha';
const project2Title = 'Project Beta';
const issueTitle = 'Test issue for multiple projects';
await login(page);
await apiCreateRepo(page.request, {name: repoName});
try {
// Create two projects via UI
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Create an issue without any project
const issue = await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: issueTitle,
});
// Navigate to the issue page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
// Open the projects dropdown in the sidebar
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
// Select both projects
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
// Click outside to close the dropdown and trigger the update
await page.locator('.issue-content-left').click();
// Verify both projects are shown in the sidebar
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('create issue with multiple projects pre-selected', async ({page}) => {
const repoName = `e2e-issue-multi-proj-${Date.now()}`;
const project1Title = 'Project One';
const project2Title = 'Project Two';
const issueTitle = 'Issue with multiple projects';
await login(page);
await apiCreateRepo(page.request, {name: repoName});
try {
// Create two projects via UI
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Navigate to new issue page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
// Fill in the issue title
await page.locator('input[name="title"]').fill(issueTitle);
// Open the projects dropdown
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
// Select both projects
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
// Click outside to close the dropdown
await page.locator('.issue-content-left').click();
// Submit the form
await page.getByRole('button', {name: 'Create Issue'}).click();
// Wait for issue to be created and page to redirect
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/\\d+`));
// Verify both projects are shown in the sidebar
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('filter issues by multiple projects in issue list', async ({page}) => {
const repoName = `e2e-filter-projects-${Date.now()}`;
const project1Title = 'Filter Project A';
const project2Title = 'Filter Project B';
await login(page);
await apiCreateRepo(page.request, {name: repoName});
try {
// Create two projects via UI
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Create issues: one in project1, one in project2, one in both
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue in Project A only',
projects: [project1.id],
});
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue in Project B only',
projects: [project2.id],
});
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue in both projects',
projects: [project1.id, project2.id],
});
// Create an issue with no project
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue with no project',
});
// Verify only project1 issues are visible
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project1.id}`);
await expect(page.locator('#issue-list')).toContainText('Issue in Project A only');
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project B only');
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
// Verify only project2 issues are visible
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project2.id}`);
await expect(page.locator('#issue-list')).toContainText('Issue in Project B only');
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project A only');
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('remove issue from one project keeping others', async ({page}) => {
const repoName = `e2e-remove-project-${Date.now()}`;
const project1Title = 'Keep This Project';
const project2Title = 'Remove This Project';
const issueTitle = 'Issue to modify projects';
await login(page);
await apiCreateRepo(page.request, {name: repoName});
try {
// Create two projects via UI
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Create an issue in both projects
const issue = await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: issueTitle,
projects: [project1.id, project2.id],
});
// Navigate to the issue page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
// Verify both projects are initially shown
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeVisible();
// Open the projects dropdown
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
// Deselect project2 (click on the already selected item to deselect)
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
// Click outside to close the dropdown and trigger the update
await page.locator('.issue-content-left').click();
// Verify project1 is still shown but project2 is removed
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project1Title}")`)).toBeVisible();
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeHidden();
// Reload the page to see the timeline comment
await page.reload();
// Verify the timeline shows "removed this from the project" comment
const timelineComments = page.locator('.timeline-item.event');
await expect(timelineComments.filter({hasText: 'removed this from the'})).toBeVisible();
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('filter issues with no project using project=-1', async ({page}) => {
const repoName = `e2e-no-project-filter-${Date.now()}`;
const projectTitle = 'Some Project';
await login(page);
await apiCreateRepo(page.request, {name: repoName});
try {
// Create a project via UI
const project = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: projectTitle,
});
// Create an issue with a project
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue with project assigned',
projects: [project.id],
});
// Create issues with no project
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue without any project',
});
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Another unassigned issue',
});
// First verify we can see all issues without the filter
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open`);
await expect(page.locator('#issue-list')).toContainText('Issue with project assigned');
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
// Navigate to issue list filtering for issues with no project (project=-1)
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open&project=-1`);
// Verify only issues with no project are visible
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
// Verify the issue with a project is NOT visible
await expect(page.locator('#issue-list')).not.toContainText('Issue with project assigned');
// Verify the last item in the list is NOT the issue with a project
const issueItems = page.locator('#issue-list .flex-item');
const lastIssueItem = issueItems.last();
await expect(lastIssueItem).not.toContainText('Issue with project assigned');
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('close project and view in closed projects list', async ({page}) => {
const repoName = `e2e-close-project-${Date.now()}`;
const openProjectTitle = 'Open Project';
const closedProjectTitle = 'Project To Close';
await login(page);
await apiCreateRepo(page.request, {name: repoName});
try {
// Create two projects via UI
await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: openProjectTitle,
});
const projectToClose = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: closedProjectTitle,
});
// Navigate to projects list
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
// Verify both projects are visible in open state
await expect(page.locator('.milestone-list')).toContainText(openProjectTitle);
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
// Close the second project by clicking the close link
const projectCard = page.locator('.milestone-card').filter({hasText: closedProjectTitle});
await projectCard.locator('a.link-action[data-url$="/close"]').click();
// Wait for redirect back to project view page
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/${projectToClose.id}`));
// Navigate to projects list
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
// Click on "Closed" tab to view closed projects
await page.locator('.list-header-toggle a.item').filter({hasText: 'Closed'}).click();
// Wait for the page to load with closed projects
await page.waitForURL(/state=closed/);
// Verify only the closed project is visible
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
await expect(page.locator('.milestone-list')).not.toContainText(openProjectTitle);
// Verify the "Closed" tab is active
await expect(page.locator('.list-header-toggle a.item.active')).toContainText('Closed');
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('select projects on new issue page shows in sidebar', async ({page}) => {
const repoName = `e2e-new-issue-project-${Date.now()}`;
const project1Title = 'Project One';
const project2Title = 'Project Two';
await login(page);
await apiCreateRepo(page.request, {name: repoName});
try {
// Create two projects
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Navigate to new issue page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
// Open the projects dropdown in the sidebar
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
// Select both projects
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
// Click outside to close dropdown
await page.locator('.issue-content-left').click();
// Verify both projects appear in the sidebar list below the dropdown
// On new issue page, these are simple cloned items rendered in the list container
const projectList = page.locator('.sidebar-project-combo > .ui.list');
await expect(projectList.locator(`.item:has-text("${project1Title}")`).first()).toBeVisible();
await expect(projectList.locator(`.item:has-text("${project2Title}")`).first()).toBeVisible();
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});