This commit is contained in:
@@ -1,20 +1,16 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiDeleteRepo, randomString} from './utils.ts';
|
||||
import {login, apiCreateRepo, randomString} from './utils.ts';
|
||||
|
||||
test('codeeditor textarea updates correctly', async ({page, request}) => {
|
||||
const repoName = `e2e-codeeditor-${randomString(8)}`;
|
||||
await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]);
|
||||
try {
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`);
|
||||
await page.getByPlaceholder('Name your file…').fill('test.js');
|
||||
await expect(page.locator('.editor-loading')).toBeHidden();
|
||||
const editor = page.locator('.cm-content[role="textbox"]');
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.click();
|
||||
await page.keyboard.type('const hello = "world";');
|
||||
await expect(page.locator('textarea[name="content"]')).toHaveValue('const hello = "world";');
|
||||
} finally {
|
||||
await apiDeleteRepo(request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`);
|
||||
await page.getByPlaceholder('Name your file…').fill('test.js');
|
||||
await expect(page.locator('[data-tab="write"] .editor-loading')).toBeHidden();
|
||||
const editor = page.locator('.cm-content[role="textbox"]');
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.click();
|
||||
await page.keyboard.type('const hello = "world";');
|
||||
await expect(page.locator('textarea[name="content"]')).toHaveValue('const hello = "world";');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch, timeoutFactor, randomString} from './utils.ts';
|
||||
import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch, timeoutFactor, randomString} from './utils.ts';
|
||||
|
||||
// These tests rely on a short EVENT_SOURCE_UPDATE_TIME in the e2e server config.
|
||||
test.describe('events', () => {
|
||||
@@ -12,20 +12,18 @@ test.describe('events', () => {
|
||||
|
||||
// Create repo and login in parallel — repo is needed for the issue, login for the event stream
|
||||
await Promise.all([
|
||||
apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(owner)}),
|
||||
apiCreateRepo(request, {name: repoName, autoInit: false, headers: apiUserHeaders(owner)}),
|
||||
loginUser(page, owner),
|
||||
]);
|
||||
await page.goto('/');
|
||||
const badge = page.locator('a.not-mobile .notification_count');
|
||||
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});
|
||||
|
||||
// Cleanup
|
||||
await Promise.all([apiDeleteUser(request, commenter), apiDeleteUser(request, owner)]);
|
||||
});
|
||||
|
||||
test('stopwatch', async ({page, request}) => {
|
||||
@@ -34,20 +32,20 @@ test.describe('events', () => {
|
||||
|
||||
await apiCreateUser(request, name);
|
||||
|
||||
// Create repo, issue, and start stopwatch before login
|
||||
await apiCreateRepo(request, {name, headers});
|
||||
await apiCreateIssue(request, name, name, {title: 'events stopwatch test', headers});
|
||||
await apiStartStopwatch(request, name, name, 1, {headers});
|
||||
|
||||
// Login — page renders with the active stopwatch element
|
||||
await loginUser(page, name);
|
||||
// Login in parallel with repo+issue+stopwatch setup (all independent after user exists)
|
||||
await Promise.all([
|
||||
loginUser(page, name),
|
||||
(async () => {
|
||||
await apiCreateRepo(request, {name, autoInit: false, headers});
|
||||
await apiCreateIssue(request, {owner: name, repo: name, title: 'events stopwatch test', headers});
|
||||
await apiStartStopwatch(request, name, name, 1, {headers});
|
||||
})(),
|
||||
]);
|
||||
await page.goto('/');
|
||||
|
||||
// Verify stopwatch is visible and links to the correct issue
|
||||
const stopwatch = page.locator('.active-stopwatch.not-mobile');
|
||||
await expect(stopwatch).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await apiDeleteUser(request, name);
|
||||
});
|
||||
|
||||
test('logout propagation', async ({browser, request}) => {
|
||||
@@ -82,8 +80,5 @@ test.describe('events', () => {
|
||||
await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
|
||||
// Cleanup
|
||||
await apiDeleteUser(request, name);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts';
|
||||
import {login, apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts';
|
||||
|
||||
test('external file', async ({page, request}) => {
|
||||
const repoName = `e2e-external-render-${randomString(8)}`;
|
||||
@@ -9,19 +9,15 @@ test('external file', async ({page, request}) => {
|
||||
apiCreateRepo(request, {name: repoName}),
|
||||
login(page),
|
||||
]);
|
||||
try {
|
||||
await apiCreateFile(request, owner, repoName, 'test.external', '<p>rendered content</p>');
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`);
|
||||
const iframe = page.locator('iframe.external-render-iframe');
|
||||
await expect(iframe).toBeVisible();
|
||||
await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/main/test\\.external`));
|
||||
const frame = page.frameLocator('iframe.external-render-iframe');
|
||||
await expect(frame.locator('p')).toContainText('rendered content');
|
||||
await assertFlushWithParent(iframe, page.locator('.file-view'));
|
||||
await assertNoJsError(page);
|
||||
} finally {
|
||||
await apiDeleteRepo(request, owner, repoName);
|
||||
}
|
||||
await apiCreateFile(request, owner, repoName, 'test.external', '<p>rendered content</p>');
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`);
|
||||
const iframe = page.locator('iframe.external-render-iframe');
|
||||
await expect(iframe).toBeVisible();
|
||||
await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/main/test\\.external`));
|
||||
const frame = page.frameLocator('iframe.external-render-iframe');
|
||||
await expect(frame.locator('p')).toContainText('rendered content');
|
||||
await assertFlushWithParent(iframe, page.locator('.file-view'));
|
||||
await assertNoJsError(page);
|
||||
});
|
||||
|
||||
test('openapi file', async ({page, request}) => {
|
||||
@@ -31,31 +27,27 @@ test('openapi file', async ({page, request}) => {
|
||||
apiCreateRepo(request, {name: repoName}),
|
||||
login(page),
|
||||
]);
|
||||
try {
|
||||
const title = 'Test <API> & "quoted"';
|
||||
const spec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: {title, version: '1.0'},
|
||||
paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}},
|
||||
components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}},
|
||||
});
|
||||
await apiCreateFile(request, owner, repoName, 'openapi.json', spec);
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`);
|
||||
const iframe = page.locator('iframe.external-render-iframe');
|
||||
await expect(iframe).toBeVisible();
|
||||
const viewer = page.frameLocator('iframe.external-render-iframe').locator('#frontend-render-viewer');
|
||||
await expect(viewer.locator('.swagger-ui')).toBeVisible();
|
||||
await expect(viewer.locator('.info .title')).toContainText(title);
|
||||
// expanding the operation triggers swagger-ui's $ref resolver, which fetches window.location
|
||||
// (about:srcdoc since the iframe is loaded via srcdoc); failure surfaces as "Could not resolve reference"
|
||||
await viewer.locator('.opblock-tag').first().click();
|
||||
await viewer.locator('.opblock').first().click();
|
||||
await expect(viewer.getByText('Could not resolve reference')).toHaveCount(0);
|
||||
// poll: postMessage resize may not have settled yet when the visibility checks pass
|
||||
await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(iframe, page.locator('.file-view'));
|
||||
await assertNoJsError(page);
|
||||
} finally {
|
||||
await apiDeleteRepo(request, owner, repoName);
|
||||
}
|
||||
const title = 'Test <API> & "quoted"';
|
||||
const spec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: {title, version: '1.0'},
|
||||
paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}},
|
||||
components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}},
|
||||
});
|
||||
await apiCreateFile(request, owner, repoName, 'openapi.json', spec);
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`);
|
||||
const iframe = page.locator('iframe.external-render-iframe');
|
||||
await expect(iframe).toBeVisible();
|
||||
const viewer = page.frameLocator('iframe.external-render-iframe').locator('#frontend-render-viewer');
|
||||
await expect(viewer.locator('.swagger-ui')).toBeVisible();
|
||||
await expect(viewer.locator('.info .title')).toContainText(title);
|
||||
// expanding the operation triggers swagger-ui's $ref resolver, which fetches window.location
|
||||
// (about:srcdoc since the iframe is loaded via srcdoc); failure surfaces as "Could not resolve reference"
|
||||
await viewer.locator('.opblock-tag').first().click();
|
||||
await viewer.locator('.opblock').first().click();
|
||||
await expect(viewer.getByText('Could not resolve reference')).toHaveCount(0);
|
||||
// poll: postMessage resize may not have settled yet when the visibility checks pass
|
||||
await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(iframe, page.locator('.file-view'));
|
||||
await assertNoJsError(page);
|
||||
});
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {apiCreateBranch, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts';
|
||||
import {apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts';
|
||||
|
||||
test('3d model file', async ({page, request}) => {
|
||||
test('3d model file', async ({page, request, browserName}) => {
|
||||
test.skip(browserName === 'firefox', 'unclear firefox-only CI-only failure'); // eslint-disable-line playwright/no-skipped-test
|
||||
const repoName = `e2e-3d-render-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName});
|
||||
try {
|
||||
const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n';
|
||||
await apiCreateFile(request, owner, repoName, 'test.stl', stl);
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`);
|
||||
const iframe = page.locator('iframe.external-render-iframe');
|
||||
await expect(iframe).toBeVisible();
|
||||
const frame = page.frameLocator('iframe.external-render-iframe');
|
||||
const viewer = frame.locator('#frontend-render-viewer');
|
||||
await expect(viewer.locator('canvas')).toBeVisible();
|
||||
expect((await viewer.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(iframe, page.locator('.file-view'));
|
||||
// bgcolor passed via gitea-iframe-bgcolor; 3D viewer reads it from body bgcolor — must match parent
|
||||
const [parentBg, iframeBg] = await Promise.all([
|
||||
page.evaluate(() => getComputedStyle(document.body).backgroundColor),
|
||||
frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor),
|
||||
]);
|
||||
expect(iframeBg).toBe(parentBg);
|
||||
await assertNoJsError(page);
|
||||
} finally {
|
||||
await apiDeleteRepo(request, owner, repoName);
|
||||
}
|
||||
const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n';
|
||||
await apiCreateFile(request, owner, repoName, 'test.stl', stl);
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`);
|
||||
const iframe = page.locator('iframe.external-render-iframe');
|
||||
await expect(iframe).toBeVisible();
|
||||
const frame = page.frameLocator('iframe.external-render-iframe');
|
||||
const viewer = frame.locator('#frontend-render-viewer');
|
||||
await expect(viewer.locator('canvas')).toBeVisible(); // unclear firefox-only CI-only failure
|
||||
expect((await viewer.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(iframe, page.locator('.file-view'));
|
||||
// bgcolor passed via gitea-iframe-bgcolor; 3D viewer reads it from body bgcolor — must match parent
|
||||
const [parentBg, iframeBg] = await Promise.all([
|
||||
page.evaluate(() => getComputedStyle(document.body).backgroundColor),
|
||||
frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor),
|
||||
]);
|
||||
expect(iframeBg).toBe(parentBg);
|
||||
await assertNoJsError(page);
|
||||
});
|
||||
|
||||
test('pdf file', async ({page, request}) => {
|
||||
@@ -34,36 +31,33 @@ test('pdf file', async ({page, request}) => {
|
||||
const repoName = `e2e-pdf-render-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName});
|
||||
try {
|
||||
await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n');
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
|
||||
const container = page.locator('.file-view-render-container');
|
||||
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
|
||||
expect((await container.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(container, page.locator('.file-view'));
|
||||
} finally {
|
||||
await apiDeleteRepo(request, owner, repoName);
|
||||
}
|
||||
await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n');
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
|
||||
const container = page.locator('.file-view-render-container');
|
||||
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
|
||||
await expect.poll(async () => (await container.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(container, page.locator('.file-view'));
|
||||
});
|
||||
|
||||
test('asciicast file', async ({page, request}) => {
|
||||
// regression for repo_file.go's RefTypeNameSubURL double-escape: readme.cast on a non-ASCII branch
|
||||
// is rendered via view_readme.go (no metas override), exposing the bug as a broken player URL
|
||||
const repoName = `e2e-asciicast-render-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
const branch = '日本語-branch';
|
||||
const branchEnc = encodeURIComponent(branch);
|
||||
await Promise.all([apiCreateRepo(request, {name: repoName, autoInit: false}), login(page)]);
|
||||
try {
|
||||
const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n';
|
||||
await apiCreateFile(request, owner, repoName, 'readme.cast', cast);
|
||||
await apiCreateBranch(request, owner, repoName, branch);
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}`);
|
||||
const container = page.locator('.asciinema-player-container');
|
||||
await expect(container).toHaveAttribute('data-asciinema-player-src', `/${owner}/${repoName}/raw/branch/${branchEnc}/readme.cast`);
|
||||
await expect(container.locator('.ap-wrapper')).toBeVisible();
|
||||
expect((await container.boundingBox())!.height).toBeGreaterThan(300);
|
||||
} finally {
|
||||
await apiDeleteRepo(request, owner, repoName);
|
||||
}
|
||||
const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "test-content"]\n';
|
||||
// on an empty repo, apiCreateFile with newBranch creates that branch as the initial commit
|
||||
await apiCreateFile(request, owner, repoName, 'test.cast', cast, {newBranch: branch});
|
||||
await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}/test.cast`);
|
||||
const iframe = page.locator('iframe.external-render-iframe');
|
||||
const frame = iframe.contentFrame();
|
||||
const viewer = frame.locator('#frontend-render-viewer[data-frontend-render-name]');
|
||||
await expect(viewer).toHaveAttribute('data-frontend-render-name', 'asciicast'); // render succeeded
|
||||
await expect(viewer).toHaveAttribute('data-window-origin', 'null'); // no same-origin, avoid XSS
|
||||
const wrapper = frame.locator('.ap-wrapper');
|
||||
await expect(wrapper).toBeVisible();
|
||||
await expect(wrapper).toContainText('test-content');
|
||||
await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300);
|
||||
await assertFlushWithParent(iframe, page.locator('.file-view'));
|
||||
await assertNoJsError(page);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateUser, apiUserHeaders, randomString} from './utils.ts';
|
||||
|
||||
test('fork a repository', async ({page, request}) => {
|
||||
const upstream = `fork-owner-${randomString(8)}`;
|
||||
const repoName = `e2e-fork-${randomString(8)}`;
|
||||
await apiCreateUser(request, upstream);
|
||||
await Promise.all([
|
||||
apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(upstream)}),
|
||||
login(page),
|
||||
]);
|
||||
await page.goto(`/${upstream}/${repoName}/fork`);
|
||||
|
||||
await page.getByRole('button', {name: 'Fork Repository'}).click();
|
||||
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}$`));
|
||||
await expect(page.getByRole('link', {name: `${upstream}/${repoName}`})).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login} from './utils.ts';
|
||||
|
||||
test('heatmap tooltip shows on hover', async ({page}) => {
|
||||
await login(page);
|
||||
await page.goto('/');
|
||||
await page.locator('.heatmap-day').first().hover();
|
||||
await expect(page.locator('.tippy-box[data-state="visible"]')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateIssue, randomString} from './utils.ts';
|
||||
|
||||
test('comment on and close an issue', async ({page, request}) => {
|
||||
const repoName = `e2e-issue-comment-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName, autoInit: false});
|
||||
await Promise.all([
|
||||
apiCreateIssue(request, {owner, repo: repoName, title: 'Comment test'}),
|
||||
login(page),
|
||||
]);
|
||||
await page.goto(`/${owner}/${repoName}/issues/1`);
|
||||
|
||||
const body = `e2e-comment-${randomString(8)}`;
|
||||
await page.getByPlaceholder('Leave a comment').fill(body);
|
||||
// exact match: the status button reads "Close with Comment" while the box has content, which substring-matches "Comment"
|
||||
await page.getByRole('button', {name: 'Comment', exact: true}).click();
|
||||
await expect(page.locator('.comment-body').filter({hasText: body})).toBeVisible();
|
||||
|
||||
// wait for the form to re-initialize (the empty box disables the comment button); a close click
|
||||
// before that does a native submit which lands on a raw JSON page instead of reloading the issue
|
||||
await expect(page.getByRole('button', {name: 'Comment', exact: true})).toBeDisabled();
|
||||
await page.getByRole('button', {name: 'Close Issue'}).click();
|
||||
await expect(page.getByRole('button', {name: 'Reopen Issue'})).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
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, autoInit: false})]);
|
||||
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, autoInit: false});
|
||||
|
||||
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, autoInit: false});
|
||||
|
||||
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, autoInit: false});
|
||||
|
||||
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, autoInit: false});
|
||||
|
||||
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, autoInit: false});
|
||||
|
||||
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, autoInit: false});
|
||||
|
||||
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 .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, autoInit: false});
|
||||
|
||||
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-list > .item').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, autoInit: false});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -5,5 +5,5 @@ test('licenses.txt', async ({page}) => {
|
||||
expect(resp?.status()).toBe(200);
|
||||
const content = await resp!.text();
|
||||
expect(content).toContain('@vue/');
|
||||
expect(content).toContain('code.gitea.io/');
|
||||
expect(content).toContain('gitea.dev/');
|
||||
});
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, logout} from './utils.ts';
|
||||
import {logout} from './utils.ts';
|
||||
|
||||
test('homepage', async ({page}) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('img', {name: 'Logo'})).toHaveAttribute('src', '/assets/img/logo.svg');
|
||||
});
|
||||
|
||||
test('login and logout', async ({page}) => {
|
||||
await login(page);
|
||||
test('login form and logout', async ({page}) => {
|
||||
await page.goto('/user/login');
|
||||
await page.getByLabel('Username or Email Address').fill(env.GITEA_TEST_E2E_USER);
|
||||
await page.getByLabel('Password').fill(env.GITEA_TEST_E2E_PASSWORD);
|
||||
await page.getByRole('button', {name: 'Sign In'}).click();
|
||||
await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden();
|
||||
await logout(page);
|
||||
});
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {apiCreateRepo, apiHeaders, assertNoJsError, baseUrl, randomString} from './utils.ts';
|
||||
import {apiCreateRepo, apiCreateIssue, assertNoJsError, randomString} from './utils.ts';
|
||||
|
||||
test('mermaid diagram in issue', async ({page, request}) => {
|
||||
const repoName = `e2e-mermaid-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName});
|
||||
await apiCreateRepo(request, {name: repoName, autoInit: false});
|
||||
const body = '```mermaid\nflowchart LR\n Alpha --> Beta\n Beta --> Gamma\n```\n';
|
||||
const response = await request.post(`${baseUrl()}/api/v1/repos/${owner}/${repoName}/issues`, {
|
||||
headers: apiHeaders(),
|
||||
data: {title: 'mermaid test', body},
|
||||
});
|
||||
expect(response.ok(), `create issue failed: ${response.status()}`).toBe(true);
|
||||
const {number} = await response.json();
|
||||
await page.goto(`/${owner}/${repoName}/issues/${number}`);
|
||||
const {index} = await apiCreateIssue(request, {owner, repo: repoName, title: 'mermaid test', body});
|
||||
await page.goto(`/${owner}/${repoName}/issues/${index}`);
|
||||
|
||||
const svg = page.frameLocator('iframe.markup-content-iframe').locator('svg');
|
||||
await expect(svg).toContainText(/Alpha[\s\S]*Beta[\s\S]*Gamma/);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiDeleteRepo, randomString} from './utils.ts';
|
||||
import {login, apiCreateRepo, randomString} from './utils.ts';
|
||||
|
||||
test('create a milestone', async ({page}) => {
|
||||
const repoName = `e2e-milestone-${randomString(8)}`;
|
||||
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName})]);
|
||||
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName, autoInit: false})]);
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/milestones/new`);
|
||||
await page.getByPlaceholder('Title').fill('Test Milestone');
|
||||
await page.getByRole('button', {name: 'Create Milestone'}).click();
|
||||
await expect(page.locator('.milestone-list')).toContainText('Test Milestone');
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiDeleteOrg, randomString} from './utils.ts';
|
||||
import {login, apiCreateOrg, apiCreateTeam, apiCreateUser, apiDeleteOrg, randomString} from './utils.ts';
|
||||
|
||||
test('create an organization', async ({page}) => {
|
||||
const orgName = `e2e-org-${randomString(8)}`;
|
||||
@@ -11,3 +11,42 @@ test('create an organization', async ({page}) => {
|
||||
// delete via API because of issues related to form-fetch-action
|
||||
await apiDeleteOrg(page.request, orgName);
|
||||
});
|
||||
|
||||
test('add team member search', async ({page, request}) => {
|
||||
const orgName = `team-add-${randomString(8)}`;
|
||||
const teamName = `team-add-${randomString(8)}`;
|
||||
const userName = `team-add-${randomString(8)}`;
|
||||
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
await apiCreateOrg(request, orgName);
|
||||
await apiCreateTeam(request, orgName, teamName);
|
||||
})(),
|
||||
apiCreateUser(request, userName),
|
||||
login(page),
|
||||
]);
|
||||
|
||||
await page.goto(`/org/${orgName}/teams/${teamName}`);
|
||||
const input = page.locator('#search-user-box input.prompt');
|
||||
await input.fill(userName.slice(-6));
|
||||
const result = page.locator('#search-user-box .results .result').first();
|
||||
await expect(result).toContainText(userName);
|
||||
});
|
||||
|
||||
test('delete team via confirm modal', async ({page, request}) => {
|
||||
const orgName = `e2e-del-team-${randomString(8)}`;
|
||||
const teamName = `team-${randomString(8)}`;
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
await apiCreateOrg(request, orgName);
|
||||
await apiCreateTeam(request, orgName, teamName);
|
||||
})(),
|
||||
login(page),
|
||||
]);
|
||||
|
||||
await page.goto(`/org/${orgName}/teams/${teamName}/edit`);
|
||||
await page.getByRole('button', {name: 'Delete Team'}).click();
|
||||
await page.getByRole('button', {name: 'Yes'}).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/org/${orgName}/teams$`));
|
||||
await expect(page.getByText('The team has been deleted.')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateFile, randomString} from './utils.ts';
|
||||
|
||||
test('create a pull request from the compare page', async ({page, request}) => {
|
||||
const repoName = `e2e-pr-create-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName});
|
||||
await Promise.all([
|
||||
apiCreateFile(request, owner, repoName, 'feat.txt', 'feature content\n', {branch: 'main', newBranch: 'feat'}),
|
||||
login(page),
|
||||
]);
|
||||
// expand=1 renders the PR form directly, skipping the "New Pull Request" toggle click
|
||||
await page.goto(`/${owner}/${repoName}/compare/main...feat?expand=1`);
|
||||
|
||||
const title = `e2e-pr-${randomString(8)}`;
|
||||
await page.getByPlaceholder('Title').fill(title);
|
||||
await page.getByRole('button', {name: 'Create Pull Request'}).click();
|
||||
|
||||
// commit, not full load: the PR title heading is server-rendered, so the assertion can resolve before the heavy diff/timeline finishes
|
||||
await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+$`), {waitUntil: 'commit'});
|
||||
await expect(page.getByRole('heading', {name: title})).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {apiCreateFile, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
|
||||
|
||||
test('pr review flow', async ({page, request}) => {
|
||||
const poster = `rv-poster-${randomString(8)}`;
|
||||
const reviewer = `rv-reviewer-${randomString(8)}`;
|
||||
await Promise.all([apiCreateUser(request, poster), apiCreateUser(request, reviewer)]);
|
||||
const posterHeaders = apiUserHeaders(poster);
|
||||
const repoName = `e2e-prreview-${randomString(8)}`;
|
||||
await apiCreateRepo(request, {name: repoName, headers: posterHeaders});
|
||||
await apiCreateFile(request, poster, repoName, 'added.txt', 'new content\n', {branch: 'main', newBranch: 'feat'});
|
||||
const prIndex = await apiCreatePR(request, poster, repoName, 'feat', 'main', 'review test', {headers: posterHeaders});
|
||||
|
||||
// reviewer seeds an inline comment via API so the poster's UI reply exercises the reply-to-review path (#35994)
|
||||
await Promise.all([
|
||||
apiCreateReview(request, poster, repoName, prIndex, {
|
||||
comments: [{path: 'added.txt', body: 'inline to reply to', new_position: 1}],
|
||||
headers: apiUserHeaders(reviewer),
|
||||
}),
|
||||
loginUser(page, poster),
|
||||
]);
|
||||
|
||||
await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`);
|
||||
|
||||
// diff viewer renders the added file with its header and one added-line row
|
||||
const fileBox = page.locator('.diff-file-box[data-new-filename="added.txt"]');
|
||||
await expect(fileBox.locator('.diff-file-header .file-link')).toHaveText('added.txt');
|
||||
await expect(fileBox.locator('tr.add-code')).toHaveCount(1);
|
||||
|
||||
// commits tab badge reflects the single PR commit, and the diff stats header counts one changed file
|
||||
const commitsTab = page.locator('.ui.pull.tabular.menu a.item', {has: page.locator('.octicon-git-commit')});
|
||||
await expect(commitsTab.locator('.label')).toHaveText('1');
|
||||
await expect(page.locator('.diff-detail-stats')).toContainText(/1 changed file/);
|
||||
|
||||
// poster replies to the reviewer's inline comment
|
||||
const conversation = fileBox.locator('.conversation-holder');
|
||||
await conversation.locator('.comment-form-reply').click();
|
||||
const replyForm = conversation.locator('form');
|
||||
await replyForm.locator('textarea[name="content"]').fill('my reply body');
|
||||
await replyForm.getByRole('button', {name: 'Reply', exact: true}).click();
|
||||
await expect(conversation.locator('.comment-body')).toContainText(['inline to reply to', 'my reply body']);
|
||||
|
||||
// switch to reviewer and submit an approve review
|
||||
await page.context().clearCookies();
|
||||
await loginUser(page, reviewer);
|
||||
await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`);
|
||||
await page.locator('#review-box .js-btn-review').click();
|
||||
const panel = page.locator('.review-box-panel');
|
||||
await panel.locator('textarea[name="content"]').fill('LGTM');
|
||||
await panel.getByRole('button', {name: 'Approve', exact: true}).click();
|
||||
await expect(page.locator('.timeline-item .octicon-check').first()).toBeVisible();
|
||||
await expect(page.locator('.timeline-item').filter({hasText: 'LGTM'})).toBeVisible();
|
||||
});
|
||||
@@ -1,30 +1,26 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, randomString} from './utils.ts';
|
||||
import {login, apiCreateRepo, apiCreateIssue, randomString} from './utils.ts';
|
||||
|
||||
test('toggle issue reactions', async ({page, request}) => {
|
||||
const repoName = `e2e-reactions-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName});
|
||||
await apiCreateRepo(request, {name: repoName, autoInit: false});
|
||||
await Promise.all([
|
||||
apiCreateIssue(request, owner, repoName, {title: 'Reaction test'}),
|
||||
apiCreateIssue(request, {owner, repo: repoName, title: 'Reaction test'}),
|
||||
login(page),
|
||||
]);
|
||||
try {
|
||||
await page.goto(`/${owner}/${repoName}/issues/1`);
|
||||
await page.goto(`/${owner}/${repoName}/issues/1`);
|
||||
|
||||
const issueComment = page.locator('.timeline-item.comment.first');
|
||||
const issueComment = page.locator('.timeline-item.comment.issue-content-comment');
|
||||
|
||||
const reactionPicker = issueComment.locator('.select-reaction');
|
||||
await reactionPicker.click();
|
||||
await reactionPicker.getByLabel('+1').click();
|
||||
const reactionPicker = issueComment.locator('.select-reaction');
|
||||
await reactionPicker.click();
|
||||
await reactionPicker.getByLabel('+1').click();
|
||||
|
||||
const reactions = issueComment.getByRole('group', {name: 'Reactions'});
|
||||
await expect(reactions.getByRole('button', {name: /^\+1:/})).toContainText('1');
|
||||
const reactions = issueComment.getByRole('group', {name: 'Reactions'});
|
||||
await expect(reactions.getByRole('button', {name: /^\+1:/})).toContainText('1');
|
||||
|
||||
await reactions.getByRole('button', {name: /^\+1:/}).click();
|
||||
await expect(reactions.getByRole('button', {name: /^\+1:/})).toHaveCount(0);
|
||||
} finally {
|
||||
await apiDeleteRepo(request, owner, repoName);
|
||||
}
|
||||
await reactions.getByRole('button', {name: /^\+1:/}).click();
|
||||
await expect(reactions.getByRole('button', {name: /^\+1:/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {apiCreateRepo, apiDeleteRepo, randomString} from './utils.ts';
|
||||
import {apiCreateRepo, randomString} from './utils.ts';
|
||||
|
||||
test('repo readme', async ({page}) => {
|
||||
const repoName = `e2e-readme-${randomString(8)}`;
|
||||
await apiCreateRepo(page.request, {name: repoName});
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}`);
|
||||
await expect(page.locator('#readme')).toContainText(repoName);
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, logout, apiDeleteUser, randomString} from './utils.ts';
|
||||
import {login, logout, randomString} from './utils.ts';
|
||||
|
||||
test.beforeEach(async ({page}) => {
|
||||
await page.goto('/user/sign_up');
|
||||
@@ -48,9 +48,6 @@ test('register then login', async ({page}) => {
|
||||
// Logout then login with the newly created account
|
||||
await logout(page);
|
||||
await login(page, username, password);
|
||||
|
||||
// delete via API because of issues related to form-fetch-action
|
||||
await apiDeleteUser(page.request, username);
|
||||
});
|
||||
|
||||
test('register with existing username shows error', async ({page}) => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, randomString} from './utils.ts';
|
||||
|
||||
test('create a release', async ({page, request}) => {
|
||||
const repoName = `e2e-release-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]);
|
||||
await page.goto(`/${owner}/${repoName}/releases/new`);
|
||||
|
||||
const tag = `v1.0.0-${randomString(8)}`;
|
||||
const title = `e2e-release-${randomString(8)}`;
|
||||
await page.getByLabel('Tag name').fill(tag);
|
||||
await page.getByLabel('Release title').fill(title);
|
||||
await page.getByRole('button', {name: 'Publish Release'}).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/${owner}/${repoName}/releases$`));
|
||||
await expect(page.locator('.release-list-title')).toContainText(title);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {apiCreateRepo, apiCreateUser, login, randomString} from './utils.ts';
|
||||
|
||||
test('add collaborator search', async ({page, request}) => {
|
||||
const userName = `repo-collab-${randomString(8)}`;
|
||||
const repoName = `repo-collab-${randomString(8)}`;
|
||||
|
||||
await Promise.all([
|
||||
apiCreateUser(request, userName),
|
||||
apiCreateRepo(request, {name: repoName, autoInit: false}),
|
||||
login(page),
|
||||
]);
|
||||
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/settings/collaboration`);
|
||||
const input = page.locator('#search-user-box input.prompt');
|
||||
await input.fill(userName.slice(-6));
|
||||
const result = page.locator('#search-user-box .results .result').first();
|
||||
await expect(result).toContainText(userName);
|
||||
await result.click();
|
||||
await expect(input).toHaveValue(userName);
|
||||
await page.getByRole('button', {name: 'Add Collaborator'}).click();
|
||||
await expect(page.locator('body')).toContainText(userName);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateUser, apiUserHeaders, randomString} from './utils.ts';
|
||||
|
||||
test('star and watch a repository', async ({page, request}) => {
|
||||
const owner = `sw-owner-${randomString(8)}`;
|
||||
const repoName = `e2e-star-watch-${randomString(8)}`;
|
||||
await apiCreateUser(request, owner);
|
||||
await Promise.all([
|
||||
apiCreateRepo(request, {name: repoName, autoInit: false, headers: apiUserHeaders(owner)}),
|
||||
login(page),
|
||||
]);
|
||||
await page.goto(`/${owner}/${repoName}`);
|
||||
|
||||
// exact match so "Star"/"Watch" don't also match "Unstar"/"Unwatch"
|
||||
await page.getByRole('button', {name: 'Star', exact: true}).click();
|
||||
await expect(page.getByRole('button', {name: 'Unstar'})).toBeVisible();
|
||||
|
||||
await page.getByRole('button', {name: 'Watch', exact: true}).click();
|
||||
await expect(page.getByRole('button', {name: 'Unwatch'})).toBeVisible();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import {env} from 'node:process';
|
||||
import {test} from '@playwright/test';
|
||||
import {login, apiDeleteRepo, randomString} from './utils.ts';
|
||||
import {login, randomString} from './utils.ts';
|
||||
|
||||
test('create a repository', async ({page}) => {
|
||||
const repoName = `e2e-repo-${randomString(8)}`;
|
||||
@@ -9,5 +9,4 @@ test('create a repository', async ({page}) => {
|
||||
await page.locator('input[name="repo_name"]').fill(repoName);
|
||||
await page.getByRole('button', {name: 'Create Repository'}).click();
|
||||
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}$`));
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
});
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {loginUser, apiCreateUser, apiDeleteUser, randomString} from './utils.ts';
|
||||
import {loginUser, apiCreateUser, randomString} from './utils.ts';
|
||||
|
||||
test('update profile biography', async ({page, request}) => {
|
||||
const username = `e2e-settings-${randomString(8)}`;
|
||||
const bio = `e2e-bio-${randomString(8)}`;
|
||||
await apiCreateUser(request, username);
|
||||
try {
|
||||
await loginUser(page, username);
|
||||
await page.goto('/user/settings');
|
||||
await page.getByLabel('Biography').fill(bio);
|
||||
await page.getByRole('button', {name: 'Update Profile'}).click();
|
||||
await expect(page.getByLabel('Biography')).toHaveValue(bio);
|
||||
await page.getByLabel('Biography').fill('');
|
||||
await page.getByRole('button', {name: 'Update Profile'}).click();
|
||||
await expect(page.getByLabel('Biography')).toHaveValue('');
|
||||
} finally {
|
||||
await apiDeleteUser(request, username);
|
||||
}
|
||||
await loginUser(page, username);
|
||||
await page.goto('/user/settings');
|
||||
await page.getByLabel('Biography').fill(bio);
|
||||
await page.getByRole('button', {name: 'Update Profile'}).click();
|
||||
await expect(page.getByLabel('Biography')).toHaveValue(bio);
|
||||
await page.getByLabel('Biography').fill('');
|
||||
await page.getByRole('button', {name: 'Update Profile'}).click();
|
||||
await expect(page.getByLabel('Biography')).toHaveValue('');
|
||||
});
|
||||
|
||||
@@ -47,11 +47,18 @@ 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`, {
|
||||
export async function apiCreateOrg(requestContext: APIRequestContext, name: string, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/orgs`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {title},
|
||||
}), 'apiCreateIssue');
|
||||
data: {username: name},
|
||||
}), 'apiCreateOrg');
|
||||
}
|
||||
|
||||
export async function apiCreateTeam(requestContext: APIRequestContext, org: string, name: string, {permission = 'read', units = ['repo.code'], headers}: {permission?: string; units?: Array<string>; headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/orgs/${org}/teams`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {name, permission, units},
|
||||
}), 'apiCreateTeam');
|
||||
}
|
||||
|
||||
export async function apiStartStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
@@ -60,10 +67,10 @@ export async function apiStartStopwatch(requestContext: APIRequestContext, owner
|
||||
}), 'apiStartStopwatch');
|
||||
}
|
||||
|
||||
export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string) {
|
||||
export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string, {branch, newBranch, message}: {branch?: string; newBranch?: string; message?: string} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents/${filepath}`, {
|
||||
headers: apiHeaders(),
|
||||
data: {content: globalThis.btoa(content)},
|
||||
data: {content: Buffer.from(content, 'utf8').toString('base64'), branch, new_branch: newBranch, message},
|
||||
}), 'apiCreateFile');
|
||||
}
|
||||
|
||||
@@ -74,6 +81,35 @@ export async function apiCreateBranch(requestContext: APIRequestContext, owner:
|
||||
}), 'apiCreateBranch');
|
||||
}
|
||||
|
||||
/** Create a PR via API. Returns the PR index for subsequent operations. */
|
||||
export async function apiCreatePR(requestContext: APIRequestContext, owner: string, repo: string, head: string, base: string, title: string, {headers}: {headers?: Record<string, string>} = {}): Promise<number> {
|
||||
let prIndex = 0;
|
||||
await apiRetry(async () => {
|
||||
const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {head, base, title},
|
||||
});
|
||||
if (response.ok()) prIndex = (await response.json()).number;
|
||||
return response;
|
||||
}, 'apiCreatePR');
|
||||
return prIndex;
|
||||
}
|
||||
|
||||
/** Create a review on a PR. `event: "COMMENT"` submits immediately without a pending review. */
|
||||
export async function apiCreateReview(requestContext: APIRequestContext, owner: string, repo: string, index: number, {event = 'COMMENT', body, comments = [], headers}: {event?: string; body?: string; comments?: Array<{path: string; body: string; new_position?: number; old_position?: number}>; headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {event, body, comments},
|
||||
}), 'apiCreateReview');
|
||||
}
|
||||
|
||||
export async function createProjectColumn(requestContext: APIRequestContext, owner: string, repo: string, projectID: string, title: string) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/${owner}/${repo}/projects/${projectID}/columns/new`, {
|
||||
headers: apiHeaders(),
|
||||
form: {title},
|
||||
}), 'createProjectColumn');
|
||||
}
|
||||
|
||||
export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) {
|
||||
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, {
|
||||
headers: apiHeaders(),
|
||||
@@ -87,7 +123,7 @@ export async function apiDeleteOrg(requestContext: APIRequestContext, name: stri
|
||||
}
|
||||
|
||||
/** Password shared by all test users — used for both API user creation and browser login. */
|
||||
const testUserPassword = 'e2e-password!aA1';
|
||||
export const testUserPassword = 'e2e-password!aA1';
|
||||
|
||||
export function apiUserHeaders(username: string) {
|
||||
return apiAuthHeader(username, testUserPassword);
|
||||
@@ -106,16 +142,74 @@ 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 > .item').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);
|
||||
}
|
||||
|
||||
export async function login(page: Page, username = env.GITEA_TEST_E2E_USER, password = env.GITEA_TEST_E2E_PASSWORD) {
|
||||
await page.goto('/user/login');
|
||||
await page.getByLabel('Username or Email Address').fill(username);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('button', {name: 'Sign In'}).click();
|
||||
await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden();
|
||||
const response = await page.request.post('/user/login', {
|
||||
form: {user_name: username, password},
|
||||
maxRedirects: 0,
|
||||
});
|
||||
const status = response.status();
|
||||
if (status !== 302 && status !== 303) throw new Error(`login as ${username} failed: HTTP ${status}`);
|
||||
}
|
||||
|
||||
export async function assertNoJsError(page: Page) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {test, expect, type Page} from '@playwright/test';
|
||||
import {apiCreateUser, loginUser, randomString, testUserPassword} from './utils.ts';
|
||||
|
||||
const signedIn = /^(?!.*\/user\/(login|webauthn))/; // the target of a finished login varies
|
||||
|
||||
async function registerKey(page: Page, nickname: string) {
|
||||
await page.goto('/user/settings/security');
|
||||
await page.getByLabel('Nickname').fill(nickname);
|
||||
await page.getByRole('button', {name: 'Add Security Key'}).click();
|
||||
}
|
||||
|
||||
async function signInWithPassword(page: Page, username: string) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/user/login');
|
||||
await page.getByLabel('Username or Email Address').fill(username);
|
||||
await page.getByLabel('Password').fill(testUserPassword);
|
||||
await page.getByRole('button', {name: 'Sign In'}).click();
|
||||
}
|
||||
|
||||
// regression: credProtect level 3 hid the credential from the second-factor login
|
||||
test('security key survives credProtect', async ({page, request, browserName}) => {
|
||||
test.skip(browserName !== 'chromium', 'only the CDP authenticator emulates credProtect'); // eslint-disable-line playwright/no-skipped-test
|
||||
|
||||
const username = `e2e-credprotect-${randomString(8)}`;
|
||||
await apiCreateUser(request, username);
|
||||
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('WebAuthn.enable');
|
||||
await cdp.send('WebAuthn.addVirtualAuthenticator', {options: {
|
||||
protocol: 'ctap2',
|
||||
ctap2Version: 'ctap2_1',
|
||||
transport: 'usb',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
hasCredBlob: true, // CDP only emulates credProtect together with credBlob
|
||||
isUserVerified: true,
|
||||
}});
|
||||
|
||||
await loginUser(page, username);
|
||||
await registerKey(page, 'e2e-key');
|
||||
await expect(page.getByText('e2e-key')).toBeVisible();
|
||||
|
||||
await registerKey(page, 'e2e-key-again');
|
||||
await expect(page.locator('#webauthn-error-msg')).toContainText('already registered');
|
||||
|
||||
await signInWithPassword(page, username);
|
||||
await expect(page).toHaveURL(signedIn);
|
||||
});
|
||||
|
||||
// this authenticator has no credProtect, so it cannot replace the test above
|
||||
test('security key signs in as second factor and as passkey', async ({page, request}) => {
|
||||
const username = `e2e-passkey-${randomString(8)}`;
|
||||
await apiCreateUser(request, username);
|
||||
await page.context().credentials.install();
|
||||
|
||||
await loginUser(page, username);
|
||||
await registerKey(page, 'e2e-key');
|
||||
await expect(page.getByText('e2e-key')).toBeVisible();
|
||||
|
||||
await signInWithPassword(page, username);
|
||||
await expect(page).toHaveURL(signedIn);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/user/login');
|
||||
await page.getByText('Sign in with a passkey').click();
|
||||
await expect(page).toHaveURL(signedIn);
|
||||
});
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func newFuzzRenderContext() *markup.RenderContext {
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@
|
||||
filemode = true
|
||||
bare = true
|
||||
[remote "origin"]
|
||||
url = /home/mura/go/src/code.gitea.io/gitea/tests/gitea-repositories-meta/user2/commits_search_test/
|
||||
url = /home/mura/go/src/gitea.dev/tests/gitea-repositories-meta/user2/commits_search_test/
|
||||
fetch = +refs/*:refs/*
|
||||
mirror = true
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# Integration tests
|
||||
|
||||
Integration tests can be run with make commands for the
|
||||
appropriate backends, namely:
|
||||
```shell
|
||||
make test-sqlite
|
||||
make test-pgsql
|
||||
make test-mysql
|
||||
make test-mssql
|
||||
```
|
||||
|
||||
Make sure to perform a clean build before running tests:
|
||||
```
|
||||
make clean build
|
||||
```
|
||||
|
||||
## Run tests via local act_runner
|
||||
|
||||
### Run all jobs
|
||||
|
||||
```
|
||||
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest
|
||||
```
|
||||
|
||||
Warning: This file defines many jobs, so it will be resource-intensive and therefor not recommended.
|
||||
|
||||
### Run single job
|
||||
|
||||
```SHELL
|
||||
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -j <job_name>
|
||||
```
|
||||
|
||||
You can list all job names via:
|
||||
|
||||
```SHELL
|
||||
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -l
|
||||
```
|
||||
|
||||
## Run sqlite integration tests
|
||||
Start tests
|
||||
```
|
||||
make test-sqlite
|
||||
```
|
||||
|
||||
## Run MySQL integration tests
|
||||
Setup a MySQL database inside docker
|
||||
```
|
||||
docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest #(just ctrl-c to stop db and clean the container)
|
||||
docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --rm --name elasticsearch elasticsearch:7.6.0 #(in a second terminal, just ctrl-c to stop db and clean the container)
|
||||
```
|
||||
Start tests based on the database container
|
||||
```
|
||||
TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-mysql
|
||||
```
|
||||
|
||||
## Run pgsql integration tests
|
||||
Setup a pgsql database inside docker
|
||||
```
|
||||
docker run -e "POSTGRES_DB=test" -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container)
|
||||
```
|
||||
Setup minio inside docker
|
||||
```
|
||||
docker run --rm -p 9000:9000 -e MINIO_ROOT_USER=123456 -e MINIO_ROOT_PASSWORD=12345678 --name minio bitnamilegacy/minio:2023.8.31
|
||||
```
|
||||
Start tests based on the database container
|
||||
```
|
||||
TEST_MINIO_ENDPOINT=localhost:9000 TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=postgres TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql
|
||||
```
|
||||
|
||||
## Run mssql integration tests
|
||||
Setup a mssql database inside docker
|
||||
```
|
||||
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest #(just ctrl-c to stop db and clean the container)
|
||||
```
|
||||
Start tests based on the database container
|
||||
```
|
||||
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql
|
||||
```
|
||||
|
||||
## Running individual tests
|
||||
|
||||
Example command to run GPG test:
|
||||
|
||||
For SQLite:
|
||||
|
||||
```
|
||||
make test-sqlite#GPG
|
||||
```
|
||||
|
||||
For other databases(replace `mssql` to `mysql`, or `pgsql`):
|
||||
|
||||
```
|
||||
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql#GPG
|
||||
```
|
||||
|
||||
## Setting timeouts for declaring long-tests and long-flushes
|
||||
|
||||
We appreciate that some testing machines may not be very powerful and
|
||||
the default timeouts for declaring a slow test or a slow clean-up flush
|
||||
may not be appropriate.
|
||||
|
||||
You can set the following environment variables:
|
||||
|
||||
```bash
|
||||
GITEA_TEST_SLOW_RUN="10s" GITEA_TEST_SLOW_FLUSH="1s" make test-sqlite
|
||||
```
|
||||
@@ -1,91 +0,0 @@
|
||||
# 关于集成测试
|
||||
|
||||
使用如下 make 命令可以运行指定的集成测试:
|
||||
```shell
|
||||
make test-mysql
|
||||
make test-pgsql
|
||||
make test-sqlite
|
||||
```
|
||||
|
||||
在执行集成测试命令前请确保清理了之前的构建环境,清理命令如下:
|
||||
```
|
||||
make clean build
|
||||
```
|
||||
|
||||
## 如何在本地 act_runner 上运行测试
|
||||
|
||||
### 运行所有任务
|
||||
|
||||
```
|
||||
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest
|
||||
```
|
||||
|
||||
警告:由于在此文件中定义了许多任务,因此此操作将花费太多的CPU和内存来运行。所以不建议这样做。
|
||||
|
||||
### 运行单个任务
|
||||
|
||||
```SHELL
|
||||
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -j <job_name>
|
||||
```
|
||||
|
||||
您可以通过以下方式列出所有任务名称:
|
||||
```SHELL
|
||||
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -l
|
||||
```
|
||||
|
||||
## 如何使用 sqlite 数据库进行集成测试
|
||||
使用该命令执行集成测试
|
||||
```
|
||||
make test-sqlite
|
||||
```
|
||||
|
||||
## 如何使用 mysql 数据库进行集成测试
|
||||
首先在docker容器里部署一个 mysql 数据库
|
||||
```
|
||||
docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:8 #(just ctrl-c to stop db and clean the container)
|
||||
```
|
||||
之后便可以基于这个数据库进行集成测试
|
||||
```
|
||||
TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-mysql
|
||||
```
|
||||
|
||||
## 如何使用 pgsql 数据库进行集成测试
|
||||
同上,首先在 docker 容器里部署一个 pgsql 数据库
|
||||
```
|
||||
docker run -e "POSTGRES_DB=test" -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container)
|
||||
```
|
||||
在docker内设置minio
|
||||
```
|
||||
docker run --rm -p 9000:9000 -e MINIO_ROOT_USER=123456 -e MINIO_ROOT_PASSWORD=12345678 --name minio bitnamilegacy/minio:2023.8.31
|
||||
```
|
||||
之后便可以基于这个数据库进行集成测试
|
||||
```
|
||||
TEST_MINIO_ENDPOINT=localhost:9000 TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=postgres TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql
|
||||
```
|
||||
|
||||
## Run mssql integration tests
|
||||
同上,首先在 docker 容器里部署一个 mssql 数据库
|
||||
```
|
||||
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest #(just ctrl-c to stop db and clean the container)
|
||||
```
|
||||
之后便可以基于这个数据库进行集成测试
|
||||
```
|
||||
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql
|
||||
```
|
||||
|
||||
## 如何进行自定义的集成测试
|
||||
|
||||
下面的示例展示了怎样在集成测试中只进行 GPG 测试:
|
||||
|
||||
sqlite 数据库:
|
||||
|
||||
```
|
||||
make test-sqlite#GPG
|
||||
```
|
||||
|
||||
其它数据库(把 MSSQL 替换为 MYSQL, PGSQL):
|
||||
|
||||
```
|
||||
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql#GPG
|
||||
```
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
{"id":1,"name":"test_repo","full_name":"gitea/test_repo","owner":{"id":1,"login":"gitea","username":"gitea"},"private":false,"html_url":"https://gitea.com/gitea/test_repo","clone_url":"https://gitea.com/gitea/test_repo.git","default_branch":"master","description":"test repo for migration"}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[{"id":1553,"body":"TESTSET for gitea2gitea migration\n","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z"},{"id":1554,"body":"Oh!\n","user":{"id":-1,"login":"Ghost","full_name":"","email":"","avatar_url":"","username":"Ghost"},"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z"}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[{"user":{"id":-1,"login":"Ghost","full_name":"","email":"","avatar_url":"","username":"Ghost"},"content":"gitea"},{"user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"content":"laugh"}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[{"number":1,"title":"issue1","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":2,"title":"issue2","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":3,"title":"issue3","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":4,"title":"what is this repo about?","body":"","state":"closed","user":{"id":-1,"login":"Ghost","full_name":"","email":"","avatar_url":"","username":"Ghost"},"labels":[{"id":2,"name":"Question","color":"#d876e3"}],"milestone":{"id":1,"title":"V1"},"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","closed_at":"2020-01-01T00:00:00Z","is_locked":true},{"number":5,"title":"issue5","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":6,"title":"issue6","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":7,"title":"issue7","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[{"id":1,"name":"Bug","color":"#ee0701","description":""},{"id":2,"name":"Question","color":"#d876e3","description":""}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[{"id":1,"title":"V1","description":"first milestone","state":"closed","created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","closed_at":"2020-01-01T00:00:00Z"},{"id":2,"title":"V2 Finalize","description":"second milestone","state":"open","created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z"}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
File diff suppressed because one or more lines are too long
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[{"id":1,"tag_name":"V1","target_commitish":"master","name":"First Release","body":"as title","draft":false,"prerelease":false,"created_at":"2020-01-01T00:00:00Z","published_at":"2020-01-01T00:00:00Z","author":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"assets":[]},{"id":2,"tag_name":"v2-rc1","target_commitish":"master","name":"Second Release","body":"this repo has:\n- issues\n- pulls","draft":false,"prerelease":true,"created_at":"2020-01-01T00:00:00Z","published_at":"2020-01-01T00:00:00Z","author":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"assets":[]}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
{"topics":[]}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
{"max_response_items":50,"default_paging_num":30,"default_git_trees_per_page":40,"default_max_blob_size":10485760}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
{"version":"1.22.0"}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
@@ -11,12 +11,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
api "gitea.dev/modules/structs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -70,8 +70,7 @@ jobs:
|
||||
Name: new("approve-all-runs-fork"),
|
||||
}).AddTokenAuth(user4Token)
|
||||
resp := MakeRequest(t, req, http.StatusAccepted)
|
||||
var apiForkRepo api.Repository
|
||||
DecodeJSON(t, resp, &apiForkRepo)
|
||||
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
||||
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(user4APICtx)(t)
|
||||
@@ -140,3 +139,107 @@ jobs:
|
||||
assert.Equal(t, actions_model.StatusWaiting, run2.Status)
|
||||
})
|
||||
}
|
||||
|
||||
// TestForkPullRequestApprovalNotBypassedByPriorApproval verifies that a single
|
||||
// approval on a fork PR does not permanently trust the contributor: a subsequent
|
||||
// fork PR from the same user must still be gated (Blocked / NeedApproval=true)
|
||||
// until that user has had a pull request merged in the repo.
|
||||
func TestForkPullRequestApprovalNotBypassedByPriorApproval(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user2Session := loginUser(t, user2.Name)
|
||||
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
user4Session := loginUser(t, user4.Name)
|
||||
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-approval-regression", false)
|
||||
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
|
||||
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(user2APICtx)(t)
|
||||
|
||||
wfTreePath := ".gitea/workflows/ci.yml"
|
||||
wfContent := `name: CI
|
||||
on: pull_request
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo ok
|
||||
`
|
||||
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath,
|
||||
getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "add ci", wfContent))
|
||||
|
||||
// user4 forks the repo
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
|
||||
&api.CreateForkOption{Name: new("fork-approval-regression-fork")}).AddTokenAuth(user4Token)
|
||||
resp := MakeRequest(t, req, http.StatusAccepted)
|
||||
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
||||
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(user4APICtx)(t)
|
||||
|
||||
// PR #1: a benign change from user4's fork — first-time contributor, gate engages.
|
||||
doAPICreateFile(user4APICtx, "first.txt", &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
NewBranchName: "first",
|
||||
Message: "first",
|
||||
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("first")),
|
||||
})(t)
|
||||
pr1, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":first")(t)
|
||||
assert.NoError(t, err)
|
||||
|
||||
run1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr1.Index)})
|
||||
assert.True(t, run1.NeedApproval, "first fork PR must require approval")
|
||||
assert.Equal(t, actions_model.StatusBlocked, run1.Status)
|
||||
|
||||
// user2 approves run1.
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", baseRepo.Link(), pr1.Head.Sha))
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run1.ID})
|
||||
assert.False(t, run1.NeedApproval)
|
||||
assert.Equal(t, user2.ID, run1.ApprovedBy)
|
||||
|
||||
// PR #2: same user, fresh branch. Pre-fix, this run was created with
|
||||
// NeedApproval=false and dispatched immediately — the bypass path.
|
||||
doAPICreateFile(user4APICtx, "second.txt", &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
NewBranchName: "second",
|
||||
Message: "second",
|
||||
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("second")),
|
||||
})(t)
|
||||
pr2, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":second")(t)
|
||||
assert.NoError(t, err)
|
||||
|
||||
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr2.Index)})
|
||||
assert.True(t, run2.NeedApproval, "second fork PR must still require approval — prior approval-to-run does not grant trust")
|
||||
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
|
||||
assert.EqualValues(t, 0, run2.ApprovedBy)
|
||||
|
||||
// After merging PR #1, user4 becomes a known contributor and the gate lifts for a new PR.
|
||||
doAPIMergePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, pr1.Index)(t)
|
||||
doAPICreateFile(user4APICtx, "third.txt", &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
NewBranchName: "third",
|
||||
Message: "third",
|
||||
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("third")),
|
||||
})(t)
|
||||
pr3, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":third")(t)
|
||||
assert.NoError(t, err)
|
||||
|
||||
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
|
||||
assert.False(t, run3.NeedApproval, "fork PR from a user with a prior merged PR should not require approval")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,19 +11,22 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
actions_web "gitea.dev/routers/web/repo/actions"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorkflowConcurrency(t *testing.T) {
|
||||
@@ -96,7 +99,7 @@ jobs:
|
||||
// fetch and exec workflow1
|
||||
task := runner.fetchTask(t)
|
||||
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -109,7 +112,7 @@ jobs:
|
||||
// fetch workflow2
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
|
||||
|
||||
// push workflow3
|
||||
@@ -125,7 +128,7 @@ jobs:
|
||||
// fetch and exec workflow3
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -201,7 +204,7 @@ jobs:
|
||||
// fetch and exec workflow1
|
||||
task := runner.fetchTask(t)
|
||||
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -214,7 +217,7 @@ jobs:
|
||||
// fetch workflow2
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
|
||||
|
||||
// push workflow3
|
||||
@@ -230,7 +233,7 @@ jobs:
|
||||
// fetch and exec workflow3
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -318,7 +321,7 @@ jobs:
|
||||
// fetch and exec workflow1
|
||||
task := runner.fetchTask(t)
|
||||
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -331,7 +334,7 @@ jobs:
|
||||
// fetch workflow2
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
|
||||
|
||||
// push workflow3
|
||||
@@ -347,7 +350,7 @@ jobs:
|
||||
// fetch and exec workflow3
|
||||
task = runner.fetchTask(t)
|
||||
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
|
||||
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{
|
||||
@@ -412,8 +415,8 @@ jobs:
|
||||
doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, "bugfix/aaa")(t)
|
||||
pr1Task1 := runner.fetchTask(t)
|
||||
_, _, pr1Run1 := getTaskAndJobAndRunByTaskID(t, pr1Task1.Id)
|
||||
assert.Equal(t, "pull-request-test", pr1Run1.ConcurrencyGroup)
|
||||
assert.True(t, pr1Run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr1Run1))
|
||||
assert.True(t, getRunConcurrencyCancel(t, pr1Run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, pr1Run1.Status)
|
||||
|
||||
// user4 forks the repo
|
||||
@@ -422,8 +425,7 @@ jobs:
|
||||
Name: new("actions-concurrency-fork"),
|
||||
}).AddTokenAuth(user4Token)
|
||||
resp := MakeRequest(t, req, http.StatusAccepted)
|
||||
var apiForkRepo api.Repository
|
||||
DecodeJSON(t, resp, &apiForkRepo)
|
||||
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
||||
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(user4APICtx)(t)
|
||||
@@ -458,8 +460,8 @@ jobs:
|
||||
// fetch the task and the previous task has been cancelled
|
||||
pr2Task1 := runner.fetchTask(t)
|
||||
_, _, pr2Run1 = getTaskAndJobAndRunByTaskID(t, pr2Task1.Id)
|
||||
assert.Equal(t, "pull-request-test", pr2Run1.ConcurrencyGroup)
|
||||
assert.True(t, pr2Run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr2Run1))
|
||||
assert.True(t, getRunConcurrencyCancel(t, pr2Run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, pr2Run1.Status)
|
||||
pr1Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr1Run1.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, pr1Run1.Status)
|
||||
@@ -484,19 +486,23 @@ jobs:
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user4-fix2")),
|
||||
})(t)
|
||||
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
|
||||
// cannot fetch the task because cancel-in-progress is false
|
||||
pr3, _ := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
|
||||
// cannot fetch the task: approval still required (user4 has no merged PR) and cancel-in-progress is false
|
||||
runner.fetchNoTask(t)
|
||||
runner.execTask(t, pr2Task1, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
pr2Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr2Run1.ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, pr2Run1.Status)
|
||||
// user2 approves the third PR's run (user4 still has no merged PR, approval still required)
|
||||
pr3Run1Pending := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, pr3Run1Pending.ID))
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
// fetch the task
|
||||
pr3Task1 := runner.fetchTask(t)
|
||||
_, _, pr3Run1 := getTaskAndJobAndRunByTaskID(t, pr3Task1.Id)
|
||||
assert.Equal(t, "pull-request-test", pr3Run1.ConcurrencyGroup)
|
||||
assert.False(t, pr3Run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr3Run1))
|
||||
assert.False(t, getRunConcurrencyCancel(t, pr3Run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, pr3Run1.Status)
|
||||
})
|
||||
}
|
||||
@@ -643,6 +649,7 @@ jobs:
|
||||
assert.Equal(t, "job-main-v1.24.0", wf2Job2Rerun1Job.ConcurrencyGroup)
|
||||
|
||||
// rerun wf2-job2
|
||||
wf2Job2ActionJob = getLatestAttemptJobByTemplateJobID(t, wf2Run.ID, wf2Job2ActionJob.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, wf2Run.ID, wf2Job2ActionJob.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
// (rerun2) fetch and exec wf2-job2
|
||||
@@ -803,7 +810,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
|
||||
|
||||
// run the workflow with appVersion=v1.22 and cancel=false
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
@@ -813,7 +820,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
|
||||
|
||||
// run the workflow with appVersion=v1.22 and cancel=false again
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
@@ -832,7 +839,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task4 := runner.fetchTask(t)
|
||||
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
|
||||
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
|
||||
})
|
||||
@@ -893,7 +900,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
|
||||
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
"ref": "refs/heads/main",
|
||||
@@ -902,7 +909,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
|
||||
|
||||
// run the workflow with appVersion=v1.22 and cancel=false again
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
@@ -927,7 +934,7 @@ jobs:
|
||||
task4 := runner.fetchTask(t)
|
||||
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
|
||||
assert.Equal(t, actions_model.StatusRunning, run4.Status)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
|
||||
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
|
||||
|
||||
@@ -945,7 +952,7 @@ jobs:
|
||||
|
||||
task5 := runner.fetchTask(t)
|
||||
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
|
||||
assert.Equal(t, run4.ID, run4_1.ID)
|
||||
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
|
||||
@@ -969,7 +976,7 @@ jobs:
|
||||
_, _, run3_2 := getTaskAndJobAndRunByTaskID(t, task6.Id)
|
||||
assert.Equal(t, run3.ID, run3_2.ID)
|
||||
assert.Equal(t, actions_model.StatusRunning, run3_2.Status)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
|
||||
|
||||
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
|
||||
@@ -1031,7 +1038,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
|
||||
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
"ref": "refs/heads/main",
|
||||
@@ -1040,7 +1047,7 @@ jobs:
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
|
||||
|
||||
// run the workflow with appVersion=v1.22 and cancel=false again
|
||||
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
||||
@@ -1065,7 +1072,7 @@ jobs:
|
||||
task4 := runner.fetchTask(t)
|
||||
_, job4, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
|
||||
assert.Equal(t, actions_model.StatusRunning, run4.Status)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
|
||||
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
|
||||
|
||||
@@ -1074,15 +1081,17 @@ jobs:
|
||||
})
|
||||
|
||||
// rerun cancel true scenario
|
||||
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
job4 = getLatestAttemptJobByTemplateJobID(t, run4.ID, job4.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run4.ID, job4.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
task5 := runner.fetchTask(t)
|
||||
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
|
||||
assert.Equal(t, run4.ID, run4_1.ID)
|
||||
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
|
||||
@@ -1093,18 +1102,20 @@ jobs:
|
||||
|
||||
// rerun cancel false scenario
|
||||
|
||||
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
run2_2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2.ID})
|
||||
assert.Equal(t, actions_model.StatusWaiting, run2_2.Status)
|
||||
|
||||
job3 = getLatestAttemptJobByTemplateJobID(t, run3.ID, job3.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run3.ID, job3.ID))
|
||||
_ = session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
task6 := runner.fetchTask(t)
|
||||
_, _, run3 = getTaskAndJobAndRunByTaskID(t, task6.Id)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
|
||||
|
||||
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
|
||||
@@ -1147,8 +1158,8 @@ jobs:
|
||||
// fetch the task triggered by push
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "schedule-concurrency", run1.ConcurrencyGroup)
|
||||
assert.True(t, run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run1))
|
||||
assert.True(t, getRunConcurrencyCancel(t, run1))
|
||||
assert.Equal(t, string(webhook_module.HookEventPush), run1.TriggerEvent)
|
||||
assert.Equal(t, actions_model.StatusRunning, run1.Status)
|
||||
|
||||
@@ -1165,8 +1176,8 @@ jobs:
|
||||
assert.Equal(t, actions_model.StatusSuccess, run1.Status)
|
||||
task2 := runner.fetchTask(t)
|
||||
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "schedule-concurrency", run2.ConcurrencyGroup)
|
||||
assert.False(t, run2.ConcurrencyCancel)
|
||||
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run2))
|
||||
assert.False(t, getRunConcurrencyCancel(t, run2))
|
||||
assert.Equal(t, string(webhook_module.HookEventSchedule), run2.TriggerEvent)
|
||||
assert.Equal(t, actions_model.StatusRunning, run2.Status)
|
||||
|
||||
@@ -1177,8 +1188,8 @@ jobs:
|
||||
assert.NoError(t, actions_service.StartScheduleTasks(t.Context()))
|
||||
runner.fetchNoTask(t) // cannot fetch because task2 is not completed
|
||||
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, Status: actions_model.StatusBlocked})
|
||||
assert.Equal(t, "schedule-concurrency", run3.ConcurrencyGroup)
|
||||
assert.False(t, run3.ConcurrencyCancel)
|
||||
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run3))
|
||||
assert.False(t, getRunConcurrencyCancel(t, run3))
|
||||
assert.Equal(t, string(webhook_module.HookEventSchedule), run3.TriggerEvent)
|
||||
|
||||
// trigger the task by push
|
||||
@@ -1204,8 +1215,8 @@ jobs:
|
||||
|
||||
task4 := runner.fetchTask(t)
|
||||
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
|
||||
assert.Equal(t, "schedule-concurrency", run4.ConcurrencyGroup)
|
||||
assert.True(t, run4.ConcurrencyCancel)
|
||||
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run4))
|
||||
assert.True(t, getRunConcurrencyCancel(t, run4))
|
||||
assert.Equal(t, string(webhook_module.HookEventPush), run4.TriggerEvent)
|
||||
run3 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run3.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, run3.Status)
|
||||
@@ -1317,7 +1328,7 @@ jobs:
|
||||
w1j2Task := runner2.fetchTask(t)
|
||||
_, w1j1Job, w1Run := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
|
||||
assert.Equal(t, "job-group-1", w1j1Job.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-1", w1Run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w1Run))
|
||||
assert.Equal(t, "concurrent-workflow-1.yml", w1Run.WorkflowID)
|
||||
assert.Equal(t, actions_model.StatusRunning, w1j1Job.Status)
|
||||
_, w1j2Job, _ := getTaskAndJobAndRunByTaskID(t, w1j2Task.Id)
|
||||
@@ -1358,7 +1369,7 @@ jobs:
|
||||
w3j1Task := runner1.fetchTask(t)
|
||||
_, w3j1Job, w3Run = getTaskAndJobAndRunByTaskID(t, w3j1Task.Id)
|
||||
assert.Equal(t, "job-group-1", w3j1Job.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-2", w3Run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w3Run))
|
||||
assert.Equal(t, "concurrent-workflow-3.yml", w3Run.WorkflowID)
|
||||
|
||||
// exec wf1-job2
|
||||
@@ -1370,7 +1381,7 @@ jobs:
|
||||
w2j2Task := runner2.fetchTask(t)
|
||||
_, w2j2Job, w2Run := getTaskAndJobAndRunByTaskID(t, w2j2Task.Id)
|
||||
assert.Equal(t, "job-group-2", w2j2Job.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-1", w2Run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w2Run))
|
||||
assert.Equal(t, "concurrent-workflow-2.yml", w2Run.WorkflowID)
|
||||
assert.Equal(t, actions_model.StatusRunning, w2j2Job.Status)
|
||||
|
||||
@@ -1397,7 +1408,7 @@ jobs:
|
||||
assert.Equal(t, actions_model.StatusCancelled, w2Run.Status)
|
||||
_, w4j1Job, w4Run := getTaskAndJobAndRunByTaskID(t, w4j1Task.Id)
|
||||
assert.Equal(t, "job-group-2", w4j1Job.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-2", w4Run.ConcurrencyGroup)
|
||||
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w4Run))
|
||||
assert.Equal(t, "concurrent-workflow-4.yml", w4Run.WorkflowID)
|
||||
})
|
||||
}
|
||||
@@ -1435,8 +1446,8 @@ jobs:
|
||||
// fetch and check the first task
|
||||
task1 := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
assert.Equal(t, "cancel-run-group", run1.ConcurrencyGroup)
|
||||
assert.False(t, run1.ConcurrencyCancel)
|
||||
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run1))
|
||||
assert.False(t, getRunConcurrencyCancel(t, run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, run1.Status)
|
||||
|
||||
// push another file to trigger the workflow again
|
||||
@@ -1473,8 +1484,8 @@ jobs:
|
||||
// fetch and check the second task
|
||||
task2 := runner.fetchTask(t)
|
||||
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, "cancel-run-group", run2.ConcurrencyGroup)
|
||||
assert.False(t, run2.ConcurrencyCancel)
|
||||
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run2))
|
||||
assert.False(t, getRunConcurrencyCancel(t, run2))
|
||||
assert.Equal(t, actions_model.StatusRunning, run2.Status)
|
||||
})
|
||||
}
|
||||
@@ -1533,7 +1544,7 @@ jobs:
|
||||
// fetch wf1-job1
|
||||
w1j1Task := runner.fetchTask(t)
|
||||
_, _, run1 := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
|
||||
assert.Equal(t, "test-group", run1.ConcurrencyGroup)
|
||||
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run1))
|
||||
assert.Equal(t, actions_model.StatusRunning, run1.Status)
|
||||
// query wf1-job2 from db and check its status
|
||||
w1j2Job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run1.ID, JobID: "wf1-job2"})
|
||||
@@ -1553,6 +1564,9 @@ jobs:
|
||||
// run2 is blocked because it is blocked by workflow1's concurrency group "test-group"
|
||||
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
|
||||
|
||||
// complete wf1-job1
|
||||
runner.execTask(t, w1j1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
// mock time
|
||||
fakeNow := now.Add(setting.Actions.AbandonedJobTimeout)
|
||||
timeutil.MockSet(fakeNow)
|
||||
@@ -1571,7 +1585,7 @@ jobs:
|
||||
// fetch wf2-job1 and check
|
||||
w2j1Task := runner.fetchTask(t)
|
||||
_, w2j1Job, run2 := getTaskAndJobAndRunByTaskID(t, w2j1Task.Id)
|
||||
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
|
||||
assert.Equal(t, "wf2-job1", w2j1Job.JobID)
|
||||
assert.Equal(t, actions_model.StatusRunning, run2.Status)
|
||||
assert.Equal(t, actions_model.StatusRunning, w2j1Job.Status)
|
||||
@@ -1650,7 +1664,7 @@ jobs:
|
||||
// cannot fetch run2 because run1 is still running
|
||||
runner.fetchNoTask(t)
|
||||
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, WorkflowID: "concurrent-workflow-2.yml"})
|
||||
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
|
||||
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
|
||||
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
|
||||
|
||||
// exec run1
|
||||
@@ -1677,3 +1691,164 @@ jobs:
|
||||
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCancelLegacyRunBlockedByConcurrency simulates a workflow run created before migration v331:
|
||||
// it has no ActionRunAttempt record (LatestAttemptID == 0) and was blocked by workflow-level concurrency.
|
||||
// Migration v331 drops action_run.concurrency_group / concurrency_cancel, so the run ends up "stuck" with no way for the job emitter to naturally unblock it.
|
||||
// The test verifies the user can still:
|
||||
// 1. view the stuck legacy run correctly (web view renders)
|
||||
// 2. cancel it from the UI, which transitions the run and all its jobs to Cancelled
|
||||
// 3. rerun the (now cancelled) legacy run successfully
|
||||
func TestCancelLegacyRunBlockedByConcurrency(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
apiRepo := createActionsTestRepo(t, token, "actions-legacy-concurrency", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(httpContext)(t)
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
// Manually insert a "legacy" run blocked by workflow-level concurrency: no ActionRunAttempt, LatestAttemptID=0.
|
||||
// Its workflow-level concurrency info would have been stored on action_run.concurrency_group pre-v331;
|
||||
// after the migration that column is gone, so we simply mark the run (and its jobs) as Blocked.
|
||||
legacyWfContent := `name: legacy-blocked
|
||||
on:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: test-group
|
||||
jobs:
|
||||
legacy-job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'legacy-job1'
|
||||
legacy-job2:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'legacy-job2'
|
||||
`
|
||||
payloads := mustParseSingleWorkflowPayloads(t, legacyWfContent)
|
||||
now := timeutil.TimeStamp(time.Now().Unix())
|
||||
legacyRun := &actions_model.ActionRun{
|
||||
Title: "legacy blocked run",
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
WorkflowID: "legacy-blocked.yml",
|
||||
Index: 1,
|
||||
TriggerUserID: user2.ID,
|
||||
Ref: "refs/heads/" + repo.DefaultBranch,
|
||||
CommitSHA: "0000000000000000000000000000000000000000",
|
||||
Event: "workflow_dispatch",
|
||||
TriggerEvent: "workflow_dispatch",
|
||||
EventPayload: "{}",
|
||||
Status: actions_model.StatusBlocked,
|
||||
Created: now - 1,
|
||||
Updated: now - 1,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyRun))
|
||||
|
||||
legacyJob1 := &actions_model.ActionRunJob{
|
||||
RunID: legacyRun.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
Name: payloads["legacy-job1"].name,
|
||||
Attempt: 1,
|
||||
WorkflowPayload: payloads["legacy-job1"].payload,
|
||||
JobID: "legacy-job1",
|
||||
Needs: payloads["legacy-job1"].needs,
|
||||
RunsOn: payloads["legacy-job1"].runsOn,
|
||||
Status: actions_model.StatusBlocked,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
}
|
||||
legacyJob2 := &actions_model.ActionRunJob{
|
||||
RunID: legacyRun.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
Name: payloads["legacy-job2"].name,
|
||||
Attempt: 1,
|
||||
WorkflowPayload: payloads["legacy-job2"].payload,
|
||||
JobID: "legacy-job2",
|
||||
Needs: payloads["legacy-job2"].needs,
|
||||
RunsOn: payloads["legacy-job2"].runsOn,
|
||||
Status: actions_model.StatusBlocked,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
|
||||
|
||||
// 1) User visits the legacy run's web view - it renders without error.
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
|
||||
// Legacy run has no attempt record, so RunAttempt is 0 and Attempts is empty.
|
||||
assert.EqualValues(t, 0, viewResp.State.Run.RunAttempt)
|
||||
assert.Empty(t, viewResp.State.Run.Attempts)
|
||||
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Status)
|
||||
assert.False(t, viewResp.State.Run.Done)
|
||||
// Legacy workflow-level concurrency info is gone (columns dropped by v331), so GetEffectiveConcurrency returns "": the run cannot self-unblock via job_emitter.
|
||||
afterLoadRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.Empty(t, getRunConcurrencyGroup(t, afterLoadRun))
|
||||
// Still Blocked, not Done, but user should be able to cancel.
|
||||
assert.True(t, viewResp.State.Run.CanCancel)
|
||||
assert.False(t, viewResp.State.Run.CanRerun)
|
||||
if assert.Len(t, viewResp.State.Run.Jobs, 2) {
|
||||
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[0].Status)
|
||||
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[1].Status)
|
||||
}
|
||||
|
||||
// 2) User cancels the legacy run to clean it up.
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/cancel", user2.Name, repo.Name, legacyRun.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
// Run and all its jobs transition to Cancelled.
|
||||
cancelledRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, cancelledRun.Status)
|
||||
cancelledJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, cancelledJob1.Status)
|
||||
cancelledJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, cancelledJob2.Status)
|
||||
|
||||
// 3) User reruns the now-cancelled legacy run.
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
rerunRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.Positive(t, rerunRun.LatestAttemptID)
|
||||
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
|
||||
// Both jobs run successfully on the registered runner.
|
||||
for range 2 {
|
||||
task := runner.fetchTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
}
|
||||
finalRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, finalRun.Status)
|
||||
})
|
||||
}
|
||||
|
||||
func getRunConcurrencyGroup(t *testing.T, run *actions_model.ActionRun) string {
|
||||
cg, _, err := run.GetEffectiveConcurrency(t.Context())
|
||||
assert.NoError(t, err)
|
||||
return cg
|
||||
}
|
||||
|
||||
func getRunConcurrencyCancel(t *testing.T, run *actions_model.ActionRun) bool {
|
||||
_, cc, err := run.GetEffectiveConcurrency(t.Context())
|
||||
assert.NoError(t, err)
|
||||
return cc
|
||||
}
|
||||
|
||||
func getLatestAttemptJobByTemplateJobID(t *testing.T, runID, templateJobID int64) *actions_model.ActionRunJob {
|
||||
t.Helper()
|
||||
|
||||
templateJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: templateJobID, RunID: runID})
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
job, err := actions_model.GetRunJobByAttemptJobID(t.Context(), run.ID, run.LatestAttemptID, templateJob.AttemptJobID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// minimalWorkflowPayload returns the minimal YAML for a single-job workflow with no steps.
|
||||
func minimalConcurrentWorkflowPayload(jobID string) []byte {
|
||||
return []byte("on: push\njobs:\n " + jobID + ":\n runs-on: ubuntu-latest\n")
|
||||
}
|
||||
|
||||
// TestCreateTaskForRunnerConcurrentClaim verifies that when multiple runners
|
||||
// poll simultaneously and all initially see the same first waiting job,
|
||||
// each runner claims a distinct job rather than all but one being left
|
||||
// empty-handed. This is the regression test for the race condition where
|
||||
// runners losing the optimistic-lock on job #1 would receive latestVersion
|
||||
// and never retry the remaining 49+ jobs.
|
||||
//
|
||||
// It lives in tests/integration rather than a unit test because SQLite
|
||||
// serializes write transactions, so the contended optimistic-lock path this
|
||||
// guards only runs concurrently against MySQL/PostgreSQL in CI.
|
||||
func TestCreateTaskForRunnerConcurrentClaim(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
const numJobs = 3
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
Title: "concurrent-claim-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 9901,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
jobs := make([]*actions_model.ActionRunJob, numJobs)
|
||||
for i := range numJobs {
|
||||
jobID := "concurrent-job-" + string(rune('a'+i))
|
||||
jobs[i] = &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: jobID,
|
||||
Attempt: 1,
|
||||
JobID: jobID,
|
||||
Status: actions_model.StatusWaiting,
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
WorkflowPayload: minimalConcurrentWorkflowPayload(jobID),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), jobs[i]))
|
||||
}
|
||||
|
||||
runners := make([]*actions_model.ActionRunner, numJobs)
|
||||
for i := range numJobs {
|
||||
r := &actions_model.ActionRunner{
|
||||
UUID: "concurrent-runner-uuid-" + string(rune('a'+i)),
|
||||
Name: "concurrent-runner-" + string(rune('a'+i)),
|
||||
AgentLabels: []string{"ubuntu-latest"},
|
||||
}
|
||||
r.GenerateAndFillToken()
|
||||
runners[i] = r
|
||||
require.NoError(t, db.Insert(t.Context(), runners[i]))
|
||||
}
|
||||
|
||||
// Simulate the burst: all runners call CreateTaskForRunner concurrently,
|
||||
// as happens when all see the same stale tasksVersion simultaneously.
|
||||
type result struct {
|
||||
task *actions_model.ActionTask
|
||||
ok bool
|
||||
err error
|
||||
}
|
||||
results := make([]result, numJobs)
|
||||
var wg sync.WaitGroup
|
||||
for i := range numJobs {
|
||||
wg.Go(func() {
|
||||
task, ok, err := actions_model.CreateTaskForRunner(t.Context(), runners[i])
|
||||
results[i] = result{task, ok, err}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Every runner must have received a task without error.
|
||||
claimedJobIDs := make(map[int64]bool)
|
||||
for i, r := range results {
|
||||
require.NoError(t, r.err, "runner %d got an unexpected error", i)
|
||||
require.True(t, r.ok, "runner %d did not get a task even though free jobs exist", i)
|
||||
require.NotNil(t, r.task)
|
||||
assert.False(t, claimedJobIDs[r.task.JobID], "job %d was claimed by more than one runner", r.task.JobID)
|
||||
claimedJobIDs[r.task.JobID] = true
|
||||
}
|
||||
assert.Len(t, claimedJobIDs, numJobs, "expected %d distinct jobs to be claimed", numJobs)
|
||||
|
||||
// All jobs must now be running with a task assigned.
|
||||
for _, j := range jobs {
|
||||
updated := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: j.ID})
|
||||
assert.Equal(t, actions_model.StatusRunning, updated.Status)
|
||||
assert.NotZero(t, updated.TaskID)
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/routers/web/repo/actions"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/routers/web/repo/actions"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
runID = run.ID
|
||||
}
|
||||
|
||||
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), runID)
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(t.Context(), apiRepo.ID, runID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for i := 0; i < len(testCase.outcomes); i++ {
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -13,18 +13,20 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -157,8 +159,7 @@ jobs:
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", user2.Name, apiRepo.Name)).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var actionTaskRespAfter api.ActionTaskResponse
|
||||
DecodeJSON(t, resp, &actionTaskRespAfter)
|
||||
actionTaskRespAfter := DecodeJSON(t, resp, &api.ActionTaskResponse{})
|
||||
for _, apiTask := range actionTaskRespAfter.Entries {
|
||||
if apiTask.HeadSHA != fileResp.Commit.SHA {
|
||||
continue
|
||||
@@ -460,8 +461,7 @@ func getRepoRunnerID(t *testing.T, authToken, ownerName, repoName string) int64
|
||||
t.Helper()
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", ownerName, repoName)).AddTokenAuth(authToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
runnerList := api.ActionRunnersResponse{}
|
||||
DecodeJSON(t, resp, &runnerList)
|
||||
runnerList := DecodeJSON(t, resp, &api.ActionRunnersResponse{})
|
||||
require.Len(t, runnerList.Entries, 1)
|
||||
return runnerList.Entries[0].ID
|
||||
}
|
||||
@@ -707,9 +707,8 @@ func createActionsTestRepo(t *testing.T, authToken, repoName string, isPrivate b
|
||||
DefaultBranch: "main",
|
||||
}).AddTokenAuth(authToken)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var apiRepo api.Repository
|
||||
DecodeJSON(t, resp, &apiRepo)
|
||||
return &apiRepo
|
||||
apiRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||
return apiRepo
|
||||
}
|
||||
|
||||
func getWorkflowCreateFileOptions(u *user_model.User, branch, msg, content string) *api.CreateFileOptions {
|
||||
@@ -738,9 +737,8 @@ func createWorkflowFile(t *testing.T, authToken, ownerName, repoName, treePath s
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ownerName, repoName, treePath), opts).
|
||||
AddTokenAuth(authToken)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var fileResponse api.FileResponse
|
||||
DecodeJSON(t, resp, &fileResponse)
|
||||
return &fileResponse
|
||||
fileResponse := DecodeJSON(t, resp, &api.FileResponse{})
|
||||
return fileResponse
|
||||
}
|
||||
|
||||
// getTaskJobNameByTaskID get the job name of the task by task ID
|
||||
@@ -750,8 +748,7 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", ownerName, repoName)).
|
||||
AddTokenAuth(authToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var taskRespBefore api.ActionTaskResponse
|
||||
DecodeJSON(t, resp, &taskRespBefore)
|
||||
taskRespBefore := DecodeJSON(t, resp, &api.ActionTaskResponse{})
|
||||
for _, apiTask := range taskRespBefore.Entries {
|
||||
if apiTask.ID == taskID {
|
||||
return apiTask.Name
|
||||
@@ -759,3 +756,161 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestLegacyRunsInCronTasks verifies that the background cron tasks correctly handle runs/jobs
|
||||
// created before migration v331 (legacy data with LatestAttemptID=0 and jobs with RunAttemptID=0).
|
||||
func TestLegacyRunsInCronTasks(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
apiRepo := createActionsTestRepo(t, token, "actions-legacy-cron", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(httpContext)(t)
|
||||
|
||||
// Far-past timestamp so the queries match regardless of the configured timeouts.
|
||||
oldTS := timeutil.TimeStamp(time.Now().Add(-30 * 24 * time.Hour).Unix())
|
||||
|
||||
// insertLegacyRunJob inserts a run + job without an ActionRunAttempt record, simulating data created before migration v331 (LatestAttemptID=0, job.RunAttemptID=0, job.AttemptJobID=0).
|
||||
insertLegacyRunJob := func(t *testing.T, index int64, runStatus, jobStatus actions_model.Status) (*actions_model.ActionRun, *actions_model.ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
Title: fmt.Sprintf("legacy run %d", index),
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
WorkflowID: fmt.Sprintf("legacy-%d.yml", index),
|
||||
Index: index,
|
||||
TriggerUserID: user2.ID,
|
||||
Ref: "refs/heads/" + repo.DefaultBranch,
|
||||
CommitSHA: "0000000000000000000000000000000000000000",
|
||||
Event: "workflow_dispatch",
|
||||
TriggerEvent: "workflow_dispatch",
|
||||
EventPayload: "{}",
|
||||
Status: runStatus,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "legacy-job",
|
||||
Attempt: 1,
|
||||
JobID: "legacy-job",
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
Status: jobStatus,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
// backfill timestamps so the cron task queries can match them.
|
||||
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), run.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = db.GetEngine(t.Context()).Exec("UPDATE action_run_job SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), job.ID)
|
||||
require.NoError(t, err)
|
||||
run.Created, run.Updated = oldTS, oldTS
|
||||
job.Created, job.Updated = oldTS, oldTS
|
||||
return run, job
|
||||
}
|
||||
|
||||
t.Run("StopZombieTasks", func(t *testing.T) {
|
||||
run, job := insertLegacyRunJob(t, 10, actions_model.StatusRunning, actions_model.StatusRunning)
|
||||
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusRunning,
|
||||
Started: oldTS,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
task.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_task SET updated=? WHERE id=?", int64(oldTS), task.ID)
|
||||
require.NoError(t, err)
|
||||
job.TaskID = task.ID
|
||||
_, err = db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, actions_service.StopZombieTasks(t.Context()))
|
||||
|
||||
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
|
||||
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
|
||||
})
|
||||
|
||||
t.Run("StopEndlessTasks", func(t *testing.T) {
|
||||
run, job := insertLegacyRunJob(t, 20, actions_model.StatusRunning, actions_model.StatusRunning)
|
||||
|
||||
task := &actions_model.ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusRunning,
|
||||
Started: oldTS,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
task.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
job.TaskID = task.ID
|
||||
_, err := db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, actions_service.StopEndlessTasks(t.Context()))
|
||||
|
||||
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
|
||||
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
|
||||
})
|
||||
|
||||
t.Run("CancelAbandonedJobs", func(t *testing.T) {
|
||||
run, job := insertLegacyRunJob(t, 30, actions_model.StatusWaiting, actions_model.StatusWaiting)
|
||||
|
||||
require.NoError(t, actions_service.CancelAbandonedJobs(t.Context()))
|
||||
|
||||
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, gotJob.Status)
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusCancelled, gotRun.Status)
|
||||
})
|
||||
|
||||
t.Run("Cleanup", func(t *testing.T) {
|
||||
run, _ := insertLegacyRunJob(t, 40, actions_model.StatusSuccess, actions_model.StatusSuccess)
|
||||
|
||||
expiredArtifact := &actions_model.ActionArtifact{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: 0, // legacy artifact
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
StoragePath: fmt.Sprintf("artifacts/legacy-expired-%d.zip", run.ID),
|
||||
FileSize: 1,
|
||||
FileCompressedSize: 1,
|
||||
ContentEncodingOrType: actions_model.ContentTypeZip,
|
||||
ArtifactPath: "legacy-expired.zip",
|
||||
ArtifactName: "legacy-expired",
|
||||
Status: actions_model.ArtifactStatusUploadConfirmed,
|
||||
ExpiredUnix: oldTS,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), expiredArtifact))
|
||||
|
||||
require.NoError(t, actions_service.Cleanup(t.Context()))
|
||||
|
||||
gotArtifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: expiredArtifact.ID})
|
||||
assert.Equal(t, actions_model.ArtifactStatusExpired, gotArtifact.Status)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,19 +12,19 @@ import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
org_model "code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
unit_model "code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/lfs"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/tests"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
org_model "gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -137,7 +137,7 @@ func TestActionsJobTokenPermissiveAccess(t *testing.T) {
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit))
|
||||
|
||||
// prepare task and its token
|
||||
require.NoError(t, task.GenerateToken())
|
||||
task.GenerateAndFillToken()
|
||||
task.Status = actions_model.StatusRunning
|
||||
task.IsForkPullRequest = tt.isFork
|
||||
err := actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status", "is_fork_pull_request")
|
||||
@@ -208,8 +208,7 @@ func TestActionsCrossRepoAccess(t *testing.T) {
|
||||
AutoInit: true,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var repo structs.Repository
|
||||
DecodeJSON(t, resp, &repo)
|
||||
repo := DecodeJSON(t, resp, &structs.Repository{})
|
||||
return repo.ID
|
||||
}
|
||||
|
||||
@@ -309,7 +308,7 @@ func TestActionsJobTokenPermissionsWriteIssue(t *testing.T) {
|
||||
repoActionsCfg.MaxTokenPermissions = nil
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit))
|
||||
|
||||
require.NoError(t, task.GenerateToken())
|
||||
task.GenerateAndFillToken()
|
||||
task.Status = actions_model.StatusRunning
|
||||
require.NoError(t, actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status"))
|
||||
|
||||
@@ -359,7 +358,7 @@ func createActionTask(t *testing.T, repoID int64, isFork bool) *actions_model.Ac
|
||||
Status: actions_model.StatusRunning,
|
||||
IsForkPullRequest: isFork,
|
||||
}
|
||||
require.NoError(t, task.GenerateToken())
|
||||
task.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionsListFilters(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
session := loginUser(t, user5.Name)
|
||||
actionsURL := fmt.Sprintf("/%s/%s/actions", user5.Name, repo.Name)
|
||||
|
||||
t.Run("BranchDropdownListsBranches", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", actionsURL)
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
var labels []string
|
||||
htmlDoc.doc.Find(`[data-test-id="filter-branch"] .menu a.item`).Each(func(_ int, a *goquery.Selection) {
|
||||
labels = append(labels, strings.TrimSpace(a.Text()))
|
||||
})
|
||||
assert.Contains(t, labels, "master")
|
||||
})
|
||||
|
||||
t.Run("FilterByBranch", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", actionsURL+"?branch=master")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
refs := htmlDoc.doc.Find(".run-list .run-list-ref")
|
||||
assert.Positive(t, refs.Length(), "filtered run list should not be empty")
|
||||
refs.Each(func(_ int, sel *goquery.Selection) {
|
||||
assert.Equal(t, "master", strings.TrimSpace(sel.Text()))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("PaginationPreservesFilters", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", actionsURL+"?branch=master&limit=1")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
pageLinks := htmlDoc.doc.Find(".pagination a[href]")
|
||||
assert.Positive(t, pageLinks.Length(), "pagination should be rendered")
|
||||
pageLinks.Each(func(_ int, a *goquery.Selection) {
|
||||
u, err := url.Parse(a.AttrOr("href", ""))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "master", u.Query().Get("branch"), "pagination link must preserve branch filter")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -9,16 +9,16 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/dbfs"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
actions_module "code.gitea.io/gitea/modules/actions"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/dbfs"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/storage"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -73,5 +73,19 @@ jobs:
|
||||
|
||||
_, err = dbfs.Open(t.Context(), actions_module.DBFSPrefix+freshTask.LogFilename)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist, "DBFS row must be cleaned up after TransferLogs")
|
||||
|
||||
// The runner re-sends its final UpdateLog when the response was lost.
|
||||
// A sealed log must ack the re-send and still reject new appended rows.
|
||||
t.Run("re-sent finalize is idempotent", func(t *testing.T) {
|
||||
finalize := &runnerv1.UpdateLogRequest{TaskId: task.Id, Index: 0, Rows: nil, NoMore: true}
|
||||
resp, err := runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(finalize))
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 0, resp.Msg.AckIndex)
|
||||
|
||||
_, err = runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(&runnerv1.UpdateLogRequest{
|
||||
TaskId: task.Id, Index: 0, Rows: []*runnerv1.LogRow{{Content: "late"}}, NoMore: true,
|
||||
}))
|
||||
require.Error(t, err, "appending rows past the seal must be rejected")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,15 +11,16 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -214,5 +215,95 @@ jobs:
|
||||
resetFunc()
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("DownloadRerunTaskLogs", func(t *testing.T) {
|
||||
treePath := ".gitea/workflows/download-rerun-logs.yml"
|
||||
fileContent := `name: download-rerun-logs
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.gitea/workflows/download-rerun-logs.yml'
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo job1
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [job1]
|
||||
steps:
|
||||
- run: echo job2
|
||||
`
|
||||
|
||||
// create the workflow file
|
||||
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+treePath, fileContent)
|
||||
createWorkflowFile(t, token, user2.Name, repo.Name, treePath, opts)
|
||||
|
||||
// first run
|
||||
job1Task1 := runner.fetchTask(t)
|
||||
_, job1, _ := getTaskAndJobAndRunByTaskID(t, job1Task1.Id)
|
||||
runner.execTask(t, job1Task1, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
logRows: []*runnerv1.LogRow{
|
||||
{
|
||||
Time: timestamppb.New(now.Add(1 * time.Second)),
|
||||
Content: "job1 first run",
|
||||
},
|
||||
},
|
||||
})
|
||||
job2Task1 := runner.fetchTask(t)
|
||||
_, job2, run := getTaskAndJobAndRunByTaskID(t, job2Task1.Id)
|
||||
runner.execTask(t, job2Task1, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
logRows: []*runnerv1.LogRow{
|
||||
{
|
||||
Time: timestamppb.New(now.Add(1 * time.Second)),
|
||||
Content: "job2 first run",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// check job1 log
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "job1 first run")
|
||||
// check job2 log
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "job2 first run")
|
||||
|
||||
// only rerun job2
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
job2TaskRerun := runner.fetchTask(t)
|
||||
runner.execTask(t, job2TaskRerun, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
logRows: []*runnerv1.LogRow{
|
||||
{
|
||||
Time: timestamppb.New(now.Add(1 * time.Second)),
|
||||
Content: "job2 rerun",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
job1Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
|
||||
assert.Equal(t, run.LatestAttemptID, job1Rerun.RunAttemptID)
|
||||
job2Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
|
||||
assert.Equal(t, run.LatestAttemptID, job2Rerun.RunAttemptID)
|
||||
|
||||
// check job1 rerun log
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1Rerun.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "job1 first run") // should return the log of first run because job1 didn't rerun
|
||||
// check job2 rerun log
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2Rerun.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "job2 rerun")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,18 +7,33 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
actions_web "gitea.dev/routers/web/repo/actions"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionsRerun(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
sessionAdmin := loginUser(t, userAdmin.Name)
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
@@ -54,6 +69,7 @@ jobs:
|
||||
|
||||
// fetch and exec job1
|
||||
job1Task := runner.fetchTask(t)
|
||||
assert.Equal(t, "1", job1Task.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
_, job1, run := getTaskAndJobAndRunByTaskID(t, job1Task.Id)
|
||||
runner.execTask(t, job1Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
@@ -67,45 +83,475 @@ jobs:
|
||||
runner.execTask(t, job2Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
assert.EqualValues(t, 1, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
// RERUN-1: rerun the run
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
sessionAdmin.MakeRequest(t, req, http.StatusOK) // triggered by admin user
|
||||
// fetch and exec job1
|
||||
job1TaskR1 := runner.fetchTask(t)
|
||||
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
_, job1R1, _ := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
|
||||
assert.Equal(t, job1.AttemptJobID, job1R1.AttemptJobID)
|
||||
runner.execTask(t, job1TaskR1, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
// fetch and exec job2
|
||||
job2TaskR1 := runner.fetchTask(t)
|
||||
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
_, job2R1, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
|
||||
assert.Equal(t, job2.AttemptJobID, job2R1.AttemptJobID)
|
||||
runner.execTask(t, job2TaskR1, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
// RERUN-2: rerun job1
|
||||
job1 = getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job1.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
// job2 needs job1, so rerunning job1 will also rerun job2
|
||||
// fetch and exec job1
|
||||
job1TaskR2 := runner.fetchTask(t)
|
||||
assert.Equal(t, "3", job1TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job1TaskR2, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
// fetch and exec job2
|
||||
job2TaskR2 := runner.fetchTask(t)
|
||||
assert.Equal(t, "3", job2TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job2TaskR2, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
assert.EqualValues(t, 3, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
// RERUN-3: rerun job2
|
||||
job2 = getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
// only job2 will rerun
|
||||
// fetch and exec job2
|
||||
job2TaskR3 := runner.fetchTask(t)
|
||||
assert.Equal(t, "4", job2TaskR3.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job2TaskR3, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
runner.fetchNoTask(t)
|
||||
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
runLatestAttempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
job2LatestAttempt := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
|
||||
assert.Equal(t, runLatestAttempt.LatestAttemptID, job2LatestAttempt.RunAttemptID)
|
||||
|
||||
t.Run("RerunFailedWithNoFailedJobs", func(t *testing.T) {
|
||||
// The run is fully successful, so an empty failed-job list must be rejected rather than fall
|
||||
// through to re-running the whole run.
|
||||
before := getRunLatestAttemptNum(t, run.ID)
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", user2.Name, repo.Name, run.ID))
|
||||
resp := session.MakeRequest(t, req, http.StatusBadRequest)
|
||||
assert.Contains(t, resp.Body.String(), "no failed jobs")
|
||||
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
|
||||
runner.fetchNoTask(t) // no new tasks were scheduled
|
||||
})
|
||||
|
||||
t.Run("RerunFailedWithNoFailedJobsAPI", func(t *testing.T) {
|
||||
// Same rejection on the API route: an empty failed-job list must not fall through to a full re-run.
|
||||
before := getRunLatestAttemptNum(t, run.ID)
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/rerun-failed-jobs", user2.Name, repo.Name, run.ID)).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusBadRequest)
|
||||
assert.Contains(t, resp.Body.String(), "no failed jobs")
|
||||
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
|
||||
runner.fetchNoTask(t)
|
||||
})
|
||||
|
||||
t.Run("AttemptAPI", func(t *testing.T) {
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2", user2.Name, repo.Name, run.ID)).
|
||||
AddTokenAuth(token)
|
||||
attemptResp := MakeRequest(t, req, http.StatusOK)
|
||||
apiAttempt := DecodeJSON(t, attemptResp, &api.ActionWorkflowRun{})
|
||||
assert.Equal(t, run.ID, apiAttempt.ID)
|
||||
assert.EqualValues(t, 2, apiAttempt.RunAttempt)
|
||||
assert.Equal(t, "completed", apiAttempt.Status)
|
||||
assert.Equal(t, "success", apiAttempt.Conclusion)
|
||||
assert.NotNil(t, apiAttempt.PreviousAttemptURL)
|
||||
assert.True(t, strings.HasSuffix(*apiAttempt.PreviousAttemptURL, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, run.ID)))
|
||||
assert.Equal(t, user2.Name, apiAttempt.Actor.UserName)
|
||||
assert.Equal(t, userAdmin.Name, apiAttempt.TriggerActor.UserName)
|
||||
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2/jobs", user2.Name, repo.Name, run.ID)).
|
||||
AddTokenAuth(token)
|
||||
attemptJobsResp := MakeRequest(t, req, http.StatusOK)
|
||||
apiAttemptJobs := DecodeJSON(t, attemptJobsResp, &api.ActionWorkflowJobsResponse{})
|
||||
assert.Len(t, apiAttemptJobs.Entries, 2)
|
||||
assert.ElementsMatch(t, []int64{job1R1.ID, job2R1.ID}, []int64{apiAttemptJobs.Entries[0].ID, apiAttemptJobs.Entries[1].ID})
|
||||
})
|
||||
|
||||
t.Run("MaxRerunAttempts", func(t *testing.T) {
|
||||
// The run has 4 attempts after the previous reruns. Lower the cap to 4 to hit the limit.
|
||||
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(4))()
|
||||
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
|
||||
resp := session.MakeRequest(t, req, http.StatusBadRequest)
|
||||
assert.Contains(t, resp.Body.String(), "workflow run has reached the maximum")
|
||||
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
|
||||
|
||||
// Raising the cap lets rerun proceed again.
|
||||
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(5))()
|
||||
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
// fetch and exec job1
|
||||
job1TaskR4 := runner.fetchTask(t)
|
||||
assert.Equal(t, "5", job1TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job1TaskR4, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
job2TaskR4 := runner.fetchTask(t)
|
||||
assert.Equal(t, "5", job2TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, job2TaskR4, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
assert.EqualValues(t, 5, getRunLatestAttemptNum(t, run.ID))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionsRerunLegacyNoAttemptRun(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
apiRepo := createActionsTestRepo(t, token, "actions-rerun-legacy", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(httpContext)(t)
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
wfTreePath := ".gitea/workflows/actions-rerun-legacy.yml"
|
||||
wfFileContent := `name: actions-rerun-legacy
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'job1'
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [job1]
|
||||
steps:
|
||||
- run: echo 'job2'
|
||||
`
|
||||
|
||||
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
|
||||
fileResp := createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
|
||||
require.NotNil(t, fileResp)
|
||||
|
||||
// Start preparing legacy data
|
||||
|
||||
payloads := mustParseSingleWorkflowPayloads(t, wfFileContent)
|
||||
now := timeutil.TimeStamp(time.Now().Unix())
|
||||
started := now - 20
|
||||
stopped := now - 10
|
||||
|
||||
legacyRun := &actions_model.ActionRun{
|
||||
Title: "legacy rerun test",
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
WorkflowID: "actions-rerun-legacy.yml",
|
||||
Index: 1,
|
||||
TriggerUserID: user2.ID,
|
||||
Ref: "refs/heads/" + repo.DefaultBranch,
|
||||
CommitSHA: fileResp.Commit.SHA,
|
||||
Event: "workflow_dispatch",
|
||||
TriggerEvent: "workflow_dispatch",
|
||||
EventPayload: "{}",
|
||||
Status: actions_model.StatusSuccess,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
Created: started - 5,
|
||||
Updated: stopped,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyRun))
|
||||
// xorm does not update "created"-tagged fields via ORM methods; use raw SQL to backfill historical timestamps.
|
||||
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(started-5), int64(stopped), legacyRun.ID)
|
||||
require.NoError(t, err)
|
||||
legacyRun.Created = started - 5
|
||||
legacyRun.Updated = stopped
|
||||
|
||||
legacyJob1 := &actions_model.ActionRunJob{
|
||||
RunID: legacyRun.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
Name: payloads["job1"].name,
|
||||
Attempt: 1,
|
||||
WorkflowPayload: payloads["job1"].payload,
|
||||
JobID: "job1",
|
||||
Needs: payloads["job1"].needs,
|
||||
RunsOn: payloads["job1"].runsOn,
|
||||
Status: actions_model.StatusSuccess,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
IsForkPullRequest: false,
|
||||
}
|
||||
legacyJob2 := &actions_model.ActionRunJob{
|
||||
RunID: legacyRun.ID,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
Name: payloads["job2"].name,
|
||||
Attempt: 1,
|
||||
WorkflowPayload: payloads["job2"].payload,
|
||||
JobID: "job2",
|
||||
Needs: payloads["job2"].needs,
|
||||
RunsOn: payloads["job2"].runsOn,
|
||||
Status: actions_model.StatusSuccess,
|
||||
RunAttemptID: 0,
|
||||
AttemptJobID: 0,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
IsForkPullRequest: false,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
|
||||
|
||||
legacyTask1 := &actions_model.ActionTask{
|
||||
JobID: legacyJob1.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
IsForkPullRequest: false,
|
||||
}
|
||||
legacyTask1.GenerateAndFillToken()
|
||||
legacyTask2 := &actions_model.ActionTask{
|
||||
JobID: legacyJob2.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Started: started,
|
||||
Stopped: stopped,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
IsForkPullRequest: false,
|
||||
}
|
||||
legacyTask2.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), legacyTask1, legacyTask2))
|
||||
|
||||
legacyJob1.TaskID = legacyTask1.ID
|
||||
legacyJob2.TaskID = legacyTask2.ID
|
||||
_, err = db.GetEngine(t.Context()).ID(legacyJob1.ID).Cols("task_id").Update(legacyJob1)
|
||||
require.NoError(t, err)
|
||||
_, err = db.GetEngine(t.Context()).ID(legacyJob2.ID).Cols("task_id").Update(legacyJob2)
|
||||
require.NoError(t, err)
|
||||
|
||||
legacyArtifact := &actions_model.ActionArtifact{
|
||||
RunID: legacyRun.ID,
|
||||
RunAttemptID: 0,
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
CommitSHA: legacyRun.CommitSHA,
|
||||
StoragePath: "artifacts/legacy-artifact.zip",
|
||||
FileSize: 123,
|
||||
FileCompressedSize: 123,
|
||||
ContentEncodingOrType: actions_model.ContentTypeZip,
|
||||
ArtifactPath: "legacy-artifact.zip",
|
||||
ArtifactName: "legacy-artifact",
|
||||
Status: actions_model.ArtifactStatusUploadConfirmed,
|
||||
ExpiredUnix: now + timeutil.Day,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), legacyArtifact))
|
||||
|
||||
// Done preparing legacy data
|
||||
|
||||
// assert the web view for the legacy run before rerun
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
|
||||
legacyResp := session.MakeRequest(t, req, http.StatusOK)
|
||||
legacyView := DecodeJSON(t, legacyResp, &actions_web.ViewResponse{})
|
||||
// legacy run has no attempt records, so RunAttempt is 0 and Attempts list is empty
|
||||
assert.EqualValues(t, 0, legacyView.State.Run.RunAttempt)
|
||||
assert.Empty(t, legacyView.State.Run.Attempts)
|
||||
assert.Equal(t, "success", legacyView.State.Run.Status)
|
||||
assert.True(t, legacyView.State.Run.Done)
|
||||
// isLatestAttempt=true, done=true: can rerun but not cancel
|
||||
assert.False(t, legacyView.State.Run.CanCancel)
|
||||
assert.False(t, legacyView.State.Run.CanApprove)
|
||||
assert.True(t, legacyView.State.Run.CanRerun)
|
||||
assert.False(t, legacyView.State.Run.CanRerunFailed) // all jobs succeeded
|
||||
assert.True(t, legacyView.State.Run.CanDeleteArtifact)
|
||||
if assert.Len(t, legacyView.State.Run.Jobs, 2) {
|
||||
assert.Equal(t, legacyJob1.ID, legacyView.State.Run.Jobs[0].ID)
|
||||
assert.Equal(t, legacyJob2.ID, legacyView.State.Run.Jobs[1].ID)
|
||||
}
|
||||
if assert.Len(t, legacyView.Artifacts, 1) {
|
||||
assert.Equal(t, legacyArtifact.ArtifactName, legacyView.Artifacts[0].Name)
|
||||
assert.Equal(t, "completed", legacyView.Artifacts[0].Status)
|
||||
}
|
||||
|
||||
// rerun the legacy run
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
runAfterRerun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
|
||||
jobsAfterRerun, err := actions_model.GetRunJobsByRunAndAttemptID(t.Context(), legacyRun.ID, runAfterRerun.LatestAttemptID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobsAfterRerun, 2)
|
||||
rerunJobsByJobID := map[string]*actions_model.ActionRunJob{}
|
||||
for _, job := range jobsAfterRerun {
|
||||
rerunJobsByJobID[job.JobID] = job
|
||||
}
|
||||
require.Contains(t, rerunJobsByJobID, "job1")
|
||||
require.Contains(t, rerunJobsByJobID, "job2")
|
||||
assert.Equal(t, actions_model.StatusWaiting, rerunJobsByJobID["job1"].Status)
|
||||
assert.Equal(t, actions_model.StatusBlocked, rerunJobsByJobID["job2"].Status)
|
||||
|
||||
// fetch job1 rerun task
|
||||
job1TaskR1 := runner.fetchTask(t)
|
||||
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
rerunJob1Task, rerunJob1, rerunRun := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
|
||||
assert.Equal(t, legacyRun.ID, rerunRun.ID)
|
||||
assert.Equal(t, rerunJob1.RunAttemptID, rerunRun.LatestAttemptID)
|
||||
runner.execTask(t, job1TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
// fetch job2 rerun task
|
||||
job2TaskR1 := runner.fetchTask(t)
|
||||
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
rerunJob2Task, rerunJob2, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
|
||||
runner.execTask(t, job2TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
runner.fetchNoTask(t)
|
||||
|
||||
// query the 2 attempts
|
||||
runAfterRerun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
|
||||
attempt1, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, legacyRun.Created, attempt1.Created)
|
||||
assert.Equal(t, legacyRun.Started, attempt1.Started)
|
||||
assert.Equal(t, legacyRun.Stopped, attempt1.Stopped)
|
||||
attempt2, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, attempt2.ID, runAfterRerun.LatestAttemptID)
|
||||
assert.Equal(t, runAfterRerun.Created, attempt1.Created)
|
||||
assert.Equal(t, runAfterRerun.Started, attempt2.Started)
|
||||
assert.Equal(t, runAfterRerun.Stopped, attempt2.Stopped)
|
||||
|
||||
// assert legacy jobs
|
||||
legacyJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
|
||||
legacyJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
|
||||
assert.Equal(t, attempt1.ID, legacyJob1.RunAttemptID)
|
||||
assert.Equal(t, attempt1.ID, legacyJob2.RunAttemptID)
|
||||
assert.EqualValues(t, 1, legacyJob1.Attempt)
|
||||
assert.EqualValues(t, 1, legacyJob2.Attempt)
|
||||
assert.EqualValues(t, 1, legacyJob1.AttemptJobID)
|
||||
assert.EqualValues(t, 2, legacyJob2.AttemptJobID)
|
||||
legacyTask1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask1.ID})
|
||||
legacyTask2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask2.ID})
|
||||
assert.EqualValues(t, 1, legacyTask1.Attempt)
|
||||
assert.EqualValues(t, 1, legacyTask2.Attempt)
|
||||
|
||||
// assert legacy artifacts
|
||||
legacyArtifact = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: legacyArtifact.ID})
|
||||
assert.Equal(t, attempt1.ID, legacyArtifact.RunAttemptID)
|
||||
|
||||
// assert jobs of the latest rerun
|
||||
rerunJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob1.ID})
|
||||
rerunJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob2.ID})
|
||||
assert.Equal(t, attempt2.ID, rerunJob1.RunAttemptID)
|
||||
assert.Equal(t, attempt2.ID, rerunJob2.RunAttemptID)
|
||||
assert.Equal(t, legacyJob1.AttemptJobID, rerunJob1.AttemptJobID)
|
||||
assert.Equal(t, legacyJob2.AttemptJobID, rerunJob2.AttemptJobID)
|
||||
assert.EqualValues(t, 2, rerunJob1Task.Attempt)
|
||||
assert.EqualValues(t, 2, rerunJob2Task.Attempt)
|
||||
|
||||
// assert the web view for the original attempt
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, legacyRun.ID))
|
||||
attempt1Resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
attempt1View := DecodeJSON(t, attempt1Resp, &actions_web.ViewResponse{})
|
||||
assert.EqualValues(t, 1, attempt1View.State.Run.RunAttempt)
|
||||
if assert.Len(t, attempt1View.State.Run.Attempts, 2) {
|
||||
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest), index 1 = attempt #1 (current)
|
||||
assert.False(t, attempt1View.State.Run.Attempts[0].Current)
|
||||
assert.True(t, attempt1View.State.Run.Attempts[0].Latest)
|
||||
assert.True(t, attempt1View.State.Run.Attempts[1].Current)
|
||||
assert.False(t, attempt1View.State.Run.Attempts[1].Latest)
|
||||
}
|
||||
// isLatestAttempt=false: all write operations disabled
|
||||
assert.False(t, attempt1View.State.Run.CanCancel)
|
||||
assert.False(t, attempt1View.State.Run.CanApprove)
|
||||
assert.False(t, attempt1View.State.Run.CanRerun)
|
||||
assert.False(t, attempt1View.State.Run.CanRerunFailed)
|
||||
assert.True(t, attempt1View.State.Run.CanDeleteArtifact)
|
||||
assert.Equal(t, legacyJob1.ID, attempt1View.State.Run.Jobs[0].ID)
|
||||
assert.Equal(t, legacyJob2.ID, attempt1View.State.Run.Jobs[1].ID)
|
||||
if assert.Len(t, attempt1View.Artifacts, 1) {
|
||||
assert.Equal(t, attempt1View.Artifacts[0].Name, legacyArtifact.ArtifactName)
|
||||
}
|
||||
|
||||
// assert the web view for the latest attempt
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
|
||||
attempt2Resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
attempt2View := DecodeJSON(t, attempt2Resp, &actions_web.ViewResponse{})
|
||||
assert.EqualValues(t, 2, attempt2View.State.Run.RunAttempt)
|
||||
if assert.Len(t, attempt2View.State.Run.Attempts, 2) {
|
||||
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest, current), index 1 = attempt #1
|
||||
assert.True(t, attempt2View.State.Run.Attempts[0].Current)
|
||||
assert.True(t, attempt2View.State.Run.Attempts[0].Latest)
|
||||
assert.False(t, attempt2View.State.Run.Attempts[1].Current)
|
||||
assert.False(t, attempt2View.State.Run.Attempts[1].Latest)
|
||||
}
|
||||
// isLatestAttempt=true, done=true: can rerun but not cancel
|
||||
assert.False(t, attempt2View.State.Run.CanCancel)
|
||||
assert.False(t, attempt2View.State.Run.CanApprove)
|
||||
assert.True(t, attempt2View.State.Run.CanRerun)
|
||||
assert.False(t, attempt2View.State.Run.CanRerunFailed) // all jobs succeeded
|
||||
assert.True(t, attempt2View.State.Run.CanDeleteArtifact)
|
||||
assert.Equal(t, rerunJob1.ID, attempt2View.State.Run.Jobs[0].ID)
|
||||
assert.Equal(t, rerunJob2.ID, attempt2View.State.Run.Jobs[1].ID)
|
||||
assert.Empty(t, attempt2View.Artifacts)
|
||||
})
|
||||
}
|
||||
|
||||
type workflowJobPayload struct {
|
||||
name string
|
||||
payload []byte
|
||||
needs []string
|
||||
runsOn []string
|
||||
}
|
||||
|
||||
func mustParseSingleWorkflowPayloads(t *testing.T, workflowContent string) map[string]workflowJobPayload {
|
||||
t.Helper()
|
||||
|
||||
workflows, err := jobparser.Parse([]byte(workflowContent))
|
||||
require.NoError(t, err)
|
||||
|
||||
payloads := make(map[string]workflowJobPayload, len(workflows))
|
||||
for _, workflow := range workflows {
|
||||
id, job := workflow.Job()
|
||||
needs := job.Needs()
|
||||
require.NoError(t, workflow.SetJob(id, job.EraseNeeds()))
|
||||
payload, err := workflow.Marshal()
|
||||
require.NoError(t, err)
|
||||
payloads[id] = workflowJobPayload{
|
||||
name: job.Name,
|
||||
payload: payload,
|
||||
needs: needs,
|
||||
runsOn: job.RunsOn(),
|
||||
}
|
||||
}
|
||||
return payloads
|
||||
}
|
||||
|
||||
func getRunLatestAttemptNum(t *testing.T, runID int64) int64 {
|
||||
t.Helper()
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: run.LatestAttemptID})
|
||||
return attempt.Attempt
|
||||
}
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/queue"
|
||||
api "gitea.dev/modules/structs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionsReusableWorkflow(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user2Session := loginUser(t, user2.Name)
|
||||
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
user4Session := loginUser(t, user4.Name)
|
||||
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
t.Run("Same-repo reusable workflow", func(t *testing.T) {
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "workflow-call-test", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
|
||||
defaultRunner := newMockRunner()
|
||||
defaultRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-default-runner", []string{"ubuntu-latest"}, false)
|
||||
customRunner := newMockRunner()
|
||||
customRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-custom-runner", []string{"custom-os"}, false)
|
||||
|
||||
// add a variable for test
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/myvar", repo.OwnerName, repo.Name), &api.CreateVariableOption{
|
||||
Value: "abcdef",
|
||||
}).
|
||||
AddTokenAuth(user2Token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
// add a secret for test
|
||||
req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/mysecret", repo.OwnerName, repo.Name), api.CreateOrUpdateSecretOption{
|
||||
Data: "secRET-t0Ken",
|
||||
}).AddTokenAuth(user2Token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable1.yaml",
|
||||
`name: Reusable1
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
str_input:
|
||||
type: string
|
||||
num_input:
|
||||
type: number
|
||||
bool_input:
|
||||
type: boolean
|
||||
parent_var:
|
||||
type: string
|
||||
needs_out:
|
||||
type: string
|
||||
secrets:
|
||||
PARENT_TOKEN:
|
||||
outputs:
|
||||
r1_out:
|
||||
value: ${{ jobs.reusable1_job2.outputs.r1j2_out }}
|
||||
|
||||
jobs:
|
||||
reusable1_job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'reusable1_job1'
|
||||
|
||||
reusable1_job2:
|
||||
needs: [reusable1_job1]
|
||||
outputs:
|
||||
r1j2_out: ${{ steps.gen_r1j2_output.outputs.out }}
|
||||
runs-on: custom-os
|
||||
steps:
|
||||
- id: gen_r1j2_output
|
||||
run: |
|
||||
echo "out=r1j2_out_data" >> "$GITHUB_OUTPUT"
|
||||
|
||||
reusable1_job3:
|
||||
needs: [reusable1_job2]
|
||||
uses: ./.gitea/workflows/reusable2.yaml
|
||||
with:
|
||||
msg: ${{ inputs.str_input }}
|
||||
`)
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable2.yaml",
|
||||
`name: Reusable2
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
msg:
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
reusable2_job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo ${{ inputs.msg }}
|
||||
`)
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
||||
`name: Caller
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.gitea/workflows/caller.yaml'
|
||||
jobs:
|
||||
caller_job1:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
prepared: ${{ steps.gen_output.outputs.pd }}
|
||||
steps:
|
||||
- id: gen_output
|
||||
run: |
|
||||
echo "pd=prepared_data" >> "$GITHUB_OUTPUT"
|
||||
|
||||
caller_job2:
|
||||
needs: [caller_job1]
|
||||
uses: './.gitea/workflows/reusable1.yaml'
|
||||
with:
|
||||
str_input: 'from_caller_job2'
|
||||
num_input: ${{ 2.3e2 }}
|
||||
bool_input: ${{ gitea.event_name == 'push' }}
|
||||
parent_var: ${{ vars.myvar }}
|
||||
needs_out: ${{ needs.caller_job1.outputs.prepared }}
|
||||
secrets:
|
||||
PARENT_TOKEN: ${{ secrets.mysecret }}
|
||||
|
||||
caller_job3:
|
||||
needs: [caller_job2]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo ${{ needs.caller_job1.outputs.r1_out }}
|
||||
`)
|
||||
|
||||
var (
|
||||
runID int64
|
||||
callerJob2ID, callerJob2AttemptJobID int64
|
||||
callerJob3AttemptJobID int64
|
||||
r1Job2ID, r1Job2AttemptJobID int64
|
||||
r1Job3ID, r1Job3AttemptJobID int64
|
||||
r2Job1AttemptJobID int64
|
||||
)
|
||||
|
||||
t.Run("Check initialized jobs", func(t *testing.T) {
|
||||
// run
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
|
||||
runID = run.ID
|
||||
|
||||
// caller_job1
|
||||
assert.Equal(t, 3, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID}))
|
||||
callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job1"})
|
||||
assert.Equal(t, actions_model.StatusWaiting, callerJob1.Status)
|
||||
assert.False(t, callerJob1.IsReusableCaller)
|
||||
|
||||
// caller_job2
|
||||
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job2"})
|
||||
callerJob2ID = callerJob2.ID
|
||||
callerJob2AttemptJobID = callerJob2.AttemptJobID
|
||||
assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status)
|
||||
assert.True(t, callerJob2.IsReusableCaller)
|
||||
|
||||
// caller_job3
|
||||
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job3"})
|
||||
callerJob3AttemptJobID = callerJob3.AttemptJobID
|
||||
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
|
||||
assert.False(t, callerJob3.IsReusableCaller)
|
||||
})
|
||||
|
||||
t.Run("First run", func(t *testing.T) {
|
||||
callerJob1Task := defaultRunner.fetchTask(t) // for caller_job1
|
||||
_, callerJob1, _ := getTaskAndJobAndRunByTaskID(t, callerJob1Task.Id)
|
||||
assert.Equal(t, "caller_job1", callerJob1.JobID)
|
||||
defaultRunner.fetchNoTask(t)
|
||||
defaultRunner.execTask(t, callerJob1Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
outputs: map[string]string{
|
||||
"prepared": "prepared_data",
|
||||
},
|
||||
})
|
||||
|
||||
r1Job1Task := defaultRunner.fetchTask(t) // for reusable1_job1
|
||||
_, r1Job1, _ := getTaskAndJobAndRunByTaskID(t, r1Job1Task.Id)
|
||||
assert.Equal(t, "reusable1_job1", r1Job1.JobID)
|
||||
assert.Equal(t, callerJob2ID, r1Job1.ParentJobID)
|
||||
payload := getWorkflowCallPayloadFromTask(t, r1Job1Task)
|
||||
if assert.Len(t, payload.Inputs, 5) {
|
||||
assert.Equal(t, "from_caller_job2", payload.Inputs["str_input"])
|
||||
assert.EqualValues(t, 230, payload.Inputs["num_input"])
|
||||
assert.Equal(t, true, payload.Inputs["bool_input"])
|
||||
assert.Equal(t, "abcdef", payload.Inputs["parent_var"])
|
||||
assert.Equal(t, "prepared_data", payload.Inputs["needs_out"])
|
||||
}
|
||||
if assert.Len(t, r1Job1Task.Secrets, 3) {
|
||||
assert.Contains(t, r1Job1Task.Secrets, "GITEA_TOKEN")
|
||||
assert.Contains(t, r1Job1Task.Secrets, "GITHUB_TOKEN")
|
||||
assert.Equal(t, "secRET-t0Ken", r1Job1Task.Secrets["PARENT_TOKEN"])
|
||||
}
|
||||
customRunner.fetchNoTask(t)
|
||||
defaultRunner.execTask(t, r1Job1Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
|
||||
// reusable1_job3 (a nested caller) needs reusable1_job2, so it stays Blocked until r1j2 succeeds.
|
||||
r1Job3Pre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"})
|
||||
assert.Equal(t, actions_model.StatusBlocked, r1Job3Pre.Status)
|
||||
assert.False(t, r1Job3Pre.IsExpanded)
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"}))
|
||||
|
||||
r1Job2Task := customRunner.fetchTask(t) // for reusable1_job2
|
||||
_, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id)
|
||||
assert.Equal(t, "reusable1_job2", r1Job2.JobID)
|
||||
r1Job2ID = r1Job2.ID
|
||||
r1Job2AttemptJobID = r1Job2.AttemptJobID
|
||||
if assert.Len(t, r1Job2Task.Needs, 1) {
|
||||
assert.Contains(t, r1Job2Task.Needs, "reusable1_job1")
|
||||
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, r1Job2Task.Needs["reusable1_job1"].Result)
|
||||
}
|
||||
customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
outputs: map[string]string{
|
||||
"r1j2_out": "r1j2_out_data",
|
||||
},
|
||||
})
|
||||
|
||||
// Now reusable1_job3 expands and reusable2_job1 becomes runnable.
|
||||
r1Job3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"})
|
||||
assert.True(t, r1Job3.IsReusableCaller)
|
||||
assert.True(t, r1Job3.IsExpanded)
|
||||
assert.Equal(t, callerJob2ID, r1Job3.ParentJobID)
|
||||
r1Job3ID = r1Job3.ID
|
||||
r1Job3AttemptJobID = r1Job3.AttemptJobID
|
||||
r2Job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"})
|
||||
assert.Equal(t, r1Job3ID, r2Job1.ParentJobID)
|
||||
r2Job1AttemptJobID = r2Job1.AttemptJobID
|
||||
|
||||
r2Job1Task := defaultRunner.fetchTask(t) // for reusable2_job1
|
||||
_, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id)
|
||||
assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID)
|
||||
assert.Equal(t, r1Job3ID, fetchedR2Job1.ParentJobID)
|
||||
r2Job1Payload := getWorkflowCallPayloadFromTask(t, r2Job1Task)
|
||||
if assert.Len(t, r2Job1Payload.Inputs, 1) {
|
||||
assert.Equal(t, "from_caller_job2", r2Job1Payload.Inputs["msg"])
|
||||
}
|
||||
defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
|
||||
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status)
|
||||
|
||||
callerJob3Task := defaultRunner.fetchTask(t) // for caller_job3
|
||||
_, callerJob3, _ := getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id)
|
||||
assert.Equal(t, "caller_job3", callerJob3.JobID)
|
||||
if assert.Len(t, callerJob3Task.Needs, 1) {
|
||||
assert.Contains(t, callerJob3Task.Needs, "caller_job2")
|
||||
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result)
|
||||
if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) {
|
||||
assert.Equal(t, "r1j2_out_data", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"])
|
||||
}
|
||||
}
|
||||
defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, callerRun.Status)
|
||||
})
|
||||
|
||||
t.Run("Rerun 'reusable1_job2'", func(t *testing.T) {
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, r1Job2ID))
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
||||
attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2})
|
||||
assert.Equal(t, actions_model.StatusWaiting, attempt2.Status)
|
||||
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob2AttemptJobID})
|
||||
assert.Equal(t, actions_model.StatusWaiting, callerJob2.Status)
|
||||
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob3AttemptJobID})
|
||||
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
|
||||
|
||||
// reusable1_job3 needs reusable1_job2, so rerunning r1j2 pulls r1j3 (and its subtree) into the rerun set
|
||||
r1Job3Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID})
|
||||
assert.Equal(t, actions_model.StatusBlocked, r1Job3Attempt2.Status)
|
||||
assert.True(t, r1Job3Attempt2.IsReusableCaller)
|
||||
assert.False(t, r1Job3Attempt2.IsExpanded)
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"}))
|
||||
|
||||
defaultRunner.fetchNoTask(t)
|
||||
r1Job2Task := customRunner.fetchTask(t)
|
||||
_, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id)
|
||||
assert.Equal(t, "reusable1_job2", r1Job2.JobID)
|
||||
assert.Equal(t, callerJob2.ID, r1Job2.ParentJobID)
|
||||
assert.Equal(t, r1Job2AttemptJobID, r1Job2.AttemptJobID)
|
||||
assert.Equal(t, actions_model.StatusRunning, r1Job2.Status)
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
assert.Equal(t, actions_model.StatusRunning, run.Status)
|
||||
customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
outputs: map[string]string{
|
||||
"r1j2_out": "r1j2_out_data_updated",
|
||||
},
|
||||
})
|
||||
|
||||
// r1j3 expands again. Its child reuses the AttemptJobID from attempt 1
|
||||
r1Job3Attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID})
|
||||
assert.True(t, r1Job3Attempt2.IsExpanded)
|
||||
r2Job1Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"})
|
||||
assert.Equal(t, r2Job1AttemptJobID, r2Job1Attempt2.AttemptJobID)
|
||||
assert.Equal(t, r1Job3Attempt2.ID, r2Job1Attempt2.ParentJobID)
|
||||
|
||||
r2Job1Task := defaultRunner.fetchTask(t)
|
||||
_, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id)
|
||||
assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID)
|
||||
defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
|
||||
callerJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2.ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status)
|
||||
|
||||
callerJob3Task := defaultRunner.fetchTask(t)
|
||||
_, callerJob3, _ = getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id)
|
||||
assert.Equal(t, "caller_job3", callerJob3.JobID)
|
||||
if assert.Len(t, callerJob3Task.Needs, 1) {
|
||||
assert.Contains(t, callerJob3Task.Needs, "caller_job2")
|
||||
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result)
|
||||
if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) {
|
||||
assert.Equal(t, "r1j2_out_data_updated", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"])
|
||||
}
|
||||
}
|
||||
defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
})
|
||||
attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2})
|
||||
assert.Equal(t, actions_model.StatusSuccess, attempt2.Status)
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Cross-repo reusable workflow with collaborative owner", func(t *testing.T) {
|
||||
// libRepo: private, owned by user2.
|
||||
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-private", true)
|
||||
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
|
||||
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml",
|
||||
`name: ReusableLib
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
from:
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
lib_job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hello-${{ inputs.from }}
|
||||
`)
|
||||
|
||||
// consumerRepo: private, owned by user4.
|
||||
consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-cross-repo", true)
|
||||
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-cross-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml",
|
||||
`name: CrossCaller
|
||||
on: push
|
||||
jobs:
|
||||
cross_job:
|
||||
uses: user2/reusable-lib-private/.gitea/workflows/reusable_lib.yaml@main
|
||||
with:
|
||||
from: 'consumer'
|
||||
`)
|
||||
|
||||
// Phase 1: no grant. The cross-repo read check fails, and NO ActionRun row gets persisted.
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
|
||||
runner.fetchNoTask(t)
|
||||
|
||||
// Phase 2: user2 (libRepo owner) adds user4 (consumer owner) as a Collaborative Owner of libRepo.
|
||||
addCollabReq := NewRequestWithValues(t, "POST",
|
||||
fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name),
|
||||
map[string]string{"collaborative_owner": user4.Name})
|
||||
user2Session.MakeRequest(t, addCollabReq, http.StatusOK)
|
||||
|
||||
// Phase 3: trigger the workflow again
|
||||
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, "marker.txt", "trigger after grant")
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})
|
||||
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
|
||||
assert.True(t, crossJob.IsReusableCaller)
|
||||
assert.True(t, crossJob.IsExpanded)
|
||||
assert.Equal(t, actions_model.StatusWaiting, crossJob.Status)
|
||||
|
||||
libJobTask := runner.fetchTask(t)
|
||||
_, fetchedLibJob, _ := getTaskAndJobAndRunByTaskID(t, libJobTask.Id)
|
||||
assert.Equal(t, "lib_job", fetchedLibJob.JobID)
|
||||
assert.Equal(t, crossJob.ID, fetchedLibJob.ParentJobID)
|
||||
assert.Equal(t, consumerRepo.ID, fetchedLibJob.RepoID)
|
||||
payload := getWorkflowCallPayloadFromTask(t, libJobTask)
|
||||
if assert.Len(t, payload.Inputs, 1) {
|
||||
assert.Equal(t, "consumer", payload.Inputs["from"])
|
||||
}
|
||||
crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID})
|
||||
assert.Equal(t, actions_model.StatusRunning, crossJob.Status)
|
||||
runner.execTask(t, libJobTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, crossJob.Status)
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
||||
})
|
||||
|
||||
t.Run("Public caller denied private target even with collaborative owner", func(t *testing.T) {
|
||||
// Isolates the run.Repo.IsPrivate gate: a public caller must be denied a private target even with a
|
||||
// collaborative-owner grant, since allowing it would expose private workflow content in a public run.
|
||||
|
||||
// libRepo: private, owned by user2.
|
||||
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-public-denied", true)
|
||||
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
|
||||
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml",
|
||||
`name: ReusableLib
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
lib_job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hello
|
||||
`)
|
||||
|
||||
// Grant first: user2 adds user4 as a collaborative owner of the private libRepo, so the grant is
|
||||
// satisfied and the public-caller gate is the only thing that can deny access.
|
||||
addCollabReq := NewRequestWithValues(t, "POST",
|
||||
fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name),
|
||||
map[string]string{"collaborative_owner": user4.Name})
|
||||
user2Session.MakeRequest(t, addCollabReq, http.StatusOK)
|
||||
|
||||
// consumerRepo: public, owned by user4.
|
||||
consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-public-denied", false)
|
||||
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-public-denied-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml",
|
||||
`name: CrossCaller
|
||||
on: push
|
||||
jobs:
|
||||
cross_job:
|
||||
uses: user2/reusable-lib-public-denied/.gitea/workflows/reusable_lib.yaml@main
|
||||
`)
|
||||
|
||||
// Denied: the cross-repo read check fails for the public caller, so NO ActionRun is persisted and no task is dispatched.
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
|
||||
runner.fetchNoTask(t)
|
||||
})
|
||||
|
||||
t.Run("Cross-repo callee with same-repo nested uses", func(t *testing.T) {
|
||||
// A same-repo `uses: ./...` inside a cross-repo reusable callee must resolve relative to the callee's own repo (matching GitHub's behavior), not the original triggering repo.
|
||||
|
||||
// Place a util.yaml with a distinguishable job name in BOTH repos to detect mis-resolution.
|
||||
|
||||
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-nested", false)
|
||||
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
|
||||
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/util.yaml",
|
||||
`name: UtilLib
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
util_lib_job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo from-lib
|
||||
`)
|
||||
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/lib.yaml",
|
||||
`name: LibNested
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
call_util_in_lib:
|
||||
uses: ./.gitea/workflows/util.yaml
|
||||
`)
|
||||
|
||||
consumerAPIRepo := createActionsTestRepo(t, user4Token, "consumer-nested-uses", false)
|
||||
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
|
||||
|
||||
// A *different* util.yaml in the consumer repo: if `./` mis-resolves we'd see this job's name.
|
||||
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/util.yaml",
|
||||
`name: UtilConsumer
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
util_consumer_job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo from-consumer
|
||||
`)
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-nested-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/caller.yaml",
|
||||
`name: NestedCaller
|
||||
on: push
|
||||
jobs:
|
||||
cross_job:
|
||||
uses: user2/reusable-lib-nested/.gitea/workflows/lib.yaml@main
|
||||
`)
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})
|
||||
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
|
||||
assert.True(t, crossJob.IsReusableCaller)
|
||||
assert.True(t, crossJob.IsExpanded)
|
||||
|
||||
// cross_job's children come from libRepo/lib.yaml - their source must be libRepo + libRepo's commit.
|
||||
libHead, err := gitrepo.GetBranchCommitID(t.Context(), libRepo, libRepo.DefaultBranch)
|
||||
require.NoError(t, err)
|
||||
callUtilJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "call_util_in_lib", ParentJobID: crossJob.ID})
|
||||
assert.True(t, callUtilJob.IsReusableCaller)
|
||||
assert.Equal(t, libRepo.ID, callUtilJob.WorkflowSourceRepoID)
|
||||
assert.Equal(t, libHead, callUtilJob.WorkflowSourceCommitSHA)
|
||||
|
||||
// call_util_in_lib has `uses: ./.gitea/workflows/util.yaml`, so its children should come from libRepo/util.yaml
|
||||
assert.True(t, callUtilJob.IsExpanded)
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_lib_job", ParentJobID: callUtilJob.ID})
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_consumer_job"})
|
||||
})
|
||||
|
||||
t.Run("Missing callee file", func(t *testing.T) {
|
||||
// A caller workflow references a callee path that does not exist in the repo.
|
||||
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "caller-missing-callee", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
||||
`name: Caller
|
||||
on: push
|
||||
jobs:
|
||||
plain_job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'job'
|
||||
call_missing:
|
||||
uses: ./.gitea/workflows/does-not-exist.yml
|
||||
`)
|
||||
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
||||
})
|
||||
|
||||
t.Run("Nested caller with missing callee fails instead of blocking", func(t *testing.T) {
|
||||
// When the expansion hits a terminal error (e.g. missing callee), the emitter must fail the caller and let the run finish as failed, not retry the expansion forever.
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-missing-callee", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
||||
`name: Caller
|
||||
on: push
|
||||
jobs:
|
||||
plain_job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'job'
|
||||
bad_caller:
|
||||
needs: plain_job
|
||||
uses: ./.gitea/workflows/does-not-exist.yml
|
||||
`)
|
||||
|
||||
// plain_job runs first; bad_caller is Blocked on needs and is NOT expanded at creation.
|
||||
plainTask := runner.fetchTask(t)
|
||||
_, plainJob, run := getTaskAndJobAndRunByTaskID(t, plainTask.Id)
|
||||
assert.Equal(t, "plain_job", plainJob.JobID)
|
||||
badCallerPre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "bad_caller"})
|
||||
assert.Equal(t, actions_model.StatusBlocked, badCallerPre.Status)
|
||||
assert.False(t, badCallerPre.IsExpanded)
|
||||
|
||||
runner.execTask(t, plainTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
// The emitter now tries to expand bad_caller, hits the missing callee, and fails the caller.
|
||||
badCaller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: badCallerPre.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, badCaller.Status)
|
||||
// No children were inserted (the terminal error precedes the child inserts).
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{ParentJobID: badCallerPre.ID}))
|
||||
|
||||
finalRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, finalRun.Status)
|
||||
|
||||
runner.fetchNoTask(t) // no task scheduled for the failed caller; the run is not stuck
|
||||
})
|
||||
|
||||
t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) {
|
||||
// user2 owns the base repo, configures a secret, and registers a reusable workflow that declares a required secret.
|
||||
// The caller workflow uses `secrets: inherit`.
|
||||
|
||||
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-pr-inherit-test", false)
|
||||
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
|
||||
|
||||
// Real secret that must never reach a fork PR task.
|
||||
req := NewRequestWithJSON(t, "PUT",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/leaked_secret", baseRepo.OwnerName, baseRepo.Name),
|
||||
api.CreateOrUpdateSecretOption{Data: "MUST-NOT-LEAK"}).AddTokenAuth(user2Token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-fork-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/reusable.yaml",
|
||||
`name: Reusable
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
leaked_secret:
|
||||
|
||||
jobs:
|
||||
callee:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`)
|
||||
createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/caller.yaml",
|
||||
`name: Caller
|
||||
on: pull_request
|
||||
jobs:
|
||||
call_reusable:
|
||||
uses: ./.gitea/workflows/reusable.yaml
|
||||
secrets: inherit
|
||||
`)
|
||||
|
||||
// user4 forks
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
|
||||
&api.CreateForkOption{Name: new("fork-pr-inherit-test-fork")}).AddTokenAuth(user4Token)
|
||||
resp := MakeRequest(t, req, http.StatusAccepted)
|
||||
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
||||
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
// user4 pushes a change on the fork and opens a PR to base
|
||||
doAPICreateFile(user4APICtx, "user4-fix.txt", &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
NewBranchName: "user4/branch",
|
||||
Message: "create user4-fix.txt",
|
||||
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("fix")),
|
||||
})(t)
|
||||
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":user4/branch")(t)
|
||||
|
||||
// Approve the fork PR run.
|
||||
forkRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID})
|
||||
assert.True(t, forkRun.IsForkPullRequest)
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, forkRun.ID))
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
task := runner.fetchTask(t)
|
||||
_, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, "callee", taskJob.JobID)
|
||||
assert.Equal(t, forkRun.ID, taskRun.ID)
|
||||
|
||||
// Only the auto-issued tokens should be present. The user-defined `leaked_secret` must not appear.
|
||||
assert.Contains(t, task.Secrets, "GITEA_TOKEN")
|
||||
assert.Contains(t, task.Secrets, "GITHUB_TOKEN")
|
||||
assert.NotContains(t, task.Secrets, "leaked_secret")
|
||||
for name, value := range task.Secrets {
|
||||
assert.NotEqual(t, "MUST-NOT-LEAK", value, "secret %q leaked the base repo's secret value into a fork PR task", name)
|
||||
}
|
||||
|
||||
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
})
|
||||
|
||||
t.Run("pull_request_target resolves a local reusable workflow at the base commit", func(t *testing.T) {
|
||||
apiBaseRepo := createActionsTestRepo(t, user2Token, "prt-reusable-test", false)
|
||||
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-prt-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
reusablePath := ".gitea/workflows/reusable.yaml"
|
||||
// A pull_request_target run's workflow should always come from the base branch.
|
||||
createRepoWorkflowFile(t, user2, user2Token, baseRepo, reusablePath, `name: Reusable
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
trusted:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo trusted
|
||||
`)
|
||||
baseFile := createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, ".gitea/workflows/prt.yaml",
|
||||
getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create prt.yaml", `name: PRT
|
||||
on: pull_request_target
|
||||
jobs:
|
||||
call_reusable:
|
||||
uses: ./.gitea/workflows/reusable.yaml
|
||||
secrets: inherit
|
||||
`))
|
||||
baseSHA := baseFile.Commit.SHA
|
||||
|
||||
// user4 forks
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
|
||||
&api.CreateForkOption{Name: new("prt-reusable-test-fork")}).AddTokenAuth(user4Token)
|
||||
resp := MakeRequest(t, req, http.StatusAccepted)
|
||||
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
||||
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
// user4 rewrites the reusable workflow the base branch calls into, and opens a PR
|
||||
req = NewRequest(t, "GET",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath)).AddTokenAuth(user4Token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
forkReusable := DecodeJSON(t, resp, &api.ContentsResponse{})
|
||||
|
||||
req = NewRequestWithJSON(t, "PUT",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath), &api.UpdateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
NewBranchName: "fork-branch",
|
||||
Message: "rewrite the reusable workflow",
|
||||
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||
},
|
||||
SHA: forkReusable.SHA,
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: Reusable
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
from-fork:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo from-fork
|
||||
`)),
|
||||
}).AddTokenAuth(user4Token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
forkHeadSHA := DecodeJSON(t, resp, &api.FileResponse{}).Commit.SHA
|
||||
require.NotEqual(t, baseSHA, forkHeadSHA)
|
||||
|
||||
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":fork-branch")(t)
|
||||
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
|
||||
prtRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
|
||||
assert.Equal(t, actions_module.GithubEventPullRequestTarget, prtRun.TriggerEvent)
|
||||
assert.True(t, prtRun.IsForkPullRequest)
|
||||
assert.False(t, prtRun.NeedApproval)
|
||||
// The run still points at the PR head, but its workflow source must be the base commit.
|
||||
assert.Equal(t, forkHeadSHA, prtRun.CommitSHA)
|
||||
assert.Equal(t, baseSHA, prtRun.WorkflowCommitSHA)
|
||||
|
||||
caller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "call_reusable"})
|
||||
assert.Equal(t, baseSHA, caller.WorkflowSourceCommitSHA)
|
||||
assert.NotContains(t, string(caller.ReusableWorkflowContent), "from-fork")
|
||||
|
||||
// The caller has no needs, so it is expanded inline at insert time: the child comes from the base branch.
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "from-fork"})
|
||||
child := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "trusted"})
|
||||
assert.Equal(t, caller.ID, child.ParentJobID)
|
||||
assert.Equal(t, baseSHA, child.WorkflowSourceCommitSHA)
|
||||
|
||||
task := runner.fetchTask(t)
|
||||
_, taskJob, _ := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
require.Equal(t, "trusted", taskJob.JobID)
|
||||
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
})
|
||||
|
||||
t.Run("Caller alternates expanding across attempts", func(t *testing.T) {
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "caller-walkback-test", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-walkback-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
// Scenario:
|
||||
// attempt 1: gate succeeds -> caller expands -> inner runs (records inner.AttemptJobID = N)
|
||||
// attempt 2: rerun gate, mock Failure -> caller is Skipped without expanding (no children inserted)
|
||||
// attempt 3: rerun gate, mock Success -> caller expands again -> inner.AttemptJobID must equal N
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yaml",
|
||||
`name: Lib
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
inner:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo inner
|
||||
`)
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/main.yaml",
|
||||
`name: Main
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.gitea/workflows/main.yaml'
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo gate
|
||||
|
||||
caller:
|
||||
needs: [gate]
|
||||
uses: ./.gitea/workflows/lib.yaml
|
||||
`)
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
|
||||
runID := run.ID
|
||||
|
||||
latestAttempt := func() *actions_model.ActionRunAttempt {
|
||||
r := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: r.LatestAttemptID})
|
||||
}
|
||||
jobInLatest := func(jobID string) *actions_model.ActionRunJob {
|
||||
a := latestAttempt()
|
||||
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: a.ID, JobID: jobID})
|
||||
}
|
||||
|
||||
// attempt 1: gate Success -> caller expands -> inner runs
|
||||
gate1Task := runner.fetchTask(t)
|
||||
_, gate1, _ := getTaskAndJobAndRunByTaskID(t, gate1Task.Id)
|
||||
assert.Equal(t, "gate", gate1.JobID)
|
||||
runner.execTask(t, gate1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
inner1Task := runner.fetchTask(t)
|
||||
_, inner1, _ := getTaskAndJobAndRunByTaskID(t, inner1Task.Id)
|
||||
assert.Equal(t, "inner", inner1.JobID)
|
||||
innerAttemptJobID := inner1.AttemptJobID
|
||||
callerAttempt1 := jobInLatest("caller")
|
||||
assert.True(t, callerAttempt1.IsExpanded)
|
||||
assert.Equal(t, callerAttempt1.ID, inner1.ParentJobID)
|
||||
runner.execTask(t, inner1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
||||
|
||||
// attempt 2: rerun gate, mock Failure -> caller stays unexpanded (Skipped)
|
||||
gateLatest := jobInLatest("gate")
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID))
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
gate2Task := runner.fetchTask(t)
|
||||
_, gate2, _ := getTaskAndJobAndRunByTaskID(t, gate2Task.Id)
|
||||
assert.Equal(t, "gate", gate2.JobID)
|
||||
runner.execTask(t, gate2Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_FAILURE})
|
||||
|
||||
runner.fetchNoTask(t) // no inner because caller did not expand
|
||||
attempt2 := latestAttempt()
|
||||
assert.Equal(t, actions_model.StatusFailure, attempt2.Status)
|
||||
callerAttempt2 := jobInLatest("caller")
|
||||
assert.Equal(t, actions_model.StatusSkipped, callerAttempt2.Status)
|
||||
assert.False(t, callerAttempt2.IsExpanded)
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "inner"}))
|
||||
|
||||
// attempt 3: rerun gate, mock Success -> caller expands and inner reuses attempt 1's AttemptJobID
|
||||
gateLatest = jobInLatest("gate")
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID))
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
gate3Task := runner.fetchTask(t)
|
||||
runner.execTask(t, gate3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
inner3Task := runner.fetchTask(t)
|
||||
_, inner3, _ := getTaskAndJobAndRunByTaskID(t, inner3Task.Id)
|
||||
assert.Equal(t, "inner", inner3.JobID)
|
||||
assert.Equal(t, innerAttemptJobID, inner3.AttemptJobID)
|
||||
runner.execTask(t, inner3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
||||
})
|
||||
|
||||
t.Run("No-needs caller if evaluated inline: false skips, true expands", func(t *testing.T) {
|
||||
// A no-needs reusable-workflow caller is processed inline during InsertRun, where its own
|
||||
// `if:` is now evaluated before expansion:
|
||||
// - a false `if:` skips the caller without inserting any children, and the skip is
|
||||
// propagated to a dependent job (via the post-commit emitter kick);
|
||||
// - a true `if:` still expands the caller into its child jobs.
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "caller-inline-if-test", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yaml",
|
||||
`name: Lib
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
inner:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo inner
|
||||
`)
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
||||
`name: Caller
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.gitea/workflows/caller.yaml'
|
||||
jobs:
|
||||
will_skip:
|
||||
if: ${{ false }}
|
||||
uses: ./.gitea/workflows/lib.yaml
|
||||
|
||||
will_run:
|
||||
if: ${{ true }}
|
||||
uses: ./.gitea/workflows/lib.yaml
|
||||
|
||||
after_skip:
|
||||
needs: [will_skip]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo after_skip
|
||||
`)
|
||||
|
||||
// drain the emitter queue so the skip has propagated to the dependent job
|
||||
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
|
||||
runID := run.ID
|
||||
|
||||
// will_skip: a caller with a false `if:` is skipped inline and never expands (no children inserted).
|
||||
willSkip := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "will_skip"})
|
||||
assert.True(t, willSkip.IsReusableCaller)
|
||||
assert.False(t, willSkip.IsExpanded)
|
||||
assert.Equal(t, actions_model.StatusSkipped, willSkip.Status)
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: runID, ParentJobID: willSkip.ID})
|
||||
|
||||
// will_run: a caller with a true `if:` still expands into its child job.
|
||||
willRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "will_run"})
|
||||
assert.True(t, willRun.IsReusableCaller)
|
||||
assert.True(t, willRun.IsExpanded)
|
||||
innerChild := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "inner"})
|
||||
assert.Equal(t, willRun.ID, innerChild.ParentJobID)
|
||||
|
||||
// after_skip: a dependent of the skipped caller resolves to Skipped instead of staying Blocked.
|
||||
afterSkip := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "after_skip"})
|
||||
assert.Equal(t, actions_model.StatusSkipped, afterSkip.Status)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// token must belong to u (the commit identity) and have write access to repo. Reuse the caller's
|
||||
// existing token rather than logging in per call, which would re-run bcrypt password verification each time.
|
||||
func createRepoWorkflowFile(t *testing.T, u *user_model.User, token string, repo *repo_model.Repository, treePath, content string) {
|
||||
opts := getWorkflowCreateFileOptions(u, repo.DefaultBranch, "create "+treePath, content)
|
||||
createWorkflowFile(t, token, repo.OwnerName, repo.Name, treePath, opts)
|
||||
}
|
||||
|
||||
func getWorkflowCallPayloadFromTask(t *testing.T, runnerTask *runnerv1.Task) *api.WorkflowCallPayload {
|
||||
eventJSON, err := runnerTask.GetContext().Fields["event"].GetStructValue().MarshalJSON()
|
||||
assert.NoError(t, err)
|
||||
var payload api.WorkflowCallPayload
|
||||
assert.NoError(t, json.Unmarshal(eventJSON, &payload))
|
||||
return &payload
|
||||
}
|
||||
@@ -9,15 +9,15 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
actions_web "code.gitea.io/gitea/routers/web/repo/actions"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
actions_web "gitea.dev/routers/web/repo/actions"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -63,19 +63,51 @@ jobs:
|
||||
task2 := runner2.fetchTask(t)
|
||||
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
|
||||
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, 0, "text/markdown", []byte("### Hello summary\n\nFrom first step.\n")))
|
||||
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, 1, "text/markdown", []byte("From second step.\n")))
|
||||
// A second job's summary in the same run/attempt: the run view must include it,
|
||||
// but the single-job view must scope it out.
|
||||
otherJobID := job1.ID + 1
|
||||
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, otherJobID, 0, "text/markdown", []byte("### Other job summary\n")))
|
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, 999999))
|
||||
user2Session.MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// run1 and job1 belong to repo1, success
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
|
||||
findSummary := func(viewResp *actions_web.ViewResponse, jobID int64) *actions_web.ViewJobSummary {
|
||||
for _, s := range viewResp.State.Run.JobSummaries {
|
||||
if s.JobID == jobID {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
assertJob1Summary := func(t *testing.T, s *actions_web.ViewJobSummary) {
|
||||
t.Helper()
|
||||
require.NotNil(t, s)
|
||||
assert.Contains(t, string(s.SummaryHTML), "Hello summary")
|
||||
assert.Contains(t, string(s.SummaryHTML), "From second step")
|
||||
}
|
||||
|
||||
// Run view: summaries for every job in the run.
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
|
||||
resp := user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
var viewResp actions_web.ViewResponse
|
||||
DecodeJSON(t, resp, &viewResp)
|
||||
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
|
||||
require.Len(t, viewResp.State.Run.JobSummaries, 2)
|
||||
assertJob1Summary(t, findSummary(viewResp, job1.ID))
|
||||
assert.Contains(t, string(findSummary(viewResp, otherJobID).SummaryHTML), "Other job summary")
|
||||
|
||||
// Job view: scoped server-side to the requested job, the other job's summary excluded.
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
|
||||
resp = user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
viewResp = DecodeJSON(t, resp, &actions_web.ViewResponse{})
|
||||
assert.Len(t, viewResp.State.Run.Jobs, 1)
|
||||
assert.Equal(t, job1.ID, viewResp.State.Run.Jobs[0].ID)
|
||||
require.Len(t, viewResp.State.Run.JobSummaries, 1)
|
||||
assertJob1Summary(t, findSummary(viewResp, job1.ID))
|
||||
assert.Nil(t, findSummary(viewResp, otherJobID))
|
||||
|
||||
// run2 and job2 do not belong to repo1, failure
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job2.ID))
|
||||
@@ -212,7 +244,7 @@ func testActionsRouteForLegacyIndexBasedURL(t *testing.T) {
|
||||
// Best-effort compatibility prefers the run ID when the same number also exists as a legacy run index.
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, collisionRun.Index))
|
||||
resp = user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-run-id="%d"`, normalRun.ID)) // because collisionRun.Index == normalRun.ID
|
||||
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-actions-view-url="/%s/%s/actions/runs/%d"`, user2.Name, repo.Name, normalRun.ID))
|
||||
|
||||
// by_index=1 should force the summary page to use the legacy run index interpretation.
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d?by_index=1", user2.Name, repo.Name, collisionRun.Index))
|
||||
|
||||
@@ -6,14 +6,16 @@ package integration
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/tests"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -163,4 +165,62 @@ func TestActionsRunnerModify(t *testing.T) {
|
||||
assertSuccess(t, sessionAdmin, adminWebURL, globalRunner.ID)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("BulkAction", func(t *testing.T) {
|
||||
// Previous subtests deleted all runners; create a fresh set scoped to this subtest.
|
||||
require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-1", TokenHash: "e", UUID: "e"}))
|
||||
require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-2", TokenHash: "f", UUID: "f"}))
|
||||
require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-3", TokenHash: "g", UUID: "g"}))
|
||||
r1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-1"})
|
||||
r2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-2"})
|
||||
r3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-3"})
|
||||
allIDs := []int64{r1.ID, r2.ID, r3.ID}
|
||||
bulkURL := adminWebURL + "/bulk"
|
||||
doBulk := func(t *testing.T, sess *TestSession, action string, ids []int64, expectedStatus int) {
|
||||
req := NewRequestWithValues(t, "POST", bulkURL, map[string]string{
|
||||
"action": action,
|
||||
"ids": strings.Join(base.Int64sToStrings(ids), ","),
|
||||
})
|
||||
sess.MakeRequest(t, req, expectedStatus)
|
||||
}
|
||||
|
||||
t.Run("NonAdminForbidden", func(t *testing.T) {
|
||||
doBulk(t, sessionUser2, "disable", allIDs, http.StatusForbidden)
|
||||
for _, id := range allIDs {
|
||||
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
|
||||
assert.False(t, v.IsDisabled, "runner %d should not have been disabled", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidAction", func(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "evict", allIDs, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("EmptyIDs", func(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "delete", nil, http.StatusBadRequest)
|
||||
for _, id := range allIDs {
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DisableEnable", func(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "disable", allIDs, http.StatusOK)
|
||||
for _, id := range allIDs {
|
||||
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
|
||||
assert.True(t, v.IsDisabled, "runner %d should be disabled", id)
|
||||
}
|
||||
doBulk(t, sessionAdmin, "enable", allIDs, http.StatusOK)
|
||||
for _, id := range allIDs {
|
||||
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
|
||||
assert.False(t, v.IsDisabled, "runner %d should be enabled", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "delete", allIDs, http.StatusOK)
|
||||
for _, id := range allIDs {
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: id})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestFindRunnersSortByStatus verifies that sorting by status ranks runners by
|
||||
// their computed status (active, then idle, then offline) instead of interleaving
|
||||
// idle runners with active ones by raw last_online, and that a disabled runner
|
||||
// sinks to the bottom of its status group rather than mixing with enabled runners.
|
||||
//
|
||||
// It lives in tests/integration rather than a unit test because the status rank is
|
||||
// a database-evaluated CASE expression; unit tests only run against SQLite, so the
|
||||
// expression must be exercised against MySQL/PostgreSQL/MSSQL in CI to catch dialect
|
||||
// differences.
|
||||
func TestFindRunnersSortByStatus(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
ctx := t.Context()
|
||||
|
||||
const ownerID = 1001
|
||||
now := time.Now()
|
||||
// An idle runner that went online most recently would sort before an active
|
||||
// runner when ordering by last_online alone; the status rank must override that.
|
||||
insert := func(name string, lastOnline, lastActive time.Time, disabled bool) {
|
||||
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunner{
|
||||
Name: name,
|
||||
UUID: "STATUS-SORT-" + name,
|
||||
TokenHash: "status-sort-token-" + name,
|
||||
OwnerID: ownerID,
|
||||
LastOnline: timeutil.TimeStamp(lastOnline.Unix()),
|
||||
LastActive: timeutil.TimeStamp(lastActive.Unix()),
|
||||
IsDisabled: disabled,
|
||||
}))
|
||||
}
|
||||
// Each disabled runner has the most recent last_online within its status group,
|
||||
// so it would sort first in that group if the disabled flag were ignored; the
|
||||
// last_active value keeps it in the intended group (offline<idle<active).
|
||||
insert("active", now.Add(-8*time.Second), now.Add(-5*time.Second), false)
|
||||
insert("active-disabled", now, now.Add(-2*time.Second), true)
|
||||
insert("idle", now.Add(-30*time.Second), now.Add(-20*time.Second), false)
|
||||
insert("idle-disabled", now, now.Add(-20*time.Second), true)
|
||||
insert("offline", now.Add(-3*time.Minute), now.Add(-3*time.Minute), false)
|
||||
insert("offline-disabled", now.Add(-90*time.Second), now.Add(-90*time.Second), true)
|
||||
|
||||
names := func(runners []*actions_model.ActionRunner) []string {
|
||||
out := make([]string, len(runners))
|
||||
for i, r := range runners {
|
||||
out[i] = r.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Active group first, then idle, then offline; within each group enabled before disabled.
|
||||
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "online"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"active", "active-disabled",
|
||||
"idle", "idle-disabled",
|
||||
"offline", "offline-disabled",
|
||||
}, names(runners))
|
||||
|
||||
// The descending status sort reverses the group order but still keeps disabled
|
||||
// runners at the bottom of their group.
|
||||
runners, err = db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "offline"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"offline", "offline-disabled",
|
||||
"idle", "idle-disabled",
|
||||
"active", "active-disabled",
|
||||
}, names(runners))
|
||||
}
|
||||
|
||||
// TestFindRunnersPaginationNoDuplicates verifies that the unique id tiebreaker in
|
||||
// FindRunnerOptions.ToOrders keeps pagination deterministic when the primary sort
|
||||
// key (last_online) is tied for every runner. It is an integration test so the
|
||||
// ordering is validated against every supported database, not only SQLite.
|
||||
func TestFindRunnersPaginationNoDuplicates(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
ctx := t.Context()
|
||||
|
||||
// Create several runners that all share the same last_online value so the
|
||||
// primary sort key (last_online) is tied for all of them.
|
||||
const ownerID = 1000
|
||||
const count = 6
|
||||
for i := range count {
|
||||
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunner{
|
||||
Name: "paginated-runner",
|
||||
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
|
||||
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
|
||||
OwnerID: ownerID,
|
||||
RepoID: 0,
|
||||
LastOnline: 42,
|
||||
}))
|
||||
}
|
||||
|
||||
// Page through the runners and ensure every id is returned exactly once.
|
||||
seen := make(map[int64]int)
|
||||
const pageSize = 2
|
||||
for page := 1; ; page++ {
|
||||
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
|
||||
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if len(runners) == 0 {
|
||||
break
|
||||
}
|
||||
for _, r := range runners {
|
||||
seen[r.ID]++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
|
||||
for id, n := range seen {
|
||||
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
pingv1 "gitea.dev/actions-proto-go/ping/v1"
|
||||
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
||||
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
||||
"connectrpc.com/connect"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -86,10 +86,9 @@ func (r *mockRunner) registerAsRepoRunner(t *testing.T, ownerName, repoName, run
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/registration-token", ownerName, repoName)).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var registrationToken struct {
|
||||
registrationToken := DecodeJSON(t, resp, &struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
DecodeJSON(t, resp, ®istrationToken)
|
||||
}{})
|
||||
r.doRegister(t, runnerName, registrationToken.Token, labels, ephemeral)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,21 +9,25 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
unit_model "code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/migration"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
mirror_service "code.gitea.io/gitea/services/mirror"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/migration"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
migrations_service "gitea.dev/services/migrations"
|
||||
mirror_service "gitea.dev/services/mirror"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
files_service "gitea.dev/services/repository/files"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScheduleUpdate(t *testing.T) {
|
||||
@@ -144,6 +148,11 @@ jobs:
|
||||
|
||||
func testScheduleUpdateMirrorSync(t *testing.T) {
|
||||
doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) {
|
||||
// the mirror sync re-validates the remote URL, which rejects the local test server unless local
|
||||
// networks are allowed; migrations.Init rebuilds the host allow-list from the setting
|
||||
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
|
||||
require.NoError(t, migrations_service.Init())
|
||||
|
||||
// create mirror repo
|
||||
opts := migration.MigrateOptions{
|
||||
RepoName: "actions-schedule-mirror",
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/queue"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/services/forms"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const scopedPushWorkflow = `name: Scoped Push
|
||||
on: push
|
||||
jobs:
|
||||
scoped-job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo scoped
|
||||
`
|
||||
|
||||
const scopedPRWorkflow = `name: Scoped PR
|
||||
on: pull_request
|
||||
jobs:
|
||||
scoped-pr-job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo scoped-pr
|
||||
`
|
||||
|
||||
func TestActionsScopedWorkflows(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user2Session := loginUser(t, user2.Name)
|
||||
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
// createTestRepo creates an Actions-enabled repo owned by user2, used as a scoped-workflow source or consumer.
|
||||
createTestRepo := func(t *testing.T, name string, private bool) *repo_model.Repository {
|
||||
apiRepo := createActionsTestRepo(t, user2Token, name, private)
|
||||
return unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
}
|
||||
|
||||
// registerUserScopedSource registers `source` as a user-level scoped-workflow source for user2 and marks `required` entry names
|
||||
registerUserScopedSource := func(t *testing.T, source *repo_model.Repository, required ...string) {
|
||||
addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add",
|
||||
map[string]string{"repo_name": source.Name})
|
||||
user2Session.MakeRequest(t, addReq, http.StatusOK)
|
||||
t.Cleanup(func() {
|
||||
removeReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/remove",
|
||||
map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
|
||||
user2Session.MakeRequest(t, removeReq, http.StatusOK)
|
||||
})
|
||||
if len(required) > 0 {
|
||||
vals := url.Values{"repo_id": {strconv.FormatInt(source.ID, 10)}, "workflow_ids": required, "required_workflow_ids": required}
|
||||
for _, id := range required {
|
||||
// a pattern that matches the source's scoped check regardless of its `name:` (each test source has one workflow)
|
||||
vals.Set("required_patterns["+id+"]", source.FullName()+": * / *")
|
||||
}
|
||||
reqReq := NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals)
|
||||
user2Session.MakeRequest(t, reqReq, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Trigger and run creation", func(t *testing.T) {
|
||||
// Registered at INSTANCE level via the admin route (owner/name resolution + OwnerID=0 storage);
|
||||
// the trigger->execute->rerun below proves an instance-level source drives a consumer run end-to-end and that a rerun stays scoped.
|
||||
adminSession := loginUser(t, "user1")
|
||||
source := createTestRepo(t, "sw-trigger-source", false)
|
||||
// commit the scoped workflow BEFORE registering so the source's own push does not self-trigger.
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
|
||||
adminAdd := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/add", map[string]string{"repo_name": source.FullName()})
|
||||
adminSession.MakeRequest(t, adminAdd, http.StatusOK)
|
||||
t.Cleanup(func() {
|
||||
rm := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/remove", map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
|
||||
adminSession.MakeRequest(t, rm, http.StatusOK)
|
||||
})
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: 0, SourceRepoID: source.ID})
|
||||
|
||||
consumer := createTestRepo(t, "sw-trigger-consumer", false)
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-trigger-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
|
||||
assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
|
||||
assert.Equal(t, "push.yaml", run.WorkflowID)
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID}), "only the scoped run, no repo-level run")
|
||||
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
|
||||
|
||||
// runs in the CONSUMER's context and reaches a terminal state
|
||||
task := runner.fetchTask(t)
|
||||
_, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
assert.Equal(t, consumer.ID, taskJob.RepoID)
|
||||
assert.Equal(t, run.ID, taskRun.ID)
|
||||
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
||||
|
||||
// rerun: the rerun is still a scoped run and again executes in the consumer's context
|
||||
rerunReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", consumer.OwnerName, consumer.Name, run.ID, job.ID))
|
||||
user2Session.MakeRequest(t, rerunReq, http.StatusOK)
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: run.ID, Attempt: 2})
|
||||
task2 := runner.fetchTask(t)
|
||||
_, taskJob2, taskRun2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
assert.Equal(t, consumer.ID, taskJob2.RepoID)
|
||||
assert.True(t, taskRun2.IsScopedRun, "the rerun is still a scoped run")
|
||||
runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
||||
})
|
||||
|
||||
t.Run("Opt-out", func(t *testing.T) {
|
||||
// opt-out: a consumer can disable a non-required scoped workflow, but a required one cannot be disabled.
|
||||
source := createTestRepo(t, "sw-optout-source", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
|
||||
registerUserScopedSource(t, source) // non-required
|
||||
|
||||
// non-required: the kebab "Disable Workflow" item is an active link; disabling then makes a push produce no scoped run.
|
||||
consumer := createTestRepo(t, "sw-optout-consumer", false)
|
||||
optBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
|
||||
fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID)),
|
||||
http.StatusOK).Body.String()
|
||||
assert.Contains(t, optBody, "Disable Workflow")
|
||||
assert.Contains(t, optBody, "disable?workflow=push.yaml", "non-required scoped workflow: Disable Workflow is a clickable link")
|
||||
disableReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID))
|
||||
user2Session.MakeRequest(t, disableReq, http.StatusOK)
|
||||
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "opted-out scoped workflow must not run")
|
||||
|
||||
// required: the kebab "Disable Workflow" item is rendered disabled (no link), and the disable endpoint rejects it.
|
||||
reqSource := createTestRepo(t, "sw-optout-req-source", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, reqSource, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
|
||||
registerUserScopedSource(t, reqSource, "push.yaml") // required
|
||||
reqConsumer := createTestRepo(t, "sw-optout-req-consumer", false)
|
||||
requiredBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
|
||||
fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID)),
|
||||
http.StatusOK).Body.String()
|
||||
assert.Contains(t, requiredBody, "Disable Workflow")
|
||||
assert.Contains(t, requiredBody, `class="item disabled"`, "required scoped workflow: Disable Workflow is rendered disabled")
|
||||
assert.NotContains(t, requiredBody, "disable?workflow=push.yaml", "required scoped workflow: Disable Workflow has no clickable link")
|
||||
rejectReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID))
|
||||
user2Session.MakeRequest(t, rejectReq, http.StatusBadRequest) // scoped_required_cannot_disable
|
||||
})
|
||||
|
||||
t.Run("Local uses resolves to source", func(t *testing.T) {
|
||||
// uses: ./ in a scoped workflow resolves against the SOURCE repo, not the consumer.
|
||||
// Here the reusable lib lives in the SCOPED workflow dir (allowed by ResolveUses), exercising that path end-to-end.
|
||||
source := createTestRepo(t, "sw-uses-source", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/lib.yaml", `name: Lib
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
lib_job_source:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo from-source
|
||||
`)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/caller.yaml", `name: Caller
|
||||
on: push
|
||||
jobs:
|
||||
caller_job:
|
||||
uses: ./.gitea/scoped_workflows/lib.yaml
|
||||
`)
|
||||
|
||||
consumer := createTestRepo(t, "sw-uses-consumer", false)
|
||||
// a DIFFERENT lib at the same path in the consumer; if uses:./ mis-resolved we would see this job
|
||||
createRepoWorkflowFile(t, user2, user2Token, consumer, ".gitea/scoped_workflows/lib.yaml", `name: Lib
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
lib_job_consumer:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo from-consumer
|
||||
`)
|
||||
// register only AFTER both repos' scoped files exist, so the setup pushes do not trigger
|
||||
registerUserScopedSource(t, source)
|
||||
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "caller.yaml"})
|
||||
callerJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "caller_job"})
|
||||
assert.True(t, callerJob.IsReusableCaller)
|
||||
assert.True(t, callerJob.IsExpanded)
|
||||
assert.Equal(t, source.ID, callerJob.WorkflowSourceRepoID, "top-level caller's content source is the source repo")
|
||||
// the expanded child comes from the SOURCE's lib.yaml, not the consumer's same-path file
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_source", ParentJobID: callerJob.ID})
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_consumer"})
|
||||
})
|
||||
|
||||
t.Run("Workflow dispatch", func(t *testing.T) {
|
||||
// a scoped on:workflow_dispatch workflow can be triggered manually from the consumer, via both the web form and the API
|
||||
source := createTestRepo(t, "sw-dispatch-source", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/dispatch.yaml", `name: Scoped Dispatch
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
dispatch-job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo dispatch
|
||||
`)
|
||||
registerUserScopedSource(t, source)
|
||||
consumer := createTestRepo(t, "sw-dispatch-consumer", false)
|
||||
|
||||
// web form: /run?...&scoped_workflow_source_repo_id=
|
||||
webReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=dispatch.yaml&scoped_workflow_source_repo_id=%d&ref=refs/heads/%s",
|
||||
consumer.OwnerName, consumer.Name, source.ID, consumer.DefaultBranch))
|
||||
user2Session.MakeRequest(t, webReq, http.StatusSeeOther)
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml"})
|
||||
assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
|
||||
assert.NotEmpty(t, run.WorkflowCommitSHA, "scoped dispatch records the source default-branch commit")
|
||||
assert.Contains(t, run.Ref, consumer.DefaultBranch, "dispatch runs on the chosen consumer ref")
|
||||
|
||||
// API: /actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=
|
||||
apiReq := NewRequestWithURLValues(t, "POST",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID),
|
||||
url.Values{"ref": {consumer.DefaultBranch}}).AddTokenAuth(user2Token)
|
||||
MakeRequest(t, apiReq, http.StatusNoContent)
|
||||
assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml", Event: "workflow_dispatch"}),
|
||||
"both the web form and the API created a scoped dispatch run")
|
||||
})
|
||||
|
||||
t.Run("Required scoped check gates the PR merge", func(t *testing.T) {
|
||||
// A required scoped workflow's check gates PR merges on a protected branch and cannot be bypassed,
|
||||
// whether or not the branch enables its own status check. The scoped check is added to the required set dynamically.
|
||||
source := createTestRepo(t, "sw-gate-source", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedPRWorkflow)
|
||||
registerUserScopedSource(t, source, "pr.yaml") // required
|
||||
|
||||
// protectAndOpenPR protects consumer's default branch and opens a PR on `branch`, returning a merge-request builder.
|
||||
// When statusCheckEnabled it also configures "ci/manual" as the only CONFIGURED required context and satisfies it,
|
||||
// so the scoped check is the only thing that can gate the merge; otherwise the rule's own status check stays off.
|
||||
protectAndOpenPR := func(t *testing.T, consumer *repo_model.Repository, branch string, statusCheckEnabled bool) func() *RequestWrapper {
|
||||
pbValues := map[string]string{
|
||||
"rule_name": consumer.DefaultBranch,
|
||||
"enable_push": "true",
|
||||
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
|
||||
}
|
||||
if statusCheckEnabled {
|
||||
pbValues["enable_status_check"] = "true"
|
||||
pbValues["status_check_contexts"] = "ci/manual"
|
||||
}
|
||||
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), pbValues), http.StatusSeeOther)
|
||||
|
||||
prFile := &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
BranchName: consumer.DefaultBranch, NewBranchName: branch, Message: "pr change",
|
||||
Author: api.Identity{Name: user2.Name, Email: user2.Email},
|
||||
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
|
||||
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
|
||||
}
|
||||
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "pr-change.txt", prFile)
|
||||
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, branch)(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
if statusCheckEnabled {
|
||||
// satisfy the configured "ci/manual" check so only the scoped check can gate the merge
|
||||
manualStatus := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", consumer.OwnerName, consumer.Name, pr.Head.Sha),
|
||||
api.CreateStatusOption{State: commitstatus.CommitStatusSuccess, Context: "ci/manual", TargetURL: "http://test.ci/"}).AddTokenAuth(user2Token)
|
||||
user2Session.MakeRequest(t, manualStatus, http.StatusCreated)
|
||||
}
|
||||
|
||||
return func() *RequestWrapper {
|
||||
return NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
|
||||
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("pending blocks, success allows", func(t *testing.T) {
|
||||
consumer := createTestRepo(t, "sw-gate-consumer", false)
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-gate-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
mergeReq := protectAndOpenPR(t, consumer, "gate-pr", true)
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
|
||||
assert.Equal(t, source.ID, run.WorkflowRepoID)
|
||||
|
||||
// the pending required scoped check blocks the merge
|
||||
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
|
||||
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
|
||||
|
||||
// the required scoped run succeeds -> merge allowed
|
||||
task := runner.fetchTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
|
||||
user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Actions disabled blocks merge (no bypass)", func(t *testing.T) {
|
||||
// must-present: disabling the consumer's Actions unit so the required scoped workflow cannot run.
|
||||
// Must BLOCK the merge (the required check is absent), not bypass it.
|
||||
consumer := createTestRepo(t, "sw-noact-consumer", false)
|
||||
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), consumer, nil, []unit_model.Type{unit_model.TypeActions}))
|
||||
|
||||
mergeReq := protectAndOpenPR(t, consumer, "noact-pr", true)
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
|
||||
"Actions disabled, so no scoped run is created")
|
||||
|
||||
// the required scoped check never posted a status -> must-present blocks the merge (no bypass)
|
||||
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
|
||||
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
|
||||
})
|
||||
|
||||
t.Run("status check disabled: the scoped check still gates", func(t *testing.T) {
|
||||
// the scoped check gates the merge even when the branch's OWN status check is off
|
||||
consumer := createTestRepo(t, "sw-nocheck-consumer", false)
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-nocheck-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
mergeReq := protectAndOpenPR(t, consumer, "nocheck-pr", false)
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
|
||||
|
||||
// pending scoped check blocks the merge despite the branch's own status check being off
|
||||
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
|
||||
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
|
||||
|
||||
// the required scoped run succeeds -> merge allowed
|
||||
task := runner.fetchTask(t)
|
||||
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
|
||||
user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Filtered required scoped check passes as skipped and allows merge", func(t *testing.T) {
|
||||
// A required scoped workflow excluded by a paths filter posts a skipped (success) commit status,
|
||||
// so the required check is satisfied and the PR can merge.
|
||||
|
||||
const scopedFilteredPRWorkflow = `name: Scoped Filtered PR
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- src/**
|
||||
jobs:
|
||||
scoped-filtered-job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo scoped-filtered
|
||||
`
|
||||
source := createTestRepo(t, "sw-filtered-source", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedFilteredPRWorkflow)
|
||||
registerUserScopedSource(t, source, "pr.yaml") // required
|
||||
|
||||
consumer := createTestRepo(t, "sw-filtered-consumer", false)
|
||||
// Protect the default branch (its own status check stays off, so only the required scoped check gates the merge).
|
||||
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), map[string]string{
|
||||
"rule_name": consumer.DefaultBranch,
|
||||
"enable_push": "true",
|
||||
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
|
||||
}), http.StatusSeeOther)
|
||||
|
||||
// Open a PR that changes a file NOT matching the workflow's `paths: [src/**]`, so it is filtered out.
|
||||
prFile := &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
BranchName: consumer.DefaultBranch, NewBranchName: "filtered-pr", Message: "pr change",
|
||||
Author: api.Identity{Name: user2.Name, Email: user2.Email},
|
||||
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
|
||||
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
|
||||
}
|
||||
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "docs.txt", prFile)
|
||||
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, "filtered-pr")(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Filtered: no scoped run is created, but a skipped commit status is posted on the PR head.
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "filtered scoped workflow creates no run")
|
||||
assertSkippedCommitStatusExists(t, consumer.ID, pr.Head.Sha, "pull_request")
|
||||
|
||||
// The skipped (success) status satisfies the required scoped check (prefixed with the source repo), so the merge is allowed.
|
||||
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
|
||||
mergeReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
|
||||
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
|
||||
user2Session.MakeRequest(t, mergeReq, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Settings page required patterns", func(t *testing.T) {
|
||||
source := createTestRepo(t, "sw-settings-source", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
|
||||
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/manual.yaml", `name: Manual
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`) // workflow_dispatch posts no status -> the settings page must warn instead of listing contexts
|
||||
registerUserScopedSource(t, source) // registered; each phase configures it via the /required endpoint
|
||||
pattern := source.FullName() + ": * / *"
|
||||
|
||||
setConfigs := func(t *testing.T, vals url.Values) {
|
||||
vals.Set("repo_id", strconv.FormatInt(source.ID, 10))
|
||||
user2Session.MakeRequest(t, NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals), http.StatusOK)
|
||||
}
|
||||
loadSource := func(t *testing.T) *actions_model.ActionScopedWorkflowSource {
|
||||
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})
|
||||
}
|
||||
settingsBody := func(t *testing.T) string {
|
||||
return user2Session.MakeRequest(t, NewRequest(t, "GET", "/user/settings/actions/scoped-workflows"), http.StatusOK).Body.String()
|
||||
}
|
||||
|
||||
t.Run("renders the saved pattern and display-name default", func(t *testing.T) {
|
||||
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
|
||||
body := settingsBody(t)
|
||||
assert.Contains(t, body, `name="required_patterns[push.yaml]"`, "patterns textarea uses the field name the parser expects")
|
||||
assert.Contains(t, body, pattern, "the saved pattern round-trips into the textarea")
|
||||
// the default prefill must use the workflow display name so it matches the status context the run posts (name: Scoped Push)
|
||||
assert.Contains(t, body, `data-default-pattern="`+source.FullName()+`: Scoped Push / *"`)
|
||||
// the expected-checks preview derives the exact context a run posts (job scoped-job, event push) for live glob matching
|
||||
assert.Contains(t, body, `data-context="`+source.FullName()+`: Scoped Push / scoped-job (push)"`)
|
||||
})
|
||||
|
||||
t.Run("live pattern kept as history after un-require", func(t *testing.T) {
|
||||
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
|
||||
// un-require: the row still submits workflow_ids + its patterns (the hidden textarea), but not required_workflow_ids
|
||||
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
|
||||
cfg := loadSource(t).WorkflowConfigs["push.yaml"]
|
||||
require.NotNil(t, cfg)
|
||||
assert.False(t, cfg.Required, "no longer required")
|
||||
assert.Equal(t, []string{pattern}, cfg.Patterns, "pattern retained as history")
|
||||
assert.Contains(t, settingsBody(t), pattern, "history pattern still rendered, so re-requiring restores it")
|
||||
})
|
||||
|
||||
t.Run("orphan config dropped when un-required", func(t *testing.T) {
|
||||
// An orphan entry (gone.yaml: required for a file that no longer exists in the source) has no history worth keeping:
|
||||
// un-checking Required must drop it entirely, unlike a live un-required workflow.
|
||||
setConfigs(t, url.Values{
|
||||
"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml", "gone.yaml"},
|
||||
"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
|
||||
})
|
||||
require.True(t, loadSource(t).IsWorkflowRequired("gone.yaml"), "orphan kept while still required")
|
||||
|
||||
// un-require gone.yaml (its row + patterns are still submitted, as the settings page does); push.yaml stays required
|
||||
setConfigs(t, url.Values{
|
||||
"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml"},
|
||||
"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
|
||||
})
|
||||
src := loadSource(t)
|
||||
assert.Nil(t, src.WorkflowConfigs["gone.yaml"], "orphan dropped after un-require, not kept as history")
|
||||
assert.True(t, src.IsWorkflowRequired("push.yaml"), "live required workflow kept")
|
||||
})
|
||||
|
||||
t.Run("warns when a workflow posts no status checks", func(t *testing.T) {
|
||||
// manual.yaml only runs on workflow_dispatch, which posts no commit status: instead of listing expected checks,
|
||||
// its row shows a warning not to mark it required (must-present would block forever).
|
||||
body := settingsBody(t)
|
||||
assert.Contains(t, body, "posts no status checks", "the no-status-check warning is shown")
|
||||
assert.NotContains(t, body, `data-context="`+source.FullName()+`: Manual /`, "a workflow_dispatch-only workflow must list no expected contexts")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Distinct sources same filename", func(t *testing.T) {
|
||||
// two DIFFERENT source repos with the same filename run independently
|
||||
s1 := createTestRepo(t, "sw-multi-s1", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, s1, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
|
||||
s2 := createTestRepo(t, "sw-multi-s2", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, s2, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
|
||||
registerUserScopedSource(t, s1)
|
||||
registerUserScopedSource(t, s2)
|
||||
|
||||
consumer := createTestRepo(t, "sw-multi-consumer", false)
|
||||
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
|
||||
|
||||
assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "same-named ci.yaml from two sources run independently")
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s1.ID})
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s2.ID})
|
||||
})
|
||||
|
||||
t.Run("Detection cache invalidates on source push", func(t *testing.T) {
|
||||
// The detection parse is cached per (source, default-branch SHA).
|
||||
source := createTestRepo(t, "sw-cache-source", false)
|
||||
created := createWorkflowFile(t, user2Token, source.OwnerName, source.Name, ".gitea/scoped_workflows/ci.yaml",
|
||||
getWorkflowCreateFileOptions(user2, source.DefaultBranch, "create ci", `name: CI
|
||||
on: pull_request
|
||||
jobs:
|
||||
j:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo a
|
||||
`))
|
||||
registerUserScopedSource(t, source)
|
||||
|
||||
consumer := createTestRepo(t, "sw-cache-consumer", false)
|
||||
|
||||
// warm the cache at the source's current SHA: the source triggers on pull_request, so a consumer push is no match
|
||||
createRepoWorkflowFile(t, user2, user2Token, consumer, "m1.txt", "trigger")
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
|
||||
"source triggers on pull_request, so a consumer push must not create a scoped run")
|
||||
|
||||
// switch the source's trigger to push on its default branch
|
||||
updateReq := NewRequestWithJSON(t, "PUT",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/contents/.gitea/scoped_workflows/ci.yaml", source.OwnerName, source.Name),
|
||||
&api.UpdateFileOptions{
|
||||
SHA: created.Content.SHA,
|
||||
FileOptions: api.FileOptions{BranchName: source.DefaultBranch, Message: "switch to push"},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: CI
|
||||
on: push
|
||||
jobs:
|
||||
j:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo a
|
||||
`)),
|
||||
}).AddTokenAuth(user2Token)
|
||||
MakeRequest(t, updateReq, http.StatusOK)
|
||||
|
||||
// the next consumer push must re-detect against the new SHA (on: push) and create the scoped run
|
||||
createRepoWorkflowFile(t, user2, user2Token, consumer, "m2.txt", "trigger")
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, Event: "push"}),
|
||||
"after the source switches to on: push, the next consumer push creates a scoped run")
|
||||
})
|
||||
|
||||
t.Run("Deletion cleans up source registration", func(t *testing.T) {
|
||||
source := createTestRepo(t, "sw-delete-source", false)
|
||||
|
||||
addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add", map[string]string{"repo_name": source.Name})
|
||||
user2Session.MakeRequest(t, addReq, http.StatusOK)
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})
|
||||
|
||||
delReq := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s", source.OwnerName, source.Name)).AddTokenAuth(user2Token)
|
||||
MakeRequest(t, delReq, http.StatusNoContent)
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionScopedWorkflowSource{SourceRepoID: source.ID})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -12,30 +12,30 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
actions_module "code.gitea.io/gitea/modules/actions"
|
||||
"code.gitea.io/gitea/modules/commitstatus"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||
issue_service "code.gitea.io/gitea/services/issue"
|
||||
pull_service "code.gitea.io/gitea/services/pull"
|
||||
release_service "code.gitea.io/gitea/services/release"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
release_service "gitea.dev/services/release"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
commitstatus_service "gitea.dev/services/repository/commitstatus"
|
||||
files_service "gitea.dev/services/repository/files"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -166,6 +166,14 @@ jobs:
|
||||
assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA)
|
||||
assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent)
|
||||
|
||||
// require the workflow's status check on the base branch, so a filtered-out run posts a skipped status to satisfy it
|
||||
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), baseRepo, &git_model.ProtectedBranch{
|
||||
RepoID: baseRepo.ID,
|
||||
RuleName: "main",
|
||||
EnableStatusCheck: true,
|
||||
StatusCheckContexts: []string{"*"},
|
||||
}, git_model.WhitelistOptions{}))
|
||||
|
||||
// add another file whose name cannot match the specified path
|
||||
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
@@ -215,8 +223,68 @@ jobs:
|
||||
err = pull_service.NewPullRequest(t.Context(), prOpts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// the new pull request cannot trigger actions, so there is still only 1 record
|
||||
// the new pull request is filtered by paths, so no run is created; a skipped commit status is posted instead
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
|
||||
assertSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
|
||||
|
||||
// delete the branch protection rule: with nothing required, a filtered-out run must post no skipped status
|
||||
pb, err := git_model.GetProtectedBranchRuleByName(t.Context(), baseRepo.ID, "main")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pb)
|
||||
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), baseRepo, pb.ID))
|
||||
|
||||
// add another file whose name cannot match the specified path
|
||||
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: "bar.txt",
|
||||
ContentReader: strings.NewReader("bar"),
|
||||
},
|
||||
},
|
||||
Message: "add bar.txt",
|
||||
OldBranch: "main",
|
||||
NewBranch: "fork-branch-3",
|
||||
Author: &files_service.IdentityOptions{
|
||||
GitUserName: user4.Name,
|
||||
GitUserEmail: user4.Email,
|
||||
},
|
||||
Committer: &files_service.IdentityOptions{
|
||||
GitUserName: user4.Name,
|
||||
GitUserEmail: user4.Email,
|
||||
},
|
||||
Dates: &files_service.CommitDateOptions{
|
||||
Author: time.Now(),
|
||||
Committer: time.Now(),
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, addFileToForkedResp)
|
||||
|
||||
// create Pull
|
||||
pullIssue = &issues_model.Issue{
|
||||
RepoID: baseRepo.ID,
|
||||
Title: "A mismatched path with no required status check posts no skipped status",
|
||||
PosterID: user4.ID,
|
||||
Poster: user4,
|
||||
IsPull: true,
|
||||
}
|
||||
pullRequest = &issues_model.PullRequest{
|
||||
HeadRepoID: forkedRepo.ID,
|
||||
BaseRepoID: baseRepo.ID,
|
||||
HeadBranch: "fork-branch-3",
|
||||
BaseBranch: "main",
|
||||
HeadRepo: forkedRepo,
|
||||
BaseRepo: baseRepo,
|
||||
Type: issues_model.PullRequestGitea,
|
||||
}
|
||||
prOpts = &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
|
||||
err = pull_service.NewPullRequest(t.Context(), prOpts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// filtered by paths and no required status check remains, so no run and no skipped commit status
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
|
||||
assertNoSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,6 +406,10 @@ jobs:
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, addFileToBranchResp)
|
||||
// the push to test-skip-ci is filtered by branches, so no run is created;
|
||||
// its context is not a required status check either, so no skipped commit status is posted.
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
||||
assertNoSkippedCommitStatusExists(t, repo.ID, addFileToBranchResp.Commit.SHA, "push")
|
||||
|
||||
resp := testPullCreate(t, session, "user2", "skip-ci", true, "master", "test-skip-ci", "[skip ci] test-skip-ci")
|
||||
|
||||
@@ -345,7 +417,7 @@ jobs:
|
||||
url := test.RedirectURL(resp)
|
||||
assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url)
|
||||
|
||||
// the pr title contains a configured skip-ci string, so there is still only 1 record
|
||||
// the pr title contains a configured skip-ci string, so no run and no skipped status are created
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
||||
})
|
||||
}
|
||||
@@ -638,7 +710,7 @@ jobs:
|
||||
return false
|
||||
}
|
||||
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -680,14 +752,18 @@ func checkCommitStatusAndInsertFakeStatus(t *testing.T, repo *repo_model.Reposit
|
||||
assert.Len(t, latestCommitStatuses, 1)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, latestCommitStatuses[0].State)
|
||||
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
}
|
||||
|
||||
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha, targetURL, context string) {
|
||||
// insertFakeStatus inserts a success status that lands in the same dedupe
|
||||
// group as `prev` — the actions runner mixes the workflow file path into
|
||||
// ContextHash, so we must reuse it (rather than recomputing from Context).
|
||||
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha string, prev *git_model.CommitStatus) {
|
||||
err := commitstatus_service.CreateCommitStatus(t.Context(), repo, user_model.NewActionsUser(), sha, &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
TargetURL: targetURL,
|
||||
Context: context,
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
TargetURL: prev.TargetURL,
|
||||
Context: prev.Context,
|
||||
ContextHash: prev.ContextHash,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -822,7 +898,7 @@ jobs:
|
||||
return false
|
||||
}
|
||||
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -931,6 +1007,76 @@ jobs:
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkflowDispatchPublicApiRequiresWorkflowDispatchTrigger(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
session := loginUser(t, user2.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
repo, err := repo_service.CreateRepository(t.Context(), user2, user2, repo_service.CreateRepoOptions{
|
||||
Name: "workflow-dispatch-requires-trigger",
|
||||
Description: "test workflow dispatch requires workflow_dispatch",
|
||||
AutoInit: true,
|
||||
Gitignores: "Go",
|
||||
License: "MIT",
|
||||
Readme: "Default",
|
||||
DefaultBranch: "main",
|
||||
IsPrivate: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, repo)
|
||||
|
||||
addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(t.Context(), repo, user2, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: ".gitea/workflows/push-only.yml",
|
||||
ContentReader: strings.NewReader(`
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo helloworld
|
||||
`),
|
||||
},
|
||||
},
|
||||
Message: "add workflow",
|
||||
OldBranch: "main",
|
||||
NewBranch: "main",
|
||||
Author: &files_service.IdentityOptions{
|
||||
GitUserName: user2.Name,
|
||||
GitUserEmail: user2.Email,
|
||||
},
|
||||
Committer: &files_service.IdentityOptions{
|
||||
GitUserName: user2.Name,
|
||||
GitUserEmail: user2.Email,
|
||||
},
|
||||
Dates: &files_service.CommitDateOptions{
|
||||
Author: time.Now(),
|
||||
Committer: time.Now(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, addWorkflowToBaseResp)
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("ref", "main")
|
||||
req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/push-only.yml/dispatches", repo.FullName()), values).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
apiError := DecodeJSON(t, resp, &api.APIError{})
|
||||
assert.Contains(t, apiError.Message, "has no workflow_dispatch event trigger")
|
||||
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{
|
||||
RepoID: repo.ID,
|
||||
Event: "workflow_dispatch",
|
||||
WorkflowID: "push-only.yml",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
@@ -1568,8 +1714,7 @@ jobs:
|
||||
Name: new("close-pull-request-with-path-fork"),
|
||||
}).AddTokenAuth(user4Token)
|
||||
resp := MakeRequest(t, req, http.StatusAccepted)
|
||||
var apiForkRepo api.Repository
|
||||
DecodeJSON(t, resp, &apiForkRepo)
|
||||
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
||||
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
@@ -1802,7 +1947,33 @@ jobs:
|
||||
testEditFile(t, session, "user2", repoName, repo.DefaultBranch, "dir1/dir1.txt", "11")
|
||||
// update by rebase
|
||||
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/pulls/%d/update?style=rebase", "user2", repoName, apiPull.Index))
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
runner.fetchNoTask(t)
|
||||
})
|
||||
}
|
||||
|
||||
// assertSkippedCommitStatusExists asserts that a filtered-out required workflow posted a skipped commit status on sha
|
||||
func assertSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
|
||||
t.Helper()
|
||||
assert.Truef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "missing skipped commit status with event %q on %s", eventSuffix, sha)
|
||||
}
|
||||
|
||||
// assertNoSkippedCommitStatusExists asserts that no skipped commit status for the given event was posted on sha,
|
||||
// e.g. a filtered-out workflow whose context is not a required status check must not leave one behind.
|
||||
func assertNoSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
|
||||
t.Helper()
|
||||
assert.Falsef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "unexpected skipped commit status with event %q on %s", eventSuffix, sha)
|
||||
}
|
||||
|
||||
// hasSkippedCommitStatus reports whether a skipped commit status for the given event was posted on sha.
|
||||
func hasSkippedCommitStatus(t *testing.T, repoID int64, sha, eventSuffix string) bool {
|
||||
t.Helper()
|
||||
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repoID, sha, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
for _, s := range statuses {
|
||||
if s.State == commitstatus.CommitStatusSkipped && strings.Contains(s.Context, "("+eventSuffix+")") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/tests"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/system"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/setting/config"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/tests"
|
||||
"gitea.dev/models/system"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/setting/config"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -21,29 +21,52 @@ func TestAdminConfig(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
session := loginUser(t, "user1")
|
||||
req := NewRequest(t, "GET", "/-/admin/config")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
|
||||
|
||||
t.Run("ConfigPage", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", "/-/admin/config")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
|
||||
})
|
||||
|
||||
t.Run("OpenEditorWithApps", func(t *testing.T) {
|
||||
cfg := setting.Config().Repository.OpenWithEditorApps
|
||||
editorApps := cfg.Value(t.Context())
|
||||
assert.Len(t, editorApps, 3)
|
||||
assert.False(t, cfg.HasValue(t.Context()))
|
||||
|
||||
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[]"}))
|
||||
config.GetDynGetter().InvalidateCache()
|
||||
t.Run("Default", func(t *testing.T) {
|
||||
editorApps := cfg.Value(t.Context())
|
||||
assert.Len(t, editorApps, 3)
|
||||
assert.False(t, cfg.HasValue(t.Context()))
|
||||
})
|
||||
|
||||
editorApps = cfg.Value(t.Context())
|
||||
assert.Len(t, editorApps, 3)
|
||||
assert.False(t, cfg.HasValue(t.Context()))
|
||||
t.Run("EmptyAsDefault", func(t *testing.T) {
|
||||
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[]"}))
|
||||
config.GetDynGetter().InvalidateCache()
|
||||
|
||||
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[{}]"}))
|
||||
config.GetDynGetter().InvalidateCache()
|
||||
editorApps := cfg.Value(t.Context())
|
||||
assert.Len(t, editorApps, 3)
|
||||
assert.False(t, cfg.HasValue(t.Context()))
|
||||
})
|
||||
|
||||
editorApps = cfg.Value(t.Context())
|
||||
assert.Len(t, editorApps, 1)
|
||||
assert.True(t, cfg.HasValue(t.Context()))
|
||||
t.Run("SingleItem", func(t *testing.T) {
|
||||
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[{}]"}))
|
||||
config.GetDynGetter().InvalidateCache()
|
||||
|
||||
editorApps := cfg.Value(t.Context())
|
||||
assert.Len(t, editorApps, 1)
|
||||
assert.True(t, cfg.HasValue(t.Context()))
|
||||
})
|
||||
|
||||
t.Run("ManualSet", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{
|
||||
"key": "repository.open-with.editor-apps",
|
||||
"value": `[{"DisplayName":"app-name","OpenURL":"my-app:?u={url}"}]`,
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
editorApps := cfg.Value(t.Context())
|
||||
assert.Len(t, editorApps, 1)
|
||||
assert.Equal(t, "app-name", editorApps[0].DisplayName)
|
||||
assert.Equal(t, "my-app:?u={url}", editorApps[0].OpenURL)
|
||||
assert.True(t, cfg.HasValue(t.Context()))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("InstanceWebBanner", func(t *testing.T) {
|
||||
@@ -51,7 +74,7 @@ func TestAdminConfig(t *testing.T) {
|
||||
assert.False(t, has)
|
||||
assert.Equal(t, setting.WebBannerType{}, banner)
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{
|
||||
req := NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{
|
||||
"key": "instance.web_banner",
|
||||
"value": `{"DisplayEnabled":true,"ContentMessage":"test-msg","StartTimeUnix":123,"EndTimeUnix":456}`,
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user