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>
This commit is contained in:
@@ -20,7 +20,7 @@ test.describe('events', () => {
|
||||
await expect(badge).toBeHidden();
|
||||
|
||||
// Create issue as another user — this generates a notification delivered via server push
|
||||
await apiCreateIssue(request, owner, repoName, {title: 'events notification test', headers: apiUserHeaders(commenter)});
|
||||
await apiCreateIssue(request, {owner, repo: repoName, title: 'events notification test', headers: apiUserHeaders(commenter)});
|
||||
|
||||
// Wait for the notification badge to appear via server event
|
||||
await expect(badge).toBeVisible({timeout: 15000 * timeoutFactor});
|
||||
@@ -37,7 +37,7 @@ test.describe('events', () => {
|
||||
loginUser(page, name),
|
||||
(async () => {
|
||||
await apiCreateRepo(request, {name, headers});
|
||||
await apiCreateIssue(request, name, name, {title: 'events stopwatch test', headers});
|
||||
await apiCreateIssue(request, {owner: name, repo: name, title: 'events stopwatch test', headers});
|
||||
await apiStartStopwatch(request, name, name, 1, {headers});
|
||||
})(),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateIssue, createProjectColumn, randomString} from './utils.ts';
|
||||
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)}`;
|
||||
@@ -16,14 +16,428 @@ test('assign issue to project and change column', async ({page}) => {
|
||||
// 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, user, repoName, {title: 'Column picker test'}),
|
||||
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 .menu a.item', {hasText: 'Kanban Board'}).click();
|
||||
const columnCombo = page.locator('.sidebar-project-column-combo');
|
||||
await expect(columnCombo).toBeVisible();
|
||||
await columnCombo.locator('.ui.dropdown').click();
|
||||
await columnCombo.locator('.menu a.item', {hasText: 'In Progress'}).click();
|
||||
await expect(columnCombo.getByTestId('sidebar-project-column-text')).toHaveText('In Progress');
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ test('toggle issue reactions', async ({page, request}) => {
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName});
|
||||
await Promise.all([
|
||||
apiCreateIssue(request, owner, repoName, {title: 'Reaction test'}),
|
||||
apiCreateIssue(request, {owner, repo: repoName, title: 'Reaction test'}),
|
||||
login(page),
|
||||
]);
|
||||
await page.goto(`/${owner}/${repoName}/issues/1`);
|
||||
|
||||
@@ -47,13 +47,6 @@ export async function apiCreateRepo(requestContext: APIRequestContext, {name, au
|
||||
}), 'apiCreateRepo');
|
||||
}
|
||||
|
||||
export async function apiCreateIssue(requestContext: APIRequestContext, owner: string, repo: string, {title, headers}: {title: string; headers?: Record<string, string>}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {title},
|
||||
}), 'apiCreateIssue');
|
||||
}
|
||||
|
||||
export async function apiStartStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}/stopwatch/start`, {
|
||||
headers: headers || apiHeaders(),
|
||||
@@ -135,6 +128,63 @@ export async function apiDeleteUser(requestContext: APIRequestContext, username:
|
||||
}), 'apiDeleteUser');
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
page: Page,
|
||||
{owner, repo, title}: {owner: string; repo: string; title: string},
|
||||
): Promise<{id: number}> {
|
||||
// Navigate to new project page
|
||||
await page.goto(`/${owner}/${repo}/projects/new`);
|
||||
|
||||
// Fill in project details
|
||||
await page.getByLabel('Title').fill(title);
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', {name: 'Create Project'}).click();
|
||||
|
||||
// Wait for redirect to projects list
|
||||
await page.waitForURL(new RegExp(`/${owner}/${repo}/projects$`));
|
||||
|
||||
// Extract the project ID from the project link in the list
|
||||
const projectLink = page.locator('.milestone-list .milestone-card').filter({hasText: title}).locator('a').first();
|
||||
const href = await projectLink.getAttribute('href');
|
||||
const match = /\/projects\/(\d+)/.exec(href || '');
|
||||
const id = match ? parseInt(match[1]) : 0;
|
||||
|
||||
return {id};
|
||||
}
|
||||
|
||||
export async function apiCreateIssue(
|
||||
requestContext: APIRequestContext,
|
||||
{owner, repo, title, body, projects, headers}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
projects?: number[];
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Promise<{index: number}> {
|
||||
let result: {index: number} = {index: 0};
|
||||
await apiRetry(async () => {
|
||||
const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {title, body: body || '', projects: projects || []},
|
||||
});
|
||||
if (response.ok()) {
|
||||
const json = await response.json();
|
||||
// API returns "number" field for the issue index
|
||||
result = {index: json.number};
|
||||
}
|
||||
return response;
|
||||
}, 'apiCreateIssue');
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) {
|
||||
await trigger.click();
|
||||
await page.getByText(itemText).click();
|
||||
}
|
||||
|
||||
export async function loginUser(page: Page, username: string) {
|
||||
return login(page, username, testUserPassword);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ func TestAPIIssue(t *testing.T) {
|
||||
t.Run("IssueContentVersion", testAPIIssueContentVersion)
|
||||
t.Run("CreateIssue", testAPICreateIssue)
|
||||
t.Run("CreateIssueParallel", testAPICreateIssueParallel)
|
||||
t.Run("IssueProjects", testAPIIssueProjects)
|
||||
}
|
||||
|
||||
func testAPIListIssues(t *testing.T) {
|
||||
@@ -496,3 +497,66 @@ func testAPIIssueContentVersion(t *testing.T) {
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
})
|
||||
}
|
||||
|
||||
func testAPIIssueProjects(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, owner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
|
||||
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner.Name, repo.Name)
|
||||
|
||||
// Create issue with a project
|
||||
req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
|
||||
Title: "issue with project",
|
||||
Body: "test body",
|
||||
Projects: []int64{1},
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var apiIssue api.Issue
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Len(t, apiIssue.Projects, 1)
|
||||
assert.EqualValues(t, 1, apiIssue.Projects[0].ID)
|
||||
|
||||
// Get issue should include projects
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%d", urlStr, apiIssue.Index)).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Len(t, apiIssue.Projects, 1)
|
||||
assert.EqualValues(t, 1, apiIssue.Projects[0].ID)
|
||||
|
||||
// Edit issue to remove projects
|
||||
emptyProjects := []int64{}
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("%s/%d", urlStr, apiIssue.Index), &api.EditIssueOption{
|
||||
Projects: &emptyProjects,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Empty(t, apiIssue.Projects)
|
||||
|
||||
// Edit issue to add project back
|
||||
projects := []int64{1}
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("%s/%d", urlStr, apiIssue.Index), &api.EditIssueOption{
|
||||
Projects: &projects,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Len(t, apiIssue.Projects, 1)
|
||||
assert.EqualValues(t, 1, apiIssue.Projects[0].ID)
|
||||
|
||||
// Test invalid project ID
|
||||
req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
|
||||
Title: "issue with invalid project",
|
||||
Body: "test body",
|
||||
Projects: []int64{99999},
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
|
||||
// Test project from different repo (project 2 is for repo 3)
|
||||
req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
|
||||
Title: "issue with inaccessible project",
|
||||
Body: "test body",
|
||||
Projects: []int64{2},
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,26 @@ func TestMoveRepoProjectColumns(t *testing.T) {
|
||||
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
|
||||
}
|
||||
|
||||
func TestUpdateIssueProject(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
sess := loginUser(t, "user2")
|
||||
|
||||
t.Run("AssignAndRemove", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
|
||||
"id": "1",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
|
||||
"id": "",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateIssueProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
@@ -160,13 +180,13 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
|
||||
resp := sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards := htmlDoc.Find(".sidebar-project-card")
|
||||
cards := htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
|
||||
assert.Equal(t, 1, cards.Length())
|
||||
|
||||
title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis")
|
||||
title := cards.Find("a span.gt-ellipsis")
|
||||
assert.Contains(t, strings.TrimSpace(title.Text()), "First project")
|
||||
|
||||
columnCombo := cards.Find(".sidebar-project-column-combo")
|
||||
columnCombo := cards.Find(".issue-sidebar-combo.sidebar-project-column-combo")
|
||||
assert.Equal(t, 1, columnCombo.Length())
|
||||
|
||||
defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
|
||||
@@ -181,16 +201,14 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "3", comboVal)
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{
|
||||
"id": "0",
|
||||
})
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{"id": ""})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "GET", "/user2/repo1/issues/4")
|
||||
resp = sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc = NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards = htmlDoc.Find(".sidebar-project-card")
|
||||
cards = htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
|
||||
assert.Equal(t, 0, cards.Length())
|
||||
}
|
||||
|
||||
@@ -293,15 +311,9 @@ func TestOrgProjectFilterByMilestone(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, project_model.NewProject(t.Context(), &project))
|
||||
|
||||
// Get the default column
|
||||
columns, err := project.GetColumns(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, columns)
|
||||
defaultColumnID := columns[0].ID
|
||||
|
||||
// Add issues to the project
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, project.ID, defaultColumnID))
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, project.ID, defaultColumnID))
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID}))
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID}))
|
||||
|
||||
sess := loginUser(t, "user1")
|
||||
projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID)
|
||||
|
||||
Reference in New Issue
Block a user